forked from ChaiHao/BPOS-MVP
基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -9,7 +9,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Session, TaskCard, TaskDetail } from '@contracts';
|
||||
import { listApprovalsForMeRoute, listMessagesRoute, listTasksRoute, replyWorkOrderRoute, sayRoute, sessionRoute, taskDetailRoute } from '@contracts';
|
||||
import {
|
||||
listApprovalsForMeRoute,
|
||||
listMessagesRoute,
|
||||
listTasksRoute,
|
||||
logoutRoute,
|
||||
sessionRoute,
|
||||
taskDetailRoute,
|
||||
} from '@contracts';
|
||||
import { TaskChat, TaskList, ViewRegion } from '@features/desk-task';
|
||||
import { ApprovalDesk } from '@features/desk-approval';
|
||||
import { MessageDesk } from '@features/desk-message';
|
||||
@@ -37,6 +44,7 @@ export function AppShell() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const split = useChatWidth();
|
||||
|
||||
|
||||
const session = useQuery({
|
||||
queryKey: queryKeys.session,
|
||||
queryFn: () => call<Session>(sessionRoute),
|
||||
@@ -67,24 +75,20 @@ export function AppShell() {
|
||||
enabled: current !== '',
|
||||
});
|
||||
|
||||
const reply = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) =>
|
||||
call<{ ok: boolean }>(replyWorkOrderRoute, { body: { reply: payload } }),
|
||||
// 回传后按目标失效缓存 —— 组件不自行轮询
|
||||
|
||||
|
||||
|
||||
/** 登出后**整体清空缓存**:上一个人的任务、审批、消息不能留在下一个人眼前 */
|
||||
const logout = useMutation({
|
||||
mutationFn: () => call<{ ok: boolean }>(logoutRoute),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.task(current) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.tasks });
|
||||
queryClient.clear();
|
||||
// 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影
|
||||
window.location.assign('/');
|
||||
},
|
||||
});
|
||||
|
||||
const say = useMutation({
|
||||
mutationFn: (text: string) => call(sayRoute, { params: { taskId: current }, body: { text } }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: queryKeys.task(current) }),
|
||||
});
|
||||
|
||||
const me = session.data?.staff.name ?? '—';
|
||||
// composer 锁:有卡在等作答时锁住自由输入
|
||||
const locked = (detail.data?.workOrders ?? []).some((o) => o.done === undefined);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@@ -119,10 +123,17 @@ export function AppShell() {
|
||||
);
|
||||
})}
|
||||
<div className="jc-rail-foot">
|
||||
<div className="jc-nav-user" title={`${me}(本工作台所有者)`}>
|
||||
{/* 轨底身份即登出入口 —— 会话在服务端,点它是真作废,不只是清本地 */}
|
||||
<button
|
||||
type="button"
|
||||
className="jc-nav-user"
|
||||
title={`${me}(本工作台所有者)· 点击登出`}
|
||||
disabled={logout.isPending}
|
||||
onClick={() => logout.mutate()}
|
||||
>
|
||||
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
|
||||
<span className="jc-nav-name">{me}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -147,9 +158,9 @@ export function AppShell() {
|
||||
onCollapse={() => setCollapsed(true)}
|
||||
/>
|
||||
{detail.data !== undefined ? (
|
||||
<ViewRegion key={current} detail={detail.data} onReply={(payload) => reply.mutate(payload)} />
|
||||
<ViewRegion key={current} detail={detail.data} />
|
||||
) : (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<main className="execution-panel">
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
{tasks.isLoading ? '载入中…' : '还没有落到你工位的任务'}
|
||||
@@ -165,13 +176,9 @@ export function AppShell() {
|
||||
/>
|
||||
)}
|
||||
{detail.data !== undefined && (
|
||||
<TaskChat
|
||||
key={current}
|
||||
chat={detail.data.chat}
|
||||
locked={locked}
|
||||
onSay={(text) => say.mutate(text)}
|
||||
onDecide={() => undefined}
|
||||
/>
|
||||
<section className="session-chat">
|
||||
<TaskChat key={current} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : module === 'approvals' ? (
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* 刷新与深链都能完整还原;前端零过程状态。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { logoutRoute } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { TABS, ZM, ZONES, routeLabel } from '@web-shared/gov/gov-route.ts';
|
||||
import type { GovZone, Nav, Route } from '@web-shared/gov/gov-route.ts';
|
||||
@@ -48,6 +51,17 @@ export function GovShell() {
|
||||
const [place, setPlace] = useState<Place>(readPlace);
|
||||
const staff = useGovStaff();
|
||||
const tasks = useGovTasks();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/** 登出后整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人 */
|
||||
const logout = useMutation({
|
||||
mutationFn: () => call<{ ok: boolean }>(logoutRoute),
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
// 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影
|
||||
window.location.assign('/');
|
||||
},
|
||||
});
|
||||
|
||||
// 浏览器前进 / 后退即导航 —— 路由状态就是应用状态
|
||||
useEffect(() => {
|
||||
@@ -145,6 +159,10 @@ export function GovShell() {
|
||||
<Ico name="list-checks" s={13} />
|
||||
工作台
|
||||
</a>
|
||||
<button className="g-tbtn" onClick={() => logout.mutate()} disabled={logout.isPending}>
|
||||
<Ico name="log-out" s={13} />
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
+81
-16
@@ -1,34 +1,99 @@
|
||||
/**
|
||||
* apps/web 唯一组装层(编码规范 §9):Provider、鉴权壳、SSE 连接。
|
||||
* 样式整文件搬自两台原型:设计令牌 + 全部 `jc-*` 组件样式 + vendor 皮肤。
|
||||
* apps/web 唯一组装层(编码规范 §9):样式装载、Provider、鉴权壳、SSE 连接。
|
||||
*
|
||||
* **两台面各自装自己的皮肤。**
|
||||
* 两份原型本是两个独立应用,各自只加载自己那两份 CSS:
|
||||
* 工作台 = `proto.vendor.css`(原型皮肤)+ `app.css`(补充,作用域 `.proto-root`)
|
||||
* 治理台 = `theme.css`(令牌 + 页面基座)+ `console.css`(gov-* 组件)
|
||||
* 两份基座都写了 `body { min-width / background }` 这类**全局**规则,谁后加载谁赢 ——
|
||||
* 四份一起塞进同一个文档,后加载的治理台基座会把工作台的壳整个盖掉(实测:布局全崩)。
|
||||
* 所以按台面**分别装载**,这也正是 1:1 平移该有的样子:每台面拿到的样式集与它的原型一致。
|
||||
*/
|
||||
import { StrictMode, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import './styles/proto.vendor.css';
|
||||
import './styles/app.css';
|
||||
import './styles/theme.css';
|
||||
import './styles/console.css';
|
||||
import { QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Session } from '@contracts';
|
||||
import { sessionRoute } from '@contracts';
|
||||
import { AppShell } from './app-shell.tsx';
|
||||
import { connectStream } from './subscribe.ts';
|
||||
import { GovShell } from './gov-shell.tsx';
|
||||
import { queryClient } from './query.ts';
|
||||
import { LoginPage } from '@features/auth';
|
||||
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
|
||||
import { queryClient, queryKeys } from './query.ts';
|
||||
import { applyTabIdentity } from './tab-identity.ts';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('#root 缺失');
|
||||
|
||||
/**
|
||||
* 两台面共一个 SPA:路径决定进哪台。
|
||||
* 路由状态即应用状态 —— 可深链(§9.2);TanStack Router 的类型化 search params 随后续接入。
|
||||
/** 路径或 hash 决定进哪台 —— 路由状态即应用状态,可深链(§9.2) */
|
||||
const isGovernance =
|
||||
window.location.pathname.startsWith('/gov') || window.location.hash.startsWith('#/gov');
|
||||
|
||||
// 网页标签的名与章 —— 样式装载前就挂上,标签从第一帧起就叫对名字
|
||||
applyTabIdentity(isGovernance);
|
||||
|
||||
/*
|
||||
* 样式先装、再渲染:CSS 落地后才挂 DOM,避免无样式闪烁。
|
||||
* 台面切换靠整页导航(两台面的 body 基座互斥,不做客户端软切换)。
|
||||
* 登录页两台面共用,故恒装 —— 它通篇作用域在 `.jc-login`,不与任一基座相争。
|
||||
*/
|
||||
await import('./styles/login.css');
|
||||
if (isGovernance) {
|
||||
await import('./styles/theme.css');
|
||||
await import('./styles/console.css');
|
||||
} else {
|
||||
await import('./styles/proto.vendor.css');
|
||||
await import('./styles/app.css');
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权壳(ADR-010)—— 会话是**服务端**的事:401 即落登录页。
|
||||
* 前端不自存身份,刷新照样问一次 `/api/session`;登出后缓存整体清空,
|
||||
* 免得上一个人的任务、审批、消息留在下一个人眼前。
|
||||
*/
|
||||
function Gate() {
|
||||
const client = useQueryClient();
|
||||
const session = useQuery({
|
||||
queryKey: queryKeys.session,
|
||||
queryFn: () => call<Session>(sessionRoute),
|
||||
// 未登录是正常状态,不是故障 —— 不重试,直接落登录页
|
||||
retry: (_count, error) => !(error instanceof ApiCallError && error.status === 401),
|
||||
});
|
||||
|
||||
/*
|
||||
* 判据要看 error,不能只看 data。
|
||||
* TanStack Query 在**重取失败时保留上一次成功的 data** —— 会话过期或登出后重取拿到 401,
|
||||
* `session.data` 依旧是旧身份;只认 data 就会停在一个空壳里,退不出去(实测如此)。
|
||||
*/
|
||||
const expired = session.error instanceof ApiCallError && session.error.status === 401;
|
||||
const authed = session.data !== undefined && !expired;
|
||||
|
||||
// SSE 只在登录后连:未登录时那条连接会被中间件 401 挡下
|
||||
useEffect(() => (authed ? connectStream(client) : undefined), [authed, client]);
|
||||
|
||||
if (session.isPending) return null;
|
||||
if (!authed) {
|
||||
// 只落身份,**不要 clear()**:clear 会连同已挂载的观察者一起移除,
|
||||
// 随后 setQueryData 建的是新条目,老观察者接不上 —— 界面会卡在「登录中…」(实测)。
|
||||
// 缓存的清空由登出那侧的整页跳转保证,这里到达时本就是空的。
|
||||
return <LoginPage onLogin={(s) => client.setQueryData(queryKeys.session, s)} />;
|
||||
}
|
||||
|
||||
return isGovernance ? (
|
||||
<GovShell />
|
||||
) : (
|
||||
// .proto-root 是工作台补充样式的作用域(原型 app.css 通篇以它起头);
|
||||
// 治理台不裹它 —— 裹了工作台的补充样式就会漏进治理台的树里
|
||||
<div className="proto-root">
|
||||
<AppShell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Root() {
|
||||
// 单一 SSE 连接在组装层建立;feature 只管订阅缓存键,不自建连接、不轮询
|
||||
useEffect(() => connectStream(queryClient), []);
|
||||
const governance = window.location.pathname.startsWith('/gov') || window.location.hash.startsWith('#/gov');
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* .proto-root 是设计令牌的作用域(原型 app.css 的 `.proto-root .app-shell`) */}
|
||||
<div className="proto-root">{governance ? <GovShell /> : <AppShell />}</div>
|
||||
<Gate />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
/* ===== 三栏衔接:列表区 / 视图区 / 对话区统一为三张等距同规格卡片 =====
|
||||
覆盖 vendor 的「左中融合 + 右侧浮卡」旧结构:等间距 gutter、同圆角、同描边、同投影 */
|
||||
/* 对话区宽度 = var(--jc-chat-w)(用户拖拽分隔柄写入 workspace inline style),未拖时走各断点默认 */
|
||||
.proto-root .workspace { position: relative; gap: 12px; padding: 12px 14px; --jc-ws-pr: 14px; --jcai-ai-chat-width: var(--jc-chat-w, 440px); grid-template-columns: 310px minmax(0, 1fr) var(--jcai-ai-chat-width); }
|
||||
.proto-root .workspace { position: relative; gap: 12px; padding: 12px 14px; --jc-ws-pr: 14px; --jcai-ai-chat-width: var(--jc-chat-w, 440px); grid-template-columns: 310px minmax(var(--jc-work-min), 1fr) minmax(0, var(--jcai-ai-chat-width)); }
|
||||
/* 收起态 grid:无媒体查询、全断点生效,覆盖 vendor 各断点的三栏定义(修列错位 bug)。
|
||||
清单收起→[视图区 1fr | 会话区];会话收起→[清单 | 视图区 1fr];都收起→[视图区 1fr]。 */
|
||||
.proto-root .workspace.jc-side-collapsed:not(.jc-chat-collapsed) { grid-template-columns: minmax(0, 1fr) var(--jcai-ai-chat-width, 440px); }
|
||||
@@ -149,13 +149,43 @@
|
||||
.proto-root .session-chat { border-radius: 12px; }
|
||||
/* 窄屏(≤1380 视口):收窄三栏配比,保证对话区不被裁切(拖拽值 --jc-chat-w 仍优先) */
|
||||
@media (max-width: 1380px) {
|
||||
.proto-root .workspace { --jc-ws-pr: 12px; --jcai-ai-chat-width: var(--jc-chat-w, clamp(430px, 34vw, 560px)); grid-template-columns: 256px minmax(0, 1fr) var(--jcai-ai-chat-width); gap: 10px; padding: 10px 12px; }
|
||||
.proto-root .workspace { --jc-ws-pr: 12px; --jcai-ai-chat-width: var(--jc-chat-w, clamp(430px, 34vw, 560px)); grid-template-columns: 256px minmax(var(--jc-work-min), 1fr) minmax(0, var(--jcai-ai-chat-width)); gap: 10px; padding: 10px 12px; }
|
||||
}
|
||||
|
||||
/* 对话区/视图区可调比例:workspace 直属分隔柄,锚在「视图区↔对话区」的 12px gutter 上
|
||||
(不能放进 .session-chat——其 overflow:hidden 会裁掉负偏移的柄);拖拽调宽、双击复位 */
|
||||
/* 柄贴对话区左缘(跨边 -1/+5px),gutter 左半留给视图区自绘 thumb,两者不抢点击 */
|
||||
.proto-root .jc-vsplit { position: absolute; top: 12px; bottom: 12px; width: 6px; right: calc(var(--jcai-ai-chat-width, 440px) + var(--jc-ws-pr, 14px) - 5px); z-index: 7; cursor: col-resize; display: grid; place-items: center; touch-action: none; }
|
||||
/*
|
||||
* 柄锚在**对话区那一列的真实左缘**,不按 `--jcai-ai-chat-width` 算。
|
||||
*
|
||||
* 原来是 `position:absolute; right: calc(var(--jcai-ai-chat-width) + …)` ——
|
||||
* 那要求对话区的实际宽度恰好等于变量值。自从对话区改成 `minmax(0, var(…))`
|
||||
* (为了给工作区让出地板),它可以比变量窄,柄就按老位置停在原地、飘进工作区。
|
||||
*
|
||||
* 改用栅格定位:`grid-column: -2 / -1` 指的是**最后一列**,列数变了也跟得上
|
||||
* (清单收起后只剩两列,-2 仍然是对话区那列)。柄与对话区重叠放在同一格,
|
||||
* `justify-self: start` + 负外边距让它跨在缝上 —— 缝在哪它就在哪。
|
||||
*/
|
||||
/* 对话区与柄**钉在同一格**:柄显式占了最后一列,对话区若还走自动排位会被顶到第二行 */
|
||||
.proto-root .workspace > .session-chat {
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.proto-root .jc-vsplit {
|
||||
position: relative;
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 1;
|
||||
justify-self: start;
|
||||
align-self: stretch;
|
||||
margin: 12px 0 12px -5px;
|
||||
width: 6px;
|
||||
z-index: 7;
|
||||
cursor: col-resize;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
touch-action: none;
|
||||
}
|
||||
.proto-root .jc-vsplit::before { content: ""; width: 3px; height: 44px; border-radius: 999px; background: var(--ink-4); opacity: 0; transition: opacity var(--t-fast), background var(--t-fast), height var(--t-fast); }
|
||||
.proto-root .jc-vsplit:hover::before { opacity: .55; }
|
||||
.proto-root .jc-vsplit.dragging::before { opacity: 1; height: 64px; background: var(--brand); }
|
||||
@@ -2765,3 +2795,130 @@
|
||||
.proto-root .jc-stars.ro .jc-star:hover { transform: none; }
|
||||
.proto-root .jc-stars-txt { margin-left: 8px; font-style: normal; font-size: 11px; font-weight: 700; color: var(--ink-3); }
|
||||
.proto-root .jc-rate-note { flex-basis: 100%; font-size: 11.5px; color: var(--ink-2); line-height: 1.6; }
|
||||
|
||||
/*
|
||||
* 视图区容器 —— 活动推送 HTML 的沙箱画框(AM 07)。
|
||||
* 这是**平台本体**的件:活动只给内容,尺寸与边框归系统。
|
||||
* iframe 无脚本执行权(sandbox=""),因此内容自带样式、不继承工作台 CSS。
|
||||
*/
|
||||
.proto-root .jc-view-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* 高度由 ViewContainer 按内容量出后写在 style 上 —— 这里不设 height/min-height:
|
||||
定高会把长报告截断,人得在内外两层滚动条之间来回找 */
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ═══ 本仓补充(非原型) ═══════════════════════════════════════════════
|
||||
* 页签条容纳第五个页签「沟通记录」。
|
||||
*
|
||||
* 原型的 `.jc-tnav` 是按**四个**页签配的。加到五个后:
|
||||
* 五个四字页签 380px + 翻页囊 112px + 间距 30px = 522px,而工作区只有 429px。
|
||||
* 差 94px —— 这不是调调 padding 能抹平的,是真的装不下。
|
||||
*
|
||||
* 在定下最终形态之前,先保证**不出卡片**:整条可横滚。
|
||||
* 相比原来「翻页囊被裁到卡片外」,这至少是所有东西都在、都够得着。
|
||||
* 备选形态(待定):缩短页签文字 / 翻页囊只留箭头 / 换行两排 —— 各有取舍,见 debt。
|
||||
*/
|
||||
.proto-root .jc-tnav {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.proto-root .jc-tnav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 一律不压缩:压了就是截字,截了还不如让人横滚 */
|
||||
.proto-root .jc-tnav-tab,
|
||||
.proto-root .jc-tnav-pg,
|
||||
.proto-root .jc-tnav-btn {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ═══ 本仓补充(非原型) ═══════════════════════════════════════════════
|
||||
* 工作区最小宽度 —— 保证页签条一排放得下。
|
||||
*
|
||||
* 页签加到五个后,页签条含图标的自然内容宽 649px,加上工作卡左右内边距 38px,
|
||||
* 工作区至少要 **687px**。
|
||||
*
|
||||
* 注意别取 570:原型在容器 ≤520px 时会把页签图标藏起来省宽,
|
||||
* 570 恰好落在「图标已经显示、却又放不下」的夹缝里 —— 要么低于 520 让它藏,
|
||||
* 要么给够 687 让它显。这里选后者:图标该在就在,让对话区让位。
|
||||
*
|
||||
* 原来中栏是 `minmax(0, 1fr)`、对话区是定宽轨:空间一紧,**塌的是中栏**,
|
||||
* 页签条就被挤到卡片外裁掉。现在反过来:
|
||||
* · 中栏 `minmax(var(--jc-work-min), 1fr)` —— 有地板,不许再塌
|
||||
* · 对话区 `minmax(0, ...)` —— 由它先让,让到多少算多少
|
||||
* 页签、翻页囊的尺寸一律不动,让的是对话区的空间。
|
||||
*
|
||||
* 690 = 687 上取整留 3px。三种 MacBook Air 都装得下:
|
||||
* 1440(Air 13" M1) → 对话区还剩 384px
|
||||
* 1470(Air 13" M2/M3) → 剩 414px
|
||||
* 1710(Air 15") → 剩 654px
|
||||
* 均高于对话区自身下限 340px。
|
||||
*/
|
||||
.proto-root .workspace {
|
||||
--jc-work-min: 690px;
|
||||
}
|
||||
|
||||
/* 收起清单 / 收起对话时中栏本就宽,地板不必再管 —— 免得收起态反被地板顶出横向溢出 */
|
||||
.proto-root .workspace.jc-side-collapsed,
|
||||
.proto-root .workspace.jc-chat-collapsed {
|
||||
--jc-work-min: 0px;
|
||||
}
|
||||
|
||||
/* 列表区空态 —— 紧凑版:软色圆 + 主副文,与主面板的整屏光环同一语气 */
|
||||
.proto-root .jc-side-empty {
|
||||
margin: 16px 10px;
|
||||
padding: 26px 16px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #fbfcfe, #f6f8fb);
|
||||
}
|
||||
|
||||
.proto-root .jc-side-empty-ico {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-bottom: 3px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(120% 120% at 30% 20%, #eef3ff, #e3ebff);
|
||||
color: var(--brand);
|
||||
box-shadow: inset 0 0 0 1px rgb(51 112 255 / 14%);
|
||||
}
|
||||
|
||||
.proto-root .jc-side-empty > b {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.proto-root .jc-side-empty > p {
|
||||
margin: 0;
|
||||
max-width: 210px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/*
|
||||
* 页签图标与文字的光学对齐。
|
||||
* 几何上图标正居中,但文字的**字面**(汉字 ink box)整体偏上约 2px ——
|
||||
* Inter 的汉字回退字形上伸高、下沉浅,行盒居中后底边比图标高。
|
||||
* 逐 0.5px 实测扫描:-2px 时两者字面中心差 0.02px(字面高度相近,居中即底齐)。
|
||||
*/
|
||||
.proto-root .jc-tnav-tab svg,
|
||||
.proto-root .jc-tnav-btn.back svg {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 登录页样式(ADR-010)—— 两份原型都没有这一屏,是新造的。
|
||||
*
|
||||
* **自带全套,不蹭原型的类。**
|
||||
* 原型补充样式 `app.css` 通篇以 `.proto-root` 起头,治理台更是压根不加载它;
|
||||
* 登录页在两个台面之前、两份皮肤之外,蹭谁的类都会在另一边落空(实测:表单塌成一行)。
|
||||
* 所以这里把用到的每一条都写全,全部作用域在 `.jc-login` 内,不留任何全局规则。
|
||||
* 视觉口径(蓝 #3370ff、圆角、字号)与原型一致,是照着抄的值,不是引用它的类。
|
||||
*/
|
||||
.jc-login {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
color: #172033;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.jc-login *,
|
||||
.jc-login *::before,
|
||||
.jc-login *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jc-login-card {
|
||||
width: 100%;
|
||||
max-width: 352px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
padding: 34px 30px 30px;
|
||||
border: 1px solid #e3e6eb;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 32px rgb(23 32 51 / 9%);
|
||||
}
|
||||
|
||||
/* 品牌头:圆角方章 + 标题 + 副题 */
|
||||
.jc-login-mark {
|
||||
align-self: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(135deg, #3370ff, #2454d6);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 16px rgb(51 112 255 / 32%);
|
||||
}
|
||||
|
||||
.jc-login-title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.jc-login-sub {
|
||||
margin: -10px 0 2px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 表单:一行一项,标签在上 */
|
||||
.jc-login-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.jc-login-field > span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.jc-login-field > input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #dfe3e9;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.jc-login-field > input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.jc-login-field > input:focus {
|
||||
border-color: #3370ff;
|
||||
box-shadow: 0 0 0 3px rgb(51 112 255 / 14%);
|
||||
}
|
||||
|
||||
/* 报错条 */
|
||||
.jc-login-err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #fbd5da;
|
||||
border-radius: 8px;
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.jc-login-err > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.jc-login-go {
|
||||
height: 38px;
|
||||
border: 1px solid #3370ff;
|
||||
border-radius: 9px;
|
||||
background: #3370ff;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.jc-login-go:hover:not(:disabled) {
|
||||
background: #2861ea;
|
||||
border-color: #2861ea;
|
||||
}
|
||||
|
||||
.jc-login-go:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 种子提示:告诉人初始口令由哪个环境变量定,**不写口令本身** */
|
||||
.jc-login-hint {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 11.5px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
@@ -59,18 +59,3 @@ body {
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 视图区容器 —— 活动推送 HTML 的沙箱画框(AM 07)。
|
||||
* 这是**平台本体**的件:活动只给内容,尺寸与边框归系统。
|
||||
* iframe 无脚本执行权(sandbox=""),因此内容自带样式、不继承工作台 CSS。
|
||||
*/
|
||||
.jc-view-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 网页标签的名与章 —— 两台面各有一套,多标签并开时一眼可辨。
|
||||
*
|
||||
* 章随台面的**皮肤基调**走,不另造视觉:
|
||||
* 工作台 = 品牌蓝渐变圆角方 + 白色机器人(与左轨品牌章、登录页方章同源);
|
||||
* 治理中心 = 深色底(治理台顶栏的那种深)+ 盾形勾(管的是规则与放行)。
|
||||
* SVG 以 data: URI 内联 —— 不添静态资产,构建零配置。
|
||||
*/
|
||||
|
||||
const DESK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3d7bff"/><stop offset="1" stop-color="#2454d6"/>
|
||||
</linearGradient></defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#g)"/>
|
||||
<g transform="translate(4,5)">
|
||||
<circle cx="12" cy="1.5" r="1.3" fill="#fff"/>
|
||||
<rect x="11.4" y="2.3" width="1.2" height="2.2" rx="0.6" fill="#fff"/>
|
||||
<rect x="3.6" y="4.6" width="16.8" height="13" rx="4.6" fill="#fff"/>
|
||||
<rect x="1.2" y="8.8" width="2.2" height="4.8" rx="1.1" fill="#fff"/>
|
||||
<rect x="20.6" y="8.8" width="2.2" height="4.8" rx="1.1" fill="#fff"/>
|
||||
<circle cx="9.2" cy="10.4" r="1.9" fill="#2454d6"/>
|
||||
<circle cx="14.8" cy="10.4" r="1.9" fill="#2454d6"/>
|
||||
<path d="M9.3 13.8c.7.9 1.7 1.35 2.7 1.35s2-.45 2.7-1.35" stroke="#2454d6" stroke-width="1.3" fill="none" stroke-linecap="round" opacity="0.55"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
const GOV_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#141b2e"/>
|
||||
<g transform="translate(4,4)" fill="none" stroke="#8fb0ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>
|
||||
<path d="m9 12 2 2 4-4" stroke="#fff"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
/** 进哪台就挂哪套 —— 登录页也按 URL 归台,标签在登录前就叫对名字 */
|
||||
export function applyTabIdentity(isGovernance: boolean): void {
|
||||
document.title = isGovernance ? 'JCAI 治理中心' : 'JCAI 工作台';
|
||||
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (link === null) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = 'data:image/svg+xml;utf8,' + encodeURIComponent(isGovernance ? GOV_ICON : DESK_ICON);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** feature: auth —— 唯一出口(编码规范 §9.1)。 */
|
||||
export { LoginPage } from './internal/LoginPage.tsx';
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 登录页(ADR-010)—— 两份原型都没有这一屏,是新造的。
|
||||
*
|
||||
* 它在两个台面**之前**渲染,拿不到任一台面的皮肤(工作台补充样式通篇作用域 `.proto-root`,
|
||||
* 治理台压根不加载它),所以视觉全部由 `login.css` 自带 —— 类名一律 `jc-login-*`,
|
||||
* 不蹭原型的类,免得在另一台面上落空。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { Session } from '@contracts';
|
||||
import { loginRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin(session: Session): void }) {
|
||||
const [account, setAccount] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ready = account.trim() !== '' && password !== '';
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
onLogin(await call<Session>(loginRoute, { body: { account: account.trim(), password } }));
|
||||
} catch (e) {
|
||||
// 后端只回一句话(不区分账号不存在 / 已停用 / 口令错),这里照实呈现,不替它猜
|
||||
setError(e instanceof ApiCallError ? e.message : '登录失败,请稍后再试');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="jc-login">
|
||||
<form
|
||||
className="jc-login-card"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<span className="jc-login-mark">
|
||||
<Ico name="bot" s={24} />
|
||||
</span>
|
||||
<h1 className="jc-login-title">JCAI 任务操作系统</h1>
|
||||
<p className="jc-login-sub">登录后进入你的工位</p>
|
||||
|
||||
<label className="jc-login-field">
|
||||
<span>账号</span>
|
||||
<input
|
||||
value={account}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
placeholder="如 fengbing"
|
||||
onChange={(e) => setAccount(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="jc-login-field">
|
||||
<span>口令</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
placeholder="口令"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error !== undefined && (
|
||||
<div className="jc-login-err" role="alert">
|
||||
<Ico name="alert-circle" s={15} />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="jc-login-go" type="submit" disabled={busy || !ready}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
</button>
|
||||
<p className="jc-login-hint">初始口令由部署时的 JCAI_SEED_PASSWORD 决定</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
const STATE_CHIP = {
|
||||
pending: { tone: 'todo' as const, label: '待审批', pulse: true },
|
||||
@@ -96,8 +96,10 @@ export function ApprovalDesk() {
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && (
|
||||
<ListEmpty icon="circle-check">
|
||||
{filter === 'todo' ? '待批清空了 —— 没有等你放行的事项' : '暂无已处理记录'}
|
||||
<ListEmpty icon="circle-check" title={filter === 'todo' ? '待批清空了' : '还没有处理记录'}>
|
||||
{filter === 'todo'
|
||||
? '没有等你放行的事项 —— 有新的审批单会第一时间到这里。'
|
||||
: '你处理过的审批会留在这里,随时回看当时的依据与裁决。'}
|
||||
</ListEmpty>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@@ -105,7 +107,12 @@ export function ApprovalDesk() {
|
||||
|
||||
{sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="shield-check">没有卡在你工位上的审批点</ListEmpty>
|
||||
<EmptyState
|
||||
ico="shield-check"
|
||||
tone="ok"
|
||||
title="没有等你放行的事项"
|
||||
desc="流程走到审批点时,审批单会连同它的依据一起送到这里 —— 你只需要看一眼就能拍板。"
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
@@ -125,10 +132,10 @@ export function ApprovalDesk() {
|
||||
/>
|
||||
|
||||
<Section icon="clipboard-check" title="依据快照" extra="调用必附上下文 · 裸调用不得发出">
|
||||
<div className="jc-rows">
|
||||
<div className="jc-kv">
|
||||
{sel.basis.map((b) => (
|
||||
<div key={b.k} className="jc-row">
|
||||
<em>{b.k}</em>
|
||||
<div key={b.k} className="jc-kv-i">
|
||||
<i>{b.k}</i>
|
||||
<b className="jc-mono">{b.v}</b>
|
||||
</div>
|
||||
))}
|
||||
@@ -149,7 +156,7 @@ export function ApprovalDesk() {
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
|
||||
<div className="jc-mod-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { answerMessageRoute, listMessagesRoute, markMessageReadRoute } from '@co
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
const KIND_META: Readonly<Record<string, { icon: string; grad: readonly [string, string]; kicker: string }>> = {
|
||||
notice: { icon: 'bell', grad: ['#7f3bf5', '#5b21cf'], kicker: '系统 · 通知' },
|
||||
@@ -90,13 +90,22 @@ export function MessageDesk() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && <ListEmpty icon="circle-check">没有{filter === 'unread' ? '未读' : ''}消息</ListEmpty>}
|
||||
{visible.length === 0 && (
|
||||
<ListEmpty icon="circle-check" title={filter === 'unread' ? '没有未读' : '还没有消息'}>
|
||||
别人请你补的信息、系统的回执与提醒,到了都会亮在这里。
|
||||
</ListEmpty>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="messages-square">选一条消息</ListEmpty>
|
||||
<EmptyState
|
||||
ico="messages-square"
|
||||
tone="brand"
|
||||
title="没有新消息"
|
||||
desc="别人请你补的信息、系统的回执与提醒都会到这里。需要你答的,就地答完即可,不用跑去别人的任务里。"
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
@@ -183,14 +192,14 @@ function ElicitationForm({
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="jc-input"
|
||||
className="jc-inp"
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
|
||||
<div className="jc-mod-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -12,7 +12,7 @@ import { appendResolutionRoute, createMinuteRoute, listMinutesRoute, registerRes
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Avatar, AvatarStack, Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
import { Avatar, AvatarStack, Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
export function MinuteDesk() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -114,7 +114,11 @@ export function MinuteDesk() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{minutes.length === 0 && <ListEmpty icon="notebook-pen">还没有纪要 —— 新建一场把团队沉淀记下来</ListEmpty>}
|
||||
{minutes.length === 0 && (
|
||||
<ListEmpty icon="notebook-pen" title="还没有纪要">
|
||||
记下一场会,把定下的事写成决议 —— 登记起单后责任才落到人。
|
||||
</ListEmpty>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
@@ -129,13 +133,13 @@ export function MinuteDesk() {
|
||||
/>
|
||||
<Section icon="pen-line" title="纪要正文">
|
||||
<input
|
||||
className="jc-input"
|
||||
className="jc-inp"
|
||||
placeholder="标题,如「7 月报价例会」"
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="jc-input"
|
||||
className="jc-inp"
|
||||
placeholder="会议日期 YYYY-MM-DD"
|
||||
value={draft.occurredOn}
|
||||
onChange={(e) => setDraft({ ...draft, occurredOn: e.target.value })}
|
||||
@@ -174,7 +178,13 @@ export function MinuteDesk() {
|
||||
</DocPanel>
|
||||
) : sel === undefined ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="notebook-pen">选一场纪要,或新建一场</ListEmpty>
|
||||
<EmptyState
|
||||
ico="notebook-pen"
|
||||
tone="brand"
|
||||
title="还没有会议纪要"
|
||||
desc="记下一场会,把会上定的事写成决议 —— 纪要是团队沉淀,决议登记起单才产生责任。"
|
||||
hint="左上角「新建纪要」开始记"
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={sel.id}>
|
||||
@@ -218,9 +228,9 @@ export function MinuteDesk() {
|
||||
已起单 · {r.workflowId?.slice(0, 8)}
|
||||
</span>
|
||||
) : registerFor === r.id ? (
|
||||
<span className="jc-res-reg">
|
||||
<span className="jc-custom-row">
|
||||
<input
|
||||
className="jc-input"
|
||||
className="jc-inp"
|
||||
value={packageId}
|
||||
onChange={(e) => setPackageId(e.target.value)}
|
||||
placeholder="流程包 id"
|
||||
@@ -247,7 +257,7 @@ export function MinuteDesk() {
|
||||
))}
|
||||
{sel.resolutions.length === 0 && <span className="jc-dim">这场纪要还没有决议</span>}
|
||||
</div>
|
||||
{error !== undefined && <div className="jc-err">{error}</div>}
|
||||
{error !== undefined && <div className="jc-callout t-bad">{error}</div>}
|
||||
<NoteBand>
|
||||
登记触发 = 人工起单的统一形态:选流程包 → 按调用接口 schema 填表(表单自动渲染)→
|
||||
提交即起单;这条工作流的来源就记为登记人。
|
||||
@@ -266,7 +276,7 @@ export function MinuteDesk() {
|
||||
function AppendResolution({ onAppend, busy }: { onAppend: (text: string) => void; busy: boolean }) {
|
||||
const [text, setText] = useState('');
|
||||
return (
|
||||
<div className="jc-res-append">
|
||||
<div className="jc-cfill">
|
||||
<textarea
|
||||
className="jc-sup"
|
||||
rows={2}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { REPORT_PERIOD_LABEL, draftReportRoute, submitReportRoute } from '@contr
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Chip, DocPanel, Hero, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
import { Chip, DocPanel, Hero, EmptyState, ListEmpty, NoteBand, Section, SidebarHead } from '@web-shared/desk/module-kit.tsx';
|
||||
|
||||
const PERIODS: ReadonlyArray<{ k: ReportPeriodName; ico: string }> = [
|
||||
{ k: 'day', ico: 'calendar-days' },
|
||||
@@ -58,7 +58,13 @@ export function ReportDesk() {
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{report !== undefined && (
|
||||
{/* 没放行过一件的草案不上榜 —— 榜上一条 = 一份真有内容的汇报 */}
|
||||
{(report === undefined || report.counts.released === 0) && (
|
||||
<ListEmpty icon="bar-chart" title="本期还没有汇报">
|
||||
办成的事会按{REPORT_PERIOD_LABEL[period]}自动聚成一份,你确认即提交。
|
||||
</ListEmpty>
|
||||
)}
|
||||
{report !== undefined && report.counts.released > 0 && (
|
||||
<button className="jc-mcard selected">
|
||||
<span className="jc-mcard-datebox">
|
||||
<b>{report.window.from.slice(8, 10)}</b>
|
||||
@@ -83,9 +89,18 @@ export function ReportDesk() {
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{report === undefined ? (
|
||||
{/*
|
||||
汇报是「我做成了什么」,不是「我在忙什么」—— 一件都还没放行,就没有可汇报的。
|
||||
这不是占位:它是这个台面**真正的空态**,办成第一件事的当天就自动有了内容。
|
||||
*/}
|
||||
{report === undefined || report.counts.released === 0 ? (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<ListEmpty icon="bar-chart">正在聚合…</ListEmpty>
|
||||
<EmptyState
|
||||
ico="bar-chart"
|
||||
tone="brand"
|
||||
title="还没有可汇报的内容"
|
||||
desc="日报、周报、月报由系统按任务日志自动聚合,不用你手写 —— 办成第一件事,这里当天就有了。"
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<DocPanel scrollKey={`${period}:${report.id}`}>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* feature: desk-task —— 唯一出口(编码规范 §9.1)。
|
||||
* 任务要素四区:任务列表 / 标题区 / 工作区(工单两模式)/ 对话区(卡族 + composer 锁)。
|
||||
*
|
||||
* 工作台正处在「一步一步描述、一步一步接」的阶段:
|
||||
* 已挂载的是三区的**框架**(任务清单 / 工作区 / 对话区),各区正文按描述逐个接回。
|
||||
* 下面两组是**备着待接**的渲染件,自原型平移、已按皮肤对齐,接回时直接用或按新口径改。
|
||||
*/
|
||||
export type { TaskCard, TaskDetail } from './contract.ts';
|
||||
export { TaskList } from './internal/TaskList.tsx';
|
||||
export { ViewRegion } from './internal/ViewRegion.tsx';
|
||||
export { TaskChat } from './internal/TaskChat.tsx';
|
||||
|
||||
/* ── 备着待接 ─────────────────────────────────────────────────────── */
|
||||
export { WorkOrderReport, GuideCard } from './internal/work-order-report.tsx';
|
||||
export { ChatStream } from './internal/chat-bubbles.tsx';
|
||||
export { FileViewer } from './internal/FileViewer.tsx';
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 成果文件的二级查看界面 —— 自原型 `_components/FileViewer.tsx` 平移。
|
||||
*
|
||||
* 按文件类型选渲染器,内容驱动、不解析二进制:
|
||||
* html → **容器沙箱**(`@ui` 的 ViewContainer,无脚本执行权,AM 07)
|
||||
* excel → 电子表格网格 · word → A4 文档 · pdf → 内嵌 · markdown → 原文纸面
|
||||
*/
|
||||
import type { FileDoc } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ViewContainer } from '@ui';
|
||||
|
||||
const KIND: Readonly<Record<FileDoc['kind'], { label: string; icon: string }>> = {
|
||||
html: { label: 'HTML', icon: 'file-text' },
|
||||
markdown: { label: 'Markdown', icon: 'file-text' },
|
||||
excel: { label: 'Excel', icon: 'file-spreadsheet' },
|
||||
word: { label: 'Word', icon: 'file-type' },
|
||||
pdf: { label: 'PDF', icon: 'file-text' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 二级界面**长在工作区内部**:顶部四个页签始终不变,
|
||||
* 「返回」由页签条右槽给出(见 ViewRegion)—— 所以这里的标题栏不带返回钮,只报文件本身。
|
||||
*/
|
||||
export function FileViewer({ doc }: { doc: FileDoc }) {
|
||||
const k = KIND[doc.kind];
|
||||
return (
|
||||
<div className={'fileview k-' + doc.kind}>
|
||||
<div className="fileview-bar">
|
||||
<div className="fileview-name">
|
||||
<Ico name={k.icon} s={15} />
|
||||
<strong>{doc.name}</strong>
|
||||
</div>
|
||||
<span className={'fileview-kind k-' + doc.kind}>{k.label}</span>
|
||||
</div>
|
||||
<div className="fileview-body">
|
||||
{doc.kind === 'html' && <ViewContainer html={doc.html} title={doc.name} />}
|
||||
{doc.kind === 'markdown' && (
|
||||
// 富文本渲染(GFM 表格 / 内联 SVG)尚未接入 —— 先以原文纸面呈现,不假装渲染过(见 debt)
|
||||
<article className="doc-paper doc-full">
|
||||
<pre className="md">{doc.md}</pre>
|
||||
</article>
|
||||
)}
|
||||
{doc.kind === 'excel' && <ExcelView doc={doc} />}
|
||||
{doc.kind === 'word' && <WordView doc={doc} />}
|
||||
{doc.kind === 'pdf' && <iframe className="pdf-frame" src={doc.src} title={doc.name} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExcelView({ doc }: { doc: Extract<FileDoc, { kind: 'excel' }> }) {
|
||||
const cols = Math.max(...doc.rows.map((r) => r.length), 1);
|
||||
const letters = Array.from({ length: cols }, (_, i) => String.fromCharCode(65 + i));
|
||||
return (
|
||||
<div className="xls">
|
||||
<table className="xls-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="xls-corner" />
|
||||
{letters.map((c) => (
|
||||
<th key={c} className="xls-colh">
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
<td className="xls-rowh">{ri + 1}</td>
|
||||
{letters.map((_, ci) => (
|
||||
<td key={ci} className={'xls-cell' + (ri === 0 ? ' xls-head' : '')}>
|
||||
{row[ci] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="xls-tabs">
|
||||
<span className="xls-tab active">{doc.sheet}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WordView({ doc }: { doc: Extract<FileDoc, { kind: 'word' }> }) {
|
||||
return (
|
||||
<article className="doc-paper word-paper">
|
||||
{doc.blocks.map((b, i) => {
|
||||
if (b.t === 'ul') {
|
||||
return (
|
||||
<ul key={i}>
|
||||
{b.items.map((it, j) => (
|
||||
<li key={j}>{it}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (b.t === 'h1') return <h1 key={i}>{b.text}</h1>;
|
||||
if (b.t === 'h2') return <h2 key={i}>{b.text}</h2>;
|
||||
return <p key={i}>{b.text}</p>;
|
||||
})}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +1,45 @@
|
||||
/**
|
||||
* 对话区 —— 自原型 `_components/TaskChat.tsx` 平移。
|
||||
* 对话区 —— 「对话流是**一切回环输入的唯一入口**」(OS 4.1)。
|
||||
*
|
||||
* 「对话区**只承载非结构化消息 + 决策选择题**;事件由系统 Agent(灰头像)发进来。」
|
||||
* 对话流是**一切回环输入的唯一入口**(OS 4.1);输入侧不参与控制权转交(AM 07)。
|
||||
* 壳(`jc-tch` 题头 / 滚动区 / composer)照原型留着;气泡与卡族备在 `chat-bubbles.tsx`。
|
||||
* 空态文案从用人的角度写 —— 空对话是这一单刚开始时的**常态**,不是缺陷。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { ChatMessage } from '@contracts';
|
||||
import { AI_TAG_LABEL, EVENT_TYPE_LABEL } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
|
||||
export type TaskChatProps = {
|
||||
readonly chat: readonly ChatMessage[];
|
||||
readonly locked: boolean;
|
||||
onSay(text: string): void;
|
||||
onDecide(cardId: string, value: string, note?: string): void;
|
||||
};
|
||||
|
||||
export function TaskChat({ chat, locked, onSay, onDecide }: TaskChatProps) {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const submit = () => {
|
||||
const text = draft.trim();
|
||||
if (text === '' || locked) return;
|
||||
onSay(text);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
export function TaskChat() {
|
||||
return (
|
||||
<section className="session-chat">
|
||||
<header className="jc-chat-head">
|
||||
<span className="jc-chat-ico">
|
||||
<Ico name="messages-square" s={16} />
|
||||
<div className="jc-tch">
|
||||
<header className="jc-tch-head">
|
||||
<span className="jc-tch-mark">
|
||||
<Ico name="bot" s={15} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>任务助理</h2>
|
||||
<span>对话流 · 唯一回环输入口</span>
|
||||
</div>
|
||||
<b className="jc-tch-name">任务助理</b>
|
||||
<em className="jc-tch-tag">Agent</em>
|
||||
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span>
|
||||
</header>
|
||||
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="jc-chat-body">
|
||||
{chat.map((msg) => (
|
||||
<Bubble key={msg.id} msg={msg} onDecide={onDecide} />
|
||||
))}
|
||||
{chat.length === 0 && (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="messages-square" s={18} />
|
||||
还没有对话
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* composer 锁:有卡在等作答时锁住自由输入,逼回卡上作答 */}
|
||||
<footer className={'jc-composer' + (locked ? ' locked' : '')}>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder={locked ? '有卡在等你作答 —— 请先在卡上回答' : '说点什么…(Enter 发送)'}
|
||||
disabled={locked}
|
||||
rows={2}
|
||||
/>
|
||||
<button className="jc-btn primary" onClick={submit} disabled={locked || draft.trim() === ''}>
|
||||
发送
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 气泡二元制:AI / 我;系统事件走灰头像 */
|
||||
function Bubble({ msg, onDecide }: { msg: ChatMessage; onDecide: TaskChatProps['onDecide'] }) {
|
||||
if (msg.k === 'ai') {
|
||||
return (
|
||||
<div className="jc-bubble side-ai">
|
||||
<span className="jc-ava ai">AI</span>
|
||||
<div className="jc-bubble-body">
|
||||
{msg.tag !== undefined && <b className={'jc-tag tag-' + msg.tag}>{AI_TAG_LABEL[msg.tag]}</b>}
|
||||
<p>{msg.text}</p>
|
||||
<div className="jc-tch-scroll">
|
||||
<div className="jc-empty t-brand">
|
||||
<span className="jc-empty-art">
|
||||
<i className="r1" />
|
||||
<i className="r2" />
|
||||
<span className="jc-empty-ico">
|
||||
<Ico name="messages-square" s={26} />
|
||||
</span>
|
||||
</span>
|
||||
<b>还没有消息</b>
|
||||
<p>AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'human') {
|
||||
return (
|
||||
<div className="jc-bubble side-me">
|
||||
<div className="jc-bubble-body">
|
||||
<p>{msg.text}</p>
|
||||
<div className="copilot-composer session-composer">
|
||||
<div className="composer-row">
|
||||
<textarea rows={2} disabled placeholder="有什么要补充或交代的?" />
|
||||
<button className="copilot-send" disabled>
|
||||
<Ico name="send" s={14} /> 发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'system') {
|
||||
return (
|
||||
<div className="jc-bubble side-sys">
|
||||
<span className="jc-ava sys">
|
||||
<Ico name="bot" s={14} />
|
||||
</span>
|
||||
<div className="jc-bubble-body">
|
||||
<b className="jc-tag tag-event">{EVENT_TYPE_LABEL[msg.type]}事件</b>
|
||||
<p>{msg.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'decision') {
|
||||
// 对话区只做选择题 —— 决策卡
|
||||
return (
|
||||
<div className="jc-bubble side-ai">
|
||||
<span className="jc-ava ai">AI</span>
|
||||
<div className="jc-bubble-body jc-card-decide">
|
||||
<p className="jc-card-ask">{msg.ask}</p>
|
||||
<dl className="jc-basis">
|
||||
{msg.basis.map((b, i) => (
|
||||
<div key={i}>
|
||||
<dt>{b.k}</dt>
|
||||
<dd>{b.v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="jc-options">
|
||||
{msg.options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={'jc-opt' + (o.recommended === true ? ' recommended' : '') + (msg.chosen?.value === o.value ? ' chosen' : '')}
|
||||
disabled={msg.chosen !== undefined}
|
||||
onClick={() => onDecide(msg.id, o.value)}
|
||||
>
|
||||
<b>{o.label}</b>
|
||||
{o.detail !== undefined && <span>{o.detail}</span>}
|
||||
{o.consequence !== undefined && <em>{o.consequence}</em>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.k === 'elicitation') {
|
||||
return (
|
||||
<div className="jc-bubble side-ai">
|
||||
<span className="jc-ava ai">AI</span>
|
||||
<div className="jc-bubble-body jc-card-fill">
|
||||
<p className="jc-card-ask">{msg.ask}</p>
|
||||
<ul>
|
||||
{msg.fields.map((f) => (
|
||||
<li key={f.key}>{f.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
{msg.done !== undefined && <p className="jc-card-done">已作答</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="jc-bubble side-ai">
|
||||
<span className="jc-ava ai">AI</span>
|
||||
<div className="jc-bubble-body jc-card-rate">
|
||||
<p className="jc-card-ask">{msg.ask}</p>
|
||||
{msg.done !== undefined && <p className="jc-card-done">已评价 {msg.done.score} 分</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useState } from 'react';
|
||||
import type { TaskCard } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ListEmpty } from '@web-shared/desk/module-kit.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
|
||||
const TASK_COLORS = ['#3370ff', '#ff8800', '#7f3bf5', '#0aa294', '#f14ba9'];
|
||||
@@ -83,12 +84,20 @@ export function TaskList({ tasks, selectedId, collapsed, onPick, onCollapse }: T
|
||||
</div>
|
||||
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{visible.length === 0 && (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="search" s={18} />
|
||||
无匹配任务
|
||||
</div>
|
||||
)}
|
||||
{visible.length === 0 &&
|
||||
(query !== '' ? (
|
||||
<ListEmpty icon="search" title="没找到">
|
||||
换个关键词试试 —— 可搜任务名、活动名、工作流主题。
|
||||
</ListEmpty>
|
||||
) : filter === 'done' ? (
|
||||
<ListEmpty icon="circle-check" title="还没有完成的任务">
|
||||
办结的任务会归到这里,随时回看它的成果与日志。
|
||||
</ListEmpty>
|
||||
) : (
|
||||
<ListEmpty icon="list-checks" title="手上没有任务">
|
||||
分派给你的任务会出现在这里 —— 到了会有角标提醒。
|
||||
</ListEmpty>
|
||||
))}
|
||||
{visible.map((task, i) => (
|
||||
<TaskCardView
|
||||
key={task.id}
|
||||
|
||||
@@ -1,232 +1,384 @@
|
||||
/**
|
||||
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(四页签结构)。
|
||||
* 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(2026-07-26 定稿)。
|
||||
*
|
||||
* 「视图区 = 全部结构化交互:HTML 交互界面(表单读写 + 图形展示)+ 工作事项列表 + 过程成果」;
|
||||
* 容器渲染活动推送的内容,**无脚本执行权**(AM 07);渲染一律经 @ui 的 ViewContainer。
|
||||
* 「视图区**承担全部结构化交互**」:表单读写 + 过程成果的图形展示,全在这里完成;
|
||||
* 决策不在这里 —— 它是对话区的选择题(AM 07)。
|
||||
*
|
||||
* 五页签:待处理 / 沟通记录 / 工作成果 / 任务详情 / 工作日志。
|
||||
* DOM 与类名逐字照搬原型 —— 皮肤是原型的原样,自造类名等于没样式。
|
||||
*
|
||||
* **空态即常态**:文案从用人的角度写「你手上现在没有什么、将会有什么」,
|
||||
* 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { TaskDetail, ViewBlock, WorkOrder } from '@contracts';
|
||||
import { ACTION_EFFECT_LABEL, WORK_ORDER_MODE_LABEL } from '@contracts';
|
||||
import type { TaskDetail } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { ViewBlocks, ViewContainer } from '@ui';
|
||||
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
|
||||
import { ViewBlocks } from '@ui';
|
||||
|
||||
type Tab = 'todo' | 'result' | 'detail' | 'log';
|
||||
|
||||
const TABS: ReadonlyArray<{ key: Tab; label: string; icon: string }> = [
|
||||
{ key: 'todo', label: '待处理', icon: 'list-checks' },
|
||||
{ key: 'result', label: '工作成果', icon: 'file-check' },
|
||||
{ key: 'detail', label: '任务详情', icon: 'bar-chart' },
|
||||
{ key: 'log', label: '工作日志', icon: 'notebook-pen' },
|
||||
];
|
||||
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
|
||||
|
||||
export type ViewRegionProps = {
|
||||
readonly detail: TaskDetail;
|
||||
onReply(reply: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
export function ViewRegion({ detail, onReply }: ViewRegionProps) {
|
||||
const [tab, setTab] = useState<Tab>('todo');
|
||||
const open = detail.workOrders.filter((o) => o.done === undefined);
|
||||
|
||||
export function ViewRegion({ detail }: ViewRegionProps) {
|
||||
const [view, setView] = useState<Tab>('open');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const orders = detail.workOrders;
|
||||
const openList = orders.filter((o) => o.done === undefined);
|
||||
const pos = Math.min(page, Math.max(0, openList.length - 1));
|
||||
const cur = openList[pos];
|
||||
|
||||
const go = (to: Tab) => {
|
||||
setView(to);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal];
|
||||
|
||||
return (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<header className="jc-vr-head">
|
||||
<div className="jc-vr-title">
|
||||
<h1>{detail.card.title}</h1>
|
||||
<span className="jc-vr-sub">{detail.card.workflowSubject}</span>
|
||||
<main className="execution-panel">
|
||||
{/* ① 标题区 —— **不滚**,两行定高:主题 · 工作流 · 状态 · 谁做 */}
|
||||
<header className="jc-topbar" aria-label="标题区">
|
||||
<div className="jc-tb-crumb">
|
||||
<span className="jc-tb-tag">
|
||||
<Ico name="list-checks" s={12} />
|
||||
{detail.card.activity}
|
||||
</span>
|
||||
<span className="jc-tb-wf" title={detail.card.workflowSubject}>
|
||||
<Ico name="git-branch" s={12} />
|
||||
<b>{detail.card.workflowSubject}</b>
|
||||
</span>
|
||||
<span className="jc-tb-sp" />
|
||||
<span className={'jc-cp-run-st' + (terminalLabel === undefined ? '' : ' done')}>
|
||||
{terminalLabel === undefined ? (
|
||||
<>
|
||||
<i className="jc-pulse-dot" />
|
||||
AI 推进中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ico name="circle-check" s={13} />
|
||||
{terminalLabel}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="jc-tb-owner" title={`负责人 · ${detail.owner.name} · ${detail.owner.role}`}>
|
||||
<em>{detail.owner.name.charAt(0)}</em>
|
||||
{detail.owner.name} · {detail.owner.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="jc-vr-budget jc-mono" title="预算与熔断由基座在环外强制">
|
||||
<span>步 {detail.budget.steps}</span>
|
||||
<span>token {detail.budget.tokens}</span>
|
||||
<span>时长 {detail.budget.activeMinutes}</span>
|
||||
<span>调人 {detail.budget.humanCalls}</span>
|
||||
<div className="jc-tb-titlerow">
|
||||
<span className="jc-tb-tico">
|
||||
<Ico name="file-text" s={16} />
|
||||
</span>
|
||||
<h2 className="jc-tb-title" title={detail.card.title}>
|
||||
{detail.card.title}
|
||||
</h2>
|
||||
<span className="jc-tb-node jc-mono">
|
||||
{detail.card.position}/{detail.card.total}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="jc-vr-tabs" role="tablist">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} className={tab === t.key ? 'active' : ''} onClick={() => setTab(t.key)} role="tab">
|
||||
<Ico name={t.icon} s={14} />
|
||||
<span>{t.label}</span>
|
||||
{t.key === 'todo' && open.length > 0 ? <b className="jc-vr-tab-badge">{open.length}</b> : null}
|
||||
{/* ② 工作区 —— 页签与点位条**不滚**,只有正文滚 */}
|
||||
<section className="wf-card jc-wc-fill">
|
||||
<div className="jc-tnav">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'open'}
|
||||
className={'jc-tnav-tab' + (view === 'open' ? ' on' : '')}
|
||||
onClick={() => go('open')}
|
||||
>
|
||||
<Ico name="list-checks" s={14} /> 待处理{openList.length > 0 && <em>{openList.length}</em>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'comms'}
|
||||
className={'jc-tnav-tab' + (view === 'comms' ? ' on' : '')}
|
||||
onClick={() => go('comms')}
|
||||
>
|
||||
<Ico name="mail" s={14} /> 沟通记录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'done'}
|
||||
className={'jc-tnav-tab' + (view === 'done' ? ' on' : '')}
|
||||
onClick={() => go('done')}
|
||||
>
|
||||
<Ico name="circle-check" s={14} /> 工作成果
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'wf'}
|
||||
className={'jc-tnav-tab' + (view === 'wf' ? ' on' : '')}
|
||||
onClick={() => go('wf')}
|
||||
>
|
||||
<Ico name="git-branch" s={14} /> 任务详情
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'log'}
|
||||
className={'jc-tnav-tab' + (view === 'log' ? ' on' : '')}
|
||||
onClick={() => go('log')}
|
||||
>
|
||||
<Ico name="archive" s={14} /> 工作日志
|
||||
</button>
|
||||
<span className="jc-tnav-sp" />
|
||||
{/*
|
||||
翻页**恒显示**:只有一件也出 1/1,两侧箭头灰显。
|
||||
按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。
|
||||
*/}
|
||||
{view === 'open' && (
|
||||
<span className="jc-tnav-pg" aria-label="翻页">
|
||||
<button
|
||||
type="button"
|
||||
className="jc-tnav-btn"
|
||||
disabled={pos === 0}
|
||||
onClick={() => setPage(pos - 1)}
|
||||
aria-label="上一件"
|
||||
>
|
||||
<Ico name="chevron-left" s={15} />
|
||||
</button>
|
||||
<b className="jc-tnav-n jc-mono">
|
||||
{openList.length === 0 ? 0 : pos + 1}/{openList.length}
|
||||
</b>
|
||||
<button
|
||||
type="button"
|
||||
className="jc-tnav-btn"
|
||||
disabled={pos >= openList.length - 1}
|
||||
onClick={() => setPage(pos + 1)}
|
||||
aria-label="下一件"
|
||||
>
|
||||
<Ico name="chevron-right" s={15} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="jc-vr-body">
|
||||
{tab === 'todo' &&
|
||||
(open.length === 0 ? (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
当前没有等你的工作事项
|
||||
</div>
|
||||
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
|
||||
<ScrollArea
|
||||
key={`${view}:${cur?.id ?? '-'}`}
|
||||
className="jc-sa--fill"
|
||||
vpClassName="wf-body"
|
||||
inset={-6}
|
||||
>
|
||||
{view === 'comms' ? (
|
||||
/* 占位:与外界的往来(邮件线程等)还没接进来 —— 明说没接,不装成「暂无数据」 */
|
||||
<EmptyState
|
||||
ico="mail"
|
||||
tone="brand"
|
||||
title="还没有往来记录"
|
||||
desc="与客户、供应商的邮件和消息都会归到这里,原件一并存档 —— 谈过什么、什么时候谈的,回头都查得到。"
|
||||
/>
|
||||
) : view === 'wf' ? (
|
||||
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
|
||||
) : view === 'log' ? (
|
||||
<EmptyState
|
||||
ico="archive"
|
||||
tone="brand"
|
||||
title="还没有进展"
|
||||
desc="AI 每推进一步都记在这里,并标明是谁的产出:AI 完成、我填的、我确认的、等外部。"
|
||||
/>
|
||||
) : view === 'done' ? (
|
||||
<EmptyState
|
||||
ico="archive"
|
||||
tone="brand"
|
||||
title="还没有成果"
|
||||
desc="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。"
|
||||
/>
|
||||
) : (
|
||||
open.map((order) => <WorkOrderCard key={order.id} order={order} onReply={onReply} />)
|
||||
))}
|
||||
|
||||
{/*
|
||||
活动推的 HTML 优先:那是它按当下局面组织的**完整一屏**;
|
||||
没推 HTML 时退回结构化块。两者都经 ViewContainer 渲染 —— feature 不碰 innerHTML。
|
||||
*/}
|
||||
{tab === 'result' &&
|
||||
(detail.viewHtml === undefined ? (
|
||||
<ViewBlocks blocks={detail.view as ViewBlock[]} />
|
||||
) : (
|
||||
<ViewContainer html={detail.viewHtml} title={`${detail.card.activity} · 视图`} />
|
||||
))}
|
||||
|
||||
{tab === 'detail' && (
|
||||
<div className="jc-vr-detail">
|
||||
<ViewBlocks blocks={detail.view as ViewBlock[]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'log' && (
|
||||
<ol className="jc-log">
|
||||
{detail.log.map((line) => (
|
||||
<li key={line.id} className={'jc-log-item side-' + line.side}>
|
||||
<span className="jc-log-side">{line.sideLabel}</span>
|
||||
<span className="jc-log-title">{line.title}</span>
|
||||
<time className="jc-mono">{line.at.slice(11, 19)}</time>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<EmptyState
|
||||
ico="list-checks"
|
||||
tone="brand"
|
||||
title="手上没有待办"
|
||||
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
|
||||
hint="AI 正在往下推进,轮到你时会第一时间出现"
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** 工单两模式:执行 = 指导书 + 空表单;验收 = 可修订报告 + 判据核对 + 放行三选一 */
|
||||
function WorkOrderCard({ order, onReply }: { order: WorkOrder; onReply(reply: Record<string, unknown>): void }) {
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
(order.mode === 'execution' ? order.fields : order.fields).map((f) => [f.key, f.ai ?? '']),
|
||||
),
|
||||
);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const set = (key: string, value: string) => setValues((v) => ({ ...v, [key]: value }));
|
||||
/* ═════════════════ 任务详情:KPI 带 + 流程链 + 上下文档案 ═════════════════ */
|
||||
|
||||
function WSec({ ico, label, aside }: { ico: string; label: string; aside?: React.ReactNode }) {
|
||||
return (
|
||||
<article className="jc-wo jc-panel-card">
|
||||
<header className="jc-wo-head">
|
||||
<span className={'jc-wo-mode mode-' + order.mode}>{WORK_ORDER_MODE_LABEL[order.mode]}</span>
|
||||
<h3>{order.title}</h3>
|
||||
</header>
|
||||
|
||||
{/* 调用必附上下文 —— 依据快照 */}
|
||||
<dl className="jc-wo-basis">
|
||||
{order.basis.map((b, i) => (
|
||||
<div key={i}>
|
||||
<dt>{b.k}</dt>
|
||||
<dd>{b.v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{order.mode === 'execution' ? (
|
||||
<>
|
||||
<p className="jc-wo-ask">{order.ask}</p>
|
||||
{order.standard !== undefined && <p className="jc-wo-standard">完成标准:{order.standard}</p>}
|
||||
{order.steps !== undefined && (
|
||||
<ol className="jc-wo-steps">
|
||||
{order.steps.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<div className="jc-wo-form">
|
||||
{order.fields.map((f) => (
|
||||
<label key={f.key} className={'jc-field' + (f.cols === 2 ? ' half' : '')}>
|
||||
<span>
|
||||
{f.label}
|
||||
{f.required === true ? <i className="jc-req">*</i> : null}
|
||||
</span>
|
||||
{f.kind === 'textarea' ? (
|
||||
<textarea value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} rows={3} />
|
||||
) : f.kind === 'radio' || f.kind === 'select' ? (
|
||||
<select value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{(f.options ?? []).map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input value={values[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} placeholder={f.placeholder} />
|
||||
)}
|
||||
{f.hint !== undefined && <em className="jc-hint">{f.hint}</em>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<footer className="jc-wo-foot">
|
||||
<button
|
||||
className="jc-btn primary"
|
||||
onClick={() => onReply({ mode: 'execution', workOrderId: order.id, values })}
|
||||
>
|
||||
{order.submitLabel ?? '提交'}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 判据逐条核对 —— 成果清单的成色标记 */}
|
||||
<ul className="jc-criteria">
|
||||
{order.criteria.map((c, i) => (
|
||||
<li key={i} className={c.pass ? 'pass' : 'fail'}>
|
||||
<b className="jc-lv">{c.level}</b>
|
||||
<span>{c.label}</span>
|
||||
<i>{c.pass ? '✓' : '○'}</i>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{order.actions !== undefined && order.actions.length > 0 && (
|
||||
<ul className="jc-actions" title="预检全过才发卡:这里是已验证的动作清单">
|
||||
{order.actions.map((a, i) => (
|
||||
<li key={i}>
|
||||
<b className={'jc-eff eff-' + a.effect}>{ACTION_EFFECT_LABEL[a.effect]}</b>
|
||||
<span>{a.label}</span>
|
||||
<em>{a.detail}</em>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label className="jc-field">
|
||||
<span>意见(驳回必填)</span>
|
||||
<textarea value={comment} onChange={(e) => setComment(e.target.value)} rows={2} />
|
||||
</label>
|
||||
<footer className="jc-wo-foot">
|
||||
{/* 三选一:放行通过 / 驳回重做 / 驳回迭代 —— 完成只出自放行通过 */}
|
||||
<button
|
||||
className="jc-btn primary"
|
||||
onClick={() =>
|
||||
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'pass', values, selectedPorts: {} })
|
||||
}
|
||||
>
|
||||
{order.passLabel ?? '放行通过'}
|
||||
</button>
|
||||
<button
|
||||
className="jc-btn"
|
||||
onClick={() =>
|
||||
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'revise', values, comment, selectedPorts: {} })
|
||||
}
|
||||
>
|
||||
驳回迭代
|
||||
</button>
|
||||
<button
|
||||
className="jc-btn danger"
|
||||
onClick={() =>
|
||||
onReply({ mode: 'acceptance', workOrderId: order.id, verdict: 'redo', values, comment, selectedPorts: {} })
|
||||
}
|
||||
>
|
||||
驳回重做
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
<div className="jc-wfd-sec">
|
||||
<span className="jc-wfd-sec-ico">
|
||||
<Ico name={ico} s={12} />
|
||||
</span>
|
||||
<b>{label}</b>
|
||||
<i className="ln" />
|
||||
{aside}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WfDetail({ detail, openN, doneN }: { detail: TaskDetail; openN: number; doneN: number }) {
|
||||
return (
|
||||
<div className="jc-wfd-body">
|
||||
<KpiBand budget={detail.budget} />
|
||||
|
||||
<WSec
|
||||
ico="git-branch"
|
||||
label="流程链"
|
||||
aside={
|
||||
<span className="jc-wfd-pos">
|
||||
<i className="jc-pulse-dot" />
|
||||
当前 · {detail.card.position}/{detail.card.total} {detail.card.activity}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="jc-wfg2">
|
||||
{detail.chain.map((n, k) => (
|
||||
<div
|
||||
key={n.nodeId}
|
||||
className={`jc-map-n s-${n.state === 'current' ? 'cur' : n.state}${n.kind === 'approval' ? ' apr' : ''}`}
|
||||
title={`${n.nodeId} ${n.label} · ${n.ownerName} · ${n.ownerRole}${n.kind === 'approval' ? '(审批点)' : ''}`}
|
||||
>
|
||||
{n.state === 'current' && (
|
||||
<span className="jc-map-me">
|
||||
<i className="jc-pulse-dot" />
|
||||
你在这
|
||||
</span>
|
||||
)}
|
||||
<div className="jc-map-nh">
|
||||
<em className="jc-mono">{k + 1}</em>
|
||||
{n.state === 'done' ? (
|
||||
<span className="jc-map-ok">
|
||||
<Ico name="check" s={11} />
|
||||
</span>
|
||||
) : n.kind === 'approval' ? (
|
||||
<span className="jc-map-ap">
|
||||
<Ico name="shield-check" s={11} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<b>{n.label}</b>
|
||||
<span className="jc-map-who">
|
||||
<i>{n.ownerName.charAt(0)}</i>
|
||||
{n.ownerName} · {n.ownerRole}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{detail.chain.length === 0 && <p className="jc-ck-none">这单不在流程上(直接起单)</p>}
|
||||
</div>
|
||||
|
||||
<div className="jc-duo2">
|
||||
<div className="jc-panel">
|
||||
<div className="jc-panel-h">
|
||||
<span className="jc-panel-ico">
|
||||
<Ico name="bar-chart" s={12} />
|
||||
</span>
|
||||
<b>过程视图</b>
|
||||
<em>活动推送</em>
|
||||
</div>
|
||||
<ViewBlocks blocks={detail.view} />
|
||||
</div>
|
||||
<div className="jc-panel">
|
||||
<div className="jc-panel-h">
|
||||
<span className="jc-panel-ico">
|
||||
<Ico name="notebook-pen" s={12} />
|
||||
</span>
|
||||
<b>上下文</b>
|
||||
<em className="jc-mono">{detail.card.id}</em>
|
||||
</div>
|
||||
<div className="jc-file-rows">
|
||||
<div className="jc-file-row">
|
||||
<i>
|
||||
<Ico name="target" s={13} />
|
||||
</i>
|
||||
<em>本单目标</em>
|
||||
<p>{detail.goal}</p>
|
||||
</div>
|
||||
<div className="jc-file-row">
|
||||
<i>
|
||||
<Ico name="git-branch" s={13} />
|
||||
</i>
|
||||
<em>所属工作流</em>
|
||||
<p>
|
||||
{detail.card.workflowSubject}
|
||||
<span>
|
||||
序位 {detail.card.position}/{detail.card.total}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="jc-file-row">
|
||||
<i>
|
||||
<Ico name="activity" s={13} />
|
||||
</i>
|
||||
<em>运行状态</em>
|
||||
<p>
|
||||
<b>已派 {openN + doneN} 件</b> · 办完 {doneN} · 待办 {openN}
|
||||
<span>AI 边推进边派工,件数还会增加</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="jc-file-row">
|
||||
<i>
|
||||
<Ico name="hash" s={13} />
|
||||
</i>
|
||||
<em>编号</em>
|
||||
<p className="jc-mono">
|
||||
{detail.card.id}
|
||||
<span className="jc-mono">起于 {detail.card.startedAt.slice(0, 16).replace('T', ' ')}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** KPI 指标带:四层卡解剖(维度徽 → 指标名 → 大数 → 限额条);限额是基座在环外强制的 */
|
||||
function KpiBand({ budget }: { budget: TaskDetail['budget'] }) {
|
||||
const meter = (p: string) => {
|
||||
const [a = '0', z = '0'] = p.split('/').map((x) => x.trim());
|
||||
const r = Math.max(0, Math.min(1, Number(a) / (Number(z) || 1)));
|
||||
return { a, z, r, tone: r >= 0.85 ? 'hot' : r >= 0.6 ? 'warm' : 'cool' };
|
||||
};
|
||||
const KPI = [
|
||||
{ dim: '步数', icon: 'activity', ...meter(budget.steps) },
|
||||
{ dim: 'Tokens', icon: 'cpu', ...meter(budget.tokens) },
|
||||
{ dim: '持续时长', icon: 'clock', ...meter(budget.activeMinutes) },
|
||||
{ dim: '调人次数', icon: 'users', ...meter(budget.humanCalls) },
|
||||
];
|
||||
return (
|
||||
<div className="jc-kpis">
|
||||
{KPI.map((k) => (
|
||||
<div key={k.dim} className="jc-kpi">
|
||||
<span className="jc-kpi-dim d-act">
|
||||
<Ico name={k.icon} s={10} />
|
||||
当前活动
|
||||
</span>
|
||||
<em>{k.dim}</em>
|
||||
<b className="jc-mono">
|
||||
{k.a}
|
||||
<i>/ {k.z}</i>
|
||||
</b>
|
||||
<span className={'jc-ck-bar t-' + k.tone}>
|
||||
<i style={{ width: `${Math.round(k.r * 100)}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═════════════════ 工单报告页 ═════════════════ */
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* 对话流的气泡与卡族 —— 自原型 `_components/TaskChat.tsx` 平移(气泡二元制,2026-07-26 定稿)。
|
||||
*
|
||||
* **当前未挂载**:对话区正处在「一步一步描述、一步一步接」的阶段,暂为空占位。
|
||||
* 这一份留着等接回 —— 描述到对话该长什么样时直接用,或按新口径改。
|
||||
*
|
||||
* 「对话流是**一切回环输入的唯一入口**」(OS 4.1);输入侧不参与控制权转交(AM 07)。
|
||||
* AI 的内容只有两种形态、不允许中间态:
|
||||
* · 纯文本气泡 —— 结论 / 说明 / 回执
|
||||
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 评价单
|
||||
* 一条消息 = 署名行(AI · 时间 · 类型章)+ 一或两个**纯**块。
|
||||
* ⛔ 补全 / 决策卡吐出期间 composer 锁定 —— 先办卡,再说话。
|
||||
*
|
||||
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
|
||||
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
|
||||
|
||||
/** 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径) */
|
||||
const TAG_UI: Readonly<Record<AiTag, { label: string; cls: string }>> = {
|
||||
conclusion: { label: '阶段结论', cls: 'auto' },
|
||||
guidance: { label: '派了工单', cls: 'exe' },
|
||||
criterion: { label: '请你验收', cls: 'acc' },
|
||||
};
|
||||
|
||||
/** ISO → HH:MM(署名行只要时分) */
|
||||
const hm = (iso: string) => iso.slice(11, 16);
|
||||
|
||||
export type TaskChatProps = {
|
||||
readonly chat: readonly ChatMessage[];
|
||||
readonly orders: readonly WorkOrder[];
|
||||
/** AI 正在回话 —— 请求在途即亮「输入中」 */
|
||||
readonly thinking: boolean;
|
||||
onSay(text: string): void;
|
||||
onReplyCard(reply: ChatCardReply): void;
|
||||
onReplyOrder(reply: WorkOrderReply): void;
|
||||
/** 点卡上的「打开工单」→ 视图区定位到那张工单 */
|
||||
onLocate(orderId: string): void;
|
||||
};
|
||||
|
||||
export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReplyOrder, onLocate }: TaskChatProps) {
|
||||
const [text, setText] = useState('');
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
boxRef.current?.scrollTo({ top: 1e9, behavior: 'smooth' });
|
||||
}, [chat.length, thinking]);
|
||||
|
||||
// ⛔ 补全 / 决策卡未办结期间不能对话 —— 锁的是**卡**,不是工单
|
||||
const blocked = chat.some(
|
||||
(m) => (m.k === 'elicitation' && m.done === undefined) || (m.k === 'decision' && m.chosen === undefined),
|
||||
);
|
||||
|
||||
const send = () => {
|
||||
const t = text.trim();
|
||||
if (t === '' || blocked) return;
|
||||
setText('');
|
||||
onSay(t);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="jc-tch">
|
||||
<header className="jc-tch-head">
|
||||
<span className="jc-tch-mark">
|
||||
<Ico name="bot" s={15} />
|
||||
</span>
|
||||
<b className="jc-tch-name">任务助理</b>
|
||||
<em className="jc-tch-tag">Agent</em>
|
||||
<span className="jc-tch-desc">盯着这单推进 · 有事随时说</span>
|
||||
</header>
|
||||
|
||||
<div className="jc-tch-scroll" ref={boxRef}>
|
||||
{chat.map((m) => (
|
||||
<Msg
|
||||
key={m.id}
|
||||
m={m}
|
||||
orders={orders}
|
||||
onReplyCard={onReplyCard}
|
||||
onReplyOrder={onReplyOrder}
|
||||
onLocate={onLocate}
|
||||
/>
|
||||
))}
|
||||
{thinking && (
|
||||
<article className="jc-ag">
|
||||
<ChatAvatar who="ai" />
|
||||
<div className="jc-ag-col">
|
||||
<div className="jc-msg-meta">AI</div>
|
||||
<span className="jc-typing">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="copilot-composer session-composer">
|
||||
{blocked && (
|
||||
<div className="jc-comp-lock">
|
||||
<Ico name="lock" s={12} /> 有一张卡在等你(补全 / 决策)——处理完就能继续对话
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-row">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={text}
|
||||
disabled={blocked}
|
||||
placeholder={blocked ? '先处理上面的卡片…' : '有什么要补充或交代的?(Enter 发送)'}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="copilot-send" disabled={text.trim() === '' || blocked} onClick={send}>
|
||||
<Ico name="send" s={14} /> 发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Agent 侧包装:头像 + (署名行 → 内容流) ── */
|
||||
function Ag({ meta, tag, children }: { meta: string; tag?: AiTag | undefined; children: React.ReactNode }) {
|
||||
const t = tag === undefined ? undefined : TAG_UI[tag];
|
||||
return (
|
||||
<article className="jc-ag">
|
||||
<ChatAvatar who="ai" />
|
||||
<div className="jc-ag-col">
|
||||
<div className="jc-msg-meta">
|
||||
{meta}
|
||||
{t !== undefined && <b className={'jc-mtag m-' + t.cls}>{t.label}</b>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Msg({
|
||||
m,
|
||||
orders,
|
||||
onReplyCard,
|
||||
onReplyOrder,
|
||||
onLocate,
|
||||
}: {
|
||||
m: ChatMessage;
|
||||
orders: readonly WorkOrder[];
|
||||
onReplyCard: (reply: ChatCardReply) => void;
|
||||
onReplyOrder: (reply: WorkOrderReply) => void;
|
||||
onLocate: (orderId: string) => void;
|
||||
}) {
|
||||
// 人的消息:右侧品牌气泡(纯文本)
|
||||
if (m.k === 'human') {
|
||||
return (
|
||||
<article className="copilot-msg human">
|
||||
<div className="jc-msg-col r">
|
||||
<div className="jc-msg-meta r">{hm(m.at)}</div>
|
||||
<div className="copilot-bubble">
|
||||
<p className="jc-content">{m.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChatAvatar who="user" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// 系统事件:灰头像 + 灰调文本气泡。三类唤醒事件在章上标明是哪一类
|
||||
if (m.k === 'system') {
|
||||
return (
|
||||
<article className="jc-ag">
|
||||
<div className="chat-avatar jc-ava-sys" aria-label="系统">
|
||||
<Ico name="server" s={15} />
|
||||
</div>
|
||||
<div className="jc-ag-col">
|
||||
<div className="jc-msg-meta">
|
||||
系统 · {hm(m.at)}
|
||||
<b className="jc-mtag m-sys">{EVENT_TYPE_LABEL[m.type]}事件</b>
|
||||
</div>
|
||||
<div className="jc-ag-b sys">
|
||||
<p className="jc-content">{m.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// AI 发言:一次回复 = 一个气泡 —— 文字在上、表单卡嵌在**同一气泡**里
|
||||
if (m.k === 'ai') {
|
||||
const order = m.ref === undefined ? undefined : orders.find((o) => o.id === m.ref);
|
||||
const settled = order?.done !== undefined;
|
||||
return (
|
||||
<Ag meta={`AI · ${hm(m.at)}`} tag={m.tag}>
|
||||
<div className={'jc-ag-b' + (order === undefined ? '' : ' card')}>
|
||||
<p className="jc-content">{m.text}</p>
|
||||
{order !== undefined &&
|
||||
(order.mode === 'acceptance' && m.tag === 'criterion' && !settled ? (
|
||||
<AcceptForm order={order} onReplyOrder={onReplyOrder} onLocate={onLocate} />
|
||||
) : (
|
||||
<WoCard order={order} onLocate={onLocate} />
|
||||
))}
|
||||
</div>
|
||||
</Ag>
|
||||
);
|
||||
}
|
||||
|
||||
// 补全:问题文字在上,表单卡嵌在同一气泡里
|
||||
if (m.k === 'elicitation') {
|
||||
return (
|
||||
<Ag meta={`AI · ${hm(m.at)}`}>
|
||||
<div className="jc-ag-b card">
|
||||
<p className="jc-content">{m.ask}</p>
|
||||
<div className="jc-fc t-fill">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="square-pen" s={13} />
|
||||
<b>请你补全</b>
|
||||
<em>{m.fields.length} 项</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
|
||||
{m.done === undefined ? (
|
||||
<FillForm
|
||||
fields={m.fields}
|
||||
onDone={(values) => onReplyCard({ k: 'elicitation', cardId: m.id, values })}
|
||||
onCant={(reason) => onReplyCard({ k: 'elicitation', cardId: m.id, values: {}, reason })}
|
||||
/>
|
||||
) : m.done.reason === undefined ? (
|
||||
<p className="jc-chosen">
|
||||
<Ico name="circle-check" s={14} /> 你补了:
|
||||
<b>{m.fields.map((f) => m.done?.values[f.key] ?? '—').join(';')}</b>
|
||||
<code className="jc-mono">{hm(m.done.at)}</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="jc-chosen cant">
|
||||
<Ico name="alert-circle" s={14} /> 你说明了没法填:
|
||||
<b>{m.done.reason}</b>
|
||||
<code className="jc-mono">{hm(m.done.at)}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Ag>
|
||||
);
|
||||
}
|
||||
|
||||
// 任务评价:星级打分 + 改进建议,并入活动记忆
|
||||
if (m.k === 'rating') {
|
||||
return (
|
||||
<Ag meta={`AI · ${hm(m.at)}`}>
|
||||
<div className="jc-ag-b card">
|
||||
<p className="jc-content">{m.ask}</p>
|
||||
<div className="jc-fc t-rate">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="star" s={13} />
|
||||
<b>任务评价</b>
|
||||
<em>5 分制</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
|
||||
{m.done === undefined ? (
|
||||
<RateForm
|
||||
onDone={(score, note) =>
|
||||
onReplyCard({ k: 'rating', cardId: m.id, score, ...(note === '' ? {} : { note }) })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p className="jc-chosen">
|
||||
<span className="jc-stars ro">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<i key={n} className={'jc-star' + (n <= (m.done?.score ?? 0) ? ' on' : '')}>
|
||||
<Ico name="star" s={16} />
|
||||
</i>
|
||||
))}
|
||||
</span>
|
||||
<b>{m.done.score} 分</b>
|
||||
{m.done.note !== undefined && <span className="jc-rate-note">{m.done.note}</span>}
|
||||
<code className="jc-mono">{hm(m.done.at)}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Ag>
|
||||
);
|
||||
}
|
||||
|
||||
// 决策:问题文字在上,选择卡嵌在同一气泡里
|
||||
const chosenLabel =
|
||||
m.chosen === undefined
|
||||
? ''
|
||||
: (m.options.find((o) => o.value === m.chosen?.value)?.label ??
|
||||
(m.chosen.value.startsWith('custom:') ? m.chosen.value.slice(7) : m.chosen.value));
|
||||
return (
|
||||
<Ag meta={`AI · ${hm(m.at)}`}>
|
||||
<div className="jc-ag-b card">
|
||||
<p className="jc-content">{m.ask}</p>
|
||||
<div className="jc-fc t-dec">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="git-branch" s={13} />
|
||||
<b>需要你定</b>
|
||||
<em>
|
||||
{m.options.length + (m.allowCustom ? 1 : 0)} 选 1
|
||||
</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
<Kv items={m.basis} />
|
||||
{m.recommend !== undefined && (
|
||||
<p className="jc-fc-note">
|
||||
<Ico name="sparkles" s={13} />
|
||||
<span>
|
||||
<b>AI 建议</b>
|
||||
{m.recommend}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{m.chosen === undefined ? (
|
||||
<div className="jc-branches">
|
||||
{m.options.map((o, i) => (
|
||||
<button
|
||||
key={o.value}
|
||||
className={'jc-branch' + (o.recommended === true ? ' rec' : '')}
|
||||
onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })}
|
||||
>
|
||||
<span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span>
|
||||
<span className="jc-branch-bd">
|
||||
<b>
|
||||
{o.label}
|
||||
{o.recommended === true && <em>推荐</em>}
|
||||
</b>
|
||||
{o.detail !== undefined && <p>{o.detail}</p>}
|
||||
{o.consequence !== undefined && (
|
||||
<p className="jc-conseq">
|
||||
<Ico name="arrow-right" s={12} /> {o.consequence}
|
||||
</p>
|
||||
)}
|
||||
</span>
|
||||
<Ico name="chevron-right" s={14} />
|
||||
</button>
|
||||
))}
|
||||
{m.allowCustom && (
|
||||
<CustomOption
|
||||
n={String.fromCharCode(65 + m.options.length)}
|
||||
onPick={(txt) => onReplyCard({ k: 'decision', cardId: m.id, value: 'custom:' + txt, note: txt })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="jc-chosen">
|
||||
<Ico name="circle-check" s={14} /> 你选了:
|
||||
<b>{chosenLabel}</b>
|
||||
<code className="jc-mono">{hm(m.chosen.at)}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Ag>
|
||||
);
|
||||
}
|
||||
|
||||
/* 参考信息:表单卡内的结构化 kv 表 */
|
||||
function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
|
||||
return (
|
||||
<div className="jc-fc-kv">
|
||||
<i>参考信息</i>
|
||||
<div className="jc-fc-kv-g">
|
||||
{items.map((b, i) => (
|
||||
<span key={i}>
|
||||
<em>{b.k}</em>
|
||||
<b>{b.v}</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 工单卡:题头 + 单体 + 打开动作 —— 派单 / 已裁决的验收都用它 */
|
||||
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
|
||||
const result = order.done?.result;
|
||||
const bad = result !== undefined && isRejection(result);
|
||||
const exe = order.mode === 'execution';
|
||||
return (
|
||||
<div className={'jc-fc ' + (exe ? 't-exe' : 't-acc')}>
|
||||
<div className="jc-fc-h">
|
||||
<Ico name={exe ? 'file-text' : 'shield-check'} s={13} />
|
||||
<b>工单 · {exe ? '执行' : '验收'}</b>
|
||||
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
|
||||
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
|
||||
</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
<p className="jc-fc-q strong">{order.title}</p>
|
||||
</div>
|
||||
<div className="jc-fc-ft">
|
||||
<button className="jc-btn jc-btn-ghost jc-fc-open" onClick={() => onLocate(order.id)}>
|
||||
<Ico name="panel-left-open" s={13} /> 打开工单 <Ico name="arrow-right" s={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 验收单:报告入口 + 判据表 + 落地动作 + 裁决(通过 / 重做)—— 驳回必附意见 */
|
||||
function AcceptForm({
|
||||
order,
|
||||
onReplyOrder,
|
||||
onLocate,
|
||||
}: {
|
||||
order: Extract<WorkOrder, { mode: 'acceptance' }>;
|
||||
onReplyOrder: (reply: WorkOrderReply) => void;
|
||||
onLocate: (id: string) => void;
|
||||
}) {
|
||||
const [why, setWhy] = useState('');
|
||||
const passN = order.criteria.filter((c) => c.pass).length;
|
||||
return (
|
||||
<div className="jc-fc t-acc">
|
||||
<div className="jc-fc-h">
|
||||
<Ico name="shield-check" s={13} />
|
||||
<b>请你验收</b>
|
||||
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
|
||||
{passN}/{order.criteria.length} 达标
|
||||
</em>
|
||||
</div>
|
||||
<div className="jc-fc-b">
|
||||
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
|
||||
<span className="jc-fc-doc-ico">
|
||||
<Ico name="file-text" s={15} />
|
||||
</span>
|
||||
<span className="jc-fc-doc-bd">
|
||||
<em>成果报告 · 视图区可读可编辑</em>
|
||||
<b>{order.title}</b>
|
||||
</span>
|
||||
<Ico name="chevron-right" s={14} />
|
||||
</button>
|
||||
<ul className="jc-rp-crit sm">
|
||||
{order.criteria.map((c, i) => (
|
||||
<li key={i} className={c.pass ? 'ok' : 'no'}>
|
||||
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
|
||||
<span className="t">{c.label}</span>
|
||||
<span className="r">
|
||||
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
|
||||
{c.pass ? '达标' : '未达标'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{order.actions !== undefined && order.actions.length > 0 && (
|
||||
<Kv
|
||||
items={order.actions.map((a) => ({
|
||||
k: a.effect === 'write' ? '通过即执行 · 写' : '通过即执行 · 读',
|
||||
v: a.label,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
className="jc-note"
|
||||
placeholder="要重做?写明原因(也可以在下面对话里聊)"
|
||||
value={why}
|
||||
onChange={(e) => setWhy(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="jc-fc-ft">
|
||||
<button
|
||||
className="jc-btn jc-btn-bad"
|
||||
disabled={why.trim() === ''}
|
||||
onClick={() =>
|
||||
onReplyOrder({
|
||||
mode: 'acceptance',
|
||||
workOrderId: order.id,
|
||||
verdict: 'redo',
|
||||
values: {},
|
||||
comment: why.trim(),
|
||||
selectedPorts: {},
|
||||
})
|
||||
}
|
||||
>
|
||||
重做
|
||||
</button>
|
||||
<span className="jc-fc-sp" />
|
||||
<button
|
||||
className="jc-btn jc-btn-ok"
|
||||
onClick={() =>
|
||||
onReplyOrder({
|
||||
mode: 'acceptance',
|
||||
workOrderId: order.id,
|
||||
verdict: 'pass',
|
||||
values: {},
|
||||
selectedPorts: {},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 决策的收尾项:自定义口径 */
|
||||
function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) {
|
||||
const [txt, setTxt] = useState('');
|
||||
return (
|
||||
<div className="jc-branch jc-branch-custom">
|
||||
<span className="jc-branch-n jc-mono">{n}</span>
|
||||
<span className="jc-branch-bd">
|
||||
<b>都不合适,我来定</b>
|
||||
<span className="jc-custom-row">
|
||||
<input
|
||||
value={txt}
|
||||
placeholder="写下你的口径…"
|
||||
onChange={(e) => setTxt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
|
||||
}}
|
||||
/>
|
||||
<button className="jc-btn" disabled={txt.trim() === ''} onClick={() => onPick(txt.trim())}>
|
||||
就按这个
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 补全卡的小表单:就地作答;「没法填」是并列的第二条路(两条必走其一) */
|
||||
function FillForm({
|
||||
fields,
|
||||
onDone,
|
||||
onCant,
|
||||
}: {
|
||||
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined }>;
|
||||
onDone: (values: Record<string, string>) => void;
|
||||
onCant: (reason: string) => void;
|
||||
}) {
|
||||
const [vals, setVals] = useState<Record<string, string>>({});
|
||||
const [cant, setCant] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const ok = fields.every((f) => (vals[f.key] ?? '').trim() !== '');
|
||||
|
||||
if (cant) {
|
||||
return (
|
||||
<div className="jc-cfill">
|
||||
<label className="jc-cfill-f">
|
||||
<span>没法填的原因(AI 会据此调整需求)</span>
|
||||
<textarea
|
||||
className="jc-note"
|
||||
rows={2}
|
||||
value={reason}
|
||||
autoFocus
|
||||
placeholder="如:没和客户谈过价位 / 这个数据在别人手里…"
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="jc-cfill-acts">
|
||||
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
|
||||
← 还是填吧
|
||||
</button>
|
||||
<button className="jc-btn" disabled={reason.trim() === ''} onClick={() => onCant(reason.trim())}>
|
||||
<Ico name="send" s={13} /> 让 AI 调整需求
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="jc-cfill">
|
||||
{fields.map((f) => (
|
||||
<label key={f.key} className="jc-cfill-f">
|
||||
<span>{f.label}</span>
|
||||
<input
|
||||
value={vals[f.key] ?? ''}
|
||||
placeholder={f.placeholder}
|
||||
onChange={(e) => setVals({ ...vals, [f.key]: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && ok) onDone(vals);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className="jc-cfill-acts">
|
||||
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
|
||||
没法填?告诉 AI 原因
|
||||
</button>
|
||||
<button className="jc-btn" disabled={!ok} onClick={() => onDone(vals)}>
|
||||
<Ico name="check" s={13} /> 补给 AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
|
||||
function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) {
|
||||
const [score, setScore] = useState(0);
|
||||
const [hover, setHover] = useState(0);
|
||||
const [note, setNote] = useState('');
|
||||
const TXT = ['', '很不顺畅,返工严重', '不顺畅,返工明显', '一般,多处需人工介入', '顺畅,个别节点需修正', '非常顺畅,全程无需返工'];
|
||||
const cur = hover !== 0 ? hover : score;
|
||||
return (
|
||||
<div className="jc-cfill">
|
||||
<div className="jc-stars" role="radiogroup" aria-label="整体评分">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={'jc-star' + (n <= cur ? ' on' : '')}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => setScore(n)}
|
||||
aria-label={`${n} 分`}
|
||||
>
|
||||
<Ico name="star" s={20} />
|
||||
</button>
|
||||
))}
|
||||
<em className="jc-stars-txt">{cur !== 0 ? `${cur} 分 · ${TXT[cur]}` : '点星打分'}</em>
|
||||
</div>
|
||||
<label className="jc-cfill-f">
|
||||
<span>改进建议(会进入活动记忆,用于优化下一单)</span>
|
||||
<textarea
|
||||
className="jc-note"
|
||||
rows={2}
|
||||
value={note}
|
||||
placeholder="如:报价台账自动关联客户项目号;英文报价邮件模板固化为标准件"
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="jc-cfill-acts">
|
||||
<button className="jc-btn" disabled={score === 0} onClick={() => onDone(score, note.trim())}>
|
||||
<Ico name="check" s={13} /> 提交评价
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 工单报告页 —— 一张工单的完整呈现:题头 / 元信息 / 依据指标带 / 正文 / 填报表单 / 落款动作。
|
||||
*
|
||||
* 自原型 `_components/ViewRegion.tsx` 的 `Report` 与卡组二平移,DOM 与类名逐字照搬。
|
||||
*
|
||||
* **当前未挂载**:工作区正处在「一步一步描述、一步一步接」的阶段,待处理页签暂为空占位。
|
||||
* 这一份留在这里等着接回来 —— 描述到工单该长什么样时,直接用或按新口径改,不必重写。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { FormField, TaskDetail, ViewBlock, WorkOrder, WorkOrderReply } from '@contracts';
|
||||
import { ACTION_EFFECT_LABEL, WORK_ORDER_RESULT_LABEL, isRejection } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { ViewBlocks, ViewContainer } from '@ui';
|
||||
|
||||
function Part({ label, aside }: { label: string; aside?: string | undefined }) {
|
||||
return (
|
||||
<div className="jc-rp-part">
|
||||
<b>{label}</b>
|
||||
<i className="ln" />
|
||||
{aside !== undefined && <em>{aside}</em>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 控件原始值 → 报告正文文字(AI 拟值 / 人改值都经它落到报告上) */
|
||||
function fieldText(f: FormField, raw: string): string {
|
||||
if (raw === '') return '—';
|
||||
if (f.kind === 'radio' || f.kind === 'select') return f.options?.find((o) => o.value === raw)?.label ?? raw;
|
||||
if (f.kind === 'check')
|
||||
return (
|
||||
raw
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((v) => f.options?.find((o) => o.value === v)?.label ?? v)
|
||||
.join('、') || '—'
|
||||
);
|
||||
if (f.kind === 'money' || f.kind === 'number') return raw + (f.unit === undefined ? '' : ' ' + f.unit);
|
||||
if (f.kind === 'file') return raw.split('|').filter(Boolean).join('、') || '—';
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡组一 · 报告 —— 待办区两种模式:
|
||||
* 验收 = **报告模式**:AI 给出填好的报告;「修改」进编辑态,裁决在对话区。
|
||||
* 执行 = **表单模式**:作业指导书(卡组二)+ 待填写表单;「提交」即生成执行报告。
|
||||
*/
|
||||
export function WorkOrderReport({
|
||||
order,
|
||||
detail,
|
||||
typeLine,
|
||||
blocks,
|
||||
html,
|
||||
srcTitle,
|
||||
values,
|
||||
onField,
|
||||
onReply,
|
||||
}: {
|
||||
order: WorkOrder;
|
||||
detail: TaskDetail;
|
||||
typeLine: string;
|
||||
blocks: readonly ViewBlock[];
|
||||
/**
|
||||
* 活动推的那一屏 —— 它**既是独立文件**(工作成果里一行一份、点开单看),
|
||||
* **也是交互视图的组成部分**:办这张工单时,人要在同一页里看着它填、照着它判。
|
||||
*/
|
||||
html?: string | undefined;
|
||||
srcTitle?: string | undefined;
|
||||
values: Readonly<Record<string, string>>;
|
||||
onField: (key: string, value: string) => void;
|
||||
onReply: (reply: WorkOrderReply) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const isAcc = order.mode === 'acceptance';
|
||||
const disabled = order.done !== undefined;
|
||||
const fields = isAcc ? [] : order.fields;
|
||||
/** 执行 = 空占位表单:值只来自人填(或办结留痕),不吃 AI 拟值 */
|
||||
const val = (f: FormField) => values[f.key] ?? order.done?.values?.[f.key] ?? '';
|
||||
const ok = fields.every((f) => f.required !== true || val(f).trim() !== '');
|
||||
const result = order.done?.result;
|
||||
const rejected = result !== undefined && isRejection(result);
|
||||
const stampTxt = result === undefined ? null : WORK_ORDER_RESULT_LABEL[result];
|
||||
|
||||
const groups: Array<{ name: string | null; list: FormField[] }> = [];
|
||||
fields.forEach((f) => {
|
||||
const g = f.group ?? null;
|
||||
const last = groups[groups.length - 1];
|
||||
if (last !== undefined && last.name === g) last.list.push(f);
|
||||
else groups.push({ name: g, list: [f] });
|
||||
});
|
||||
|
||||
return (
|
||||
<article className={'jc-report' + (disabled ? ' sealed' : '')}>
|
||||
<header className="jc-rp-head">
|
||||
<div className="jc-rp-badge">
|
||||
<Ico name={isAcc ? 'shield-check' : 'file-text'} s={17} />
|
||||
</div>
|
||||
<div className="jc-rp-tit">
|
||||
<em>
|
||||
{detail.card.activity} · {typeLine}
|
||||
</em>
|
||||
<h3>{order.title}</h3>
|
||||
</div>
|
||||
{stampTxt === null ? (
|
||||
<span className="jc-rp-wait">
|
||||
<i className="jc-pulse-dot" />
|
||||
{isAcc ? '待验收' : '待执行'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={'jc-rp-stamp' + (rejected ? ' bad' : '')}>{stampTxt}</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="jc-rp-meta">
|
||||
<span>
|
||||
<i>编号</i>
|
||||
<b className="jc-mono">{order.id.toUpperCase()}</b>
|
||||
</span>
|
||||
<span>
|
||||
<i>所属</i>
|
||||
<b className="jc-mono">{detail.card.id}</b>
|
||||
</span>
|
||||
<span>
|
||||
<i>编制</i>
|
||||
<b>{isAcc ? `AI 拟 · ${detail.owner.name}审定` : `${detail.owner.name}填报 · AI 定稿`}</b>
|
||||
</span>
|
||||
<span>
|
||||
<i>派发</i>
|
||||
<b className="jc-mono">{order.at.slice(5, 16).replace('T', ' ')}</b>
|
||||
</span>
|
||||
{order.done !== undefined && (
|
||||
<span>
|
||||
<i>办结</i>
|
||||
<b className="jc-mono">{order.done.at.slice(5, 16).replace('T', ' ')}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关键指标带 —— 调用必附上下文(OS 05) */}
|
||||
{order.basis.length > 0 && (
|
||||
<div className="jc-rp-stats">
|
||||
{order.basis.map((b, i) => (
|
||||
<div key={i} className="jc-rp-stat">
|
||||
<i>{b.k}</i>
|
||||
<b>{b.v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="jc-rp-bd">
|
||||
{disabled && isAcc && (
|
||||
<p className={'jc-rp-verdict' + (rejected ? ' bad' : '')}>
|
||||
<Ico name={rejected ? 'circle-x' : 'circle-check'} s={15} />
|
||||
<b>{rejected ? '退回重做' : '验收通过 · 系统已执行落地动作'}</b>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 验收报告的判据逐条核对 —— 成果清单的成色标记 */}
|
||||
{isAcc && order.criteria.length > 0 && (
|
||||
<>
|
||||
<Part label="验收判据" aside={`${order.criteria.filter((c) => c.pass).length}/${order.criteria.length} 达标`} />
|
||||
<ul className="jc-rp-crit">
|
||||
{order.criteria.map((c, i) => (
|
||||
<li key={i} className={c.pass ? 'ok' : 'no'}>
|
||||
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
|
||||
<span className="t">{c.label}</span>
|
||||
<span className="r">
|
||||
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
|
||||
{c.pass ? '达标' : '未达标'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预检全过才发卡:这里是**已验证**的落地动作清单(OS 7.3) */}
|
||||
{isAcc && order.actions !== undefined && order.actions.length > 0 && (
|
||||
<>
|
||||
<Part label="通过即执行的落地动作" />
|
||||
<div className="jc-kv">
|
||||
{order.actions.map((a, i) => (
|
||||
<div key={i} className="jc-kv-i">
|
||||
<i>
|
||||
{ACTION_EFFECT_LABEL[a.effect]} · {a.label}
|
||||
</i>
|
||||
<b>{a.detail}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{html !== undefined && (
|
||||
<div>
|
||||
<Part label={isAcc ? '成果内容' : '数据与分析'} aside="活动汇报报告" />
|
||||
<ViewContainer html={html} title={`${detail.card.activity} · 汇报报告`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blocks.length > 0 && (
|
||||
<div>
|
||||
<Part label={html === undefined ? (isAcc ? '成果内容' : '数据与分析') : '结构化明细'} aside={srcTitle} />
|
||||
<ViewBlocks blocks={blocks} editing={isAcc && editing && !disabled} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((g, gi) => (
|
||||
<div key={gi}>
|
||||
<Part label={g.name ?? '填报内容'} />
|
||||
{!disabled ? (
|
||||
<div className="jc-grid">
|
||||
{g.list.map((f) => (
|
||||
<div key={f.key} className={'jc-fld' + (f.cols === 2 ? ' half' : '')}>
|
||||
<label className="jc-fld-l" htmlFor={f.key}>
|
||||
{f.label}
|
||||
{f.required === true && <em className="jc-req">必填</em>}
|
||||
</label>
|
||||
{f.hint !== undefined && <p className="jc-fld-h">{f.hint}</p>}
|
||||
<Control f={f} v={val(f)} onField={onField} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="jc-rp-vals">
|
||||
{g.list.map((f) => (
|
||||
<div key={f.key} className="row">
|
||||
<dt>{f.label}</dt>
|
||||
<dd>{fieldText(f, val(f).trim())}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="jc-rp-foot">
|
||||
<div className="jc-rp-sign">
|
||||
<span className="jc-rp-ava">{detail.owner.name.charAt(0)}</span>
|
||||
<div>
|
||||
<b>
|
||||
{detail.owner.name} · {detail.owner.role}
|
||||
</b>
|
||||
<span>
|
||||
{disabled
|
||||
? `已于 ${(order.done?.at ?? order.at).slice(5, 16).replace('T', ' ')} 办结 · 已记入工作日志`
|
||||
: isAcc
|
||||
? editing
|
||||
? '改完记得保存 —— 裁决在对话区的验收单上'
|
||||
: '可修改报告内容;验收裁决在对话区'
|
||||
: '按作业指导书填写,提交即生成执行报告'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!disabled &&
|
||||
(isAcc ? (
|
||||
editing ? (
|
||||
<div className="jc-rp-acts2">
|
||||
<button className="jc-btn jc-btn-ghost" onClick={() => setEditing(false)}>
|
||||
完成编辑
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="jc-btn jc-btn-ghost jc-rp-act" onClick={() => setEditing(true)}>
|
||||
<Ico name="pen-line" s={13} /> 修改
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
className="jc-btn jc-rp-submit"
|
||||
disabled={!ok}
|
||||
onClick={() =>
|
||||
onReply({
|
||||
mode: 'execution',
|
||||
workOrderId: order.id,
|
||||
values: Object.fromEntries(fields.map((f) => [f.key, val(f)])),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Ico name="check" s={14} /> {order.submitLabel ?? '提交'}
|
||||
</button>
|
||||
))}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═════════════════ 卡组二 · 作业指导书 ═════════════════ */
|
||||
|
||||
export function GuideCard({ order }: { order: Extract<WorkOrder, { mode: 'execution' }> }) {
|
||||
return (
|
||||
<section className="jc-side jc-side-exe">
|
||||
<header className="jc-side-h">
|
||||
<span className="jc-side-ico">
|
||||
<Ico name="clipboard-check" s={14} />
|
||||
</span>
|
||||
<b>作业指导书</b>
|
||||
{order.steps !== undefined && order.steps.length > 0 && <em>{order.steps.length} 步</em>}
|
||||
</header>
|
||||
<div className="jc-side-b">
|
||||
<p className="jc-side-ask">{order.ask}</p>
|
||||
{order.steps !== undefined && order.steps.length > 0 && (
|
||||
<ol className="jc-steps">
|
||||
{order.steps.map((x, i) => (
|
||||
<li key={i}>
|
||||
<em>{i + 1}</em>
|
||||
<span>{x}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{order.standard !== undefined && (
|
||||
<p className="jc-rp-std">
|
||||
<Ico name="target" s={13} />
|
||||
<span>
|
||||
<b>算做完的标准</b>
|
||||
{order.standard}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* 单个控件:8 种 kind */
|
||||
function Control({ f, v, onField }: { f: FormField; v: string; onField: (k: string, v: string) => void }) {
|
||||
const set = (x: string) => onField(f.key, x);
|
||||
|
||||
if (f.kind === 'radio')
|
||||
return (
|
||||
<div className="jc-radios">
|
||||
{f.options?.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={'jc-radio' + (v === o.value ? ' on' : '') + (o.negative === true ? ' neg' : '')}
|
||||
onClick={() => set(o.value)}
|
||||
>
|
||||
<Ico name={v === o.value ? 'circle-check' : 'circle-dot'} s={13} />
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (f.kind === 'check')
|
||||
return (
|
||||
<div className="jc-checks">
|
||||
{f.options?.map((o) => {
|
||||
const on = v.split(',').filter(Boolean).includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={'jc-check' + (on ? ' on' : '')}
|
||||
onClick={() => {
|
||||
const cur = v.split(',').filter(Boolean);
|
||||
set((on ? cur.filter((x) => x !== o.value) : [...cur, o.value]).join(','));
|
||||
}}
|
||||
>
|
||||
<span className="jc-check-box">{on && <Ico name="check" s={11} />}</span>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (f.kind === 'select')
|
||||
return (
|
||||
<div className="jc-sel-wrap">
|
||||
<select id={f.key} className="jc-sel" value={v} onChange={(e) => set(e.target.value)}>
|
||||
<option value="">{f.placeholder ?? '请选择…'}</option>
|
||||
{f.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Ico name="chevron-down" s={14} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (f.kind === 'textarea')
|
||||
return (
|
||||
<textarea
|
||||
id={f.key}
|
||||
className="jc-ta"
|
||||
rows={3}
|
||||
placeholder={f.placeholder}
|
||||
value={v}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (f.kind === 'number' || f.kind === 'money')
|
||||
return (
|
||||
<div className="jc-num">
|
||||
{f.kind === 'money' && <span className="jc-num-pre">¥</span>}
|
||||
<input
|
||||
id={f.key}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
placeholder={f.placeholder}
|
||||
value={v}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
{f.unit !== undefined && <span className="jc-num-unit">{f.unit}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (f.kind === 'file') {
|
||||
const files = v.split('|').filter(Boolean);
|
||||
return (
|
||||
<div className="jc-up">
|
||||
{files.map((n, i) => (
|
||||
<span key={i} className="jc-up-chip">
|
||||
<Ico name="paperclip" s={12} />
|
||||
{n}
|
||||
<button type="button" aria-label="移除" onClick={() => set(files.filter((_, x) => x !== i).join('|'))}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<label className="jc-up-drop">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const ns = [...(e.target.files ?? [])].map((x) => x.name);
|
||||
if (ns.length > 0) set([...files, ...ns].join('|'));
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Ico name="upload" s={14} /> 点击上传文件(可多个)
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (f.kind === 'date')
|
||||
return <input id={f.key} type="date" className="jc-inp" value={v} onChange={(e) => set(e.target.value)} />;
|
||||
|
||||
return (
|
||||
<input id={f.key} className="jc-inp" placeholder={f.placeholder} value={v} onChange={(e) => set(e.target.value)} />
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 聊天消息头像 —— 自原型 `_components/ChatAvatar.tsx` 平移(成对设计)。
|
||||
* 圆角方形 + 实心白色剪影:AI = 机器人(飞书蓝),员工 = 人像半身(浅蓝绿)。
|
||||
* `<g transform>` 校正图标在框内垂直居中。
|
||||
*/
|
||||
export function ChatAvatar({ who }: { who: 'ai' | 'user' }) {
|
||||
const isAi = who === 'ai';
|
||||
return (
|
||||
<div
|
||||
className={'chat-avatar ' + (isAi ? 'chat-avatar-ai' : 'chat-avatar-user')}
|
||||
aria-label={isAi ? 'AI 助手' : '员工'}
|
||||
>
|
||||
{isAi ? (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<g transform="translate(0,2.35)">
|
||||
<circle cx="12" cy="3" r="1.1" fill="#fff" />
|
||||
<rect x="11.45" y="3.7" width="1.1" height="1.9" rx="0.55" fill="#fff" />
|
||||
<rect x="4.3" y="5.6" width="15.4" height="11.8" rx="4.3" fill="#fff" />
|
||||
<rect x="2.2" y="9.4" width="2.1" height="4.5" rx="1.05" fill="#fff" />
|
||||
<rect x="19.7" y="9.4" width="2.1" height="4.5" rx="1.05" fill="#fff" />
|
||||
<circle cx="9.4" cy="10.9" r="1.7" fill="#245bdb" />
|
||||
<circle cx="14.6" cy="10.9" r="1.7" fill="#245bdb" />
|
||||
<path
|
||||
d="M9.5 14c.65.85 1.55 1.25 2.5 1.25s1.85-.4 2.5-1.25"
|
||||
stroke="#245bdb"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.55"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<g transform="translate(0,-0.5)">
|
||||
<circle cx="12" cy="8.2" r="3.7" fill="#fff" />
|
||||
<path
|
||||
d="M5.5 19.4c0-3.6 2.9-5.8 6.5-5.8s6.5 2.2 6.5 5.8c0 .6-.5 1.1-1.1 1.1H6.6c-.6 0-1.1-.5-1.1-1.1z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+5
-112
@@ -1,112 +1,5 @@
|
||||
// 共享内联 lucide 图标(会话面板 / 工作区驾驶舱 / 设置等共用)。
|
||||
export function Ico({ name, s = 18 }: { name: string; s?: number }) {
|
||||
const p: Record<string, React.ReactNode> = {
|
||||
"chevron-right": (<path d="m9 18 6-6-6-6" />),
|
||||
"chevron-down": (<path d="m6 9 6 6 6-6" />),
|
||||
check: (<path d="M20 6 9 17l-5-5" />),
|
||||
"circle-check": (<><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></>),
|
||||
star: (<><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" /></>),
|
||||
sparkles: (<><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z" /><path d="M20 3v4" /><path d="M22 5h-4" /><path d="M4 17v2" /><path d="M5 18H3" /></>),
|
||||
terminal: (<><polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" /></>),
|
||||
clock: (<><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></>),
|
||||
info: (<><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></>),
|
||||
"clipboard-check": (<><rect width="8" height="4" x="8" y="2" rx="1" ry="1" /><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /><path d="m9 14 2 2 4-4" /></>),
|
||||
target: (<><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /></>),
|
||||
"list-checks": (<><path d="m3 17 2 2 4-4" /><path d="m3 7 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></>),
|
||||
plus: (<><path d="M5 12h14" /><path d="M12 5v14" /></>),
|
||||
"panel-left-close": (<><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="m16 15-3-3 3-3" /></>),
|
||||
"sliders-horizontal": (<><line x1="21" x2="14" y1="4" y2="4" /><line x1="10" x2="3" y1="4" y2="4" /><line x1="21" x2="12" y1="12" y2="12" /><line x1="8" x2="3" y1="12" y2="12" /><line x1="21" x2="16" y1="20" y2="20" /><line x1="12" x2="3" y1="20" y2="20" /><line x1="14" x2="14" y1="2" y2="6" /><line x1="8" x2="8" y1="10" y2="14" /><line x1="16" x2="16" y1="18" y2="22" /></>),
|
||||
filter: (<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />),
|
||||
"arrow-left": (<><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></>),
|
||||
"file-spreadsheet": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M8 13h2" /><path d="M14 13h2" /><path d="M8 17h2" /><path d="M14 17h2" /></>),
|
||||
"file-type": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M9 13v-1h6v1" /><path d="M11 18h2" /><path d="M12 12v6" /></>),
|
||||
wrench: (<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />),
|
||||
"git-branch": (<><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></>),
|
||||
search: (<><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></>),
|
||||
bell: (<><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></>),
|
||||
settings: (<><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></>),
|
||||
user: (<><circle cx="12" cy="12" r="10" /><circle cx="12" cy="10" r="3" /><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662" /></>),
|
||||
"user-round": (<><circle cx="12" cy="8" r="5" /><path d="M20 21a8 8 0 0 0-16 0" /></>),
|
||||
bot: (<><path d="M12 8V4H8" /><rect width="16" height="12" x="4" y="8" rx="2" /><path d="M2 14h2" /><path d="M20 14h2" /><path d="M15 13v2" /><path d="M9 13v2" /></>),
|
||||
dashboard: (<><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></>),
|
||||
mail: (<><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></>),
|
||||
"mail-open": (<><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z" /><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10" /></>),
|
||||
inbox: (<><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></>),
|
||||
"shield-alert": (<><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="M12 8v4" /><path d="M12 16h.01" /></>),
|
||||
send: (<><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" /><path d="m21.854 2.147-10.94 10.939" /></>),
|
||||
archive: (<><rect width="20" height="5" x="2" y="3" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" /><path d="M10 12h4" /></>),
|
||||
"file-text": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" /></>),
|
||||
"pen-line": (<><path d="M12 20h9" /><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z" /></>),
|
||||
"messages-square": (<><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z" /><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1" /></>),
|
||||
"notebook-pen": (<><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></>),
|
||||
"calendar-days": (<><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /><path d="M8 14h.01" /><path d="M12 14h.01" /><path d="M16 14h.01" /><path d="M8 18h.01" /><path d="M12 18h.01" /><path d="M16 18h.01" /></>),
|
||||
hourglass: (<><path d="M5 22h14" /><path d="M5 2h14" /><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22" /><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2" /></>),
|
||||
play: (<polygon points="6 3 20 12 6 21 6 3" />),
|
||||
paperclip: (<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />),
|
||||
"building-2": (<><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z" /><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" /><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2" /><path d="M10 6h4" /><path d="M10 10h4" /><path d="M10 14h4" /><path d="M10 18h4" /></>),
|
||||
"panel-left-open": (<><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="m14 9 3 3-3 3" /></>),
|
||||
"trash-2": (<><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><line x1="10" x2="10" y1="11" y2="17" /><line x1="14" x2="14" y1="11" y2="17" /></>),
|
||||
calculator: (<><rect width="16" height="20" x="4" y="2" rx="2" /><line x1="8" x2="16" y1="6" y2="6" /><line x1="16" x2="16" y1="14" y2="18" /><path d="M16 10h.01" /><path d="M12 10h.01" /><path d="M8 10h.01" /><path d="M12 14h.01" /><path d="M8 14h.01" /><path d="M12 18h.01" /><path d="M8 18h.01" /></>),
|
||||
/* —— 治理中心扩充图标 —— */
|
||||
gauge: (<><path d="m12 14 4-4" /><path d="M3.34 19a10 10 0 1 1 17.32 0" /></>),
|
||||
database: (<><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14a9 3 0 0 0 18 0V5" /><path d="M3 12a9 3 0 0 0 18 0" /></>),
|
||||
layers: (<><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z" /><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65" /><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65" /></>),
|
||||
users: (<><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></>),
|
||||
"share-2": (<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /><line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></>),
|
||||
cpu: (<><rect width="16" height="16" x="4" y="4" rx="2" /><rect width="6" height="6" x="9" y="9" rx="1" /><path d="M15 2v2" /><path d="M15 20v2" /><path d="M2 15h2" /><path d="M2 9h2" /><path d="M20 15h2" /><path d="M20 9h2" /><path d="M9 2v2" /><path d="M9 20v2" /></>),
|
||||
zap: (<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />),
|
||||
"git-merge": (<><circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><path d="M6 21V9a9 9 0 0 0 9 9" /></>),
|
||||
"refresh-cw": (<><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M8 16H3v5" /></>),
|
||||
"rotate-ccw": (<><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /></>),
|
||||
"alert-triangle": (<><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>),
|
||||
"shield-check": (<><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></>),
|
||||
scan: (<><path d="M3 7V5a2 2 0 0 1 2-2h2" /><path d="M17 3h2a2 2 0 0 1 2 2v2" /><path d="M21 17v2a2 2 0 0 1-2 2h-2" /><path d="M7 21H5a2 2 0 0 1-2-2v-2" /><path d="M7 12h10" /></>),
|
||||
"file-check": (<><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="m9 15 2 2 4-4" /></>),
|
||||
"trending-up": (<><path d="M16 7h6v6" /><path d="m22 7-8.5 8.5-5-5L2 17" /></>),
|
||||
ban: (<><circle cx="12" cy="12" r="10" /><path d="m4.9 4.9 14.2 14.2" /></>),
|
||||
key: (<><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" /><path d="m21 2-9.6 9.6" /><circle cx="7.5" cy="15.5" r="5.5" /></>),
|
||||
workflow: (<><rect width="8" height="8" x="3" y="3" rx="2" /><path d="M7 11v4a2 2 0 0 0 2 2h4" /><rect width="8" height="8" x="13" y="13" rx="2" /></>),
|
||||
"arrow-right": (<><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></>),
|
||||
"circle-dot": (<><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="3" /></>),
|
||||
/* —— 治理中心补充图标(运行 / 连接 / 岗位 / 事件 / 度量)—— */
|
||||
activity: (<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />),
|
||||
route: (<><circle cx="6" cy="19" r="3" /><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" /><circle cx="18" cy="5" r="3" /></>),
|
||||
plug: (<><path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" /><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" /></>),
|
||||
radio: (<><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" /><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" /><circle cx="12" cy="12" r="2" /><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" /><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" /></>),
|
||||
"git-commit": (<><circle cx="12" cy="12" r="3" /><line x1="3" x2="9" y1="12" y2="12" /><line x1="15" x2="21" y1="12" y2="12" /></>),
|
||||
history: (<><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></>),
|
||||
"user-check": (<><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><polyline points="16 11 18 13 22 9" /></>),
|
||||
"user-plus": (<><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><line x1="19" x2="19" y1="8" y2="14" /><line x1="22" x2="16" y1="11" y2="11" /></>),
|
||||
power: (<><path d="M12 2v10" /><path d="M18.4 6.6a9 9 0 1 1-12.77.04" /></>),
|
||||
"alert-circle": (<><circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" /></>),
|
||||
"bar-chart": (<><line x1="12" x2="12" y1="20" y2="10" /><line x1="18" x2="18" y1="20" y2="4" /><line x1="6" x2="6" y1="20" y2="16" /></>),
|
||||
shuffle: (<><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22" /><path d="m18 2 4 4-4 4" /><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2" /><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8" /><path d="m18 14 4 4-4 4" /></>),
|
||||
pin: (<><path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" /></>),
|
||||
timer: (<><line x1="10" x2="14" y1="2" y2="2" /><line x1="12" x2="15" y1="14" y2="11" /><circle cx="12" cy="14" r="8" /></>),
|
||||
repeat: (<><path d="m17 2 4 4-4 4" /><path d="M3 11v-1a4 4 0 0 1 4-4h14" /><path d="m7 22-4-4 4-4" /><path d="M21 13v1a4 4 0 0 1-4 4H3" /></>),
|
||||
briefcase: (<><path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" /><rect width="20" height="14" x="2" y="6" rx="2" /></>),
|
||||
fingerprint: (<><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4" /><path d="M14 13.12c0 2.38 0 6.38-1 8.88" /><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02" /><path d="M2 12a10 10 0 0 1 18-6" /><path d="M2 16h.01" /><path d="M21.8 16c.2-2 .131-5.354 0-6" /><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2" /><path d="M8.65 22c.21-.66.45-1.32.57-2" /><path d="M9 6.8a6 6 0 0 1 9 5.2v2" /></>),
|
||||
server: (<><rect width="20" height="8" x="2" y="2" rx="2" /><rect width="20" height="8" x="2" y="14" rx="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" /></>),
|
||||
network: (<><rect x="16" y="16" width="6" height="6" rx="1" /><rect x="2" y="16" width="6" height="6" rx="1" /><rect x="9" y="2" width="6" height="6" rx="1" /><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" /><path d="M12 12V8" /></>),
|
||||
eye: (<><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></>),
|
||||
flag: (<><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" /><line x1="4" x2="4" y1="22" y2="15" /></>),
|
||||
lock: (<><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></>),
|
||||
percent: (<><line x1="19" x2="5" y1="5" y2="19" /><circle cx="6.5" cy="6.5" r="2.5" /><circle cx="17.5" cy="17.5" r="2.5" /></>),
|
||||
hash: (<><line x1="4" x2="20" y1="9" y2="9" /><line x1="4" x2="20" y1="15" y2="15" /><line x1="10" x2="8" y1="3" y2="21" /><line x1="16" x2="14" y1="3" y2="21" /></>),
|
||||
"external-link": (<><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></>),
|
||||
upload: (<><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></>),
|
||||
puzzle: (<path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z" />),
|
||||
"circle-x": (<><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6" /><path d="m9 9 6 6" /></>),
|
||||
"chevron-left": (<path d="m15 18-6-6 6-6" />),
|
||||
"calendar-clock": (<><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" /><path d="M16 2v4" /><path d="M8 2v4" /><path d="M3 10h5" /><circle cx="16" cy="16" r="6" /><path d="M16 14v2l1 1" /></>),
|
||||
"file-search": (<><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h5" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><circle cx="16" cy="17" r="3" /><path d="m21 22-1.5-1.5" /></>),
|
||||
boxes: (<><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="m7 16.5-4.74-2.85" /><path d="m7 16.5 5-3" /><path d="M7 16.5v5.17" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5-5-3" /><path d="m17 16.5 4.74-2.85" /><path d="M17 16.5v5.17" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" /><path d="M12 8 7.26 5.15" /><path d="m12 8 4.74-2.85" /><path d="M12 13.5V8" /></>),
|
||||
"square-pen": (<><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" /></>),
|
||||
"log-out": (<><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" x2="9" y1="12" y2="12" /></>),
|
||||
};
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{p[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 内联 lucide 图标 —— 本体已迁入 `@ui`(无业务语义的展示原语,编码规范 §2)。
|
||||
* 此处保留纯再导出,让既有的 `@web-shared/Ico.tsx` 引用点不动。
|
||||
*/
|
||||
export { Ico } from '@ui';
|
||||
|
||||
@@ -137,11 +137,26 @@ export function SidebarHead({ icon, title, sub }: { icon: string; title: string;
|
||||
);
|
||||
}
|
||||
|
||||
export function ListEmpty({ icon, children }: { icon: string; children: ReactNode }) {
|
||||
/**
|
||||
* 列表区空态 —— 侧栏窄,用紧凑版:软色圆里一枚图标 + 主句 + 一小段说明。
|
||||
* 与主面板的 `EmptyState`(整屏光环)同一语气,尺寸各归其位。
|
||||
*/
|
||||
export function ListEmpty({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: string;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="jc-list-empty">
|
||||
<Ico name={icon} s={22} />
|
||||
<span>{children}</span>
|
||||
<div className="jc-side-empty">
|
||||
<span className="jc-side-empty-ico">
|
||||
<Ico name={icon} s={18} />
|
||||
</span>
|
||||
{title !== undefined && <b>{title}</b>}
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,3 +184,43 @@ export function NoteBand({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 空态整屏 —— 层叠光环 + 图标 + 主副文 + 提示囊(自原型 `EmptyState` 平移)。
|
||||
*
|
||||
* **空态是产品的常态,不是缺陷**:文案一律从用人的角度写「你手上现在没有什么」,
|
||||
* 而不是从开发角度写「这块还没做」—— 后者是给自己看的,前者才是用户要的那句话。
|
||||
*/
|
||||
export function EmptyState({
|
||||
ico,
|
||||
tone,
|
||||
title,
|
||||
desc,
|
||||
hint,
|
||||
}: {
|
||||
ico: string;
|
||||
tone: 'ok' | 'brand';
|
||||
title: string;
|
||||
desc: string;
|
||||
hint?: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className={'jc-empty t-' + tone}>
|
||||
<span className="jc-empty-art">
|
||||
<i className="r1" />
|
||||
<i className="r2" />
|
||||
<span className="jc-empty-ico">
|
||||
<Ico name={ico} s={26} />
|
||||
</span>
|
||||
</span>
|
||||
<b>{title}</b>
|
||||
<p>{desc}</p>
|
||||
{hint !== undefined && (
|
||||
<span className="jc-empty-hint">
|
||||
<Ico name="sparkles" s={12} />
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,9 +125,9 @@ export interface FlowDiagramProps {
|
||||
/** 点击活动节点(下钻);不传则节点不可点。 */
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
/** 双击活动节点就地改文字(标题/小字/主办);不传则不可改。 */
|
||||
onEditNode?: (nodeId: string, patch: { label: string; owner?: string }) => Promise<void>;
|
||||
onEditNode?: ((nodeId: string, patch: { label: string; owner?: string }) => Promise<void>) | undefined;
|
||||
/** 执行人候选(L1 业务能力)——传了则执行字段只能下拉选缩写,不开自由填。 */
|
||||
ownerOptions?: SpPosition[];
|
||||
ownerOptions?: SpPosition[] | undefined;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
// lucide-react 图标换成 @ui 的内联 Ico —— 本仓不引 lucide 包(1:1 平移规则:视觉同、依赖归一)
|
||||
import { Ico } from "@ui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FlowEdge, FlowGraph, FlowNode, FlowNodeKind } from "./flow-layout.ts";
|
||||
import type { SpPosition } from "./support.tsx";
|
||||
import { OwnerSelect } from "./support.tsx";
|
||||
import { createFlowEditor, syncElementLabel, type FlowEditorHandles } from "./flow-editor-core.ts";
|
||||
import "diagram-js/assets/diagram-js.css";
|
||||
|
||||
/**
|
||||
* 手工绘图编辑器(React 壳):内核是 flow-editor-core 的 diagram-js modeling 全栈
|
||||
* (bpmn.js 的绘图层机制,阉割掉 BPMN 数据层)。这里只管:
|
||||
* 工具条(撤销/重做/适配视图/取消/保存)、双击属性卡(字段范围与画布一致:
|
||||
* 活动 名称/SOP↔Case/执行人;网关 名称/×|+/判据/判定;卡点 名称/去向;连线 标签/颜色)、
|
||||
* 删除确认(节点已有下钻内容时)、键盘(Del 删除、⌘Z 撤销、⇧⌘Z 重做)。
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Any = any;
|
||||
|
||||
export interface FlowEditorProps {
|
||||
/** 进入编辑时的图(编译产物/手绘/空);内核转手绘工作副本。 */
|
||||
initial: FlowGraph | null | undefined;
|
||||
/** 已存在的下钻(operations)键:铸新号要避开;删这些节点要确认。 */
|
||||
drilledIds: string[];
|
||||
/** idMap = 编辑期活动改号映射(自动顺序编号),服务端据此重键 operations。 */
|
||||
onSave: (graph: FlowGraph, idMap: Record<string, string>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
/** 执行人候选(L1 业务能力)——传了则活动属性卡的执行字段只能下拉选缩写。 */
|
||||
positions?: SpPosition[] | undefined;
|
||||
}
|
||||
|
||||
interface PropState {
|
||||
element: Any;
|
||||
isEdge: boolean;
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export function FlowEditor({ initial, drilledIds, onSave, onCancel, positions }: FlowEditorProps) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<FlowEditorHandles | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const [propDirty, setPropDirty] = useState(false);
|
||||
const [hasShapes, setHasShapes] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [prop, setProp] = useState<PropState | null>(null);
|
||||
|
||||
const drilled = useMemo(() => new Set(drilledIds), [drilledIds]);
|
||||
const drilledRef = useRef(drilled);
|
||||
drilledRef.current = drilled;
|
||||
|
||||
// 内核装配(一次):diagram-js modeling 全栈 + 导入种子图。
|
||||
useEffect(() => {
|
||||
const container = canvasRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const requestRemove = (raw: Any[]) => {
|
||||
// 标签映射到宿主(旁注不单独删——文字随元素生死)。
|
||||
const elements = [...new Set(raw.map((el: Any) => el.labelTarget ?? el))];
|
||||
const hit = elements.filter((el: Any) => !el.waypoints && drilledRef.current.has(el.businessObject?.id));
|
||||
if (hit.length > 0) {
|
||||
const names = hit.map((el: Any) => el.businessObject.id).join("、");
|
||||
if (!window.confirm(`节点 ${names} 已有下钻内容(任务卡/指导书等),删除后将一并移除。确定删除?`)) return;
|
||||
}
|
||||
const modeling = editor.diagram.get("modeling");
|
||||
modeling.removeElements(elements.slice());
|
||||
};
|
||||
|
||||
// 小盘"编辑"入口 / 网关拉线后自动弹卡:按元素位置(画布坐标→容器坐标)打开属性卡。
|
||||
const requestProps = (raw: Any) => {
|
||||
const element = raw?.labelTarget ?? raw;
|
||||
if (!element || !element.parent) return;
|
||||
if (!element.businessObject) element.businessObject = {};
|
||||
const rect = wrapRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const vb = editor.diagram.get("canvas").viewbox();
|
||||
const px = (element.x + (element.width ?? 0) / 2 - vb.x) * vb.scale;
|
||||
const py = (element.y + (element.height ?? 0) / 2 - vb.y) * vb.scale;
|
||||
setProp({
|
||||
element,
|
||||
isEdge: Boolean(element.waypoints),
|
||||
left: Math.max(8, Math.min(px - 140, rect.width - 300)),
|
||||
top: Math.max(8, Math.min(py + 16, rect.height - 250)),
|
||||
});
|
||||
};
|
||||
|
||||
const editor = createFlowEditor(initial, { container, reserved: drilledRef.current, requestRemove, requestProps });
|
||||
editorRef.current = editor;
|
||||
|
||||
const eventBus = editor.diagram.get("eventBus");
|
||||
const commandStack = editor.diagram.get("commandStack");
|
||||
const elementRegistry = editor.diagram.get("elementRegistry");
|
||||
const canvas = editor.diagram.get("canvas");
|
||||
|
||||
// 从网关拉出的线是分支:连好立刻弹属性卡,当场写分支文字(通过/拒绝…)。
|
||||
eventBus.on("commandStack.connection.create.postExecuted", (event: Any) => {
|
||||
const connection = event.context?.connection;
|
||||
if (connection?.source?.businessObject?.kind === "gateway") {
|
||||
setTimeout(() => requestProps(connection), 50);
|
||||
}
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
setCanUndo(commandStack.canUndo());
|
||||
setCanRedo(commandStack.canRedo());
|
||||
setHasShapes(elementRegistry.getAll().some((el: Any) => el.parent && !el.waypoints));
|
||||
};
|
||||
refresh();
|
||||
eventBus.on("commandStack.changed", refresh);
|
||||
|
||||
// 双击 = 属性卡(节点/连线;双击旁注/线标签落到其宿主;字段范围与展示态一致)。
|
||||
eventBus.on("element.dblclick", (event: Any) => {
|
||||
const element = event.element?.labelTarget ?? event.element;
|
||||
if (!element || !element.parent) return;
|
||||
if (!element.businessObject) element.businessObject = {};
|
||||
const rect = wrapRef.current?.getBoundingClientRect();
|
||||
const oe: MouseEvent | undefined = event.originalEvent;
|
||||
if (!rect) return;
|
||||
setProp({
|
||||
element,
|
||||
isEdge: Boolean(element.waypoints),
|
||||
left: Math.max(8, Math.min((oe?.clientX ?? rect.left + 60) - rect.left, rect.width - 300)),
|
||||
top: Math.max(8, Math.min((oe?.clientY ?? rect.top + 60) - rect.top + 10, rect.height - 250)),
|
||||
});
|
||||
});
|
||||
|
||||
canvas.zoom("fit-viewport", "auto");
|
||||
setReady(true);
|
||||
|
||||
return () => {
|
||||
editorRef.current = null;
|
||||
editor.destroy();
|
||||
setReady(false);
|
||||
};
|
||||
// initial 只作首次种子——编辑期间外部刷新不重建画布(草稿在命令栈里)。
|
||||
}, []);
|
||||
|
||||
// 键盘:Del 删除(过输入框)、⌘/Ctrl+Z 撤销、⇧⌘Z / Ctrl+Y 重做、Esc 关属性卡。
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (mod && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
const commandStack = editor.diagram.get("commandStack");
|
||||
if (e.shiftKey) commandStack.redo();
|
||||
else commandStack.undo();
|
||||
return;
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === "y") {
|
||||
e.preventDefault();
|
||||
editor.diagram.get("commandStack").redo();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
const selection = [...new Set(editor.diagram.get("selection").get().map((el: Any) => el.labelTarget ?? el))];
|
||||
if (selection.length > 0) {
|
||||
e.preventDefault();
|
||||
const hit = selection.filter((el: Any) => !el.waypoints && drilledRef.current.has(el.businessObject?.id));
|
||||
if (hit.length > 0) {
|
||||
const names = hit.map((el: Any) => el.businessObject.id).join("、");
|
||||
if (!window.confirm(`节点 ${names} 已有下钻内容,删除后将一并移除。确定删除?`)) return;
|
||||
}
|
||||
editor.diagram.get("modeling").removeElements(selection.slice());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") setProp(null);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const dirty = canUndo || propDirty;
|
||||
const canSave = dirty && hasShapes && !busy && ready;
|
||||
|
||||
const run = (fn: (editor: FlowEditorHandles) => void) => {
|
||||
const editor = editorRef.current;
|
||||
if (editor) fn(editor);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flow-editor">
|
||||
<div className="fe-bar">
|
||||
<button type="button" className="be-btn ghost" disabled={!canUndo || busy} title="撤销(⌘Z)" onClick={() => run((ed) => ed.diagram.get("commandStack").undo())}>
|
||||
<Ico name="undo-2" s={12} /> 撤销
|
||||
</button>
|
||||
<button type="button" className="be-btn ghost" disabled={!canRedo || busy} title="重做(⇧⌘Z)" onClick={() => run((ed) => ed.diagram.get("commandStack").redo())}>
|
||||
<Ico name="redo-2" s={12} /> 重做
|
||||
</button>
|
||||
<button type="button" className="be-btn ghost" title="适配视图(空白处拖拽平移,⌘+滚轮缩放)" onClick={() => run((ed) => ed.diagram.get("canvas").zoom("fit-viewport", "auto"))}>
|
||||
<Ico name="maximize-2" s={12} /> 适配
|
||||
</button>
|
||||
<span className="fe-hint">
|
||||
左侧元素盘拖放落子 · 点选节点用旁边小盘 追加/连线/删除 · 拖节点自动修线 · 拖线段调走线 · 双击改属性
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="be-btn ghost"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!dirty || window.confirm("放弃本次绘图修改?")) onCancel();
|
||||
}}
|
||||
>
|
||||
<Ico name="x" s={12} /> 取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="be-btn solid"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { graph, idMap } = editor.serialize();
|
||||
onSave(graph, idMap).catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "保存失败");
|
||||
setBusy(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ico name="check" s={12} /> {busy ? "保存中…" : "保存绘图"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="fe-error">{error}</p>}
|
||||
|
||||
<div ref={wrapRef} className="fe-canvas">
|
||||
<div ref={canvasRef} className="fe-djs" />
|
||||
{prop && (
|
||||
<PropCard
|
||||
key={`${prop.element.id}-${prop.top}`}
|
||||
prop={prop}
|
||||
registry={editorRef.current?.diagram.get("elementRegistry")}
|
||||
positions={positions}
|
||||
onClose={() => setProp(null)}
|
||||
onApply={(patch) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const bo = prop.element.businessObject as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v === undefined) delete bo[k];
|
||||
else bo[k] = v;
|
||||
}
|
||||
editor.diagram.get("eventBus").fire("elements.changed", { elements: [prop.element] });
|
||||
// 文字变了 → 同步外部标签(重量尺寸;连线标签按有无文字建/撤)。
|
||||
syncElementLabel(editor.diagram, prop.element);
|
||||
setPropDirty(true);
|
||||
setProp(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="fe-foot">珊格 10px · 只有直线与 90° 折线 · 连线从节点小盘拖出 · Del 删除 · ⌘Z 撤销</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 属性卡(双击节点/连线):gw-pop 纸卡,字段范围与画布展示一致 ---- */
|
||||
|
||||
const KIND_NAME: Record<FlowNodeKind, string> = {
|
||||
activity: "活动",
|
||||
gateway: "网关",
|
||||
start: "起始事件",
|
||||
end: "结束事件",
|
||||
"end-bad": "异常终止",
|
||||
callout: "卡点",
|
||||
};
|
||||
|
||||
function PropCard({
|
||||
prop,
|
||||
registry,
|
||||
positions,
|
||||
onClose,
|
||||
onApply,
|
||||
}: {
|
||||
prop: PropState;
|
||||
registry: Any;
|
||||
positions?: SpPosition[] | undefined;
|
||||
onClose: () => void;
|
||||
onApply: (patch: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const node = prop.isEdge ? undefined : (prop.element.businessObject as FlowNode);
|
||||
const edge = prop.isEdge ? (prop.element.businessObject as FlowEdge) : undefined;
|
||||
|
||||
const [label, setLabel] = useState(node?.label ?? edge?.label ?? "");
|
||||
const [owner, setOwner] = useState(node?.owner ?? "");
|
||||
|
||||
const [flavor, setFlavor] = useState<"sop" | "case" | "approval">(
|
||||
node?.flavor === "case" ? "case" : node?.flavor === "approval" ? "approval" : "sop",
|
||||
);
|
||||
const [mode, setMode] = useState<"x" | "and">(node?.mode === "and" ? "and" : "x");
|
||||
const [basis, setBasis] = useState(node?.basis ?? "");
|
||||
const [to, setTo] = useState(node?.to ?? "");
|
||||
const [tone, setTone] = useState<string>(edge?.tone ?? "");
|
||||
// 数据线(进/出/查:台账规范名顿号分隔)——L2 只讲流转与数据对象;
|
||||
// 过程细节(分工/逐条)属 L3 指导书内部,节点级 per/assign 已退役(保存即清遗留值)。
|
||||
const [dataIn, setDataIn] = useState((node?.in ?? []).join("、"));
|
||||
const [dataOut, setDataOut] = useState((node?.out ?? []).join("、"));
|
||||
const [dataUse, setDataUse] = useState((node?.use ?? []).join("、"));
|
||||
|
||||
const targets: string[] = useMemo(() => {
|
||||
if (!registry) return [];
|
||||
const ids: string[] = [];
|
||||
for (const el of registry.getAll() as Any[]) {
|
||||
const bo = el.businessObject as FlowNode | undefined;
|
||||
if (el.parent && !el.waypoints && bo?.id && bo.id !== node?.id) ids.push(bo.id);
|
||||
}
|
||||
return ids.sort();
|
||||
}, [registry, node?.id]);
|
||||
|
||||
const save = () => {
|
||||
if (node) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const text = label.trim();
|
||||
if (text) patch.label = text.slice(0, 16);
|
||||
if (node.kind === "activity") {
|
||||
patch.owner = owner.trim() ? owner.trim().slice(0, 16) : undefined;
|
||||
patch.flavor = flavor === "sop" ? undefined : flavor;
|
||||
const lines = (raw: string) =>
|
||||
raw.split(/[、,,\n]+/).map((part) => part.trim().slice(0, 20)).filter(Boolean).slice(0, 5);
|
||||
const din = lines(dataIn);
|
||||
const dout = lines(dataOut);
|
||||
const duse = lines(dataUse);
|
||||
patch.in = din.length ? din : undefined;
|
||||
patch.out = dout.length ? dout : undefined;
|
||||
patch.use = duse.length ? duse : undefined;
|
||||
// 活动调用整体退役(2026-07 终拍,user「所有节点都是内置节点」):保存即清遗留 wait。
|
||||
patch.wait = undefined;
|
||||
patch.per = undefined;
|
||||
patch.assign = undefined;
|
||||
} else if (node.kind === "gateway") {
|
||||
patch.owner = owner.trim() ? owner.trim().slice(0, 12) : undefined;
|
||||
patch.mode = mode === "and" ? "and" : undefined;
|
||||
patch.basis = mode === "x" && basis.trim() ? basis.trim().slice(0, 120) : undefined;
|
||||
} else if (node.kind === "callout") {
|
||||
patch.to = to || undefined;
|
||||
}
|
||||
onApply(patch);
|
||||
} else if (edge) {
|
||||
onApply({
|
||||
label: label.trim() ? label.trim().slice(0, 10) : undefined,
|
||||
tone: tone === "ok" || tone === "bad" || tone === "main" ? tone : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gw-pop fe-prop" style={{ left: prop.left, top: prop.top }} role="dialog" aria-label="属性">
|
||||
<div className="gp-hd">
|
||||
<span className="gp-dia" aria-hidden>
|
||||
{node ? (node.kind === "gateway" ? "◇" : node.kind === "callout" || node.kind === "activity" ? "▭" : "●") : "→"}
|
||||
</span>
|
||||
<span className="gp-nm">{node ? `${node.id} · ${KIND_NAME[node.kind]}` : "连线"}</span>
|
||||
</div>
|
||||
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">{node ? "名称" : "标签"}</span>
|
||||
<input
|
||||
value={label}
|
||||
maxLength={node ? 16 : 10}
|
||||
placeholder={edge ? "分支名(可空,如 通过/拒绝)" : undefined}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{node?.kind === "activity" && (
|
||||
<>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">类型</span>
|
||||
<select value={flavor} onChange={(e) => setFlavor(e.target.value as "sop" | "case" | "approval")}>
|
||||
<option value="sop">标准活动 · flow(蓝 · 照谱执行)</option>
|
||||
<option value="case">案例活动 · loop(绿 · 临场演绎)</option>
|
||||
<option value="approval">审批点(琥珀 · 人裁节点)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">执行</span>
|
||||
{positions ? (
|
||||
<OwnerSelect value={owner} positions={positions} onChange={setOwner} />
|
||||
) : (
|
||||
<input value={owner} maxLength={16} placeholder="执行人岗位缩写(可空)" onChange={(e) => setOwner(e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">进</span>
|
||||
<input value={dataIn} placeholder="吃进的流转件,顿号分隔 ≤5(规范名)" onChange={(e) => setDataIn(e.target.value)} />
|
||||
</div>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">出</span>
|
||||
<input value={dataOut} placeholder="产出的流转件;可带状态词:单据名(已受理)" onChange={(e) => setDataOut(e.target.value)} />
|
||||
</div>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">查</span>
|
||||
<input value={dataUse} placeholder="照着查/判的底座与规则,顿号分隔" onChange={(e) => setDataUse(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{node?.kind === "gateway" && (
|
||||
<>
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">类型</span>
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value as "x" | "and")}>
|
||||
<option value="x">判断 ×(按判据走一条)</option>
|
||||
<option value="and">并行 +(全部同时走)</option>
|
||||
</select>
|
||||
</div>
|
||||
{mode === "x" && (
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">判据</span>
|
||||
<textarea value={basis} maxLength={120} rows={2} placeholder="白话可判的一句;卡点写死后果" onChange={(e) => setBasis(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">判定</span>
|
||||
<input value={owner} maxLength={12} placeholder={mode === "and" ? "(并行可空)" : "判定人缩写"} onChange={(e) => setOwner(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{node?.kind === "callout" && (
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">去向</span>
|
||||
<select value={to} onChange={(e) => setTo(e.target.value)}>
|
||||
<option value="">(不标去向)</option>
|
||||
{targets.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{edge && (
|
||||
<div className="gp-edit-row">
|
||||
<span className="lb">颜色</span>
|
||||
<select value={tone} onChange={(e) => setTone(e.target.value)}>
|
||||
<option value="">钢灰(主线)</option>
|
||||
<option value="ok">绿(通过/接受)</option>
|
||||
<option value="bad">红(退回/拒绝)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gp-btns">
|
||||
<button type="button" className="be-btn ghost" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="be-btn solid" disabled={node ? !label.trim() : false} onClick={save}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,125 @@
|
||||
/**
|
||||
* 编排画布 —— diagram-js 画布件的宿主(编码规范 §6.1「PRD 画布件 1:1 平移」)。
|
||||
*
|
||||
* 原型的 `HostExample.FlowFigureBlock` 同时带手绘编辑器;此处先接**只读态**:
|
||||
* 流程包 → `FlowGraph`(`pkg-to-graph`)→ 布局引擎算几何 → `FlowDiagram` 绘制。
|
||||
* 节点实例落位以角注呈现,几何不因运行态而变——**图是定义,标注是运行**。
|
||||
* `FlowFigureBlock` 自原型 `HostExample` 平移:区块小标题右侧「编辑」芯片进手绘编辑器
|
||||
* (FlowEditor,画布内工具栏含 取消/保存绘图);展示态 hero/cap 框 + FlowDiagram
|
||||
* (单击节点下钻/选中、双击活动就地改文字);图例贴在流程图正下方。
|
||||
* 必须包在 `.process-desk > .process-doc` 纸面容器内(flow-canvas.css 规则 scoped 到它)。
|
||||
*
|
||||
* `FlowFigure` 保留为只读便捷壳(任务页的流程图用它)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import { Ico } from '@ui';
|
||||
import { FlowDiagram } from './FlowDiagram.tsx';
|
||||
import { FlowEditor } from './FlowEditor.tsx';
|
||||
import type { FlowGraph } from './flow-layout.ts';
|
||||
import type { SpPosition } from './support.tsx';
|
||||
import { packageToGraph } from './pkg-to-graph.ts';
|
||||
import type { InstanceMark } from './pkg-to-graph.ts';
|
||||
import './flow-canvas.css';
|
||||
|
||||
export function FlowFigureBlock({
|
||||
title,
|
||||
oneLiner,
|
||||
graph,
|
||||
drilledIds,
|
||||
positions,
|
||||
canEdit,
|
||||
onNodeClick,
|
||||
onEditNodeText,
|
||||
onSaveFlowGraph,
|
||||
}: {
|
||||
title: string;
|
||||
oneLiner?: string | undefined;
|
||||
graph: FlowGraph | null;
|
||||
/** 已建立下钻内容的节点 id(删除这些节点时编辑器会二次确认) */
|
||||
drilledIds: string[];
|
||||
positions?: SpPosition[] | undefined;
|
||||
canEdit: boolean;
|
||||
onNodeClick: (nodeId: string) => void;
|
||||
/** 双击流程图活动就地改文字(标题 / 主办) */
|
||||
onEditNodeText?: ((nodeId: string, patch: { label: string; owner?: string }) => Promise<void>) | undefined;
|
||||
/** 手工绘图:整图保存(idMap = 本次编辑会话 原id→现id,宿主用它重键下钻内容) */
|
||||
onSaveFlowGraph?: ((graph: FlowGraph, idMap: Record<string, string>) => Promise<void>) | undefined;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="view-lbl">
|
||||
流程图 · 顺序
|
||||
{canEdit && onSaveFlowGraph !== undefined && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="dc2-addbtn"
|
||||
title="手工绘图(珊格正交:活动/网关/起止/卡点,拖拽连线);画布内工具栏有 取消/保存绘图"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Ico name="pen-line" s={11} /> 编辑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing && onSaveFlowGraph !== undefined ? (
|
||||
<div className="hero">
|
||||
<div className="cap">
|
||||
<span>FIG — {title}流程 · 手工绘图</span>
|
||||
<span>珊格 10px · 直线与直角折线</span>
|
||||
</div>
|
||||
<FlowEditor
|
||||
initial={graph}
|
||||
drilledIds={drilledIds}
|
||||
positions={positions}
|
||||
onSave={async (next, idMap) => {
|
||||
await onSaveFlowGraph(next, idMap);
|
||||
setEditing(false);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hero">
|
||||
<div className="cap">
|
||||
<span>FIG — {title}流程</span>
|
||||
<span>{oneLiner ?? '活动 · 网关 · 顺序流'}</span>
|
||||
</div>
|
||||
<div className="fig-body">
|
||||
<FlowDiagram
|
||||
graph={graph}
|
||||
variant="business"
|
||||
onNodeClick={onNodeClick}
|
||||
onEditNode={canEdit ? onEditNodeText : undefined}
|
||||
ownerOptions={positions}
|
||||
ariaLabel={`${title}业务流程图`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图例贴在流程图正下方,图标与绘图工具(元素盘)同款 SVG */}
|
||||
<div className="legend">
|
||||
<span>
|
||||
<b>图例</b>
|
||||
</span>
|
||||
<span><i className="lg-i fe-pi-start" aria-hidden /> 起始</span>
|
||||
<span><i className="lg-i fe-pi-end" aria-hidden /> 结束</span>
|
||||
<span><i className="lg-i fe-pi-sop" aria-hidden /> 标准活动</span>
|
||||
<span><i className="lg-i fe-pi-case" aria-hidden /> 案例活动</span>
|
||||
<span><i className="lg-i fe-pi-approval" aria-hidden /> 审批点</span>
|
||||
<span><i className="lg-i fe-pi-gwx" aria-hidden /> 判断网关</span>
|
||||
<span><i className="lg-i fe-pi-gwand" aria-hidden /> 并行网关</span>
|
||||
<span><i className="lg-i fe-pi-callout" aria-hidden /> 卡点</span>
|
||||
<span><i className="lg-i lg-flow" aria-hidden /> 顺序流</span>
|
||||
<span className="sep">|</span>
|
||||
<span>
|
||||
<b>责任</b>
|
||||
</span>
|
||||
<span>右上角标 责任人</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 只读壳:流程包 → 图 → 画(任务页 / 工作流实例页用) */
|
||||
export function FlowFigure({
|
||||
pkg,
|
||||
instances,
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
import Diagram from "diagram-js";
|
||||
import BaseRenderer from "diagram-js/lib/draw/BaseRenderer";
|
||||
import BaseLayouter from "diagram-js/lib/layout/BaseLayouter";
|
||||
import CroppingConnectionDocking from "diagram-js/lib/layout/CroppingConnectionDocking";
|
||||
import { repairConnection, withoutRedundantPoints } from "diagram-js/lib/layout/ManhattanLayout";
|
||||
import { getMid, getOrientation } from "diagram-js/lib/layout/LayoutUtil";
|
||||
import CommandInterceptor from "diagram-js/lib/command/CommandInterceptor";
|
||||
import RuleProvider from "diagram-js/lib/features/rules/RuleProvider";
|
||||
import OrderingProvider from "diagram-js/lib/features/ordering/OrderingProvider";
|
||||
|
||||
import ModelingModule from "diagram-js/lib/features/modeling";
|
||||
import RulesModule from "diagram-js/lib/features/rules";
|
||||
import LabelSupportModule from "diagram-js/lib/features/label-support";
|
||||
import MoveModule from "diagram-js/lib/features/move";
|
||||
import OutlineModule from "diagram-js/lib/features/outline";
|
||||
import SelectionModule from "diagram-js/lib/features/selection";
|
||||
import InteractionEventsModule from "diagram-js/lib/features/interaction-events";
|
||||
import HoverFixModule from "diagram-js/lib/features/hover-fix";
|
||||
import ConnectModule from "diagram-js/lib/features/connect";
|
||||
import ConnectionPreviewModule from "diagram-js/lib/features/connection-preview";
|
||||
import BendpointsModule from "diagram-js/lib/features/bendpoints";
|
||||
import CreateModule from "diagram-js/lib/features/create";
|
||||
import PaletteModule from "diagram-js/lib/features/palette";
|
||||
import ContextPadModule from "diagram-js/lib/features/context-pad";
|
||||
import GridSnappingModule from "diagram-js/lib/features/grid-snapping";
|
||||
import SnappingModule from "diagram-js/lib/features/snapping";
|
||||
import AutoPlaceModule from "diagram-js/lib/features/auto-place";
|
||||
import MoveCanvasModule from "diagram-js/lib/navigation/movecanvas";
|
||||
import ZoomScrollModule from "diagram-js/lib/navigation/zoomscroll";
|
||||
|
||||
import {
|
||||
estTextWidth,
|
||||
MANUAL_GRID,
|
||||
MANUAL_SIZES,
|
||||
type FlowEdge,
|
||||
type FlowGraph,
|
||||
type FlowNode,
|
||||
type FlowNodeKind,
|
||||
type FlowTone,
|
||||
} from "./flow-layout.ts";
|
||||
import { cleanPoints, mintNodeId, orderActivityIds, seedManualGraph, snap, swapIdTokens, type OrderNode } from "./manual-flow.ts";
|
||||
import { arrowHeadPoints, CAPTION_STYLE, FLOW_MONO, paintNode, svgEl, TONE_COLOR } from "./flow-node-paint.ts";
|
||||
|
||||
/**
|
||||
* 手工绘图编辑器内核:照 bpmn-js 的绘图层架构组装 diagram-js——
|
||||
* modeling+命令栈(撤销/重做)、move 拖拽预览、connect 拖线、bendpoints 折点/线段拖拽、
|
||||
* palette 拖放落子、context-pad 元素旁快捷盘(追加/连线/删除)、grid-snapping 珊格吸附、
|
||||
* 曼哈顿正交布线 + 裁剪停靠(线永远直角、端点扣框边)。
|
||||
* 阉割的数据层:没有 moddle/XML——businessObject 就是我们的 FlowNode/FlowEdge,
|
||||
* 保存时从 elementRegistry 序列化回 manual FlowGraph(吸附珊格),走原 flow 路由校验落库。
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Any = any;
|
||||
|
||||
export interface NewNodeSpec {
|
||||
kind: FlowNodeKind;
|
||||
flavor?: "case" | "approval";
|
||||
mode?: "x" | "and";
|
||||
}
|
||||
|
||||
const DEFAULT_LABEL: Record<FlowNodeKind, string> = {
|
||||
activity: "新活动",
|
||||
gateway: "判断",
|
||||
start: "开始",
|
||||
end: "结束",
|
||||
"end-bad": "终止",
|
||||
callout: "卡点",
|
||||
};
|
||||
|
||||
function specNode(spec: NewNodeSpec): FlowNode {
|
||||
return {
|
||||
id: "",
|
||||
kind: spec.kind,
|
||||
label: spec.kind === "gateway" && spec.mode === "and" ? "并行" : DEFAULT_LABEL[spec.kind],
|
||||
...(spec.flavor ? { flavor: spec.flavor } : {}),
|
||||
...(spec.mode === "and" ? { mode: "and" as const } : {}),
|
||||
} as FlowNode;
|
||||
}
|
||||
|
||||
/** 注册表当前的 BO 集合(铸新号避开已用编号;调用方再叠加下钻 operations 键)。 */
|
||||
function usedIds(elementRegistry: Any): FlowGraph {
|
||||
const nodes: FlowNode[] = [];
|
||||
for (const el of elementRegistry.getAll()) {
|
||||
const bo = el.businessObject as FlowNode | undefined;
|
||||
if (bo?.id) nodes.push(bo);
|
||||
}
|
||||
return { nodes, rows: [], anchors: [], edges: [], manual: true };
|
||||
}
|
||||
|
||||
/* ---------- 渲染器:与展示态共用 flow-node-paint 画笔 ---------- */
|
||||
|
||||
class FlowEditorRenderer extends BaseRenderer {
|
||||
static $inject = ["eventBus"];
|
||||
|
||||
constructor(eventBus: Any) {
|
||||
super(eventBus, 2000);
|
||||
}
|
||||
|
||||
override canRender(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override drawShape(parent: SVGElement, element: Any): SVGElement {
|
||||
// 外部 label 元素(起止/网关旁注、连线标签):可选中可拖动,文字随宿主 BO。
|
||||
if (element.labelTarget) {
|
||||
const group = svgEl("g", {});
|
||||
const host = element.labelTarget;
|
||||
const bo = (host.businessObject ?? {}) as Partial<FlowNode & FlowEdge>;
|
||||
const text = bo.label ?? "";
|
||||
let fill = "#3A4452";
|
||||
let font = CAPTION_STYLE.gateway!.font;
|
||||
let size = 10;
|
||||
let weight: number | undefined = 600;
|
||||
if (host.waypoints) {
|
||||
fill = TONE_COLOR[(bo.tone as FlowTone) ?? "main"];
|
||||
font = FLOW_MONO;
|
||||
size = 9.8;
|
||||
weight = undefined;
|
||||
} else {
|
||||
const kind = (bo as FlowNode).kind;
|
||||
const role = kind === "gateway" ? "gateway" : kind;
|
||||
const style = CAPTION_STYLE[role] ?? CAPTION_STYLE.gateway!;
|
||||
fill = style.fill;
|
||||
font = style.font;
|
||||
size = style.size("business");
|
||||
weight = style.weight;
|
||||
}
|
||||
// 透明命中垫(文字太细,给一块可点可拖的区域)。
|
||||
group.append(svgEl("rect", { x: 0, y: 0, width: element.width, height: element.height, fill: "transparent" }));
|
||||
group.append(
|
||||
svgEl(
|
||||
"text",
|
||||
{
|
||||
x: element.width / 2,
|
||||
y: element.height / 2 + 3.5,
|
||||
"text-anchor": "middle",
|
||||
"font-family": font,
|
||||
"font-size": size,
|
||||
...(weight ? { "font-weight": weight } : {}),
|
||||
fill,
|
||||
"pointer-events": "none",
|
||||
},
|
||||
text,
|
||||
),
|
||||
);
|
||||
parent.appendChild(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
const bo = (element.businessObject ?? { id: "", kind: "activity", label: "" }) as FlowNode;
|
||||
const group = svgEl("g", {});
|
||||
paintNode(group, bo, element.width, element.height, "business");
|
||||
parent.appendChild(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
override drawConnection(parent: SVGElement, connection: Any): SVGElement {
|
||||
const bo = (connection.businessObject ?? {}) as Partial<FlowEdge>;
|
||||
const waypoints = (connection.waypoints ?? []) as Array<{ x: number; y: number }>;
|
||||
const group = svgEl("g", {});
|
||||
if (waypoints.length < 2) {
|
||||
parent.appendChild(group);
|
||||
return group;
|
||||
}
|
||||
const color = TONE_COLOR[bo.tone ?? "main"];
|
||||
const d = `M${waypoints[0]!.x},${waypoints[0]!.y} ${waypoints
|
||||
.slice(1)
|
||||
.map((p) => `L${p.x},${p.y}`)
|
||||
.join(" ")}`;
|
||||
// 加宽透明命中层(线段拖拽/选中手感);标签文字是独立 label 元素,不在此画。
|
||||
group.append(svgEl("path", { d, fill: "none", stroke: "transparent", "stroke-width": 12 }));
|
||||
group.append(svgEl("path", { d, fill: "none", stroke: color, "stroke-width": 1.4 }));
|
||||
group.append(svgEl("polygon", { points: arrowHeadPoints(waypoints), fill: color, "pointer-events": "none" }));
|
||||
parent.appendChild(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** 裁剪停靠(CroppingConnectionDocking)按此路径求交点——菱形/圆按真形状,线才扣在框边。 */
|
||||
override getShapePath(shape: Any): string {
|
||||
const bo = (shape.businessObject ?? {}) as Partial<FlowNode>;
|
||||
const { x, y, width: w, height: h } = shape;
|
||||
if (bo.kind === "gateway") {
|
||||
return `M${x + w / 2},${y}L${x + w},${y + h / 2}L${x + w / 2},${y + h}L${x},${y + h / 2}Z`;
|
||||
}
|
||||
if (bo.kind === "start" || bo.kind === "end" || bo.kind === "end-bad") {
|
||||
const r = w / 2;
|
||||
const cx = x + r;
|
||||
const cy = y + r;
|
||||
return `M${cx},${cy}m0,${-r}a${r},${r},0,1,1,0,${2 * r}a${r},${r},0,1,1,0,${-2 * r}z`;
|
||||
}
|
||||
return `M${x},${y}l${w},0l0,${h}l${-w},0z`;
|
||||
}
|
||||
|
||||
override getConnectionPath(connection: Any): string {
|
||||
const waypoints = (connection.waypoints ?? []) as Array<{ x: number; y: number }>;
|
||||
if (waypoints.length < 2) return "";
|
||||
return `M${waypoints[0]!.x},${waypoints[0]!.y}${waypoints
|
||||
.slice(1)
|
||||
.map((p) => `L${p.x},${p.y}`)
|
||||
.join("")}`;
|
||||
}
|
||||
}
|
||||
|
||||
const rendererModule = {
|
||||
__init__: ["flowEditorRenderer"],
|
||||
flowEditorRenderer: ["type", FlowEditorRenderer],
|
||||
};
|
||||
|
||||
/* ---------- 外部 label:起止/网关旁注、连线标签(可拖动换位、随宿主移动) ---------- */
|
||||
|
||||
const CAPTIONED_KINDS = new Set<FlowNodeKind>(["start", "end", "end-bad", "gateway"]);
|
||||
|
||||
function labelDims(text: string): { width: number; height: number } {
|
||||
return { width: Math.max(24, Math.ceil(estTextWidth(text || " ", 10)) + 10), height: 16 };
|
||||
}
|
||||
|
||||
function labelOf(host: Any): Any | null {
|
||||
return host.label ?? host.labels?.[0] ?? null;
|
||||
}
|
||||
|
||||
/** 缺省标签中心:节点=正下方;连线=最长段中点上方一点。 */
|
||||
function defaultLabelMid(host: Any): { x: number; y: number } {
|
||||
if (host.waypoints) {
|
||||
const pts = host.waypoints as Array<{ x: number; y: number }>;
|
||||
let best = 1;
|
||||
let bl = -1;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const L = Math.abs(pts[i]!.x - pts[i - 1]!.x) + Math.abs(pts[i]!.y - pts[i - 1]!.y);
|
||||
if (L > bl) {
|
||||
bl = L;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return { x: (pts[best - 1]!.x + pts[best]!.x) / 2, y: (pts[best - 1]!.y + pts[best]!.y) / 2 - 10 };
|
||||
}
|
||||
return { x: host.x + host.width / 2, y: host.y + host.height + 10 };
|
||||
}
|
||||
|
||||
/** 新建带旁注的节点时自动挂标签(palette/追加/撤销重做都经命令栈,一并生效)。 */
|
||||
class FlowLabelBehavior extends CommandInterceptor {
|
||||
static override $inject = ["eventBus", "modeling"];
|
||||
|
||||
constructor(eventBus: Any, modeling: Any) {
|
||||
super(eventBus);
|
||||
// 拖拽连出的线没有 businessObject——统一在创建时种一个空 BO,
|
||||
// 否则属性卡打不开(分支文字/颜色没入口),序列化也要处处兜底。
|
||||
this.preExecute(
|
||||
"connection.create",
|
||||
(context: Any) => {
|
||||
const connection = context.connection;
|
||||
if (connection && !connection.businessObject) connection.businessObject = {};
|
||||
},
|
||||
true,
|
||||
);
|
||||
this.postExecuted(
|
||||
"shape.create",
|
||||
(context: Any) => {
|
||||
const shape = context.shape;
|
||||
const bo = shape?.businessObject as FlowNode | undefined;
|
||||
if (!bo || shape.labelTarget || !CAPTIONED_KINDS.has(bo.kind)) return;
|
||||
if (labelOf(shape)) return;
|
||||
modeling.createLabel(shape, defaultLabelMid(shape), {
|
||||
...labelDims(bo.label ?? ""),
|
||||
businessObject: bo,
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性卡改完文字后同步标签:重量尺寸、建/删连线标签(文字清空=撤签)。
|
||||
* 节点旁注常驻(起止/网关必有名);连线标签跟随 label 字段有无。
|
||||
*/
|
||||
export function syncElementLabel(diagram: Any, host: Any): void {
|
||||
const modeling = diagram.get("modeling") as Any;
|
||||
const eventBus = diagram.get("eventBus") as Any;
|
||||
const bo = host.businessObject as Partial<FlowNode & FlowEdge>;
|
||||
const existing = labelOf(host);
|
||||
const text = bo.label ?? "";
|
||||
if (host.waypoints) {
|
||||
if (text && !existing) {
|
||||
modeling.createLabel(host, defaultLabelMid(host), { ...labelDims(text), businessObject: bo });
|
||||
return;
|
||||
}
|
||||
if (!text && existing) {
|
||||
modeling.removeShape(existing);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (existing) {
|
||||
const dims = labelDims(text);
|
||||
const mid = { x: existing.x + existing.width / 2, y: existing.y + existing.height / 2 };
|
||||
existing.width = dims.width;
|
||||
existing.height = dims.height;
|
||||
existing.x = Math.round(mid.x - dims.width / 2);
|
||||
existing.y = Math.round(mid.y - dims.height / 2);
|
||||
eventBus.fire("elements.changed", { elements: [existing] });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 规则:能连什么、能放哪(阉割版——自由绘,只拦真错误) ---------- */
|
||||
|
||||
function isRoot(element: Any): boolean {
|
||||
return !element || !element.parent;
|
||||
}
|
||||
|
||||
function duplicateEdge(source: Any, target: Any, except?: Any): boolean {
|
||||
return (source?.outgoing ?? []).some((c: Any) => c !== except && c.target === target);
|
||||
}
|
||||
|
||||
class FlowRules extends RuleProvider {
|
||||
static override $inject = ["eventBus"];
|
||||
|
||||
override init(): void {
|
||||
this.addRule("shape.resize", () => false);
|
||||
|
||||
this.addRule(["shape.create", "elements.create"], (context: Any) => {
|
||||
const target = context.target;
|
||||
const elements: Any[] = context.elements ?? (context.shape ? [context.shape] : []);
|
||||
if (elements.some((el) => el.waypoints)) return false;
|
||||
return isRoot(target) || target === undefined ? true : false;
|
||||
});
|
||||
|
||||
this.addRule("elements.move", (context: Any) => {
|
||||
const shapes: Any[] = context.shapes ?? [];
|
||||
// 标签是注记:自由摆放,允许落在任何元素上方(不算嵌套)。
|
||||
if (shapes.length > 0 && shapes.every((s) => s.labelTarget)) return true;
|
||||
const target = context.target;
|
||||
if (target === undefined || isRoot(target)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
this.addRule("connection.start", (context: Any) => {
|
||||
const source = context.source;
|
||||
return Boolean(source && !source.waypoints && !isRoot(source));
|
||||
});
|
||||
|
||||
this.addRule("connection.create", (context: Any) => {
|
||||
const { source, target } = context;
|
||||
if (!source || !target || source === target) return false;
|
||||
if (source.waypoints || target.waypoints) return false;
|
||||
if (isRoot(source) || isRoot(target)) return false;
|
||||
if (duplicateEdge(source, target)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
this.addRule("connection.reconnect", (context: Any) => {
|
||||
const { source, target, connection } = context;
|
||||
if (!source || !target || source === target) return false;
|
||||
if (source.waypoints || target.waypoints) return false;
|
||||
if (duplicateEdge(source, target, connection)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
this.addRule("connection.updateWaypoints", () => true);
|
||||
}
|
||||
}
|
||||
|
||||
const rulesModule = {
|
||||
__depends__: [RulesModule],
|
||||
__init__: ["flowRules"],
|
||||
flowRules: ["type", FlowRules],
|
||||
};
|
||||
|
||||
/* ---------- 布线:曼哈顿正交(bpmn 同款 repairConnection),移动节点自动修线 ---------- */
|
||||
|
||||
class FlowLayouter extends BaseLayouter {
|
||||
override layoutConnection(connection: Any, hints: Any = {}): Array<{ x: number; y: number }> {
|
||||
const source = hints.source ?? connection.source;
|
||||
const target = hints.target ?? connection.target;
|
||||
const start = hints.connectionStart ?? getMid(source);
|
||||
const end = hints.connectionEnd ?? getMid(target);
|
||||
const waypoints = hints.waypoints ?? connection.waypoints ?? [];
|
||||
|
||||
// 上下关系为主走竖向端口,否则横向(近似 bpmn 的 preferredLayouts 选择)。
|
||||
const orientation = getOrientation(source, target, 0) as string;
|
||||
const vertical = orientation === "top" || orientation === "bottom";
|
||||
const preferred = hints.preferredLayouts ?? [vertical ? "v:v" : "h:h"];
|
||||
|
||||
const repaired =
|
||||
repairConnection(source, target, start, end, waypoints, { ...hints, preferredLayouts: preferred }) ??
|
||||
[start, end];
|
||||
return withoutRedundantPoints(repaired);
|
||||
}
|
||||
}
|
||||
|
||||
/** 连线建/修后按真实形状裁剪端点(bpmn 的 crop 行为)。 */
|
||||
class FlowCropper extends CommandInterceptor {
|
||||
static override $inject = ["eventBus", "connectionDocking"];
|
||||
|
||||
constructor(eventBus: Any, connectionDocking: Any) {
|
||||
super(eventBus);
|
||||
// 注意:第三参 unwrap=true 时回调直接收 context(不是 event)。
|
||||
const crop = (context: Any) => {
|
||||
const connection = context.connection;
|
||||
if (!connection?.source || !connection?.target) return;
|
||||
try {
|
||||
connection.waypoints = connectionDocking.getCroppedWaypoints(connection);
|
||||
} catch {
|
||||
// 裁剪失败(极端交点)保持原折线——渲染仍正交。
|
||||
}
|
||||
};
|
||||
this.executed(["connection.create", "connection.layout", "connection.reconnect"], crop, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** 连线垫底、节点在上(免得线盖住框)。 */
|
||||
class FlowOrdering extends OrderingProvider {
|
||||
static override $inject = ["eventBus"];
|
||||
|
||||
override getOrdering(element: Any, newParent: Any): Any {
|
||||
if (element.waypoints) return { parent: newParent, index: 0 };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const modelingModule = {
|
||||
__depends__: [ModelingModule, LabelSupportModule],
|
||||
__init__: ["flowCropper", "flowOrdering", "flowLabelBehavior"],
|
||||
layouter: ["type", FlowLayouter],
|
||||
connectionDocking: ["type", CroppingConnectionDocking],
|
||||
flowCropper: ["type", FlowCropper],
|
||||
flowOrdering: ["type", FlowOrdering],
|
||||
flowLabelBehavior: ["type", FlowLabelBehavior],
|
||||
};
|
||||
|
||||
/* ---------- 元素盘(palette):拖放/点击落子 ---------- */
|
||||
|
||||
export const PALETTE_SPECS: Array<{ key: string; label: string; className: string; spec: NewNodeSpec }> = [
|
||||
{ key: "create.sop", label: "标准活动 · flow(蓝 · 照谱执行)", className: "fe-pi fe-pi-sop", spec: { kind: "activity" } },
|
||||
{ key: "create.case", label: "案例活动 · loop(绿 · 临场演绎)", className: "fe-pi fe-pi-case", spec: { kind: "activity", flavor: "case" } },
|
||||
{ key: "create.approval", label: "审批点(琥珀 · 人裁节点)", className: "fe-pi fe-pi-approval", spec: { kind: "activity", flavor: "approval" } },
|
||||
{ key: "create.gw-x", label: "判断网关 ×(按判据走一条)", className: "fe-pi fe-pi-gwx", spec: { kind: "gateway", mode: "x" } },
|
||||
{ key: "create.gw-and", label: "并行网关 +(全部同时走)", className: "fe-pi fe-pi-gwand", spec: { kind: "gateway", mode: "and" } },
|
||||
{ key: "create.start", label: "起始事件", className: "fe-pi fe-pi-start", spec: { kind: "start" } },
|
||||
{ key: "create.end", label: "结束事件", className: "fe-pi fe-pi-end", spec: { kind: "end" } },
|
||||
{ key: "create.callout", label: "卡点(红 · 标去向)", className: "fe-pi fe-pi-callout", spec: { kind: "callout" } },
|
||||
];
|
||||
|
||||
class FlowPalette {
|
||||
static $inject = ["palette", "create", "elementFactory", "elementRegistry", "flowEditorConfig"];
|
||||
|
||||
private _create: Any;
|
||||
private _elementFactory: Any;
|
||||
private _elementRegistry: Any;
|
||||
private _config: Any;
|
||||
|
||||
constructor(palette: Any, create: Any, elementFactory: Any, elementRegistry: Any, flowEditorConfig: Any) {
|
||||
this._create = create;
|
||||
this._elementFactory = elementFactory;
|
||||
this._elementRegistry = elementRegistry;
|
||||
this._config = flowEditorConfig;
|
||||
palette.registerProvider(this);
|
||||
}
|
||||
|
||||
getPaletteEntries(): Record<string, Any> {
|
||||
const entries: Record<string, Any> = {};
|
||||
for (const item of PALETTE_SPECS) {
|
||||
const action = (event: Any) => {
|
||||
const bo = specNode(item.spec);
|
||||
bo.id = mintNodeId(item.spec.kind, usedIds(this._elementRegistry), this._config.reserved);
|
||||
const size = MANUAL_SIZES[item.spec.kind];
|
||||
const shape = this._elementFactory.createShape({ width: size.w, height: size.h, businessObject: bo });
|
||||
this._create.start(event, shape);
|
||||
};
|
||||
entries[item.key] = {
|
||||
group: "flow",
|
||||
className: item.className,
|
||||
title: `${item.label} — 拖到画布或点击后点画布落子`,
|
||||
action: { dragstart: action, click: action },
|
||||
};
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
const paletteModule = {
|
||||
__depends__: [PaletteModule, CreateModule],
|
||||
__init__: ["flowPalette"],
|
||||
flowPalette: ["type", FlowPalette],
|
||||
};
|
||||
|
||||
/* ---------- 上下文小盘(context-pad):选中元素旁的 追加/连线/删除 ---------- */
|
||||
|
||||
class FlowContextPad {
|
||||
static $inject = ["contextPad", "connect", "elementFactory", "elementRegistry", "autoPlace", "flowEditorConfig"];
|
||||
|
||||
private _connect: Any;
|
||||
private _elementFactory: Any;
|
||||
private _elementRegistry: Any;
|
||||
private _autoPlace: Any;
|
||||
private _config: Any;
|
||||
|
||||
constructor(contextPad: Any, connect: Any, elementFactory: Any, elementRegistry: Any, autoPlace: Any, flowEditorConfig: Any) {
|
||||
this._connect = connect;
|
||||
this._elementFactory = elementFactory;
|
||||
this._elementRegistry = elementRegistry;
|
||||
this._autoPlace = autoPlace;
|
||||
this._config = flowEditorConfig;
|
||||
contextPad.registerProvider(this);
|
||||
}
|
||||
|
||||
getContextPadEntries(element: Any): Record<string, Any> {
|
||||
const entries: Record<string, Any> = {};
|
||||
// 标签元素不出小盘(拖动换位、双击改字就够了;删除随宿主)。
|
||||
if (element.labelTarget) return entries;
|
||||
const removeElements = () => this._config.requestRemove([element]);
|
||||
const openProps = () => this._config.requestProps(element);
|
||||
|
||||
if (element.waypoints) {
|
||||
entries["edit"] = {
|
||||
group: "edit",
|
||||
className: "fe-ci fe-ci-edit",
|
||||
title: "编辑分支文字/颜色(双击线也可以)",
|
||||
action: { click: openProps },
|
||||
};
|
||||
entries["delete"] = {
|
||||
group: "edit",
|
||||
className: "fe-ci fe-ci-del",
|
||||
title: "删除连线",
|
||||
action: { click: removeElements },
|
||||
};
|
||||
return entries;
|
||||
}
|
||||
entries["edit"] = {
|
||||
group: "edit",
|
||||
className: "fe-ci fe-ci-edit",
|
||||
title: "编辑属性(双击节点也可以)",
|
||||
action: { click: openProps },
|
||||
};
|
||||
|
||||
const appendEntry = (key: string, title: string, className: string, spec: NewNodeSpec) => {
|
||||
entries[key] = {
|
||||
group: "append",
|
||||
className,
|
||||
title,
|
||||
action: {
|
||||
click: () => {
|
||||
const bo = specNode(spec);
|
||||
bo.id = mintNodeId(spec.kind, usedIds(this._elementRegistry), this._config.reserved);
|
||||
const size = MANUAL_SIZES[spec.kind];
|
||||
const shape = this._elementFactory.createShape({ width: size.w, height: size.h, businessObject: bo });
|
||||
this._autoPlace.append(element, shape);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
appendEntry("append.sop", "后接标准活动(自动连线)", "fe-ci fe-pi-sop", { kind: "activity" });
|
||||
appendEntry("append.case", "后接案例活动(自动连线)", "fe-ci fe-pi-case", { kind: "activity", flavor: "case" });
|
||||
appendEntry("append.approval", "后接审批点(自动连线)", "fe-ci fe-pi-approval", { kind: "activity", flavor: "approval" });
|
||||
appendEntry("append.gw", "后接判断网关 ×(自动连线)", "fe-ci fe-pi-gwx", { kind: "gateway", mode: "x" });
|
||||
appendEntry("append.end", "后接结束事件(自动连线)", "fe-ci fe-pi-end", { kind: "end" });
|
||||
|
||||
entries["connect"] = {
|
||||
group: "edit",
|
||||
className: "fe-ci fe-ci-connect",
|
||||
title: "连线(拖到目标节点)",
|
||||
action: {
|
||||
click: (event: Any) => this._connect.start(event, element),
|
||||
dragstart: (event: Any) => this._connect.start(event, element),
|
||||
},
|
||||
};
|
||||
entries["delete"] = {
|
||||
group: "edit",
|
||||
className: "fe-ci fe-ci-del",
|
||||
title: "删除节点(连带其连线)",
|
||||
action: { click: removeElements },
|
||||
};
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
const contextPadModule = {
|
||||
__depends__: [ContextPadModule, ConnectModule, AutoPlaceModule],
|
||||
__init__: ["flowContextPad"],
|
||||
flowContextPad: ["type", FlowContextPad],
|
||||
};
|
||||
|
||||
/* ---------- 珊格底纹图层 ---------- */
|
||||
|
||||
class FlowGridVisual {
|
||||
static $inject = ["canvas"];
|
||||
|
||||
constructor(canvas: Any) {
|
||||
const layer = canvas.getLayer("flow-grid", -3) as SVGElement;
|
||||
const defs = svgEl("defs", {});
|
||||
const pattern = svgEl("pattern", {
|
||||
id: "fe-grid-dots",
|
||||
width: MANUAL_GRID * 2,
|
||||
height: MANUAL_GRID * 2,
|
||||
patternUnits: "userSpaceOnUse",
|
||||
});
|
||||
pattern.append(svgEl("circle", { cx: 1, cy: 1, r: 1, fill: "#DCE3EC" }));
|
||||
defs.append(pattern);
|
||||
layer.append(defs);
|
||||
layer.append(
|
||||
svgEl("rect", {
|
||||
x: -4000,
|
||||
y: -4000,
|
||||
width: 12000,
|
||||
height: 12000,
|
||||
fill: "url(#fe-grid-dots)",
|
||||
"pointer-events": "none",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const gridVisualModule = {
|
||||
__init__: ["flowGridVisual"],
|
||||
flowGridVisual: ["type", FlowGridVisual],
|
||||
};
|
||||
|
||||
/* ---------- 组装 / 导入 / 序列化 ---------- */
|
||||
|
||||
export interface FlowEditorHandles {
|
||||
diagram: Any;
|
||||
/** 从 elementRegistry 序列化回 manual FlowGraph(坐标/折点吸附珊格,去重去悬空);
|
||||
* idMap = 本次编辑期活动的 旧id→新id(自动顺序编号所致),服务端据此重键 operations。 */
|
||||
serialize: () => { graph: FlowGraph; idMap: Record<string, string> };
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export interface FlowEditorOptions {
|
||||
container: HTMLElement;
|
||||
/** 已存在的下钻(operations)键:铸号避开。 */
|
||||
reserved: ReadonlySet<string>;
|
||||
/** 删除请求(带下钻确认)由 React 壳接管:确认后自己调 modeling.removeElements。 */
|
||||
requestRemove: (elements: Any[]) => void;
|
||||
/** 打开某元素的属性卡(小盘"编辑"入口 / 网关拉线后自动弹卡)。 */
|
||||
requestProps: (element: Any) => void;
|
||||
}
|
||||
|
||||
export function createFlowEditor(initial: FlowGraph | null | undefined, options: FlowEditorOptions): FlowEditorHandles {
|
||||
// React 壳的回调/上下文作为 DI value 供给(palette/context-pad 注入 flowEditorConfig)。
|
||||
const configModule = {
|
||||
flowEditorConfig: [
|
||||
"value",
|
||||
{
|
||||
reserved: options.reserved,
|
||||
requestRemove: options.requestRemove,
|
||||
requestProps: options.requestProps,
|
||||
},
|
||||
],
|
||||
};
|
||||
const diagram = new Diagram({
|
||||
canvas: { container: options.container },
|
||||
modules: [
|
||||
configModule,
|
||||
rendererModule,
|
||||
rulesModule,
|
||||
modelingModule,
|
||||
gridVisualModule,
|
||||
InteractionEventsModule,
|
||||
HoverFixModule,
|
||||
SelectionModule,
|
||||
OutlineModule,
|
||||
MoveModule,
|
||||
ConnectModule,
|
||||
ConnectionPreviewModule,
|
||||
BendpointsModule,
|
||||
CreateModule,
|
||||
paletteModule,
|
||||
contextPadModule,
|
||||
GridSnappingModule,
|
||||
SnappingModule,
|
||||
AutoPlaceModule,
|
||||
MoveCanvasModule,
|
||||
ZoomScrollModule,
|
||||
],
|
||||
} as Any);
|
||||
|
||||
const canvas = diagram.get("canvas") as Any;
|
||||
const elementFactory = diagram.get("elementFactory") as Any;
|
||||
const elementRegistry = diagram.get("elementRegistry") as Any;
|
||||
const eventBus = diagram.get("eventBus") as Any;
|
||||
|
||||
const root = elementFactory.createRoot({ id: "flow-edit-root" });
|
||||
canvas.setRootElement(root);
|
||||
|
||||
// 追加(autoPlace)落点:源右侧 90px,撞车往下让(简版 bpmn AutoPlaceUtil)。
|
||||
eventBus.on("autoPlace", (event: Any) => {
|
||||
const { source, shape } = event;
|
||||
const x = snap(source.x + source.width + 90 + shape.width / 2);
|
||||
let y = snap(source.y + source.height / 2);
|
||||
const overlaps = (px: number, py: number) =>
|
||||
elementRegistry
|
||||
.getAll()
|
||||
.some(
|
||||
(el: Any) =>
|
||||
!el.waypoints &&
|
||||
el.parent &&
|
||||
Math.abs(el.x + el.width / 2 - px) < (el.width + shape.width) / 2 + 20 &&
|
||||
Math.abs(el.y + el.height / 2 - py) < (el.height + shape.height) / 2 + 20,
|
||||
);
|
||||
let guard = 0;
|
||||
while (overlaps(x, y) && guard++ < 20) y = snap(y + 70);
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
// 导入既有图(种子):不进命令栈(不可撤销的基线)。
|
||||
const seeded = seedManualGraph(initial);
|
||||
const shapeById = new Map<string, Any>();
|
||||
const addLabelFor = (host: Any, bo: { label?: string; lx?: number; ly?: number }) => {
|
||||
const dims = labelDims(bo.label ?? "");
|
||||
const mid = bo.lx !== undefined && bo.ly !== undefined ? { x: bo.lx, y: bo.ly } : defaultLabelMid(host);
|
||||
const label = elementFactory.createLabel({
|
||||
labelTarget: host,
|
||||
businessObject: bo,
|
||||
x: Math.round(mid.x - dims.width / 2),
|
||||
y: Math.round(mid.y - dims.height / 2),
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
});
|
||||
canvas.addShape(label, root);
|
||||
};
|
||||
for (const node of seeded.nodes) {
|
||||
const size = MANUAL_SIZES[node.kind];
|
||||
const shape = elementFactory.createShape({
|
||||
x: node.x ?? 0,
|
||||
y: node.y ?? 0,
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
businessObject: node,
|
||||
});
|
||||
canvas.addShape(shape, root);
|
||||
shapeById.set(node.id, shape);
|
||||
if (CAPTIONED_KINDS.has(node.kind)) addLabelFor(shape, node);
|
||||
}
|
||||
for (const edge of seeded.edges) {
|
||||
const source = shapeById.get(edge.from);
|
||||
const target = shapeById.get(edge.to);
|
||||
if (!source || !target || !edge.points || edge.points.length < 2) continue;
|
||||
const connection = elementFactory.createConnection({
|
||||
source,
|
||||
target,
|
||||
waypoints: edge.points.map((p) => ({ x: p.x, y: p.y })),
|
||||
businessObject: edge,
|
||||
});
|
||||
canvas.addConnection(connection, root);
|
||||
if (edge.label) addLabelFor(connection, edge);
|
||||
}
|
||||
|
||||
/* ---- 活动自动顺序编号:结构一变(命令栈)就按流向重排 A1..An ----
|
||||
* 编号是真 id:卡点去向/判据文字同步换号;进场时记住每个节点的原始 id,
|
||||
* 保存时产出 旧id→新id 映射,服务端据此把 operations(两件套)跟着重键。 */
|
||||
const origIds = new WeakMap<Any, string>();
|
||||
for (const el of elementRegistry.getAll()) {
|
||||
if (el.parent && !el.waypoints && !el.labelTarget && el.businessObject?.id) {
|
||||
origIds.set(el, el.businessObject.id as string);
|
||||
}
|
||||
}
|
||||
|
||||
const renumberAll = () => {
|
||||
const shapes = elementRegistry
|
||||
.getAll()
|
||||
.filter((el: Any) => el.parent && !el.waypoints && !el.labelTarget && el.businessObject?.id);
|
||||
const nodes: OrderNode[] = shapes.map((el: Any) => ({
|
||||
id: el.businessObject.id,
|
||||
kind: el.businessObject.kind,
|
||||
x: el.x + el.width / 2,
|
||||
y: el.y + el.height / 2,
|
||||
}));
|
||||
const edges = elementRegistry
|
||||
.getAll()
|
||||
.filter((el: Any) => el.waypoints && el.parent && el.source?.businessObject?.id && el.target?.businessObject?.id)
|
||||
.map((el: Any) => ({ from: el.source.businessObject.id, to: el.target.businessObject.id }));
|
||||
const mapping = orderActivityIds(nodes, edges);
|
||||
if (mapping.size === 0) return;
|
||||
const changed: Any[] = [];
|
||||
for (const el of shapes) {
|
||||
const bo = el.businessObject as FlowNode;
|
||||
let touched = false;
|
||||
if (mapping.has(bo.id)) {
|
||||
bo.id = mapping.get(bo.id)!;
|
||||
touched = true;
|
||||
}
|
||||
if (bo.to && mapping.has(bo.to)) {
|
||||
bo.to = mapping.get(bo.to)!;
|
||||
touched = true;
|
||||
}
|
||||
if (bo.basis) {
|
||||
const swapped = swapIdTokens(bo.basis, mapping);
|
||||
if (swapped !== bo.basis) {
|
||||
bo.basis = swapped;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
if (touched) {
|
||||
changed.push(el);
|
||||
const label = labelOf(el);
|
||||
if (label) changed.push(label);
|
||||
}
|
||||
}
|
||||
if (changed.length) eventBus.fire("elements.changed", { elements: changed });
|
||||
};
|
||||
renumberAll();
|
||||
eventBus.on("commandStack.changed", renumberAll);
|
||||
|
||||
const serialize = (): { graph: FlowGraph; idMap: Record<string, string> } => {
|
||||
// 画布是无限域(可有负坐标)——先求整体包围,统一平移到非负(保相对几何/正交),再吸附珊格。
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
for (const el of elementRegistry.getAll()) {
|
||||
if (!el.parent) continue;
|
||||
if (el.waypoints) {
|
||||
for (const p of el.waypoints as Array<{ x: number; y: number }>) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
}
|
||||
} else {
|
||||
minX = Math.min(minX, el.x);
|
||||
minY = Math.min(minY, el.y);
|
||||
}
|
||||
}
|
||||
const pad = MANUAL_GRID * 2;
|
||||
const dx = Number.isFinite(minX) && minX < pad ? pad - minX : 0;
|
||||
const dy = Number.isFinite(minY) && minY < pad ? pad - minY : 0;
|
||||
|
||||
// 标签位(注记中心,随整体平移;不吸附网格):从 label 元素读回宿主 BO 的 lx/ly。
|
||||
const labelMidOf = (host: Any): { lx: number; ly: number } | null => {
|
||||
const label = labelOf(host);
|
||||
if (!label) return null;
|
||||
return {
|
||||
lx: Math.max(0, Math.round(label.x + label.width / 2 + dx)),
|
||||
ly: Math.max(0, Math.round(label.y + label.height / 2 + dy)),
|
||||
};
|
||||
};
|
||||
|
||||
const nodes: FlowNode[] = [];
|
||||
const edges: FlowEdge[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const el of elementRegistry.getAll()) {
|
||||
if (!el.parent) continue; // root
|
||||
if (el.waypoints || el.labelTarget) continue;
|
||||
const bo = el.businessObject as FlowNode;
|
||||
if (!bo?.id || seen.has(bo.id)) continue;
|
||||
seen.add(bo.id);
|
||||
const node: FlowNode = { ...bo, x: Math.max(0, snap(el.x + dx)), y: Math.max(0, snap(el.y + dy)) };
|
||||
const mid = labelMidOf(el);
|
||||
if (mid) {
|
||||
node.lx = mid.lx;
|
||||
node.ly = mid.ly;
|
||||
} else {
|
||||
delete node.lx;
|
||||
delete node.ly;
|
||||
}
|
||||
nodes.push(node);
|
||||
}
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const pairs = new Set<string>();
|
||||
for (const el of elementRegistry.getAll()) {
|
||||
if (!el.waypoints || !el.parent) continue;
|
||||
const from = el.source?.businessObject?.id as string | undefined;
|
||||
const to = el.target?.businessObject?.id as string | undefined;
|
||||
if (!from || !to || from === to || !ids.has(from) || !ids.has(to)) continue;
|
||||
const key = `${from}→${to}`;
|
||||
if (pairs.has(key)) continue;
|
||||
pairs.add(key);
|
||||
const bo = (el.businessObject ?? {}) as FlowEdge;
|
||||
const points = cleanPoints(
|
||||
(el.waypoints as Array<{ x: number; y: number }>).map((p) => ({
|
||||
x: Math.max(0, snap(p.x + dx)),
|
||||
y: Math.max(0, snap(p.y + dy)),
|
||||
})),
|
||||
);
|
||||
if (points.length < 2) continue;
|
||||
const edge: FlowEdge = { ...bo, from, to, points };
|
||||
const mid = labelMidOf(el);
|
||||
if (mid && edge.label) {
|
||||
edge.lx = mid.lx;
|
||||
edge.ly = mid.ly;
|
||||
} else {
|
||||
delete edge.lx;
|
||||
delete edge.ly;
|
||||
}
|
||||
edges.push(edge);
|
||||
}
|
||||
// 卡点去向指向已删节点的,摘除。
|
||||
for (const node of nodes) {
|
||||
if (node.to && node.to !== "E" && !ids.has(node.to)) delete node.to;
|
||||
}
|
||||
|
||||
// 编辑期改号映射(自动顺序编号):旧id→新id,服务端据此重键 operations。
|
||||
const idMap: Record<string, string> = {};
|
||||
for (const el of elementRegistry.getAll()) {
|
||||
if (!el.parent || el.waypoints || el.labelTarget) continue;
|
||||
const orig = origIds.get(el);
|
||||
const cur = el.businessObject?.id as string | undefined;
|
||||
if (orig && cur && orig !== cur) idMap[orig] = cur;
|
||||
}
|
||||
return { graph: { nodes, rows: [], anchors: [], edges, manual: true }, idMap };
|
||||
};
|
||||
|
||||
// 开发/自动化调试句柄(E2E 直查 registry/commandStack;销毁时清理)。
|
||||
(window as Any).__feDiagram = diagram;
|
||||
|
||||
return {
|
||||
diagram,
|
||||
serialize,
|
||||
destroy: () => {
|
||||
if ((window as Any).__feDiagram === diagram) delete (window as Any).__feDiagram;
|
||||
diagram.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,28 +25,28 @@ export interface FlowNode {
|
||||
* 编排层五要素之一,零开发、不进活动库。它挂在 kind:"activity" 下**仅因共用同一套方框几何**
|
||||
* (蛇形折行与尺寸计算),不表示它在模型上属于活动。
|
||||
*/
|
||||
flavor?: "case" | "approval";
|
||||
flavor?: "case" | "approval" | undefined;
|
||||
/** Case 节点引用的库案例 id(cases[].id):L2 只是引用,下钻编辑共享库案例。 */
|
||||
caseRef?: string;
|
||||
label: string;
|
||||
sub?: string;
|
||||
sub?: string | undefined;
|
||||
code?: string;
|
||||
ghost?: boolean;
|
||||
note?: string;
|
||||
/** (遗留)仅卡点:旧编译器按方向着色的产物——卡点现统一红,渲染忽略。 */
|
||||
tone?: "ok" | "bad";
|
||||
/** 仅卡点:去向节点 id(回跳返工/前跳直达)——渲染时框内标 →id。 */
|
||||
to?: string;
|
||||
to?: string | undefined;
|
||||
/** 责任下沉:活动=主办(A);网关=判定人。活动框内角标,网关随判据进浮层。 */
|
||||
owner?: string;
|
||||
owner?: string | undefined;
|
||||
/** 判据下沉:仅网关——判定标准(节点数据;画布不再弹窗,导出派生表可见)。 */
|
||||
basis?: string;
|
||||
basis?: string | undefined;
|
||||
/** 仅网关:x=判断(×,默认)/ and=并行(+,所有出口同时走)。 */
|
||||
mode?: "x" | "and";
|
||||
mode?: "x" | "and" | undefined;
|
||||
/** 数据线下沉(仅活动):进/出=流转,查=底座——L2 图后「数据 · 台账与加工」段由此派生。 */
|
||||
in?: string[];
|
||||
out?: string[];
|
||||
use?: string[];
|
||||
in?: string[] | undefined;
|
||||
out?: string[] | undefined;
|
||||
use?: string[] | undefined;
|
||||
/** 执行基数(exec-spec):item=逐条办;缺省=跟单办。 */
|
||||
per?: "item";
|
||||
/** 人机分工:auto 自动 / assist 帮我做 / human 我来做(缺省)。 */
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 画布图 → 流程包:**合并**,不是重建。
|
||||
*
|
||||
* 编辑器只动「画得出来的」:节点增删改名、连线、几何、注记。
|
||||
* 语义里画布装不下的部分 —— 边映射(装配)、失败走向、驳回走向、催办阶梯、
|
||||
* 负责人策略、调用接口 —— 一律**从旧包按 id 保留**;id 变过号的经 idMap 跟过去。
|
||||
* 所以保存永远不会把「画布上看不见的声明」抹掉。
|
||||
*
|
||||
* 卡点(callout)与行分组/锚定/折线点属于画布事实,语义 schema 不收:
|
||||
* 整张手绘图原样存进 `pkg.diagram`(手绘几何即呈现真相),流转判断不读它。
|
||||
*/
|
||||
import type { Edge, ProcessNode, ProcessPackage } from '@contracts';
|
||||
import { activityIdSchema, nodeIdSchema } from '@contracts';
|
||||
import type { FlowGraph } from './flow-layout.ts';
|
||||
|
||||
/** 新画的活动还没绑活动库条目:先以标签占位软引用,闭合校验会标「未安装」,发布被拦 */
|
||||
const placeholderActivityId = (label: string) =>
|
||||
activityIdSchema.parse(`未安装:${label.trim() === '' ? '未命名' : label.trim()}`);
|
||||
|
||||
export function mergeGraphIntoPackage(
|
||||
pkg: ProcessPackage,
|
||||
graph: FlowGraph,
|
||||
idMap: Readonly<Record<string, string>> = {},
|
||||
): ProcessPackage {
|
||||
// 旧节点按「现 id」可查:编辑器改过号的,先把旧包的 id 翻译成新号
|
||||
const renamed = new Map<string, ProcessNode>();
|
||||
for (const n of pkg.nodes) renamed.set(idMap[n.id] ?? n.id, n);
|
||||
const priorEdge = new Map<string, Edge>();
|
||||
for (const e of pkg.edges) {
|
||||
const from = idMap[e.from] ?? e.from;
|
||||
const to = idMap[e.to] ?? e.to;
|
||||
priorEdge.set(`${from}→${to}`, e);
|
||||
}
|
||||
|
||||
// 画布节点里只有五要素入语义;卡点(callout)留在 diagram
|
||||
const semantic = graph.nodes.filter((n) => n.kind !== 'callout');
|
||||
|
||||
const degree = (id: string, dir: 'in' | 'out') =>
|
||||
graph.edges.filter((e) => (dir === 'in' ? e.to === id : e.from === id)).length;
|
||||
|
||||
const nodes: ProcessNode[] = semantic.map((n) => {
|
||||
const prior = renamed.get(n.id);
|
||||
const id = nodeIdSchema.parse(n.id);
|
||||
const layout = {
|
||||
row: prior?.layout?.row ?? 0,
|
||||
col: prior?.layout?.col ?? 0,
|
||||
...(n.x === undefined ? {} : { x: n.x }),
|
||||
...(n.y === undefined ? {} : { y: n.y }),
|
||||
};
|
||||
const base = {
|
||||
id,
|
||||
label: n.label,
|
||||
...(n.sub === undefined ? {} : { sub: n.sub }),
|
||||
layout,
|
||||
};
|
||||
|
||||
if (n.kind === 'start') return { ...base, kind: 'start' as const };
|
||||
if (n.kind === 'end' || n.kind === 'end-bad') {
|
||||
return {
|
||||
...base,
|
||||
kind: 'end' as const,
|
||||
...(prior?.kind === 'end' && prior.output !== undefined ? { output: prior.output } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (n.kind === 'gateway') {
|
||||
const priorGw = prior?.kind === 'gateway' ? prior : undefined;
|
||||
return {
|
||||
...base,
|
||||
kind: 'gateway' as const,
|
||||
gateway: (n.mode === 'and' ? 'parallel' : 'exclusive') as 'parallel' | 'exclusive',
|
||||
// 分叉/汇合看度数:出边多为叉,入边多为汇;旧声明优先(度数打平时不猜)
|
||||
mode: priorGw?.mode ?? (degree(n.id, 'out') > degree(n.id, 'in') ? ('fork' as const) : ('join' as const)),
|
||||
...(n.basis === undefined ? {} : { basis: n.basis }),
|
||||
};
|
||||
}
|
||||
|
||||
if (n.flavor === 'approval') {
|
||||
const priorAp = prior?.kind === 'approval' ? prior : undefined;
|
||||
return {
|
||||
...base,
|
||||
kind: 'approval' as const,
|
||||
approver: {
|
||||
position: n.owner ?? priorAp?.approver.position ?? '未声明',
|
||||
strategy: priorAp?.approver.strategy ?? ('sole' as const),
|
||||
},
|
||||
// 驳回走向是声明式功能属性,画布不改它 —— 没有旧声明就先「终止」,发布前在属性外补
|
||||
reject: priorAp?.reject ?? { kind: 'terminate' as const },
|
||||
...(priorAp?.nudge === undefined ? {} : { nudge: priorAp.nudge }),
|
||||
...(priorAp?.message === undefined ? {} : { message: priorAp.message }),
|
||||
};
|
||||
}
|
||||
|
||||
const priorAct = prior?.kind === 'activity' ? prior : undefined;
|
||||
return {
|
||||
...base,
|
||||
kind: 'activity' as const,
|
||||
activityId: priorAct?.activityId ?? placeholderActivityId(n.label),
|
||||
owner: {
|
||||
position: n.owner ?? priorAct?.owner.position ?? '未声明',
|
||||
strategy: priorAct?.owner.strategy ?? ('sole' as const),
|
||||
...(priorAct?.owner.staffId === undefined ? {} : { staffId: priorAct.owner.staffId }),
|
||||
},
|
||||
failure: priorAct?.failure ?? { kind: 'terminate' as const },
|
||||
...(priorAct?.message === undefined ? {} : { message: priorAct.message }),
|
||||
...(n.in === undefined || n.in.length === 0 ? {} : { in: [...n.in] }),
|
||||
...(n.out === undefined || n.out.length === 0 ? {} : { out: [...n.out] }),
|
||||
};
|
||||
});
|
||||
|
||||
const semanticIds = new Set(semantic.map((n) => n.id));
|
||||
const edges: Edge[] = graph.edges
|
||||
.filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to))
|
||||
.map((e) => {
|
||||
const prior = priorEdge.get(`${e.from}→${e.to}`);
|
||||
return {
|
||||
id: prior?.id ?? `e-${e.from}-${e.to}`,
|
||||
from: nodeIdSchema.parse(e.from),
|
||||
to: nodeIdSchema.parse(e.to),
|
||||
// 装配映射与条件是「画布上看不见的声明」—— 原样保留,画布删不掉它们
|
||||
mapping: prior?.mapping ?? [],
|
||||
...(prior?.condition === undefined ? {} : { condition: prior.condition }),
|
||||
reopen: prior?.reopen ?? false,
|
||||
...(e.label === undefined ? {} : { label: e.label }),
|
||||
...(e.tone === 'ok' || e.tone === 'bad' ? { tone: e.tone } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
nodes,
|
||||
edges,
|
||||
// 手绘图原样存证:卡点/行分组/锚定/折线点都在这里,呈现以它为真相
|
||||
diagram: JSON.parse(JSON.stringify(graph)) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
MANUAL_GRID,
|
||||
layoutFlow,
|
||||
manualNodeSize,
|
||||
type FlowEdge,
|
||||
type FlowGraph,
|
||||
type FlowNode,
|
||||
type FlowNodeKind,
|
||||
} from "./flow-layout.ts";
|
||||
|
||||
/**
|
||||
* 手绘流程图的纯几何底座(零 DOM,可单测):
|
||||
* 珊格吸附、端口(节点四边中点;手绘尺寸取网格友好值,端口天然落珊格)、
|
||||
* 正交路由/端点重接(编译图→手绘种子用)、编号铸造(A#/G#/X#/B# 未用最小号,避开下钻键)。
|
||||
* 交互(拖拽/连线/折点)不在这里——编辑器内核 flow-editor-core 用 diagram-js modeling 全栈
|
||||
* (bpmn.js 的绘图层机制);保存序列化时借这里的 snap/cleanPoints 收口。
|
||||
* 服务端 manual-flow.ts 的校验(网格/正交/引用)是同一契约的落库硬门。
|
||||
*/
|
||||
|
||||
export const snap = (v: number): number => Math.round(v / MANUAL_GRID) * MANUAL_GRID;
|
||||
|
||||
export type PortSide = "n" | "e" | "s" | "w";
|
||||
export interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function nodeBox(node: FlowNode): { x: number; y: number; w: number; h: number } {
|
||||
const size = manualNodeSize(node);
|
||||
return { x: node.x ?? 0, y: node.y ?? 0, w: size.w, h: size.h };
|
||||
}
|
||||
|
||||
export function nodeCenter(node: FlowNode): Pt {
|
||||
const b = nodeBox(node);
|
||||
return { x: b.x + b.w / 2, y: b.y + b.h / 2 };
|
||||
}
|
||||
|
||||
export function portPoint(node: FlowNode, side: PortSide): Pt {
|
||||
const b = nodeBox(node);
|
||||
switch (side) {
|
||||
case "n":
|
||||
return { x: b.x + b.w / 2, y: b.y };
|
||||
case "s":
|
||||
return { x: b.x + b.w / 2, y: b.y + b.h };
|
||||
case "e":
|
||||
return { x: b.x + b.w, y: b.y + b.h / 2 };
|
||||
case "w":
|
||||
return { x: b.x, y: b.y + b.h / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 按目标方位取水平/垂直向端口。 */
|
||||
function portToward(node: FlowNode, toward: Pt, axis: "h" | "v"): Pt {
|
||||
const c = nodeCenter(node);
|
||||
if (axis === "h") return portPoint(node, toward.x >= c.x ? "e" : "w");
|
||||
return portPoint(node, toward.y >= c.y ? "s" : "n");
|
||||
}
|
||||
|
||||
/** 折线清理:去零段、并共线;至少保两点。 */
|
||||
export function cleanPoints(points: Pt[]): Pt[] {
|
||||
const pts = points.map((p) => ({ x: p.x, y: p.y }));
|
||||
let changed = true;
|
||||
while (changed && pts.length > 2) {
|
||||
changed = false;
|
||||
for (let i = pts.length - 2; i >= 0; i--) {
|
||||
if (pts[i]!.x === pts[i + 1]!.x && pts[i]!.y === pts[i + 1]!.y) {
|
||||
pts.splice(i + 1, 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (let i = pts.length - 3; i >= 0; i--) {
|
||||
const [a, b, c] = [pts[i]!, pts[i + 1]!, pts[i + 2]!];
|
||||
if ((a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y)) {
|
||||
pts.splice(i + 1, 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** 两节点间的默认正交路由:同轴直线,否则 Z 形(中线拐两折)。种子重接兜底用。 */
|
||||
export function routeBetween(from: FlowNode, to: FlowNode): Pt[] {
|
||||
const fc = nodeCenter(from);
|
||||
const tc = nodeCenter(to);
|
||||
const dx = tc.x - fc.x;
|
||||
const dy = tc.y - fc.y;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
const exit = portPoint(from, dx >= 0 ? "e" : "w");
|
||||
const enter = portPoint(to, dx >= 0 ? "w" : "e");
|
||||
if (exit.y === enter.y) return [exit, enter];
|
||||
const midX = snap((exit.x + enter.x) / 2);
|
||||
return cleanPoints([exit, { x: midX, y: exit.y }, { x: midX, y: enter.y }, enter]);
|
||||
}
|
||||
const exit = portPoint(from, dy >= 0 ? "s" : "n");
|
||||
const enter = portPoint(to, dy >= 0 ? "n" : "s");
|
||||
if (exit.x === enter.x) return [exit, enter];
|
||||
const midY = snap((exit.y + enter.y) / 2);
|
||||
return cleanPoints([exit, { x: exit.x, y: midY }, { x: enter.x, y: midY }, enter]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 端点重接:节点换尺寸/吸附后,保留中段走线,只把首末段重新扣回端口
|
||||
* (按首末段原方向选端口,邻点顺轴平移保正交)。两点直线直接整条重路由。
|
||||
*/
|
||||
export function attachEndpoints(points: Pt[], from: FlowNode, to: FlowNode): Pt[] {
|
||||
if (points.length <= 2) return routeBetween(from, to);
|
||||
const pts = points.map((p) => ({ x: p.x, y: p.y }));
|
||||
|
||||
const headHorizontal = pts[0]!.y === pts[1]!.y;
|
||||
const head = portToward(from, pts[1]!, headHorizontal ? "h" : "v");
|
||||
pts[0] = { ...head };
|
||||
if (headHorizontal) pts[1] = { x: pts[1]!.x, y: head.y };
|
||||
else pts[1] = { x: head.x, y: pts[1]!.y };
|
||||
|
||||
const n = pts.length;
|
||||
const tailHorizontal = pts[n - 1]!.y === pts[n - 2]!.y;
|
||||
const tail = portToward(to, pts[n - 2]!, tailHorizontal ? "h" : "v");
|
||||
pts[n - 1] = { ...tail };
|
||||
if (tailHorizontal) pts[n - 2] = { x: pts[n - 2]!.x, y: tail.y };
|
||||
else pts[n - 2] = { x: tail.x, y: pts[n - 2]!.y };
|
||||
|
||||
const cleaned = cleanPoints(pts);
|
||||
return cleaned.length >= 2 ? cleaned : routeBetween(from, to);
|
||||
}
|
||||
|
||||
const ID_PREFIX: Partial<Record<FlowNodeKind, string>> = {
|
||||
activity: "A",
|
||||
gateway: "G",
|
||||
callout: "X",
|
||||
"end-bad": "B",
|
||||
};
|
||||
|
||||
/* ---- 活动自动顺序编号:沿流向拓扑序,几何 (y,x) 决胜 ---- */
|
||||
|
||||
export interface OrderNode {
|
||||
id: string;
|
||||
kind: FlowNodeKind;
|
||||
/** 节点中心(决胜用)。 */
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算活动的规范编号 A1..An:从入度 0 的节点(起始)沿连线做拓扑遍历,
|
||||
* 同时就绪者按 (y,x) 取先(上面的先、左边的先)——汇合点等所有来路编号完才轮到,
|
||||
* 所以网关分支链先占号、汇后主线随后,与剧本世界的编号纪律同构;
|
||||
* 几何即排序把手:想换号,挪节点。环用 (剩余入度,y,x) 最小者破;
|
||||
* 不可达的活动排在最后。只返回**有变化**的 旧id→新id 映射。
|
||||
*/
|
||||
export function orderActivityIds(nodes: OrderNode[], edges: Array<{ from: string; to: string }>): Map<string, string> {
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0]));
|
||||
const out = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!byId.has(e.from) || !byId.has(e.to) || e.from === e.to) continue;
|
||||
out.set(e.from, [...(out.get(e.from) ?? []), e.to]);
|
||||
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1);
|
||||
}
|
||||
const geo = (id: string): [number, number] => {
|
||||
const n = byId.get(id)!;
|
||||
return [n.y, n.x];
|
||||
};
|
||||
const byGeo = (a: string, b: string) => geo(a)[0] - geo(b)[0] || geo(a)[1] - geo(b)[1];
|
||||
|
||||
const visited = new Set<string>();
|
||||
const ready = new Set<string>(nodes.filter((n) => (indeg.get(n.id) ?? 0) === 0).map((n) => n.id));
|
||||
const mapping = new Map<string, string>();
|
||||
let seq = 0;
|
||||
|
||||
const visit = (id: string) => {
|
||||
visited.add(id);
|
||||
ready.delete(id);
|
||||
const node = byId.get(id)!;
|
||||
if (node.kind === "activity") mapping.set(id, `A${++seq}`);
|
||||
for (const t of out.get(id) ?? []) {
|
||||
indeg.set(t, (indeg.get(t) ?? 1) - 1);
|
||||
if ((indeg.get(t) ?? 0) <= 0 && !visited.has(t)) ready.add(t);
|
||||
}
|
||||
};
|
||||
|
||||
while (visited.size < nodes.length) {
|
||||
const candidates = [...ready].filter((id) => !visited.has(id));
|
||||
if (candidates.length > 0) {
|
||||
visit(candidates.sort(byGeo)[0]!);
|
||||
} else {
|
||||
// 环/汇合死锁:挑未访问中 (剩余入度, y, x) 最小者破环。
|
||||
const rest = nodes.filter((n) => !visited.has(n.id)).map((n) => n.id);
|
||||
rest.sort((a, b) => (indeg.get(a) ?? 0) - (indeg.get(b) ?? 0) || byGeo(a, b));
|
||||
visit(rest[0]!);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [oldId, newId] of [...mapping]) {
|
||||
if (oldId === newId) mapping.delete(oldId);
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/** 文本里的 id 引用按映射换号:占位符两段式(防级联),长 id 先换(防 A10 被 A1 误伤)。 */
|
||||
export function swapIdTokens(text: string, mapping: ReadonlyMap<string, string>): string {
|
||||
const pairs = [...mapping.entries()].filter(([from, to]) => from !== to).sort((a, b) => b[0].length - a[0].length);
|
||||
let outText = text;
|
||||
pairs.forEach(([from], i) => {
|
||||
outText = outText.split(from).join(`⟦${i}⟧`);
|
||||
});
|
||||
pairs.forEach(([, to], i) => {
|
||||
outText = outText.split(`⟦${i}⟧`).join(to);
|
||||
});
|
||||
return outText;
|
||||
}
|
||||
|
||||
/** 铸新编号:前缀取未用最小号;起/止先用 S/E,再 S2/E2…;避开 reserved(下钻 operations 键)。 */
|
||||
export function mintNodeId(kind: FlowNodeKind, graph: FlowGraph, reserved: ReadonlySet<string>): string {
|
||||
const used = new Set([...graph.nodes.map((n) => n.id), ...reserved]);
|
||||
if (kind === "start" || kind === "end") {
|
||||
const base = kind === "start" ? "S" : "E";
|
||||
if (!used.has(base)) return base;
|
||||
for (let n = 2; ; n++) if (!used.has(`${base}${n}`)) return `${base}${n}`;
|
||||
}
|
||||
const prefix = ID_PREFIX[kind] ?? "N";
|
||||
for (let n = 1; ; n++) if (!used.has(`${prefix}${n}`)) return `${prefix}${n}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入绘图模式的种子:
|
||||
* - 已是手绘图 → 深拷贝;
|
||||
* - 自动布局图(编译产物/旧数据)→ 按当前布局几何转手绘:节点取布局位置换手绘尺寸(保中心),
|
||||
* 连线沿布局折线吸附珊格再把端点扣回新端口——用户从"看到的样子"起步,而不是被重排;
|
||||
* - 空 → 空白画布。
|
||||
*/
|
||||
export function seedManualGraph(graph: FlowGraph | null | undefined): FlowGraph {
|
||||
if (!graph || graph.nodes.length === 0) {
|
||||
return { nodes: [], rows: [], anchors: [], edges: [], manual: true };
|
||||
}
|
||||
if (graph.manual) return JSON.parse(JSON.stringify(graph)) as FlowGraph;
|
||||
|
||||
const layout = layoutFlow(graph, "business");
|
||||
let minY = Infinity;
|
||||
for (const shape of layout.shapes) minY = Math.min(minY, shape.y);
|
||||
for (const c of layout.connections) for (const p of c.waypoints) minY = Math.min(minY, p.y);
|
||||
const off = minY < MANUAL_GRID * 2 ? Math.ceil((MANUAL_GRID * 2 - minY) / MANUAL_GRID) * MANUAL_GRID : 0;
|
||||
|
||||
const nodes: FlowNode[] = layout.shapes.map((shape) => {
|
||||
const size = manualNodeSize(shape.node);
|
||||
const cx = shape.x + shape.w / 2;
|
||||
const cy = shape.y + shape.h / 2 + off;
|
||||
return {
|
||||
...shape.node,
|
||||
x: Math.max(0, snap(cx - size.w / 2)),
|
||||
y: Math.max(0, snap(cy - size.h / 2)),
|
||||
};
|
||||
});
|
||||
const byId = (id: string) => nodes.find((n) => n.id === id);
|
||||
|
||||
const edges: FlowEdge[] = [];
|
||||
for (const connection of layout.connections) {
|
||||
const from = byId(connection.edge.from);
|
||||
const to = byId(connection.edge.to);
|
||||
if (!from || !to) continue;
|
||||
const pts = connection.waypoints.map((p) => ({ x: Math.max(0, snap(p.x)), y: Math.max(0, snap(p.y + off)) }));
|
||||
edges.push({ ...connection.edge, points: attachEndpoints(pts, from, to) });
|
||||
}
|
||||
|
||||
return { nodes, rows: [], anchors: [], edges, manual: true };
|
||||
}
|
||||
@@ -20,23 +20,31 @@ export type InstanceMark = {
|
||||
/** 每行最多几个方框 —— 蛇形折行的行宽;取原型 business 预设的观感 */
|
||||
const ROW_WIDTH = 4;
|
||||
|
||||
export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark[] = []): FlowGraph {
|
||||
export function packageToGraph(
|
||||
pkg: ProcessPackage,
|
||||
marks: readonly InstanceMark[] = [],
|
||||
/** 活动范式(activityId → loop|flow):案例活动画绿 —— 范式住活动库,包里只有引用 */
|
||||
paradigms?: ReadonlyMap<string, string>,
|
||||
): FlowGraph {
|
||||
const markOf = new Map(marks.map((m) => [m.nodeId, m]));
|
||||
|
||||
/*
|
||||
* 手绘图存证优先:编辑器保存过,**手绘几何即呈现真相**。
|
||||
* 运行标注仍要每次现算(它是运行态,不入存证),合到存证节点的 note 上。
|
||||
*/
|
||||
const stored = readStoredDiagram(pkg.diagram);
|
||||
if (stored !== undefined) {
|
||||
return {
|
||||
...stored,
|
||||
nodes: stored.nodes.map((n) => {
|
||||
const state = stateOf(markOf.get(n.id));
|
||||
return state === undefined ? n : { ...n, note: state };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const nodes: FlowNode[] = pkg.nodes.map((n) => {
|
||||
const mark = markOf.get(n.id);
|
||||
const state =
|
||||
mark === undefined || !mark.activated
|
||||
? undefined
|
||||
: mark.terminal !== undefined
|
||||
? mark.terminal === 'released'
|
||||
? '已放行'
|
||||
: '已终止'
|
||||
: mark.running
|
||||
? '运行中'
|
||||
: mark.waiting
|
||||
? '等待中'
|
||||
: '已激活';
|
||||
const state = stateOf(markOf.get(n.id));
|
||||
|
||||
if (n.kind === 'gateway') {
|
||||
return {
|
||||
@@ -45,7 +53,8 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
|
||||
label: n.label,
|
||||
// 并行网关画 +,异或网关画 ×(判断)
|
||||
mode: n.gateway === 'parallel' ? 'and' : 'x',
|
||||
...(n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }),
|
||||
...(n.sub === undefined ? (n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }) : { sub: n.sub }),
|
||||
...(n.basis === undefined ? {} : { basis: n.basis }),
|
||||
...(state === undefined ? {} : { note: state }),
|
||||
};
|
||||
}
|
||||
@@ -58,13 +67,20 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
|
||||
kind: 'activity',
|
||||
label: n.label,
|
||||
code: n.id,
|
||||
...(n.sub === undefined ? {} : { sub: n.sub }),
|
||||
...(state === undefined ? {} : { note: state }),
|
||||
};
|
||||
if (n.kind === 'approval') {
|
||||
// approval **不是一种活动**:它挂在 kind:"activity" 下仅因共用同一套方框几何
|
||||
return { ...base, flavor: 'approval', owner: n.approver.position };
|
||||
}
|
||||
return { ...base, owner: n.owner.position };
|
||||
return {
|
||||
...base,
|
||||
owner: n.owner.position,
|
||||
...(paradigms?.get(n.activityId) === 'loop' ? { flavor: 'case' as const } : {}),
|
||||
...(n.in === undefined ? {} : { in: [...n.in] }),
|
||||
...(n.out === undefined ? {} : { out: [...n.out] }),
|
||||
};
|
||||
});
|
||||
|
||||
/** 蛇形折行:偶数行 L→R、奇数行 R→L —— 行的划分交给拓扑序 */
|
||||
@@ -76,13 +92,38 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
// 回边按声明走「回 A4 重开新实例」,视觉上标红以区分正向流转
|
||||
...(e.reopen ? { tone: 'bad' as const } : {}),
|
||||
...(e.tone !== undefined ? { tone: e.tone } : e.reopen ? { tone: 'bad' as const } : {}),
|
||||
...(e.label === undefined ? (e.condition === undefined ? {} : { label: e.condition }) : { label: e.label }),
|
||||
}));
|
||||
|
||||
return { nodes, rows, anchors: [], edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* 手绘图存证的在场校验:`diagram` 由本仓 `graph-to-pkg` 序列化而来,结构即 FlowGraph;
|
||||
* 这里只验骨架在不在(nodes/edges 数组、非空),不逐字段复检 —— 写入侧是唯一生产者。
|
||||
*/
|
||||
function readStoredDiagram(d: Record<string, unknown> | undefined): FlowGraph | undefined {
|
||||
if (d === undefined) return undefined;
|
||||
const nodes = d['nodes'];
|
||||
const edges = d['edges'];
|
||||
if (!Array.isArray(nodes) || nodes.length === 0 || !Array.isArray(edges)) return undefined;
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
rows: Array.isArray(d['rows']) ? d['rows'] : [],
|
||||
anchors: Array.isArray(d['anchors']) ? d['anchors'] : [],
|
||||
manual: d['manual'] === true,
|
||||
} as FlowGraph;
|
||||
}
|
||||
|
||||
/** 运行标注:节点实例态 → 画布角注文字;未激活不标 */
|
||||
function stateOf(mark: InstanceMark | undefined): string | undefined {
|
||||
if (mark === undefined || !mark.activated) return undefined;
|
||||
if (mark.terminal !== undefined) return mark.terminal === 'released' ? '已放行' : '已终止';
|
||||
return mark.running ? '运行中' : mark.waiting ? '等待中' : '已激活';
|
||||
}
|
||||
|
||||
/** 拓扑序:回边不参与定序(否则成环),同层按声明顺序稳定排列 */
|
||||
function topologicalOrder(pkg: ProcessPackage): string[] {
|
||||
const ids = pkg.nodes.map((n) => n.id as string);
|
||||
|
||||
@@ -108,7 +108,11 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
|
||||
{ key: 'emp', label: '员工', ico: 'users' },
|
||||
{ key: 'roles', label: '岗位授权', ico: 'key' },
|
||||
],
|
||||
workflows: [{ key: 'pkgs', label: '流程包', ico: 'boxes' }],
|
||||
workflows: [
|
||||
{ key: 'overview', label: '总览', ico: 'gauge' },
|
||||
{ key: 'pkgs', label: '流程包', ico: 'boxes' },
|
||||
{ key: 'todo', label: '需处置', ico: 'flag' },
|
||||
],
|
||||
triggers: [{ key: 'rules', label: '起单规则', ico: 'zap' }],
|
||||
library: [{ key: 'shelf', label: '活动货架', ico: 'boxes' }],
|
||||
tools: [{ key: 'catalog', label: '能力目录', ico: 'wrench' }],
|
||||
|
||||
@@ -389,7 +389,7 @@ export type ActSpec = {
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
ask?: AskSpec;
|
||||
ask?: AskSpec | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const MIN_W = 340;
|
||||
// 上限随视口收敛:给 视图区(≥480) + 清单列 + 间距 留足空间,避免拖到中栏塌陷
|
||||
const maxW = () => Math.max(MIN_W, Math.min(720, window.innerWidth - 830));
|
||||
/**
|
||||
* 视图区的地板 —— 与 app.css 的 `--jc-work-min` 同一个数,两处必须一致:
|
||||
* CSS 管**默认布局**不塌,这里管**拖拽**不越界。
|
||||
* 690 的来处:页签条含图标的自然内容 649px + 工作卡内边距 38px = 687,上取整留 3px。
|
||||
*/
|
||||
const WORK_MIN = 690;
|
||||
/** 左轨 86 + 清单 256 + 间距与内边距 ≈ 40 */
|
||||
const CHROME = 86 + 256 + 40;
|
||||
// 上限随视口收敛:先把视图区的地板扣掉,剩下的才是对话区能占的
|
||||
const maxW = () => Math.max(MIN_W, Math.min(720, window.innerWidth - CHROME - WORK_MIN));
|
||||
|
||||
export function useChatWidth(storageKey = "jc-chat-w") {
|
||||
const [w, setW] = useState<number | null>(null); // null = 未拖拽,走断点默认
|
||||
|
||||
Reference in New Issue
Block a user