From a702a5c14be8a58b5f61b6098436dd3db9f77310 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Thu, 15 Oct 2026 12:09:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=99=AE=E9=80=9A=E7=94=A8=E6=88=B7=E5=BB=BA?= =?UTF-8?q?=E5=BA=94=E7=94=A8=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E5=BA=94=E7=94=A8=E7=9A=84=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E8=B5=B7=E7=82=B9=EF=BC=88=E8=AF=B4=E9=9C=80=E6=B1=82=E3=80=81?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E6=A8=A1=E6=9D=BF=E3=80=81=E4=BB=8E=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E5=BC=80=E5=A7=8B=EF=BC=89=EF=BC=9B=E8=93=9D?= =?UTF-8?q?=E5=9B=BE=E9=A1=B5=EF=BC=88=E5=AF=B9=E8=AF=9D=E5=8A=A0=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E3=80=81=E5=8F=8D=E9=97=AE=E9=80=89=E9=A1=B9=E3=80=81?= =?UTF-8?q?=E5=85=B3=E8=8A=82=E9=80=90=E7=BB=84=E8=BF=9B=E5=BA=A6=E3=80=81?= =?UTF-8?q?=E8=AF=95=E8=BF=90=E8=A1=8C=E3=80=81=E5=8F=91=E5=B8=83=E6=91=98?= =?UTF-8?q?=E8=A6=81=E6=8C=89=E4=BD=A0=E6=9D=A5=E5=AE=9A=20/=20=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E5=8F=AF=E4=BB=A5=E4=BF=AE=20/=20=E8=A6=81=E6=89=BE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E5=88=86=E6=A0=8F=E3=80=81=E9=80=90?= =?UTF-8?q?=E6=AD=A5=E6=89=93=E5=8B=BE=E3=80=81=E6=8E=A5=E7=9D=80=E5=81=9A?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E9=97=AE=E6=95=B0=E6=8D=AE=E3=80=8C=E5=81=9A?= =?UTF-8?q?=E6=88=90=E5=BA=94=E7=94=A8=E3=80=8D=E6=94=B9=E8=B5=B0=E8=93=9D?= =?UTF-8?q?=E5=9B=BE=EF=BC=9B=E9=97=AE=E6=95=B0=E6=8D=AE=E5=92=8C=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=9A=84=E5=8F=8D=E9=97=AE=E6=94=B6=E5=B0=BE=EF=BC=9B?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8A=E7=9B=B4=E6=8E=A5=E6=94=B9=EF=BC=88?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E3=80=81=E6=94=B9=E5=AD=97=E3=80=81=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E3=80=81=E6=8C=AA=E5=8A=A8=E3=80=81=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E3=80=81=E5=8A=A0=E6=95=B0=E6=8D=AE=E5=8D=A1=E7=89=87=E3=80=81?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=AD=98=E8=8D=89=E7=A8=BF=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=8E=A7=E4=BB=B6=EF=BC=9B=E6=88=91=E7=9A=84?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=97=E8=A1=A8=EF=BC=9B=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=9A=84=E7=95=8C=E9=9D=A2=EF=BC=88=E6=88=91?= =?UTF-8?q?=E7=9A=84=E5=BA=94=E7=94=A8=E3=80=81=E5=88=B7=E6=96=B0=E4=B8=8E?= =?UTF-8?q?=E5=89=A9=E4=BD=99=E6=AC=A1=E6=95=B0=E3=80=81=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E3=80=81=E5=AE=A1=E6=A0=B8=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E5=8D=A1=EF=BC=89=EF=BC=9B=E8=AE=BE=E7=BD=AE=E9=A1=B5=E7=9A=84?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E5=BA=94=E7=94=A8=E5=BC=80=E5=85=B3=EF=BC=9B?= =?UTF-8?q?=E5=9B=9B=E4=B8=AA=E6=96=B0=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E8=BF=9B=E5=AE=8C=E6=95=B4=E5=85=B3=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 3 + frontend/src/api/blueprintCopilot.ts | 101 +++ frontend/src/api/blueprints.ts | 9 +- frontend/src/apps/ViewRenderer.tsx | 48 +- frontend/src/apps/components/display.tsx | 4 + frontend/src/apps/components/index.ts | 3 + frontend/src/apps/components/records.tsx | 274 ++++++++ frontend/src/apps/context.ts | 3 +- frontend/src/components/FileInput.tsx | 210 ++++++ frontend/src/components/SchemaForm.tsx | 28 +- frontend/src/lib/schemaForm.ts | 39 +- frontend/src/lib/terms.ts | 3 + frontend/src/lib/termsApp.ts | 189 ++++- frontend/src/lib/termsBlueprint.ts | 447 ++++++++++++ frontend/src/pages/ChatPage.tsx | 48 +- frontend/src/pages/SettingsPage.tsx | 28 +- frontend/src/pages/apps/AppPage.tsx | 73 +- frontend/src/pages/apps/AppsHome.tsx | 52 +- frontend/src/pages/apps/EntryPage.tsx | 1 + frontend/src/pages/apps/PageEdit.tsx | 530 ++++++++++++++ frontend/src/pages/apps/edit/AppEditor.tsx | 4 +- frontend/src/pages/apps/edit/PagesSection.tsx | 16 +- .../src/pages/apps/edit/ReviewSections.tsx | 4 +- frontend/src/pages/apps/edit/appOps.ts | 40 +- frontend/src/pages/apps/personal.tsx | 200 ++++++ .../src/pages/blueprints/BlueprintCards.tsx | 562 +++++++++++++++ .../src/pages/blueprints/BlueprintChat.tsx | 110 +++ .../src/pages/blueprints/BlueprintPage.tsx | 229 +++++- frontend/src/pages/blueprints/NewAppStart.tsx | 295 +++++++- .../src/pages/blueprints/PublishSummary.tsx | 288 ++++++++ .../src/pages/blueprints/TrialSection.tsx | 200 ++++++ frontend/src/pages/blueprints/editors.tsx | 488 +++++++++++++ frontend/src/pages/blueprints/shared.ts | 80 +++ frontend/src/run/AssistantPanel.tsx | 6 +- frontend/src/run/TaskCard.tsx | 115 ++- frontend/src/run/decode.ts | 67 +- frontend/src/run/inputSchema.ts | 31 +- frontend/src/store/blueprint.ts | 386 ++++++++++ frontend/src/store/chat.ts | 62 +- frontend/src/store/studio.ts | 19 +- scripts/check-all.mjs | 7 + scripts/check-app-editor.mjs | 7 + scripts/check-apps.mjs | 219 +++++- scripts/check-blueprint.mjs | 661 ++++++++++++++++++ scripts/check-chat.mjs | 122 +++- scripts/check-files.mjs | 244 +++++++ scripts/check-personal.mjs | 261 +++++++ scripts/check-records.mjs | 223 ++++++ scripts/check-studio.mjs | 47 +- 49 files changed, 6953 insertions(+), 133 deletions(-) create mode 100644 frontend/src/api/blueprintCopilot.ts create mode 100644 frontend/src/apps/components/records.tsx create mode 100644 frontend/src/components/FileInput.tsx create mode 100644 frontend/src/pages/apps/PageEdit.tsx create mode 100644 frontend/src/pages/apps/personal.tsx create mode 100644 frontend/src/pages/blueprints/BlueprintCards.tsx create mode 100644 frontend/src/pages/blueprints/BlueprintChat.tsx create mode 100644 frontend/src/pages/blueprints/PublishSummary.tsx create mode 100644 frontend/src/pages/blueprints/TrialSection.tsx create mode 100644 frontend/src/pages/blueprints/editors.tsx create mode 100644 frontend/src/pages/blueprints/shared.ts create mode 100644 frontend/src/store/blueprint.ts create mode 100644 scripts/check-blueprint.mjs create mode 100644 scripts/check-files.mjs create mode 100644 scripts/check-personal.mjs create mode 100644 scripts/check-records.mjs diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8abf8f7..f312c5f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import { AppEditor } from './pages/apps/edit/AppEditor' // 普通用户建应用:新建应用的起点、蓝图页(F5) import { NewAppStart } from './pages/blueprints/NewAppStart' import { BlueprintPage } from './pages/blueprints/BlueprintPage' +import { personalAppsOn } from './pages/blueprints/shared' import { LoginPage } from './pages/LoginPage' import { Logo } from './components/Logo' import { @@ -221,6 +222,8 @@ function useRoleRedirect(): string | null { const me = useAuth((s) => s.me) const { pathname } = useLocation() if (!loginOn(me) || atLeast(me, 'builder') || pathname === '/') return null + // 个人蓝图(docs/design/normal-user-apps.md N7):管理员开了个人应用时,使用者能进自己的蓝图页。它不是导航页,不在页面目录里 + if (personalAppsOn(me) && pathname.startsWith('/blueprints/')) return null const ok = visiblePages(me).some((p) => pathname === p.to || pathname.startsWith(`${p.to}/`)) return ok ? null : '/apps' } diff --git a/frontend/src/api/blueprintCopilot.ts b/frontend/src/api/blueprintCopilot.ts new file mode 100644 index 00000000..9e20aa81 --- /dev/null +++ b/frontend/src/api/blueprintCopilot.ts @@ -0,0 +1,101 @@ +/** + * 蓝图助手和填关节(docs/design/normal-user-apps.md §2.2、§2.3、§2.11):两条 SSE,帧同 streamCopilot(一帧一行 + * `data: `,空行分隔)。状态在 store/blueprint.ts,翻译在 run/decode.ts,这里只管收发。 + * + * - POST /api/copilot/blueprint-stream {blueprint_id, instruction, model?}:蓝图对话(服务端 api/copilot_blueprint.py)。 + * 操作有 model、thinking、heartbeat、look、plan、bp_set / bp_add / bp_remove / bp_template(模型提的,服务端落地)、 + * bp_ask(问回来一句,带候选,这一轮到此结束)、check(自查)、reply(只回答、不改卡片)、done、 + * final {blueprint, explanation, issues, asked?}(存好的整份蓝图以它为准;issues 是没采纳的操作)、error。 + * 这一轮落进蓝图对话、回答上一轮的反问都由服务端做。 + * - POST /api/copilot/fill-joints-stream {blueprint_id, groups?}:按模板填生成关节,不同组并行,每个操作带 group: + * group {group, status: started|retrying|filled|failed, joints?, issues?, message?}、各组自己的 look、plan、fill_joint、check; + * 最后 final {blueprint, groups, problems}。 + * + * 开流之前就被拒(4xx/5xx)时 onEnd 带服务端那句话、状态码和机读码;流到一半断了和一开始就连不上一样,说无法连接服务。 + * 请求函数从 ./http 导入,不要从 ./client 导入(循环导入,见 http.ts 开头)。 + */ +import { ApiError, BASE, NETWORK_MESSAGE, actorHeader, codeOf, describeDetail, isGatewayFailure, parseBody, report } from './http' + +export interface BlueprintStreamRequest { + blueprint_id: string + /** 想要什么、要怎么改,或者回答助手的反问 */ + instruction: string + model?: string | null +} + +export interface FillJointsRequest { + blueprint_id: string + /** 只填这几组;不给 = 还有没填上的关节的那几组 */ + groups?: string[] +} + +export type StreamEnd = (error?: string, info?: { status?: number; code?: string; aborted?: boolean }) => void + +/** 一条 SSE:返回取消函数 */ +function sse(path: string, body: unknown, onOp: (op: any) => void, onEnd: StreamEnd): () => void { + const controller = new AbortController() + void (async () => { + try { + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', ...actorHeader() }, + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!res.ok || !res.body) { + const text = await res.text().catch(() => '') + let parsed: any + let isJson = false + try { parsed = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ } + if (isGatewayFailure(res.status, text, isJson)) { + report(false, new ApiError(res.status, NETWORK_MESSAGE, { kind: 'network' })) + onEnd(NETWORK_MESSAGE) + return + } + report(true) + onEnd(isJson ? describeDetail(parsed?.detail ?? parsed, path) + : res.status >= 500 ? `服务端错误(${res.status}),详情请查看服务日志` + : `请求失败(HTTP ${res.status})`, { status: res.status, code: isJson ? codeOf(parsed) : undefined }) + return + } + report(true) + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + const flush = (frame: string) => { + if (!frame.startsWith('data: ')) return + try { + onOp(parseBody(frame.slice(6))) + } catch { /* 半截帧,忽略 */ } + } + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let idx: number + while ((idx = buffer.indexOf('\n\n')) >= 0) { + flush(buffer.slice(0, idx)) + buffer = buffer.slice(idx + 2) + } + } + buffer += decoder.decode() + if (buffer.trim()) flush(buffer.trim()) + onEnd() + } catch (e: any) { + if (e?.name === 'AbortError') { onEnd(undefined, { aborted: true }); return } + if (e instanceof TypeError) { + report(false, new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: String(e) })) + onEnd(NETWORK_MESSAGE) + return + } + onEnd(e?.message ?? NETWORK_MESSAGE) + } + })() + return () => controller.abort() +} + +export const streamBlueprint = (body: BlueprintStreamRequest, onOp: (op: any) => void, onEnd: StreamEnd) => + sse('/copilot/blueprint-stream', body, onOp, onEnd) + +export const streamFillJoints = (body: FillJointsRequest, onOp: (op: any) => void, onEnd: StreamEnd) => + sse('/copilot/fill-joints-stream', body, onOp, onEnd) diff --git a/frontend/src/api/blueprints.ts b/frontend/src/api/blueprints.ts index 6a6beb56..763b9eca 100644 --- a/frontend/src/api/blueprints.ts +++ b/frontend/src/api/blueprints.ts @@ -2,6 +2,7 @@ * 普通用户建应用的接口(docs/design/normal-user-apps.md §3.10):蓝图、试运行、发布单、模板、文件、个人应用、页面编辑候选、记录。 * 约定在 typesBlueprint.ts。流式的蓝图助手、填关节走 store 里的 SSE(同 copilot),不在这里。 */ +import type { ConversationDetail } from '../types' import type { AppSpec } from '../typesApp' import type { AppCandidate, Blueprint, BlueprintScope, BlueprintSourceKind, BpOp, FileRef, PlainIssue, PublishPlanRow, RecordsPage, @@ -22,13 +23,17 @@ export const blueprints = { post(`/blueprints/${encodeURIComponent(id)}/trial`, body), preview: (id: string, q: { page?: string; run?: string } = {}) => get(`/blueprints/${encodeURIComponent(id)}/preview${qs(q)}`), - publishPlan: (id: string, body: { level: 'published' | 'governed'; first_run?: boolean }) => + /** dispositions:固定版本升了版时每一项的处置(别名 → recompute / dual / incomparable),口径升版必须处置 */ + publishPlan: (id: string, body: { level: 'published' | 'governed'; first_run?: boolean; + dispositions?: Record }) => post(`/blueprints/${encodeURIComponent(id)}/publish-plan`, body), plan: (planId: string) => get(`/publish-plans/${encodeURIComponent(planId)}`), runPlan: (planId: string) => post(`/publish-plans/${encodeURIComponent(planId)}/run`, {}), resumePlan: (planId: string) => post(`/publish-plans/${encodeURIComponent(planId)}/resume`, {}), // ---- 个人应用(N7) savePersonal: (id: string) => post<{ app_id: string; blueprint: Blueprint }>(`/blueprints/${encodeURIComponent(id)}/save-personal`, {}), + /** 蓝图对话的历史:权限按蓝图判,没开问数据的使用者也读得到自己蓝图的对话 */ + conversation: (id: string) => get(`/blueprints/${encodeURIComponent(id)}/conversation`), copyForReview: (id: string) => post(`/blueprints/${encodeURIComponent(id)}/copy`, {}), } @@ -53,6 +58,6 @@ export const personal = { submitReview: (appId: string, body: { to: { kind: 'user' | 'group'; name: string }[]; note?: string }) => post<{ task_id: string }>(`/apps/${encodeURIComponent(appId)}/submit-review`, body), candidates: (appId: string) => get<{ items: AppCandidate[] }>(`/apps/${encodeURIComponent(appId)}/candidates`), - records: (appId: string, q: { table?: string; cursor?: string; history?: boolean } = {}) => + records: (appId: string, q: { table?: string; who?: 'mine' | 'all'; cursor?: string; history?: boolean } = {}) => get(`/apps/${encodeURIComponent(appId)}/records${qs(q)}`), } diff --git a/frontend/src/apps/ViewRenderer.tsx b/frontend/src/apps/ViewRenderer.tsx index f25358d5..49f40d0b 100644 --- a/frontend/src/apps/ViewRenderer.tsx +++ b/frontend/src/apps/ViewRenderer.tsx @@ -7,12 +7,16 @@ * * 应用编辑器的预览(FE-6)也用它:传按草稿解析出的视图模型、preview 为真——不发起运行、不提交表单,点数字照样能看出处。 * 一个组件画坏了只坏它自己(每个组件各包一层错误边界),不连累整页。 + * + * 隐藏(normal-user-apps N6):作者隐藏的组件,发布版解析时服务端就跳过;按草稿解析时照样下发、带 hidden。这里只看这个 + * 标记:应用页上直接改的编辑状态(editing)画成半透明、标「已隐藏」,别处(编辑器的预览)照使用者看到的样子不画。 + * 编辑状态另给草稿里隐藏了的组件 id(editing.hidden):服务端没带标记的也认得出来。 */ -import { useCallback, useMemo, useState } from 'react' +import { createContext, useCallback, useContext, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import { AlertTriangle, PackageX } from 'lucide-react' +import { AlertTriangle, EyeOff, PackageX } from 'lucide-react' import { ErrorBoundary } from '../components/ui' -import { APP_TEXT, VIEW_TEXT } from '../lib/termsApp' +import { APP_TEXT, PAGE_EDIT_TEXT, VIEW_TEXT } from '../lib/termsApp' import type { ViewEvidence, ViewModel, ViewNode } from '../typesApp' import { ViewCtx, type ViewCtxValue } from './context' import { RENDERERS } from './components' @@ -29,9 +33,22 @@ export interface ViewRendererProps { onNavigate?: (pageId: string) => void /** 视图刷新、实例发起之后要重新取视图模型 */ onReload?: () => void + /** 应用页上直接改的编辑状态:隐藏的组件画成半透明(不传时隐藏的组件不画) */ + editing?: ViewEditMarks } -export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload }: ViewRendererProps) { +/** 编辑状态的标记:草稿里隐藏了的组件 id */ +export interface ViewEditMarks { + hidden: ReadonlySet +} + +const EditMarksCtx = createContext(null) + +/** 服务端按草稿解析时给隐藏的组件带的标记(发布版里没有这样的组件) */ +const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => + (node as ViewNode & { hidden?: boolean }).hidden === true || !!marks?.hidden.has(node.id) + +export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing }: ViewRendererProps) { const [evidence, setEvidence] = useState(null) const openEvidence = useCallback((ev: ViewEvidence, label: string, text?: string) => { setEvidence({ evidence: ev, label, text }) @@ -46,7 +63,7 @@ export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, return ( -
+
{problems.length > 0 && (
@@ -63,7 +80,7 @@ export function ViewRenderer({ model, appId, runId, preview = false, onNavigate,
)} - {render(model.tree)} + {render(model.tree)}
setEvidence(null)} /> @@ -73,15 +90,30 @@ export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, let generation = 0 function Node({ node, gen }: { node: ViewNode; gen: string }) { + const marks = useContext(EditMarksCtx) + const hidden = isHidden(node, marks) + // 隐藏的组件只在编辑状态下画(半透明);别处照使用者看到的样子跳过 + if (hidden && !marks) return null const Renderer = RENDERERS[node.type] if (!Renderer) return - return ( + const body = ( -
+ {/* 编辑状态下组件可以聚焦:键盘也能选中(编辑层按回车选中) */} +
) + if (!hidden) return body + return ( +
+ + {PAGE_EDIT_TEXT.hiddenTag} + + {body} +
+ ) } function Unknown({ node }: { node: ViewNode }) { diff --git a/frontend/src/apps/components/display.tsx b/frontend/src/apps/components/display.tsx index cf3984b0..f7a841ec 100644 --- a/frontend/src/apps/components/display.tsx +++ b/frontend/src/apps/components/display.tsx @@ -8,7 +8,9 @@ import { useEffect, useMemo, useState } from 'react' import { ArrowDown, ArrowUp, RefreshCw, ShieldCheck } from 'lucide-react' import clsx from 'clsx' import { api } from '../../api/client' +import { FileLink } from '../../components/FileInput' import { Notice, Spinner, StatusPill, useToast } from '../../components/ui' +import { isFileRef } from '../../lib/schemaForm' import { Markdown } from '../../run/Markdown' import { EvidenceDoc } from '../../run/EvidenceDoc' import { useEvidence } from '../../store/evidence' @@ -353,6 +355,8 @@ export function FieldBlock({ node }: NodeProps) { {label} {state ? : data.state === 'masked' ? {text || VIEW_TEXT.masked} + // 绑定的输入是提交的文件(normal-user-apps N9):写成可下载的链接 + : isFileRef(data.value) ? : !text ? — : evidence && evidence.kind !== 'report' ? : evidence ? diff --git a/frontend/src/apps/components/index.ts b/frontend/src/apps/components/index.ts index 000d8211..e983eb24 100644 --- a/frontend/src/apps/components/index.ts +++ b/frontend/src/apps/components/index.ts @@ -12,6 +12,7 @@ import { FormBlock } from './input' import { ActionStatus, EntryButton, InstanceHeader, InstanceList, InstanceTimeline, SignoffsBlock, TaskForm, TaskList, } from './process' +import { RecordsList } from './records' export const RENDERERS: Record> = { // 布局 @@ -42,4 +43,6 @@ export const RENDERERS: Record> = { task_form: TaskForm, signoffs: SignoffsBlock, action_status: ActionStatus, + // 填报登记的逐行查看(normal-user-apps N8):按看的人实时取,使用者只看自己的 + records_list: RecordsList, } diff --git a/frontend/src/apps/components/records.tsx b/frontend/src/apps/components/records.tsx new file mode 100644 index 00000000..5449a516 --- /dev/null +++ b/frontend/src/apps/components/records.tsx @@ -0,0 +1,274 @@ +/** + * 记录列表 records_list(docs/design/normal-user-apps.md N8、§3.7、§4.7):填报登记每次提交是记录表里的一行。 + * + * 不走封存:每次打开按看的人实时取,行级可见在服务端做——使用(use)只看自己提交的,查看全部(observe)和管理(manage) + * 看到全部,这里只照服务端说的 mine_only 写标题和说明(「我的记录」「全部记录」),不在浏览器里按人过滤(P4)。第一页用 + * 服务端解析页面时已经取好的(node.data,apps/resolve._records_list),不再取一遍;重新读取、下一页、更正历史才调 + * GET /api/apps/{id}/records。没写 table 的取这个应用的第一张记录表(同服务端);who 照组件属性带上(只看自己的)。 + * 别人提交的行里遮罩的列服务端已经写成「已遮罩」,照原样写。 + * + * 列表、筛选(按内容或提交人)、按时间排序(序号就是提交的先后);每行可以展开看全部字段和附件。更正不改原行:更正是 + * 一次新的提交,指向被更正的那一行(_supersedes),默认只列当前有效的行;更正过的行标「已更正」,展开能看更正历史 + * (带 history 再取一次,顺着指向往前找)。值照服务端交来的原样写,这里不格式化数、不合计(叙述层无算术)。 + */ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { ChevronDown, ChevronRight, History, Paperclip, RotateCw, Search } from 'lucide-react' +import { api } from '../../api/client' +import { FileLink } from '../../components/FileInput' +import { ErrorState, Spinner } from '../../components/ui' +import { formatDateTime } from '../../lib/format' +import { isFileRef } from '../../lib/schemaForm' +import { RECORDS_TEXT as T } from '../../lib/termsApp' +import type { RecordRow, RecordsPage } from '../../typesBlueprint' +import { str, useView, type NodeProps } from '../context' + +/** 服务端可能给的下一页游标、状态(typesBlueprint.RecordsPage 还没有这两项,契约待补):empty 是这个应用还没有记录表 */ +type Page = RecordsPage & { next_cursor?: string | null; state?: string } + +/** 服务端解析页面时给的第一页(node.data):形状对得上才用 */ +function pageOf(data: unknown): Page | null { + return data && typeof data === 'object' && Array.isArray((data as Page).items) && Array.isArray((data as Page).columns) + ? data as Page : null +} + +/** 一格的值写给人看:文件是可下载的链接,数组用顿号连,布尔写是或否,其余原样 */ +function Value({ v }: { v: unknown }) { + if (v == null || v === '') return — + if (isFileRef(v)) return + if (Array.isArray(v)) { + return v.some(isFileRef) + ? {v.map((x, i) => )} + : {v.map((x) => String(x)).join('、')} + } + if (typeof v === 'boolean') return {v ? T.yes : T.no} + if (typeof v === 'object') return {JSON.stringify(v)} + return {String(v)} +} + +/** 筛选用的文字:各列的值和提交人(文件按文件名) */ +function haystack(row: RecordRow): string { + const parts = Object.values(row.values ?? {}).map((v) => (isFileRef(v) ? v.name : Array.isArray(v) ? v.join(' ') : v == null ? '' : String(v))) + return [...parts, row._submitted_by ?? ''].join(' ').toLowerCase() +} + +export function RecordsList({ node }: NodeProps) { + const { appId } = useView() + const table = str(node.props.table) || undefined + const who = node.props.who === 'mine' || node.props.who === 'all' ? node.props.who : undefined + const initial = pageOf(node.data) + const [page, setPage] = useState(initial) + const [error, setError] = useState(null) + const [more, setMore] = useState(false) + const [query, setQuery] = useState('') + const [order, setOrder] = useState<'newest' | 'oldest'>('newest') + const [open, setOpen] = useState>(new Set()) + // 更正历史:带 history 取一次(含已被更正的行),按 _id 找 + const [history, setHistory] = useState | null>(null) + const [historyError, setHistoryError] = useState(null) + const [historyFor, setHistoryFor] = useState>(new Set()) + + const load = useCallback(() => { + let alive = true + setError(null) + api.personal.records(appId, { table, who }).then((p) => { if (alive) setPage(p) }, (e) => { if (alive) setError(e) }) + return () => { alive = false } + }, [appId, table, who]) + // 页面重新解析过(node.data 换了)就用新的那一页;服务端没给(老服务端)才自己取 + useEffect(() => { + if (initial) { setPage(initial); return } + return load() + }, [initial, load]) + const loadMore = async () => { + if (!page?.next_cursor) return + setMore(true) + try { + const next: Page = await api.personal.records(appId, { table, who, cursor: page.next_cursor }) + setPage({ ...next, items: [...page.items, ...next.items] }) + } catch (e) { + setError(e) + } finally { + setMore(false) + } + } + const showHistory = (id: string) => { + setHistoryFor((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n }) + if (history || historyError) return + api.personal.records(appId, { table, who, history: true }).then( + (p) => setHistory(new Map(p.items.map((r) => [r._id, r]))), + setHistoryError, + ) + } + + const rows = useMemo(() => { + const q = query.trim().toLowerCase() + const list = (page?.items ?? []).filter((r) => !q || haystack(r).includes(q)) + return [...list].sort((a, b) => (order === 'newest' ? b._seq - a._seq : a._seq - b._seq)) + }, [page, query, order]) + const columns = page?.columns ?? [] + const mine = !!page?.mine_only + const title = mine ? T.mine : str(node.props.title) || T.all + + return ( +
+
+
+
{title}
+ {page && ( +
+ {mine ? T.mineHint : T.allHint} + {mine && str(node.props.title) ? ` · ${str(node.props.title)}` : ''} + {page.version > 1 ? ` · ${T.version(page.version)}` : ''} +
+ )} +
+ {page && page.items.length > 0 && ( + {T.count(rows.length, page.items.length)} + )} + +
+ {page?.state === 'empty' && !page.items.length ?
{T.tableMissing}
+ : error && !page ? + : !page ?
+ : ( + <> + {page.items.length > 0 && ( +
+ + +
+ )} + {!page.items.length ?
{mine ? T.emptyMine : T.empty}
+ : !rows.length ?
{T.emptyFiltered}
+ : ( +
    + {rows.map((r) => ( + setOpen((s) => { const n = new Set(s); if (n.has(r._id)) n.delete(r._id); else n.add(r._id); return n })} + historyOpen={historyFor.has(r._id)} onHistory={() => showHistory(r._id)} + history={history} historyError={historyError} /> + ))} +
+ )} + {page.next_cursor && ( +
+ +
+ )} + + )} +
+ ) +} + +function RecordItem({ row, columns, mine, open, onToggle, historyOpen, onHistory, history, historyError }: { + row: RecordRow; columns: RecordsPage['columns']; mine: boolean; open: boolean; onToggle: () => void + historyOpen: boolean; onHistory: () => void; history: Map | null; historyError: unknown +}) { + // 收起时写前两列,够认出是哪一条;全部字段展开看 + const brief = columns.slice(0, 2) + const corrected = !!row._supersedes + const panelId = `record-${row._id}` + return ( +
  • + + {open && ( +
    + +
    + {T.seq(row._seq)} + {row._submitted_by && {T.by(row._submitted_by)}} + {formatDateTime(row._submitted_at)} +
    + {corrected && ( +
    + + {historyOpen && } +
    + )} +
    + )} +
  • + ) +} + +/** 收起时的一格:文件只写文件名,长文字截断交给外层 */ +function BriefValue({ v }: { v: unknown }) { + if (v == null || v === '') return — + if (isFileRef(v)) return {v.name} + if (Array.isArray(v)) return {v.map((x) => (isFileRef(x) ? x.name : String(x))).join('、')} + if (typeof v === 'boolean') return {v ? T.yes : T.no} + return {typeof v === 'object' ? JSON.stringify(v) : String(v)} +} + +function Fields({ row, columns }: { row: RecordRow; columns: RecordsPage['columns'] }) { + return ( +
    + {columns.map((c) => ( +
    +
    {c.type === 'file' ? <>{c.label || c.name} : c.label || c.name}
    +
    +
    + ))} +
    + ) +} + +/** 更正历史:顺着 _supersedes 往前找,最近的一次更正前在最上面 */ +function HistoryChain({ row, columns, history, error }: { + row: RecordRow; columns: RecordsPage['columns']; history: Map | null; error: unknown +}) { + if (error) return + if (!history) return
    {T.historyLoading}
    + const chain: RecordRow[] = [] + const seen = new Set([row._id]) + for (let at = row._supersedes; at && !seen.has(at); at = history.get(at)?._supersedes ?? null) { + seen.add(at) + const prev = history.get(at) + if (!prev) break + chain.push(prev) + } + if (!chain.length) return
    {T.historyEmpty}
    + return ( +
      + {chain.map((r) => ( +
    1. +
      + {T.historyItem(r._seq, formatDateTime(r._submitted_at))}{r._submitted_by ? ` · ${T.by(r._submitted_by)}` : ''} +
      + +
    2. + ))} +
    + ) +} diff --git a/frontend/src/apps/context.ts b/frontend/src/apps/context.ts index 82c872a6..06ce2b9f 100644 --- a/frontend/src/apps/context.ts +++ b/frontend/src/apps/context.ts @@ -15,7 +15,8 @@ * - table、chart:{state, columns, rows(二维数组,遮罩的格子服务端已换成「已遮罩」), masked, total_rows, truncated, * evidence(kind 为 query,带查询快照的工件)} * - report:{state, node, doc_artifact, evidence} - * - field(输入、成果、运行信息):{state, text, value?, evidence?};status:{state, value(运行状态), text} + * - field(输入、成果、运行信息):{state, text, value?, evidence?}(value 是提交的文件引用时画成下载链接); + * status:{state, value(运行状态), text} * - form:{form}(表单名,表单在 ViewModel.forms 里)。提交即按这些值重新解析本页(按需视图的参数),见 input.tsx * - entry_button:{entry, label, disabled, reason?} * - instance_list:{who, entry, items: InstanceRow[]} diff --git a/frontend/src/components/FileInput.tsx b/frontend/src/components/FileInput.tsx new file mode 100644 index 00000000..2ce97bc0 --- /dev/null +++ b/frontend/src/components/FileInput.tsx @@ -0,0 +1,210 @@ +/** + * 文件控件(docs/design/normal-user-apps.md N9、§3.8、§4.7):表单里 x-widget 为 file 的字段。 + * + * 两步走:先上传(POST /api/files,带上下文:应用的入口、待办或蓝图),拿回引用 {file_id, name, size, sha256, type}, + * 引用就是这个字段的值,随表单一起提交;服务端提交时核对引用属于提交人、没被别的提交用过,再绑到这次运行。 + * 上下文由用它的地方给(SchemaForm 的 fileContext):不在入口、待办、试运行里的表单(画布的发起表单、页面的筛选表单) + * 没有上下文,照实说这里不能上传。 + * + * 拖放或点选;写明文件名、大小、真实的上传进度(XHR 的字节进度,发完之后是服务端在核对,不把条拉满冒充完成)、允许的 + * 类型和大小。类型和大小先在这里按 x-file 拦一道(省一次白传),服务端照样核对、以服务端为准;被拒的照实说原因。 + * 这次上传、还没提交的文件,换掉或移除时顺手删掉(只能删自己没绑定的,删不掉的服务端 24 小时后清理)。 + * 只读(已提交)时显示成可下载的链接(GET /api/files/{id},服务端按绑定的运行或记录核对读权限)。 + */ +import { useEffect, useRef, useState } from 'react' +import type { DragEvent as ReactDragEvent } from 'react' +import { FileUp, Paperclip, RefreshCw, Upload, X } from 'lucide-react' +import clsx from 'clsx' +import { api } from '../api/client' +import type { UploadProgress } from '../api/http' +import { formatBytes } from '../lib/format' +import { humanizeError } from '../lib/errors' +import { FILE_TEXT } from '../lib/termsApp' +import { isFileRef, toFileRef, type FileLimits } from '../lib/schemaForm' +import type { FileRef } from '../typesBlueprint' +import { Spinner } from './ui' + +/** 允许的类型写给人看:MIME 换成常见的叫法,扩展名照写;同名的只写一次 */ +export function typesText(accept: string[]): string { + const labels = accept.map((a) => FILE_TEXT.typeLabels[a.toLowerCase()] ?? (a.startsWith('.') ? a.slice(1).toUpperCase() : a)) + return [...new Set(labels)].join('、') +} + +/** 这个文件在不在允许的类型里:扩展名、MIME、MIME 通配(image/*)。浏览器认不出类型、又只给了 MIME 的,交给服务端判 */ +export function accepts(accept: string[], file: { name: string; type: string }): boolean { + if (!accept.length) return true + const name = file.name.toLowerCase() + const type = (file.type || '').toLowerCase() + if (!type && !accept.some((a) => a.startsWith('.'))) return true + return accept.some((raw) => { + const a = raw.trim().toLowerCase() + if (a.startsWith('.')) return name.endsWith(a) + if (a.endsWith('/*')) return type.startsWith(a.slice(0, -1)) + return type === a + }) +} + +/** 已提交的文件:可下载的链接(服务端按绑定的运行或记录核对读权限),写文件名和大小。记录列表、字段组件也用它 */ +export function FileLink({ file, id, describedBy }: { file: FileRef; id?: string; describedBy?: string }) { + return ( + + + {file.name} + {formatBytes(file.size)} + + ) +} + +export interface FileInputProps { + id: string + /** 控件的名字(字段标题),读屏和取消按钮用 */ + label: string + value: unknown + onChange: (v: FileRef | undefined) => void + readOnly: boolean + /** 上传的上下文:{app_id, entry} | {task_id} | {blueprint_id};没有就不能上传 */ + context?: Record + limits: FileLimits + invalid?: boolean + describedBy?: string +} + +interface Sending { name: string; size: number; progress: UploadProgress | null } + +export function FileInput({ id, label, value, onChange, readOnly, context, limits, invalid, describedBy }: FileInputProps) { + const ref = isFileRef(value) ? value : null + const accept = limits.accept ?? [] + const [dragging, setDragging] = useState(false) + const [sending, setSending] = useState(null) + const [failure, setFailure] = useState(null) + const ctrl = useRef(null) + const picker = useRef(null) + // 这次上传的:换掉、移除时删掉(还没绑定)。默认值、带回来的引用不是这里传的,不删 + const mine = useRef(new Set()) + useEffect(() => () => ctrl.current?.abort(), []) + + if (readOnly) { + return ref ? + :
    {FILE_TEXT.empty}
    + } + + const allowed = accept.length ? typesText(accept) : null + const limitLine = [allowed ? FILE_TEXT.types(allowed) : null, limits.max_mb ? FILE_TEXT.maxSize(limits.max_mb) : null] + .filter(Boolean).join(';') || FILE_TEXT.serverChecks + + const drop = (old: FileRef | null) => { + if (old && mine.current.has(old.file_id)) { + mine.current.delete(old.file_id) + void api.files.remove(old.file_id).catch(() => undefined) + } + } + const upload = async (file: File) => { + setFailure(null) + if (!context) return + if (limits.max_mb && file.size > limits.max_mb * 1024 * 1024) { setFailure(FILE_TEXT.tooLarge(file.name, limits.max_mb)); return } + if (!accepts(accept, file)) { setFailure(FILE_TEXT.badType(file.name, allowed ?? '')); return } + const c = new AbortController() + ctrl.current?.abort() + ctrl.current = c + setSending({ name: file.name, size: file.size, progress: null }) + try { + const res = toFileRef(await api.files.upload(file, context, { + signal: c.signal, + onProgress: (progress) => setSending((s) => (s ? { ...s, progress } : s)), + })) + mine.current.add(res.file_id) + drop(ref) + onChange(res) + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') setFailure(FILE_TEXT.cancelled) + else { + const h = humanizeError(e) + setFailure(FILE_TEXT.failed(file.name, h.reason ? `${h.title},${h.reason}` : h.title)) + } + } finally { + if (ctrl.current === c) ctrl.current = null + setSending(null) + } + } + const choose = () => picker.current?.click() + const hasFiles = (e: ReactDragEvent) => Array.from(e.dataTransfer?.types ?? []).includes('Files') + const pct = sending?.progress?.total ? Math.min(1, sending.progress.loaded / sending.progress.total) : null + + return ( +
    + { const f = e.target.files?.[0]; e.target.value = ''; if (f) void upload(f) }} /> + {sending ? ( +
    +
    + + {sending.name} + {formatBytes(sending.size)} + {!sending.progress?.sent && ( + + )} +
    +
    + {sending.progress?.sent ? FILE_TEXT.sent : ( + {FILE_TEXT.sending}{pct != null ? ` · ${Math.round(pct * 100)}%` : ''} + )} +
    + {pct != null && !sending.progress?.sent && ( +
    +
    +
    + )} +
    + ) : ref ? ( +
    + + {ref.name} + {formatBytes(ref.size)} + {context && ( + + )} + +
    + ) : ( + + )} + {failure &&
    {failure}
    } + {context && ( +
    + {limitLine}。{FILE_TEXT.whoCanDownload} +
    + )} +
    + ) +} diff --git a/frontend/src/components/SchemaForm.tsx b/frontend/src/components/SchemaForm.tsx index 8c264351..ffa52aaa 100644 --- a/frontend/src/components/SchemaForm.tsx +++ b/frontend/src/components/SchemaForm.tsx @@ -6,7 +6,8 @@ * 按 path 挂到字段下面。对不上任何字段的错误(服务端说的是整张表,或者字段名对不上)照实列在表单顶上,不吞掉。 * * 值的类型和表单声明的一致:数字字段存数(输入途中的「1.」这类半截写法留在框里,不回写成别的数),日期时间存带本地 - * 时区的 RFC 3339,多选存字符串数组。没填的不进值(undefined),必填由校验拦。 + * 时区的 RFC 3339,多选存字符串数组,文件存上传后拿回的引用(FileInput)。没填的不进值(undefined),必填由校验拦。 + * 文件要先上传:上传的上下文(应用的入口、待办、蓝图)由用表单的地方给(fileContext),不给的地方不能上传。 */ import { useEffect, useId, useState } from 'react' import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react' @@ -14,12 +15,13 @@ import clsx from 'clsx' import type { FormError, FormField, FormSchema } from '../typesApp' import { FORM_TEXT } from '../lib/termsApp' import { - fieldOfPath, fieldOrder, localToRfc3339, optionsOf, rfc3339ToLocal, widgetOf, + fieldOfPath, fieldOrder, fileLimitsOf, localToRfc3339, optionsOf, rfc3339ToLocal, widgetOf, } from '../lib/schemaForm' import { matchShortcut } from '../lib/keys' import { useAtLeast } from '../store/auth' import { JsonInput, isComposing } from './ui' import { PrincipalPicker } from './PrincipalPicker' +import { FileInput } from './FileInput' export interface SchemaFormProps { schema: FormSchema @@ -32,9 +34,11 @@ export interface SchemaFormProps { onSubmit?: () => void /** 控件 id 的前缀,同一页有多份表单时避免撞 id */ idPrefix?: string + /** 文件控件上传的上下文:{app_id, entry}(入口)、{task_id}(待办)、{blueprint_id}(试运行)。不给就不能上传 */ + fileContext?: Record } -export function SchemaForm({ schema, value, onChange, errors, readOnly, onSubmit, idPrefix }: SchemaFormProps) { +export function SchemaForm({ schema, value, onChange, errors, readOnly, onSubmit, idPrefix, fileContext }: SchemaFormProps) { const auto = useId().replace(/[^a-zA-Z0-9_-]/g, '') const prefix = idPrefix ?? `sf${auto}` const order = fieldOrder(schema) @@ -77,15 +81,16 @@ export function SchemaForm({ schema, value, onChange, errors, readOnly, onSubmit {order.map((key) => ( set(key, v)} /> + readOnly={readOnly || !!schema.properties[key].readOnly} onChange={(v) => set(key, v)} + fileContext={fileContext} /> ))}
    ) } -function FieldRow({ id, name, field, required, value, error, readOnly, onChange }: { +function FieldRow({ id, name, field, required, value, error, readOnly, onChange, fileContext }: { id: string; name: string; field: FormField; required: boolean; value: unknown; error?: FormError - readOnly: boolean; onChange: (v: unknown) => void + readOnly: boolean; onChange: (v: unknown) => void; fileContext?: Record }) { const widget = widgetOf(field) const title = field.title || name @@ -145,6 +150,17 @@ function FieldRow({ id, name, field, required, value, error, readOnly, onChange
    ) } + if (widget === 'file') { + // 文件控件自己有按钮和链接:标题指着它的主按钮(id),限制和「谁能下载」写在控件下面 + return ( +
    + + + {foot} +
    + ) + } if (widget === 'checkbox') { return (
    diff --git a/frontend/src/lib/schemaForm.ts b/frontend/src/lib/schemaForm.ts index e3da233e..5822bf1c 100644 --- a/frontend/src/lib/schemaForm.ts +++ b/frontend/src/lib/schemaForm.ts @@ -6,9 +6,43 @@ * 后端拒收的情形就是从这里来的(P5)。 * * 只有一层:顶层 object,字段是 string / number / integer / boolean,数组只装枚举字符串(多选)。 + * + * 文件(normal-user-apps N9、§3.8):字段 type 为 object、x-widget 为 file,只有这个控件能用 object。值是上传后拿回的 + * 引用 {file_id, name, size, sha256, type},规则和服务端 apps/forms.py 同一份:必填,形状是文件引用。允许的类型和大小 + * 写在 x-file(accept、max_mb),只在上传时用,提交时不再查(上传那一步服务端已经核对过)。 */ import type { FormError, FormField, FormSchema, FormWidget } from '../typesApp' -import { FORM_TEXT } from './termsApp' +import type { FileRef } from '../typesBlueprint' +import { FILE_TEXT, FORM_TEXT } from './termsApp' + +/** 文件控件的限制:允许的类型(MIME 或扩展名)、大小上限(MB)。x-file 由服务端随表单给 */ +export interface FileLimits { accept?: string[]; max_mb?: number | null } + +/** 表单字段上的 x-file(typesApp.FormField 还没有这一项,见报告的接口约定请求) */ +export function fileLimitsOf(field: FormField): FileLimits { + const raw = (field as FormField & { 'x-file'?: unknown })['x-file'] + if (!raw || typeof raw !== 'object') return {} + const x = raw as { accept?: unknown; max_mb?: unknown } + const accept = Array.isArray(x.accept) ? x.accept.filter((a): a is string => typeof a === 'string' && !!a.trim()) + : typeof x.accept === 'string' ? x.accept.split(',').map((a) => a.trim()).filter(Boolean) : undefined + return { + accept: accept?.length ? accept : undefined, + max_mb: typeof x.max_mb === 'number' && Number.isFinite(x.max_mb) && x.max_mb > 0 ? x.max_mb : null, + } +} + +/** 是不是上传后拿回的文件引用:五个键(同服务端 app/apps/spec.FILE_REF_KEYS)都在、类型对 */ +export function isFileRef(v: unknown): v is FileRef { + if (!v || typeof v !== 'object' || Array.isArray(v)) return false + const r = v as Record + return typeof r.file_id === 'string' && !!r.file_id && typeof r.name === 'string' && typeof r.sha256 === 'string' + && typeof r.type === 'string' && typeof r.size === 'number' && Number.isFinite(r.size) && r.size >= 0 +} + +/** 上传接口的回包 → 表单值:只留引用的五个键,服务端另带的不进运行输入 */ +export function toFileRef(raw: FileRef): FileRef { + return { file_id: raw.file_id, name: raw.name, size: raw.size, sha256: raw.sha256, type: raw.type } +} /** 字段顺序:x-order 里写到的在前(按写的顺序),没写到的按 properties 的原顺序跟在后面;不存在的名字跳过 */ export function fieldOrder(schema: FormSchema): string[] { @@ -20,6 +54,8 @@ export function fieldOrder(schema: FormSchema): string[] { /** 这个字段画成哪种控件:写了 x-widget 照它,没写按类型推 */ export function widgetOf(field: FormField): FormWidget { if (field['x-widget']) return field['x-widget'] + // 只有文件控件用 object(typesApp.FormField 的 type 还没列 object) + if ((field.type as string) === 'object') return 'file' switch (field.type) { case 'boolean': return 'checkbox' case 'array': return 'multiselect' @@ -103,6 +139,7 @@ export function validateForm(schema: FormSchema, values: Record function fieldError(field: FormField, v: unknown, required: boolean): Omit | null { if (isEmpty(v)) return required ? { code: 'required', message: FORM_TEXT.required } : null + if (widgetOf(field) === 'file') return isFileRef(v) ? null : { code: 'type', message: FILE_TEXT.invalid } switch (field.type) { case 'string': { if (typeof v !== 'string') return { code: 'type', message: FORM_TEXT.type.string } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 5469d568..bce668b1 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5213,6 +5213,7 @@ export const LOOK_TEXT = { tables: (n: number) => `${formatNumber(n)} 张表`, capabilities: (n: number) => `${formatNumber(n)} 个能力`, versions: (n: number) => `${formatNumber(n)} 个工作流版本`, + calibers: (n: number) => `${formatNumber(n)} 张口径卡`, title: (what: string) => `查看了 ${what}`, /** 系统代为查看:发布前修复、升级证据按要修的 SQL 点名;应用润色先查看固定的版本 */ systemTables: (what: string) => `按要修的 SQL 查看了 ${what}`, @@ -5634,6 +5635,8 @@ export const APP_SETTING_TEXT = { onDemandLimit: '按需视图每日上限', onDemandLimitHint: '每个应用每天最多按需发起几次运行,命中缓存的不计;填 0 表示不再发起按需运行。到上限后,视图提示次日再试', onDemandLimitInvalid: '请填写 0 到 100,000 之间的整数', + personalApps: '使用者可以建个人应用', + personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制', } /** 自定义工具绑定登记的端点(§3.9):只填路径,地址和认证头由端点提供 */ diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 310a222d..9ae506fc 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -65,8 +65,11 @@ export const APP_HOME_TEXT = { taskNotFound: '待办不存在,所属的运行可能已被删除', } -/** 待办的种类:看中断内容里的 kind,不看 mode(§3.7) */ -export const TASK_KIND_LABEL: Record = { +/** + * 待办的种类:看中断内容里的 kind,不看 mode(§3.7)。review 是个人应用提交审核(docs/design/normal-user-apps.md N7): + * typesApp 的 TaskKind 还没有它(契约待补),这里先按多一种写 + */ +export const TASK_KIND_LABEL: Record = { approve: '人工审批', input: '补充输入', edit: '改稿', @@ -76,6 +79,7 @@ export const TASK_KIND_LABEL: Record = { action_confirm: '动作确认', reconcile: '核实结果', signal: '等待外部系统', + review: '审核个人应用', } export const TASK_STATUS_LABEL: Record = { @@ -456,3 +460,184 @@ export const PUBLISH_ROLE_TEXT = { downgradeConsequences: ['当前为受管工作流,发布后降为已发布', '此后从这一版发起的正式运行不再受治理门禁约束'], downgradeConfirm: '降级并发布', } + +// --------------------------------------------------------------------------- +// 普通用户建应用(docs/design/normal-user-apps.md §4.5–§4.7):页面上直接改、文件控件、记录列表、个人应用。 +// 蓝图、模板、试运行、发布摘要的说法在 termsBlueprint.ts +// --------------------------------------------------------------------------- + +/** 应用页上直接改(N6):编辑层、小工具条、加一张数据卡片、发布 */ +export const PAGE_EDIT_TEXT = { + edit: '编辑', + editHint: '在页面上直接改文字、隐藏组件、调整顺序;改动自动存为草稿,发布后使用者才看得到', + done: '完成编辑', + editing: '编辑中', + pickHint: '点选页面上的组件进行修改。改动自动存为草稿,按草稿重新显示;发布之前,使用者看到的还是当前发布版。', + saving: '正在保存…', + saved: '已存为草稿', + saveFailed: '草稿保存失败,页面已恢复为上一次保存的样子', + unpublished: '有未发布的改动', + neverPublished: '这个应用还没有发布', + draftSameAsPublished: '草稿和当前发布版一致', + publish: '发布', + publishing: '正在发布…', + publishTitle: '发布这些改动?', + publishConsequences: ['按现在的草稿生成新的应用版本,使用者打开时看到改过的样子', '只改页面呈现,不改工作流,也不发起运行'], + publishConfirm: '发布', + published: (v: number) => `已发布应用版本 v${v}`, + publishBlocked: '发布前检查没有通过,需要先处理这些问题:', + openCheck: '到应用编辑器处理', + openEditor: '应用编辑器', + openEditorHint: '入口、表单、岗位和授权在应用编辑器里改', + changeData: '改取数', + changeDataHint: '取数逻辑在蓝图里改:改完要用真数再试一次,口径变了还要说清以前的数怎么处理', + personalNoPublish: '个人应用没有发布这一步:改动保存后直接生效', + // ---- 小工具条 + toolbar: (label: string) => `「${label}」的操作`, + editText: '改文字', + editTextTitle: (label: string) => `改「${label}」的文字`, + editTextHint: '只改组件上显示的文字,不改它的数据来源', + noText: '这个组件没有可改的文字', + saveText: '保存文字', + hide: '隐藏', + unhide: '取消隐藏', + hiddenTag: '已隐藏', + hiddenHint: '作者隐藏了它:使用者看不到。可见性条件是按运行情况决定显示与否,隐藏是不要它', + moveUp: '上移', + moveDown: '下移', + remove: '删除', + removeTitle: (label: string) => `删除「${label}」?`, + removeConsequences: ['从草稿里删掉这个组件,发布后使用者不再看到它', '数据来源不受影响,以后可以用「加一张数据卡片」再加回来'], + removeConfirm: '删除组件', + /** 草稿里隐藏了、页面上没有画出来的组件:列在页底,可以取消隐藏 */ + hiddenElsewhere: (n: number) => `这一页还有 ${n} 个隐藏的组件没有显示出来:`, + // ---- 加一张数据卡片 + addCard: '加一张数据卡片', + addCardHint: '从这个应用固定版本的结果里挑:口径卡的指标、报告、明细或趋势', + candidatesLoading: '正在列出可加的数据卡片…', + candidatesEmpty: '没有可加的数据卡片:固定版本里没有口径卡、报告、取数结果或趋势', + candidateKinds: { metric: '口径卡指标', report: '报告', table: '明细', series: '趋势' } as Record, + addThis: '加上', + added: (label: string) => `已加上「${label}」`, + close: '关闭', + /** 应用编辑器属性面板里的「隐藏」开关 */ + hiddenSwitch: '隐藏这个组件', +} + +/** 文件控件(N9):拖放或点选,先上传拿引用,再随表单提交 */ +export const FILE_TEXT = { + drop: '把文件拖到这里,或点击选择', + dropNow: '松开即上传', + choose: '选择文件', + sending: '正在上传', + sent: '上传完成,正在等服务端核对', + cancel: '取消上传', + cancelled: '已取消上传', + remove: '移除', + replace: '换一个文件', + download: '下载', + types: (list: string) => `允许的类型:${list}`, + maxSize: (mb: number) => `单个文件不超过 ${mb} MB`, + serverChecks: '类型和大小由服务端核对', + tooLarge: (name: string, mb: number) => `「${name}」超过 ${mb} MB,不能上传`, + badType: (name: string, list: string) => `「${name}」的类型不在允许范围内,允许:${list}`, + failed: (name: string, reason: string) => `「${name}」上传失败:${reason}`, + noContext: '这里不能上传文件:只有应用的入口、待办和蓝图的试运行能带文件', + whoCanDownload: '能看到这次提交的人都能下载附件', + /** 值不是上传后拿回的引用(前后端同一条:必填,形状是文件引用) */ + invalid: '请上传文件', + empty: '未上传', + typeLabels: { + 'application/pdf': 'PDF', 'application/msword': 'Word', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word', + 'application/vnd.ms-excel': 'Excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel', + 'text/csv': 'CSV', 'text/plain': '纯文本', 'image/png': 'PNG 图片', 'image/jpeg': 'JPG 图片', + } as Record, +} + +/** 记录列表 records_list(N8):使用者只看自己的,观察者、管理者看全部 */ +export const RECORDS_TEXT = { + mine: '我的记录', + all: '全部记录', + mineHint: '只显示你自己提交的记录', + allHint: '你能查看这个应用的全部记录,包括别人提交的', + filter: '筛选', + filterPlaceholder: '按内容或提交人筛选', + sort: '排序', + newest: '最新的在前', + oldest: '最早的在前', + empty: '还没有记录', + emptyMine: '你还没有提交过记录', + emptyFiltered: '没有符合筛选条件的记录', + count: (shown: number, total: number) => (shown === total ? `${total} 条` : `${shown} / ${total} 条`), + seq: (n: number) => `第 ${n} 条`, + by: (who: string) => `由「${who}」提交`, + expand: '展开全部字段', + collapse: '收起', + corrected: '已更正', + correctedHint: '这条是对之前一次提交的更正,原来的那条保留在更正历史里', + superseded: '已被更正', + history: '更正历史', + hideHistory: '收起更正历史', + historyLoading: '正在读取更正历史…', + historyEmpty: '没有找到更正前的记录', + historyItem: (n: number, at: string) => `第 ${n} 条 · ${at}`, + attachment: '附件', + version: (v: number) => `表版本 v${v}`, + more: '加载更多', + reload: '重新读取', + tableMissing: '这个应用还没有记录表:发布之后、有人提交过才会有记录', + yes: '是', + no: '否', +} + +/** 个人应用(N7):使用者自己建、只有自己看得见、永远是探索 */ +export const PERSONAL_TEXT = { + group: '我的应用', + groupHint: '你自己建的个人应用,只有你看得见', + sharedGroup: '共享的应用', + badge: '个人', + badgeHint: '个人应用:只有你看得见,数据来自你自己发起的探索运行,未经发布审核', + banner: '这是你的个人应用:只有你看得见,结果按你的权限遮罩。数据来自你自己发起的探索运行,未经发布审核,不是正式结果。', + refresh: '刷新', + refreshLeft: (n: number) => `今天还剩 ${n} 次`, + refreshLimitHint: '每天能刷新的次数有上限,由管理员设置', + refreshUsedUp: '今天的刷新次数已用完,明天再来', + refreshing: '正在刷新…', + refreshed: (n: number) => `已发起刷新,完成后自动更新。今天还剩 ${n} 次`, + submitReview: '提交审核', + submitReviewHint: '想给别人用:交给一位搭建者或一个组审核,审核通过后由搭建者发布成正式应用', + reviewTitle: '提交审核', + reviewIntro: '搭建者会在一份副本上用真数试运行、再发布成正式应用,你的个人应用不会被改动。发布后会通知你,个人应用留不留由你决定。', + reviewTo: '交给谁审核', + reviewToHint: '选一位搭建者,或者一个组(组里任一人都能处理)', + reviewNote: '说明(选填)', + reviewNotePlaceholder: '这个应用解决什么问题、给谁用', + reviewSubmit: '提交审核', + reviewPick: '请先选择交给谁审核', + reviewSent: '已提交审核', + cancel: '取消', + inReview: '审核中', + inReviewTo: (names: string) => `已提交给 ${names} 审核,等待处理`, + inReviewPlain: '已提交审核,等待搭建者处理', + formal: '已有正式版本', + formalGo: '去看看', + rejected: '上次的审核已退回,可以改好后再提交', + rejectedNote: (note: string) => `上次的审核已退回:${note}`, + graphChanged: '蓝图在存成个人应用之后又改过:刷新按存下的那一版运行,要用改过的,请回到蓝图重新存成你的应用。', + graphChangedGo: '回到蓝图', +} + +/** 审核待办(个人应用提交审核,N7):处理人在待办里取一份共享副本,在副本上试运行、走发布单;不发布就退回 */ +export const REVIEW_TASK_TEXT = { + intro: '使用者把自己的个人应用交给你审核。同意不在这里点:取一份共享副本,在副本上用真数试一次、走发布单,发布之后这条待办自动记为已发布;原来的个人应用不会被改动。', + submittedBy: (who: string) => `提交人:${who}`, + purpose: '目的', + note: '提交说明', + copy: '取副本,去发布', + copying: '正在取副本…', + openCopy: '打开我的副本', + reject: '退回', + rejectHint: '不发布就退回:提交人会收到通知,他的个人应用照常可用', + rejectNote: '退回的原因(选填)', + rejected: '已退回', +} diff --git a/frontend/src/lib/termsBlueprint.ts b/frontend/src/lib/termsBlueprint.ts index c04cec3c..8e2fc13d 100644 --- a/frontend/src/lib/termsBlueprint.ts +++ b/frontend/src/lib/termsBlueprint.ts @@ -1,12 +1,459 @@ /** * 普通用户建应用的文案(docs/design/normal-user-apps.md 第四部分):新建应用的起点、蓝图卡片、试运行、发布摘要。 * 包 F5 独占这个文件;页面编辑、文件控件、记录列表、个人应用的文案在 termsApp.ts(包 F6)。由 terms.ts 统一再导出。 + * + * 规矩同 terms.ts 开头的术语表:「固定版本」不写钉住;「运行」不写跑;「服务端」不写后端;不在句子里裸写配置键名; + * 中文句子用全角标点。卡片和问题的说法给普通人看:写「看哪些数」「谁来做」,不写节点、关节的技术名。 */ +import type { + BlueprintScope, BlueprintStatus, ItemSource, ItemStatus, PlanStep, PlanStepKind, ProblemWho, +} from '../typesBlueprint' + +// ---------------------------------------------------------------- 新建应用(§4.1) export const NEW_APP_TEXT = { title: '新建应用', + subtitle: '说清要什么、对一遍卡片、用真数试一次,再确认发布', + /** 使用者(管理员开了个人应用):只能建自己用的 */ + personalNote: '你建的是个人应用:只有你自己看得见,按你的权限读数据、结果会遮罩;想给别人用,做好后提交给搭建者审核发布。', + /** 使用者、开关没开 */ + personalOff: '管理员还没有开放个人应用', + personalOffBody: '使用者建个人应用需要管理员在设置中开启。现在可以在「问数据」里提问,或请搭建者为你做一个应用。', + goChat: '去问数据', + unfinished: '接着做:还没做完的应用', + // 三个起点 + talkTitle: '说说你要什么', + talkHint: '一句话说清看什么数、多久看一次、谁来处理。助手先问清楚,再整理成卡片给你确认。', + talkLabel: '你想做一个什么样的应用', + talkPlaceholder: '例如:每周一看各门店上周的销售额、订单数和退款率,不含退款单', + talkExamples: [ + '每周一看各门店上周的销售额、订单数和退款率,不含退款单', + '员工发起报销,金额超过 5000 元要财务组全员同意,48 小时没人处理就交给总监', + '门店每天登记报损,月底按门店汇总看', + ], + exampleHint: '填入输入框,修改后开始', + talkStart: '开始', + talkBusy: '正在新建蓝图…', + templateTitle: '从场景模板开始', + templateHint: '选一个最接近的场景,卡片按模板预置好,你只需要补齐和确认。', + templateWhen: '适用场景', + templateProduces: '会产出什么', + templatePrepare: '要准备什么', + templateUse: '用这个模板', + templatePersonalNo: '个人应用不能用这个模板(它要派人审批或写入外部系统)', + templatePersonalPart: (part: string) => `个人应用只能用其中一部分:${part}`, + templatesEmpty: '服务端还没有登记场景模板', + existingTitle: '从已有的开始', + existingHint: '把一次问数据的回答、或一个已发布的工作流做成应用。', + recentAnswers: '最近的问数据回答', + recentAnswersEmpty: '还没有可以做成应用的回答:先在「问数据」里问一次,回答完成后再来。', + publishedWorkflows: '已发布的工作流', + publishedWorkflowsEmpty: '还没有已发布的工作流', + publishedVersion: (v: number) => `已发布 v${v}`, + useThis: '做成应用', + creating: '正在新建蓝图…', + /** 应用首页的「新建应用」:说清新建走的是哪条路(取代原来「从已发布的工作流派生」的说法) */ + homeHint: '说需求、看蓝图、用真数试一次,再确认发布', + homeHintPersonal: '建一个只有自己看得见的个人应用', + homeEmptyBody: '点「新建应用」:说说你要什么、从场景模板开始,或把已有的问数据回答做成应用。', } +/** 首批模板的名字(docs/design/normal-user-apps.md §3.3):对话过程里只拿得到模板 id 时用它说;模板页、卡片用服务端给的名字 */ +export const TEMPLATE_LABEL: Record = { + periodic_board: '周期看板', approval: '审批流程', exception_handling: '异常处置', records: '填报登记', + reconcile_writeback: '核对写回', freeform: '自由形', +} + +/** 模板会产出什么(服务端的模板说明里没给时,按首批模板的应用页说,docs/design/normal-user-apps.md §3.3) */ +export const TEMPLATE_PRODUCES: Record = { + periodic_board: '一页看板:印章、指标、趋势、明细,可以按条件查看', + approval: '发起按钮、我发起的、每一单的进度和待办', + exception_handling: '异常数、本期异常清单、处置进度', + records: '填报按钮、我的记录、汇总看板', + reconcile_writeback: '差异指标、待复核清单、写回记录', + freeform: '按导入的工作流派生的应用页', +} + +// ---------------------------------------------------------------- 蓝图(§4.2) + export const BLUEPRINT_TEXT = { title: '应用蓝图', + untitled: '未命名的应用', + loadFailed: '无法加载蓝图', + notFound: '蓝图不存在或已被删除', + noAccess: '你不能查看这个蓝图:个人蓝图只有建它的人能看,共享蓝图要有管理权限。', + back: '返回应用', + sections: { cards: '卡片', trial: '试运行' } as Record, + scope: { personal: '个人', shared: '共享' } as Record, + status: { + drafting: '起草中', trialed: '试运行过', published: '已发布', personal: '个人应用', in_review: '审核中', + } as Record, + copiedFrom: '这是审核用的共享副本,原个人应用不受影响', + rename: '改名', + renamePrompt: '应用叫什么', + renamed: '已改名', + // 左边的对话 + chatTitle: '和助手对话', + chatEmpty: '说说你要什么,助手会先问清楚,再把需求整理成右边的卡片。卡片上的每一项都可以点开直接改。', + chatLabel: '对助手说', + chatPlaceholder: '补充需求,或者说要改哪里…', + chatAnswerPlaceholder: '回答助手的问题…', + chatStop: '停止这一轮', + chatHint: '助手只提议,改动落在卡片上;你在卡片上改的会记成「你」。', + historyFailed: '之前的对话没能取回:这一轮照常可用,只是助手接不上前面聊过的。', + /** 这一轮以反问收尾:常显,直到回答 */ + waiting: '这一轮在等你回答', + waitingHint: '点一个选项,或者在下方输入框里回答。', + // 一轮的状态 + turnRunning: '助手正在整理…', + turnAsked: '助手反问', + turnFailed: '这一轮没有完成', + turnStopped: '已停止', + // 右边的卡片 + cardsTitle: '卡片', + cardsHint: '每张卡片是一件你能判断对错的事。「缺」的要补上才能试运行,「待确认」的发布前要确认。', + edit: '改', + editTitle: (what: string) => `改「${what}」`, + save: '保存', + cancel: '取消', + saving: '正在保存…', + saved: '已保存,来源记成「你」', + confirm: '确认', + confirmHint: '这一项没问题:标成已确认,来源不变', + confirmed: '已确认', + remove: '删掉这一项', + removed: '已删掉', + add: '加一项', + empty: '(空)', + why: '为什么', + item: (n: number) => `第 ${n} 项`, + noTemplate: '还没选场景:助手会按你的需求推荐,也可以回到新建页选一个模板。', + templateOf: (label: string, version: number) => `${label}(模板 v${version})`, + // 对到数据的项 + dataTable: '表', + dataTableLabel: '中文名', + dataGrain: '粒度', + dataDate: '业务日期', + dataReuseText: (metric: string, version: number) => `复用口径卡里的「${metric}」(v${version}),不重写指标式`, + // 关节 + jointsTitle: '生成的工作流', + jointsHint: '模板的骨架是固定的,助手只填声明过的位置:取数的 SQL、指标的算法。其余按卡片直接填。', + jointsEmpty: '还没有按卡片生成工作流。卡片补齐之后点「按卡片生成」。', + instantiate: '按卡片生成', + instantiating: '正在按卡片生成…', + instantiated: '已按卡片生成工作流和应用骨架', + fill: '让助手填', + fillAgain: '让助手重新填', + fillStop: '停止', + fillFailed: '有的没填上', + group: (n: number) => `第 ${n} 组`, + groupRunning: '正在填', + groupDone: '已填好', + groupFailed: '没填上', + groupRetrying: '刚才没填完,正在按顺序再试一次', + groupChecked: '正在检查填的内容', + groupUnknown: '没等到这一组的结果,可以再让助手填一次', + jointsTemplate: (n: number) => `另有 ${n} 处按卡片直接填好(表单、处理人、定时这些)`, + jointPendingNote: '这一项助手没填上,请补充或换个说法后再让助手填', + // 底部 + trial: '用真数试一次', + trialBusy: '正在发起试运行…', + trialHint: '发起一次探索运行:数据是真的,但不是正式结果,也不会发布。', + missingTitle: '还缺这些才能试运行:', + publish: '发布', + publishHint: '一次确认:保存版本、发布工作流、建应用、定时任务、授权', + toConfirm: (n: number) => `发布前还有 ${n} 项要你确认或补上,点「发布」逐条看`, + savePersonal: '存成我的应用', + savePersonalUpdate: '更新我的应用', + savingPersonal: '正在保存…', + savedPersonal: '已存成你的个人应用', + openApp: '打开应用', +} + +/** 卡片名(blueprints/spec.CARDS,顺序同界面) */ +export const CARD_LABEL: Record = { + purpose: '目的', + template: '场景', + data: '看哪些数', + cadence: '多久更新', + people: '谁来做', + fields: '填什么', + writeback: '写回哪里', + access: '谁能看', +} + +/** 卡片上一项的名字(按字段名) */ +export const ITEM_LABEL: Record = { + purpose: '一句话说清给谁用、解决什么', + label: '名称', + unit: '单位', + table: '对到哪张表', + measure: '怎么算', + date: '业务日期', + filter: '筛选条件', + grain: '粒度', + who: '处理人', + policy: '几个人同意算通过', + due: '时限', + on_timeout: '超时怎么办', + threshold: '什么条件下走这一步', + spec: '字段', + sensitive: '敏感', + capability: '写到哪个系统', + mapping: '字段对应', + confirm: '要不要人确认', + cadence: '多久更新、统计哪一期', + use: '可以使用', + observe: '可以查看全部', + manage: '可以管理', + answer: '回答', +} + +export const ITEM_STATUS_LABEL: Record = { confirmed: '已确认', proposed: '待确认', missing: '缺' } +/** 来源:用户填的写「你」 */ +export const ITEM_SOURCE_LABEL: Record = { + user: '你', assistant: '助手', template: '模板默认', catalog: '数据目录', caliber: '已发布的口径卡', +} +export const itemSourceText = (s: ItemSource) => `来源:${ITEM_SOURCE_LABEL[s] ?? s}` + +export const POLICY_LABEL: Record = { any: '任一人同意', all: '全体同意', quorum: '至少几人同意' } +export const policyText = (p: { kind?: string; m?: number } | null | undefined): string => + !p?.kind ? '' : p.kind === 'quorum' ? `至少 ${p.m ?? '?'} 人同意` : POLICY_LABEL[p.kind] ?? p.kind +export const TIMEOUT_LABEL: Record = { remind: '提醒处理人', escalate: '交给上级', reject: '按驳回处理' } +export const principalText = (p: { kind: string; name: string }): string => + p.kind === 'initiator' ? '发起人' : p.kind === 'slot' ? `岗位「${p.name}」` : p.kind === 'group' ? `${p.name}(组)` : p.name + +export const CADENCE_PRESET_LABEL: Record = { daily: '每天', weekly: '每周', monthly: '每月' } +export const WEEKDAY_LABEL = ['', '一', '二', '三', '四', '五', '六', '日'] +/** 统计哪一期(定时任务的模板变量,同服务端 scheduler/service.py) */ +export const PERIOD_LABEL: Record = { + today: '当天', yesterday: '前一天', this_iso_week: '本周', last_iso_week: '上周', this_month: '本月', last_month: '上月', +} + +type Cadence = { preset?: string; weekday?: number; day?: number; time?: string; tz?: string; period?: string } + +/** 「每周一 09:00」这类说法(什么时候出数);不全的照有的写 */ +export function cadenceWhen(c: Cadence | null | undefined): string { + if (!c?.preset) return '' + const when = c.preset === 'weekly' ? `每周${WEEKDAY_LABEL[c.weekday ?? 0] || ''}` + : c.preset === 'monthly' ? `每月 ${c.day ?? 1} 日` : CADENCE_PRESET_LABEL[c.preset] ?? c.preset + return `${when}${c.time ? ` ${c.time}` : ''}` +} + +/** 卡片上的整句:「每周一 09:00,统计上周」 */ +export function cadenceText(c: Cadence | null | undefined): string { + const when = cadenceWhen(c) + if (!when) return '' + return `${when}${c?.period ? `,统计${PERIOD_LABEL[c.period] ?? c.period}` : ''}` +} + +/** ISO 8601 时长 → 「2 天」「48 小时」;认不出的原样 */ +export function durationText(iso: string | null | undefined): string { + if (!iso) return '' + const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/.exec(iso) + if (!m) return iso + const [, w, d, h, min] = m + return [w && `${w} 周`, d && `${d} 天`, h && `${h} 小时`, min && `${min} 分钟`].filter(Boolean).join(' ') || iso +} +/** 「天 / 小时」两栏 → ISO 8601 时长 */ +export function durationIso(days: number, hours: number): string { + const d = Math.max(0, Math.floor(days || 0)) + const h = Math.max(0, Math.floor(hours || 0)) + if (!d && !h) return 'PT0H' + return `P${d ? `${d}D` : ''}${h ? `T${h}H` : ''}` +} + +/** + * 蓝图里的一个位置 → 给人看的说法:/data/0/table → 「看哪些数」第 1 项的「对到哪张表」。 + * bp_* 操作、缺项提示都用它,卡片名和项名只有这一份 + */ +export function bpPathText(path: string | null | undefined): string { + const parts = String(path ?? '').split('/').filter(Boolean) + if (!parts.length) return '蓝图' + const card = CARD_LABEL[parts[0]] ?? (parts[0] === 'asks' ? '助手的问题' : parts[0]) + const rest = parts.slice(1).filter((p) => !['value', 'status', 'source', 'why'].includes(p)) + const index = rest.find((p) => /^\d+$/.test(p)) + const field = [...rest].reverse().find((p) => !/^\d+$/.test(p) && p !== '-') + return `「${card}」${index != null ? BLUEPRINT_TEXT.item(Number(index) + 1) : ''}${field && ITEM_LABEL[field] ? `的「${ITEM_LABEL[field]}」` : ''}` +} + +/** 助手的 bp_* 操作在对话过程里怎么说(run/decode.ts 的 decodeCopilot 用) */ +export const BP_OP_TEXT = { + set: (where: string) => `改了${where}`, + add: (where: string) => `在${where}加了一项`, + remove: (where: string) => `删掉了${where}`, + template: (label: string) => `选了场景「${label}」`, + ask: '助手问你一个问题', + askNext: '点一个选项或在下方回答,助手接着整理', + confirm: (where: string) => `确认了${where}`, + done: '蓝图已整理好', + checkLeft: (n: number) => `自查后仍有 ${n} 处问题,写在卡片上`, + refused: (n: number) => `助手提的改动有 ${n} 处没有采纳`, + fill: (joint: string) => `填好了「${joint}」`, +} + +/** 卡片上各种项的编辑框 */ +export const BP_EDIT_TEXT = { + source: '数据源', + table: '表名', + column: '日期列', + rule: '按什么算(例如按支付日)', + preset: '频率', + weekday: '星期', + day: '每月几日', + time: '时间', + tz: '时区', + period: '统计哪一期', + periodNone: '不按期统计', + initiator: '发起人自己', + slotKept: (names: string) => `现在派给岗位:${names}(发布时绑人);选了具体的人或组就换掉岗位`, + slotReplaced: '保存后派给上面选的人或组,原来的岗位不再用', + policy: '通过规则', + quorum: '至少几人同意', + days: '天', + hours: '小时', + timeoutAction: '超时之后', + timeoutTo: '交给谁', + thresholdField: '按哪个字段', + thresholdOp: '怎么比', + thresholdValue: '和多少比', + fieldTitle: '字段名称', + fieldKind: '类型', + fieldOptions: '选项(用顿号或逗号隔开)', + mappingParam: '参数', + mappingFrom: '取自', + mapForm: '表单', + mapMetric: '指标', + mapRows: '核对出来的行', + mapConst: '固定值', + required: '必填', + everyText: (span: string) => `,每 ${span}提醒一次`, + mappingAdd: '加一行', + yes: '是', + no: '否', + sensitive: '敏感', + sensitiveHint: '标成敏感的字段在结果里会遮罩;能看到这条记录的人也能下载它的附件。', + addData: '加一个要看的数', + addDataPrompt: '这个数叫什么(例如「销售额」)', + addStep: '加一步', + addStepPrompt: '这一步叫什么(例如「财务审批」)', + addField: '加一个字段', + addFieldPrompt: '字段叫什么(例如「金额」)', + addWriteback: '要写回系统', + removeTitle: (where: string) => `删掉${where}?`, + removeConfirm: '删掉', + template: '换一个场景', + templateHint: '换场景会按新模板重新预置卡片,已填的内容尽量保留。', + accessEmpty: '还没有授权给谁:发布后只有你能看到。', +} + +/** 表单字段的类型(「填什么」卡片) */ +export const FIELD_KIND_LABEL: Record = { + text: '文字', textarea: '长文字', number: '数字', integer: '整数', date: '日期', select: '选项', checkbox: '是或否', file: '文件', +} + +/** 金额线这类规则:只许「字段 比较 常量」 */ +export const THRESHOLD_OP_LABEL: Record = { + '>': '大于', '>=': '不小于', '<': '小于', '<=': '不大于', '==': '等于', '!=': '不等于', +} + +export const JOINT_STATUS_LABEL: Record = { filled: '已填', pending: '待填', refused: '没填上' } +export const JOINT_BY_LABEL: Record = { template: '按卡片', assistant: '助手', user: '你' } +export const JOINT_KIND_LABEL: Record = { + sql: '取数的 SQL', metric_expr: '指标的算法', fields: '表单', assignee: '处理人', policy: '通过规则', due: '时限', + on_timeout: '超时处理', threshold: '金额线', capability: '写回目标', contract: '必需指标', +} + +// ---------------------------------------------------------------- 试运行(§4.3) + +export const TRIAL_TEXT = { + title: '试运行', + banner: '试运行:数据是真的,但这不是正式结果', + bannerBody: '发布之后才会按定时任务出正式结果。试运行只有你看得见,写入一律走测试环境或演练,不会发送到真实系统。', + inputs: '试运行的输入', + inputsUnknown: '按「多久更新」卡片算出的最近一期', + retry: '换一期再试', + retryTitle: '换一份输入再试一次', + retryGo: '再试一次', + retrying: '正在发起…', + none: '还没有试运行过', + noneBody: '卡片补齐之后,回到卡片页点「用真数试一次」。', + backToCards: '回到卡片改', + askAssistant: '交给助手修', + askAssistantText: (what: string) => `试运行出错了:${what}。请按这个原因改一下蓝图。`, + running: '试运行正在进行,完成后自动刷新', + failedTitle: '试运行没有成功', + tasksTitle: '试运行里的待办', + tasksHint: '流程里的每一步都派给了你自己:以试运行身份点一遍,看看流程走得通不通。', + tasksEmpty: '现在没有等你处理的待办', + writeNote: '写入步骤走测试环境;没有测试环境的只演练,不发送。', + // 待办卡的按钮(run/TaskCard 的 trial) + approve: '以试运行身份同意', + reject: '以试运行身份驳回', + submit: '以试运行身份提交', + rejectStop: '以试运行身份驳回', +} + +// ---------------------------------------------------------------- 发布摘要(§4.4) + +export const PLAN_TEXT = { + title: '发布摘要', + planning: '正在核对要做的每一步…', + steps: '会发生什么', + problems: '发布前要处理的问题', + noProblems: '没有要处理的问题', + who: { you: '你来定', assistant: '助手可以修', admin: '要找管理员' } as Record, + whoHint: { + you: '只有你知道答案:在卡片上补齐或确认。', + assistant: '交给助手按卡片改,改完再核对一次。', + admin: '这些要管理员处理,或改选已发布级。', + } as Record, + whoEmpty: '没有', + fixWithAssistant: '交给助手修', + fixText: (lines: string[]) => `发布前核对出这些问题,请按卡片改好:\n${lines.map((l, i) => `${i + 1}. ${l}`).join('\n')}`, + blocked: '还有问题没处理,暂时不能发布', + level: '发布级别', + levelPublished: '已发布', + levelGoverned: '受管', + levelHint: '受管级要求指标都追到原始数据,只有管理员能发布。', + firstRun: '发布后立即运行一次', + firstRunHint: '应用一打开就有数;不勾的话等定时任务第一次出数。', + confirm: '确认发布', + confirmWith: (when: string) => `发布并${when}自动出数`, + running: '正在发布…', + done: '发布完成', + doneBody: '工作流、应用、定时任务和授权都已就绪。', + stopped: (step: string) => `停在「${step}」`, + stoppedBody: '已经完成的步骤保留着,修好之后从这一步接着做,不会重做前面的步骤。', + resume: '接着做', + resuming: '正在接着做…', + close: '关闭', + openApp: '打开应用', +} + +export const PLAN_STEP_LABEL: Record = { + save_version: '保存版本', + preflight: '发布前核对', + publish_workflow: '发布工作流', + create_schedule: '建定时任务', + create_app: '建应用', + publish_app: '发布应用', + enable_schedule: '启用定时任务', + grant: '授权', + first_run: '运行第一次', +} +export const PLAN_STEP_STATUS_LABEL: Record = { + pending: '待做', done: '已完成', failed: '没有完成', skipped: '跳过', +} + +// ---------------------------------------------------------------- 问数据的「做成应用」、反问收尾(N11) + +export const CHAT_BLUEPRINT_TEXT = { + makeApp: '做成应用(蓝图)', + makeAppHint: '带着这次回答进应用蓝图:整理成卡片、用真数试一次,再发布', + makeAppHintPersonal: '带着这次回答建一个只有自己看得见的个人应用', + makeAppBusy: '正在新建蓝图…', + /** 问数据、画布:助手问回来一句,这一轮到此为止 */ + asked: '助手反问', + answerPlaceholder: '回答助手的问题…', } diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 7c26bf50..8bae6511 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -17,9 +17,12 @@ import { humanizeError } from '../lib/errors' import { explainRunError } from '../lib/explain' import { canLeave, leavePass } from '../lib/leave' import { formatNumber, formatTime } from '../lib/format' -import { MAKE_APP_TEXT } from '../lib/terms' +import { CHAT_BLUEPRINT_TEXT } from '../lib/terms' import { useCatalog, useCatalogListState, useDatasources, useOnReconnect, type CatalogListState } from '../store/catalog' import { useAtLeast } from '../store/auth' +import { useBlueprint } from '../store/blueprint' +import type { BlueprintScope } from '../typesBlueprint' +import { blueprintPath, useCreateScope } from './blueprints/shared' import { agentSteps, busyTurn, isBusy, stepsVisible, useChat, type Attempt, type ChatTurn, type Failure, type ScopeSource, } from '../store/chat' @@ -198,6 +201,9 @@ export function ChatPage() { const streamTurns = useMemo(() => (turns ?? []).map(toStreamTurn), [turns]) const lastId = turns?.[turns.length - 1]?.id + // 上一轮以助手反问收尾:输入框提示回答它(N11) + const lastTurn = turns?.[turns.length - 1] + const answering = !!lastTurn?.asked && (lastTurn.phase === 'done' || lastTurn.phase === 'ready') useLazyHydrate(currentId, turns) // 三种「没有轮次」要分开画:还在取、取失败、真的是空的。以前三者都是「问点什么」, @@ -242,7 +248,7 @@ export function ChatPage() { stopLabel="停止这一轮" blocked={blocked} label="向数据提问" - placeholder="输入你的问题…" + placeholder={answering ? CHAT_BLUEPRINT_TEXT.answerPlaceholder : '输入你的问题…'} leading={picker} />
    @@ -379,11 +385,14 @@ function toStreamTurn(turn: ChatTurn): StreamTurn { // 没核对上的不知道结局,用「未运行」那个空心点,不画成完成也不画成失败 statusCode: turn.phase === 'cancelled' ? 'cancelled' : turn.phase === 'suspended' ? 'suspended' + // 助手反问:这一轮结束了,在等用户回答 + : turn.asked && turn.phase === 'done' ? 'waiting' : turn.phase === 'ready' || turn.phase === 'unknown' ? 'idle' : undefined, tone: turn.phase === 'suspended' || turn.phase === 'unknown' ? 'warn' : undefined, // 进行中的写此刻在做什么;结束了的用全站统一的状态词 status: live ? liveLine(turn) + : turn.asked && (turn.phase === 'done' || turn.phase === 'ready') ? CHAT_BLUEPRINT_TEXT.asked : turn.phase === 'checking' || turn.phase === 'suspended' || turn.phase === 'ready' || turn.phase === 'unknown' ? turn.status : undefined, @@ -472,32 +481,29 @@ function lastSentence(text: string): string { * 是几十个请求换一堆没人看的步骤。所以默认只显示问题和答案,想看过程再取。 */ /** - * 回答上的「做成应用」(A8):从这次运行提取工作流(/copilot/from-run,只保留实际执行的步骤,新工作流记下来源), - * 跳到画布去发布——应用只能固定已发布的版本;发布成功的提示里再点「做成应用」进入应用编辑。 - * 「在画布里打开」照旧在回答的流程图上 + * 回答上的「做成应用」(docs/design/normal-user-apps.md §4.1):带着这次运行建一个运行起点的蓝图(服务端按 from-run 提取, + * 只保留实际执行的步骤),直接进蓝图页——整理成卡片、用真数试一次、确认发布,不再跳画布的发布弹窗。搭建者建共享蓝图; + * 使用者在管理员开了个人应用时建个人蓝图。「在画布里打开」照旧在回答的流程图上 */ -function MakeAppButton({ runId, conversationId, turnId }: { runId: string; conversationId: string; turnId: string | null }) { +function MakeAppButton({ runId, scope }: { runId: string; scope: BlueprintScope }) { const navigate = useNavigate() - const refreshCatalog = useCatalog((s) => s.refresh) const [busy, setBusy] = useState(false) return ( - ) } @@ -512,15 +518,15 @@ function TurnExtras({ turnId, readOnly }: { const approvals = useCatalog((s) => s.approvals) const { reattach, runNow, toggleSteps } = useChat.getState() const busy = useChat((s) => (conversationId ? isBusy(s.byConversation[conversationId]) : false)) - // 做成应用要去画布发布:只给搭建者以上(开了登录的使用者问数据时没有画布) - const builder = useAtLeast('builder') + // 做成应用进蓝图:搭建者建共享的;使用者在管理员开了个人应用时建个人的,都不是就没有这个按钮 + const makeScope = useCreateScope() if (!turn || !conversationId) return null const runId = turn.run?.id const pending = runId ? approvals.filter((a) => a.run_id === runId && a.status === 'pending') : [] const canSteps = !!turn.restored && !!runId && !BUSY.has(turn.phase) const scoped = !!turn.scope?.length - // 回答成功、有运行的轮次可以做成应用(A8):先从这次运行提取工作流,在画布上发布后再做成应用 - const canApp = !!runId && turn.phase === 'done' && !readOnly && builder + // 回答成功、有运行的轮次可以做成应用:带着这次运行进蓝图 + const canApp = !!runId && turn.phase === 'done' && !readOnly && !!makeScope if (!pending.length && !turn.pendingRun && !canSteps && !scoped && !canApp) return null return ( @@ -549,7 +555,7 @@ function TurnExtras({ turnId, readOnly }: { {turn.steps === 'loading' ? '正在加载执行过程…' : stepsVisible(turn) ? '收起执行过程' : '查看执行过程'} )} - {canApp && } + {canApp && }
    )} {/* 通过了运行就接着跑:回收站里的会话左栏找不到,跑起来没人看着 */} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 54654413..68314672 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -665,8 +665,9 @@ function PrefsTab() { jClaims: string | null; jCost: string | null; jTimeout: string | null; jClick: string | null; jDaily: string | null // 起草模型(数据目录的用模型起草、按配方导入的 AI 起草):空串 = 跟随助手的模型(存 null) draftProvider: string; draftModel: string - // 应用一组(D-j):按需视图每个应用每天最多发起几次 + // 应用一组(D-j):按需视图每个应用每天最多发起几次;使用者能不能建个人应用(N7,默认关) appsDaily: string + appsPersonal: boolean } | null>(null) const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null) // 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取 @@ -719,6 +720,7 @@ function PrefsTab() { draftModel: typeof values?.catalog_draft?.model === 'string' ? values.catalog_draft.model : '', // 服务端没给这一项时按默认的 200 显示(同服务端的缺省值) appsDaily: String(values?.apps?.on_demand_daily_limit ?? APPS_DAILY_DEFAULT), + appsPersonal: values?.apps?.user_personal_apps === true, } const cur = draft ?? saved const stepCap = Number(values?.limits?.max_agent_steps) || 100 @@ -799,8 +801,8 @@ function PrefsTab() { } const judgeChanged = changed.some((k) => JUDGE_DRAFT.has(k)) const draftChanged = changed.some((k) => DRAFT_MODEL_DRAFT.has(k)) - const appsChanged = changed.includes('appsDaily') - const runChanged = changed.some((k) => k !== 'actor' && k !== 'appsDaily' && !JUDGE_DRAFT.has(k) && !DRAFT_MODEL_DRAFT.has(k)) + const appsChanged = changed.some((k) => APPS_DRAFT.has(k)) + const runChanged = changed.some((k) => k !== 'actor' && !APPS_DRAFT.has(k) && !JUDGE_DRAFT.has(k) && !DRAFT_MODEL_DRAFT.has(k)) try { // 裁判一组整组写:后端对没给的键回落到默认值,只发改过的那一项会把别的项冲回默认 const judge = judgeChanged ? { @@ -817,7 +819,9 @@ function PrefsTab() { // 起草模型一组:两项一起写,空着的存 null(跟随助手的模型) const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null // 应用一组同样整组写:别的键(以后加的)照原样带回去 - const apps = appsChanged ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()) } : null + const apps = appsChanged + ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal } + : null if (runChanged || judge || verify || catalogDraft || apps) { const run = { ...(values.run ?? {}), @@ -1172,6 +1176,17 @@ function PrefsTab() { onChange={(e) => edit({ appsDaily: e.target.value })} data-apps-daily="" /> )} + @@ -1198,7 +1213,7 @@ function PrefsTab() { jClaims: '每份报告最多裁判句数', jCost: '每份报告的金额上限', jTimeout: '每份报告的时长上限', jClick: '每次点击的金额上限', jDaily: '每日金额上限', draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model, - appsDaily: APP_SETTING_TEXT.onDemandLimit, + appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps, })[k]).concat(verifyLabels(verifyKeys)).join('、')} @@ -1231,6 +1246,9 @@ function PrefsTab() { ) } +/** 应用一组在草稿里的几项:保存时整组写 apps */ +const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal']) + /** 按需视图每日上限的缺省值和最大值(同服务端 settings.APPS_DEFAULTS、APPS_DAILY_LIMIT_MAX) */ const APPS_DAILY_DEFAULT = 200 const APPS_DAILY_MAX = 100_000 diff --git a/frontend/src/pages/apps/AppPage.tsx b/frontend/src/pages/apps/AppPage.tsx index 00b1bfca..8723a10d 100644 --- a/frontend/src/pages/apps/AppPage.tsx +++ b/frontend/src/pages/apps/AppPage.tsx @@ -3,25 +3,67 @@ * * 没带页面的落到应用的首页。页面参数(筛选表单交的那一组)在地址的 params 里,换了就重新解析。有视图正在生成时 * 每 5 秒重取一次,生成完自动换上;引用的版本退役了照实提示,入口暂停发起。 + * + * 另取一次应用详情:有 manage 的人点「编辑」在页面上直接改(地址上记 edit=1,见 PageEdit.tsx,normal-user-apps N6); + * 个人应用(scope 为 personal)顶上写明是个人应用,给「刷新」和「提交审核」(personal.tsx,N7)。详情取不到不挡页面: + * 照只能用的人画。 */ import { useNavigate, useParams, useSearchParams } from 'react-router-dom' -import { LayoutGrid, PencilLine, RotateCw } from 'lucide-react' +import { LayoutGrid, PencilLine, RotateCw, Settings2 } from 'lucide-react' import { api } from '../../api/client' import { ViewRenderer } from '../../apps/ViewRenderer' import { parseViewParams } from '../../apps/components/input' import { Notice, PageHeader, Skeleton, Spinner, Tabs } from '../../components/ui' import { useAtLeast } from '../../store/auth' import { usePageTitle } from '../../lib/notify' -import { APP_TEXT } from '../../lib/termsApp' +import { APP_TEXT, PAGE_EDIT_TEXT, PERSONAL_TEXT } from '../../lib/termsApp' +import type { AppDetail } from '../../typesApp' import { AppMeta, BackLink, LoadError, appPath, loadHome, useLoad, usePoll } from './shared' import { BackfillPanel } from './backfill' +import { PageEditor } from './PageEdit' +import { PersonalBar } from './personal' const POLL_MS = 5000 +/** 能在页面上直接改:manage 才拿得到草稿;归档了的不能再改草稿(服务端同样拒绝) */ +const editableOf = (d: AppDetail | undefined): boolean => + !!d && d.my_role === 'manage' && !!d.draft && d.status !== 'archived' + export function AppPage() { const { appId = '', pageId } = useParams() - const [search] = useSearchParams() + const [search, setSearch] = useSearchParams() const raw = search.get('params') + const navigate = useNavigate() + const { data: detail, reload: reloadDetail } = useLoad((signal) => api.apps.get(appId, { signal }), [appId]) + const editing = editableOf(detail) && search.get('edit') === '1' + const setEditing = (on: boolean) => setSearch((prev) => { + const next = new URLSearchParams(prev) + if (on) next.set('edit', '1') + else next.delete('edit') + return next + }, { replace: true }) + // 换页带着页面参数,编辑状态下也还在编辑状态 + const go = (id: string) => { + const q = new URLSearchParams() + if (raw) q.set('params', raw) + if (editing) q.set('edit', '1') + const tail = q.toString() + navigate(`${appPath(appId)}/p/${encodeURIComponent(id)}${tail ? `?${tail}` : ''}`) + } + + if (editing && detail) { + return ( + { setEditing(false); reloadDetail() }} onPublished={reloadDetail} /> + ) + } + return setEditing(true)} onNavigate={go} /> +} + +function AppView({ appId, pageId, raw, detail, onEdit, onNavigate }: { + appId: string; pageId?: string; raw: string | null; detail?: AppDetail + onEdit: () => void; onNavigate: (pageId: string) => void +}) { const navigate = useNavigate() const builder = useAtLeast('builder') const { data: model, error, loading, reload } = useLoad((signal) => { @@ -31,10 +73,13 @@ export function AppPage() { usePageTitle(model ? `${APP_TEXT.title} · ${model.app.name}` : null) const running = !!model && Object.values(model.sources).some((s) => s.state === 'running') usePoll(reload, POLL_MS, running) + const editable = editableOf(detail) + const personal = detail?.scope === 'personal' + // 归档了的应用不能在页面上改;搭建者照旧能打开应用编辑器看(编辑器里写明已归档) + const editorOnly = !editable && builder && detail?.my_role === 'manage' && !personal - const go = (id: string) => navigate(`${appPath(appId)}/p/${encodeURIComponent(id)}${raw ? `?params=${encodeURIComponent(raw)}` : ''}`) return ( -
    +
    } title={model?.app.name ?? APP_TEXT.title} @@ -42,12 +87,18 @@ export function AppPage() { actions={(
    {loading && model && } + {personal && {PERSONAL_TEXT.badge}} - {builder && ( + {editable && ( + + )} + {editorOnly && ( )}
    @@ -55,7 +106,7 @@ export function AppPage() { /> {model && model.pages.length > 1 && ( ({ key: p.id, label: p.title || p.id }))} - active={model.page.id} onChange={go} /> + active={model.page.id} onChange={onNavigate} /> )}
    @@ -64,14 +115,16 @@ export function AppPage() { : ( <> } /> + {/* 刷新之后重取页面:来源换成正在运行的那次,生成完自动换上(剩余次数以刷新的回包为准) */} + {personal && detail && } {model.app.needs_upgrade && ( {APP_TEXT.needsUpgrade}:{APP_TEXT.needsUpgradeHint} )} {/* 按新口径补算(D-f):只有 manage 看得到,固定版本升了版、处置为按新口径重算时才有 */} - - + {!personal && } + )}
    diff --git a/frontend/src/pages/apps/AppsHome.tsx b/frontend/src/pages/apps/AppsHome.tsx index 733312f4..17750ba4 100644 --- a/frontend/src/pages/apps/AppsHome.tsx +++ b/frontend/src/pages/apps/AppsHome.tsx @@ -16,9 +16,11 @@ import { useCatalog } from '../../store/catalog' import { useAtLeast, useLoginOn } from '../../store/auth' import { formatDateTime, formatRelative, formatSpan, formatTime, parseServerTime } from '../../lib/format' import { runName } from '../../lib/terms' -import { APP_HOME_TEXT, APP_TEXT, TASK_KIND_LABEL, TASK_STATUS_LABEL, TASK_TEXT } from '../../lib/termsApp' +import { APP_HOME_TEXT, APP_TEXT, PERSONAL_TEXT, TASK_KIND_LABEL, TASK_STATUS_LABEL, TASK_TEXT } from '../../lib/termsApp' import type { AppSummary, InstanceRow, Task } from '../../typesApp' -import { LoadError, appPath, newAppPath, useLoad } from './shared' +import { LoadError, appPath, useLoad } from './shared' +import { NEW_APP_TEXT } from '../../lib/termsBlueprint' +import { CREATE_APP_PATH, useCreateScope } from '../blueprints/shared' const TABS = ['inbox', 'apps', 'mine'] as const type Tab = (typeof TABS)[number] @@ -28,11 +30,13 @@ export function AppsHome() { const [search, setSearch] = useSearchParams() const tab: Tab = (TABS as readonly string[]).includes(search.get('tab') ?? '') ? search.get('tab') as Tab : 'inbox' const loginOn = useLoginOn() - const builder = useAtLeast('builder') const inboxCount = useCatalog((s) => s.taskCounts?.mine ?? 0) - // 新建应用给搭建者及以上:是新建向导的起点,现在从已有的工作流开始(选一个已发布且未退役的版本 → 派生 → 进编辑器) - const create = builder && ( - + // 新建应用(docs/design/normal-user-apps.md §4.1):搭建者建共享应用;使用者在管理员开了个人应用时也有,只能建个人应用。 + // 三个起点(说需求、场景模板、已有的回答或已发布的工作流)都在新建页上 + const scope = useCreateScope() + const create = scope && ( + {APP_TEXT.create} ) @@ -152,14 +156,39 @@ function InboxRow({ task }: { task: Task }) { function AppsTab() { const builder = useAtLeast('builder') + const scope = useCreateScope() const { data, error, reload } = useLoad((signal) => api.apps.list({ signal }), []) if (error && !data) return if (!data) return + // 个人应用(N7)单独一组「我的应用」放在前面:只有拥有者看得见,服务端只把自己的交来 + const mine = data.items.filter((a) => a.scope === 'personal') + const shared = data.items.filter((a) => a.scope !== 'personal') + if (mine.length) { + return ( +
    +
    +

    {PERSONAL_TEXT.group}

    +

    {PERSONAL_TEXT.groupHint}

    +
      + {mine.map((a) => )} +
    +
    + {shared.length > 0 && ( +
    +

    {PERSONAL_TEXT.sharedGroup}

    +
      + {shared.map((a) => )} +
    +
    + )} +
    + ) + } if (!data.items.length) { return ( - } title={APP_TEXT.noApps} body={builder ? APP_TEXT.noAppsBody : APP_TEXT.noAppsUser} - action={builder ? ( - + } title={APP_TEXT.noApps} body={scope ? NEW_APP_TEXT.homeEmptyBody : APP_TEXT.noAppsUser} + action={scope ? ( + {APP_TEXT.create} ) : undefined} /> @@ -189,7 +218,10 @@ function AppCard({ app, canEdit }: { app: AppSummary; canEdit: boolean }) {
    - {app.published_version ? APP_TEXT.publishedAt(app.published_version) : APP_TEXT.unpublished} + {/* 个人应用没有应用版本,不写「未发布」:角标写「个人」 */} + {app.scope === 'personal' + ? {PERSONAL_TEXT.badge} + : {app.published_version ? APP_TEXT.publishedAt(app.published_version) : APP_TEXT.unpublished}} {app.level && {app.level === 'governed' ? APP_TEXT.levelGoverned : APP_TEXT.levelPublished}} {app.status === 'archived' && {APP_TEXT.archived}} {app.needs_upgrade && ( diff --git a/frontend/src/pages/apps/EntryPage.tsx b/frontend/src/pages/apps/EntryPage.tsx index cc227f0e..02ea6245 100644 --- a/frontend/src/pages/apps/EntryPage.tsx +++ b/frontend/src/pages/apps/EntryPage.tsx @@ -87,6 +87,7 @@ export function EntryPage() {

    {label}

    {blocked && {ENTRY_TEXT.upgrade}} { setValues(v); if (errors.length) setErrors([]) }} onSubmit={() => void submit()} />
    diff --git a/frontend/src/pages/apps/PageEdit.tsx b/frontend/src/pages/apps/PageEdit.tsx new file mode 100644 index 00000000..adaf6be2 --- /dev/null +++ b/frontend/src/pages/apps/PageEdit.tsx @@ -0,0 +1,530 @@ +/** + * 应用页上直接改(docs/design/normal-user-apps.md N6、§4.5):有 manage 的人在应用页点「编辑」进入编辑状态 + * (地址上记 edit=1,刷新、换页都还在编辑状态)。 + * + * 编辑层叠在视图渲染器外面,按 data-node 认组件:悬停描边,点中出小工具条。点击在捕获阶段拦下——编辑状态下点数字、 + * 发起按钮、表头不打开出处、不跳转、不排序,只是选中。小工具条只有五样:改文字(组件目录里标了 x-text 的属性)、 + * 隐藏、上移、下移、删除;页底「加一张数据卡片」从固定版本的结果里挑(GET /api/apps/{id}/candidates)。 + * + * 落地全走应用操作(edit/appOps 的 applyAppOp,和应用编辑器、助手同一套),改完自动存草稿(PATCH /api/apps/{id}), + * 再按草稿重新解析这一页——视图模型由服务端解析,这里不在浏览器里改树(P7)。几次改动挨着到时按顺序存,存的永远是 + * 最新的那份;存失败了照实说,恢复成上一次存上的样子。隐藏的组件按草稿解析时照样下发,画成半透明;服务端没画出来的 + * 列在页底,照样能取消隐藏。 + * + * 改取数不在这里:回到蓝图(/blueprints/:id),重走试运行和发布单,口径变了要处置(不可回退:口径升版必须处置)。 + * 呈现的改动只进草稿,「发布」走现有的应用发布(不动工作流);固定版本升了版要选处置的,用应用编辑器的同一个处置框。 + * 个人应用没有应用版本,没有发布这一步。 + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react' +import { Link } from 'react-router-dom' +import { + ArrowDown, ArrowUp, Check, Database, Eye, EyeOff, LayoutGrid, PencilLine, Plus, Settings2, ShieldCheck, Trash2, Type, +} from 'lucide-react' +import clsx from 'clsx' +import { api } from '../../api/client' +import { ViewRenderer, type ViewEditMarks } from '../../apps/ViewRenderer' +import { parseViewParams } from '../../apps/components/input' +import { + EmptyState, Modal, Notice, PageHeader, Skeleton, Spinner, Tabs, confirmDialog, toast, +} from '../../components/ui' +import { useAtLeast } from '../../store/auth' +import { usePageTitle } from '../../lib/notify' +import { APP_TEXT, PAGE_EDIT_TEXT as T } from '../../lib/termsApp' +import type { + AppComponent, AppDetail, AppSpec, CatalogComponent, ComponentCatalog, Disposition, LintIssue, PublishResult, ViewModel, + ViewNode, +} from '../../typesApp' +import type { AppCandidate } from '../../typesBlueprint' +import { applyAppOp, findComponent, freshComponent, textPropsOf } from './edit/appOps' +import { loadCatalog } from './edit/AppEditor' +import { DispositionDialog } from './edit/ReviewSections' +import { AppMeta, BackLink, LoadError, appPath } from './shared' + +/** 编辑层上的矩形:相对编辑层的左上角 */ +interface Box { top: number; left: number; width: number; height: number } + +/** + * 服务端给的「草稿和当前发布版是否一致」(AppDetail.draft_changed,见报告的接口约定请求)。没给的当不知道: + * 只在这一次改过之后说「有未发布的改动」,不在浏览器里拿草稿和发布版逐项比(发布时服务端会规整 spec,比不准) + */ +const draftChangedOf = (d: AppDetail): boolean | null => { + const v = (d as AppDetail & { draft_changed?: unknown }).draft_changed + return typeof v === 'boolean' ? v : null +} + +/** 组件在界面上叫什么:属性里写的名字、标题,再退到目录里的中文名 */ +function labelOf(comp: AppComponent | undefined, entry: CatalogComponent | undefined): string { + const named = comp?.props?.label ?? comp?.props?.title + return typeof named === 'string' && named.trim() ? named.trim() : entry?.label ?? comp?.type ?? '' +} + +/** 一页里(含子组件)的全部组件 */ +function walkSpec(list: AppComponent[], out: AppComponent[] = []): AppComponent[] { + for (const c of list) { out.push(c); if (c.children?.length) walkSpec(c.children, out) } + return out +} +function walkView(list: ViewNode[] | undefined, out = new Set()): Set { + for (const n of list ?? []) { out.add(n.id); walkView(n.children, out) } + return out +} + +export interface PageEditorProps { + appId: string + /** manage 取到的详情:带着草稿 */ + detail: AppDetail + pageId?: string + /** 地址里的页面参数(原文) */ + rawParams: string | null + onExit: () => void + onNavigate: (pageId: string) => void + /** 发布了新的应用版本:页面外层重取详情 */ + onPublished: () => void +} + +export function PageEditor({ appId, detail, pageId, rawParams, onExit, onNavigate, onPublished }: PageEditorProps) { + const builder = useAtLeast('builder') + const personal = detail.scope === 'personal' + const [draft, setDraftState] = useState(() => detail.draft as AppSpec) + // 最新的草稿、最近一次存上的草稿:几次改动挨着到时,存的是最新的那份;存失败退回存上的那份 + const latest = useRef(detail.draft as AppSpec) + const saved = useRef(detail.draft as AppSpec) + const chain = useRef>(Promise.resolve()) + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle') + const [changed, setChanged] = useState(() => draftChangedOf(detail)) + const [catalog, setCatalog] = useState(null) + useEffect(() => { loadCatalog().then(setCatalog).catch(() => undefined) }, []) + const byName = useMemo(() => new Map((catalog?.components ?? []).map((c) => [c.name, c])), [catalog]) + + const page = draft.pages.find((p) => p.id === pageId) ?? draft.pages.find((p) => p.id === 'home') ?? draft.pages[0] + const pageKey = page?.id ?? '' + usePageTitle(`${APP_TEXT.title} · ${draft.name} · ${T.editing}`) + + // ---- 按草稿解析这一页 + const [model, setModel] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const seq = useRef(0) + const resolve = useCallback(async () => { + if (!pageKey) return + const me = ++seq.current + setLoading(true) + try { + const next = await api.apps.page(appId, pageKey, { draft: true, params: parseViewParams(rawParams) }) + if (me === seq.current) { setModel(next); setError(null) } + } catch (e) { + if (me === seq.current) setError(e) + } finally { + if (me === seq.current) setLoading(false) + } + }, [appId, pageKey, rawParams]) + useEffect(() => { void resolve() }, [resolve]) + + // ---- 落地:应用操作 → 存草稿 → 重新解析 + const persist = useCallback(async () => { + const spec = latest.current + if (spec === saved.current) return + setSaveState('saving') + try { + const next = await api.apps.update(appId, { draft: spec }) + saved.current = spec + setChanged(draftChangedOf(next) ?? true) + // 存的期间又改了的,下一轮接着存,这一轮先不报「已存」 + if (latest.current === spec) setSaveState('saved') + } catch (e) { + toast.error(e) + latest.current = saved.current + setDraftState(saved.current) + setSaveState('failed') + } + await resolve() + }, [appId, resolve]) + const apply = useCallback((op: Record): boolean => { + const next = applyAppOp(latest.current, op) + if (next === latest.current) return false + latest.current = next + setDraftState(next) + chain.current = chain.current.then(persist) + return true + }, [persist]) + + // ---- 选中、悬停、编辑层上的位置 + const layer = useRef(null) + const [selected, setSelected] = useState(null) + const [hover, setHover] = useState(null) + const [boxes, setBoxes] = useState<{ hover?: Box; selected?: Box }>({}) + const measure = useCallback(() => { + const root = layer.current + const rectOf = (id: string | null): Box | undefined => { + if (!root || !id) return undefined + const el = root.querySelector(`[data-node="${CSS.escape(id)}"]`) + if (!el) return undefined + const r = el.getBoundingClientRect() + const o = root.getBoundingClientRect() + return { top: r.top - o.top, left: r.left - o.left, width: r.width, height: r.height } + } + setBoxes({ hover: hover !== selected ? rectOf(hover) : undefined, selected: rectOf(selected) }) + }, [hover, selected]) + useLayoutEffect(measure, [measure, model]) + const measureRef = useRef(measure) + measureRef.current = measure + useEffect(() => { + const root = layer.current + if (!root) return + // 图表、表格晚到会把下面的组件往下推:编辑层的大小一变就重量一次 + const again = () => measureRef.current() + const ro = new ResizeObserver(again) + ro.observe(root) + window.addEventListener('resize', again) + return () => { ro.disconnect(); window.removeEventListener('resize', again) } + }, [model]) + // 选中的组件被删掉了、换页了:不再选中 + useEffect(() => { if (selected && !findComponent(draft, selected)) setSelected(null) }, [draft, selected]) + useEffect(() => { setSelected(null); setHover(null) }, [pageKey]) + + const nodeOf = (target: EventTarget | null): string | null => { + const el = target instanceof Element ? target.closest('[data-node]') : null + if (!el || !layer.current?.contains(el)) return null + const id = el.getAttribute('data-node') + return id && findComponent(latest.current, id) ? id : null + } + /** 编辑状态下点组件就是选中:捕获阶段拦下,组件里的按钮、链接不再响应 */ + const onClickCapture = (e: ReactMouseEvent) => { + if ((e.target as Element).closest?.('[data-page-edit-toolbar]')) return + const id = nodeOf(e.target) + if (!id) return + e.preventDefault() + e.stopPropagation() + setSelected(id) + } + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key === 'Escape' && selected) { e.stopPropagation(); setSelected(null); return } + // 键盘:组件在编辑状态下可以聚焦(ViewRenderer 给 data-node 加了 tabIndex),回车选中 + if ((e.key === 'Enter' || e.key === ' ') && (e.target as Element).hasAttribute?.('data-node')) { + const id = nodeOf(e.target) + if (id) { e.preventDefault(); setSelected(id) } + } + } + + // ---- 草稿里隐藏了的组件:编辑状态下画成半透明;服务端没画出来的列在页底 + const hiddenIds = useMemo(() => new Set(walkSpec(page?.body ?? []).filter((c) => c.hidden).map((c) => c.id)), [page]) + const marks = useMemo(() => ({ hidden: hiddenIds }), [hiddenIds]) + const notDrawn = useMemo(() => { + const drawn = walkView(model?.tree) + return walkSpec(page?.body ?? []).filter((c) => c.hidden && !drawn.has(c.id)) + }, [model, page]) + + const at = selected ? findComponent(draft, selected) : null + const [textFor, setTextFor] = useState(null) + const [adding, setAdding] = useState(false) + + const remove = async (id: string) => { + const comp = findComponent(latest.current, id)?.node + if (!comp) return + const label = labelOf(comp, byName.get(comp.type)) + if (!(await confirmDialog({ title: T.removeTitle(label), consequences: T.removeConsequences, confirmLabel: T.removeConfirm, + danger: true }))) return + if (apply({ op: 'app_remove', id })) setSelected(null) + } + const move = (delta: -1 | 1) => { + if (!at) return + // 同一层里挪一格(应用操作的 index 指拿掉之后的位置) + apply({ op: 'app_move', id: at.node.id, parent: at.parent?.id ?? at.page.id, index: at.index + delta }) + } + + // ---- 发布:走现有的应用发布 + const [publishing, setPublishing] = useState(false) + const [blocked, setBlocked] = useState(null) + const [asking, setAsking] = useState | null>(null) + const publish = async (dispositions?: Record) => { + if (!dispositions && !(await confirmDialog({ title: T.publishTitle, consequences: T.publishConsequences, + confirmLabel: T.publishConfirm }))) return + setPublishing(true) + setBlocked(null) + try { + // 还在存的先存完:发布读的是服务端那份草稿 + await chain.current + const res = await api.apps.publish(appId, dispositions ? { dispositions } : {}) + if (res.ok && res.version != null) { + toast.ok(T.published(res.version)) + setAsking(null) + setChanged(false) + onPublished() + return + } + if (res.needs_disposition?.length && !dispositions) setAsking(res.needs_disposition) + const rest = res.issues.filter((i) => i.code !== 'app.upgrade_policy' || !res.needs_disposition?.length) + setBlocked(rest.length ? rest : null) + } catch (e) { + toast.error(e) + } finally { + setPublishing(false) + } + } + + const status = personal ? T.personalNoPublish + : detail.published_version == null ? T.neverPublished + : changed === true ? T.unpublished + : changed === false ? T.draftSameAsPublished + : null + const pages = model?.pages ?? draft.pages.map((p) => ({ id: p.id, title: p.title })) + + return ( +
    + } + title={draft.name} + subtitle={T.editing} + actions={( +
    + {loading && model && } + +
    + )} + /> + {pages.length > 1 && ( + ({ key: p.id, label: p.title || p.id }))} + active={pageKey} onChange={onNavigate} /> + )} +
    + {/* 编辑条:停在顶上,状态、发布、改取数随时够得着 */} +
    +
    + {T.editing} + {status && ( + + {status} + + )} + + {saveState === 'saving' ? T.saving : saveState === 'saved' ? T.saved : saveState === 'failed' ? T.saveFailed : ''} + + + {detail.blueprint_id && ( + + {T.changeData} + + )} + {builder && !personal && ( + + {T.openEditor} + + )} + {!personal && ( + + )} +
    +
    +
    + } /> +

    {T.pickHint}

    + {blocked && ( + +
    {T.publishBlocked}
    +
      + {blocked.map((issue, i) =>
    • {issue.message}
    • )} +
    + {T.openCheck} +
    + )} + {error && !model ? void resolve()} /> + : !model ? + : ( + // 编辑层:叠在渲染器外面,捕获点击、悬停描边、选中出工具条 +
    setHover(nodeOf(e.target))} onMouseLeave={() => setHover(null)}> + void resolve()} /> + {boxes.hover && ( +
    + )} + {boxes.selected && at && ( + <> +
    + = at.list.length - 1} + onText={() => setTextFor(at.node.id)} + onHide={() => apply({ op: 'app_update', id: at.node.id, hidden: !at.node.hidden })} + onMove={move} onRemove={() => void remove(at.node.id)} /> + + )} +
    + )} + {notDrawn.length > 0 && ( +
    +
    {T.hiddenElsewhere(notDrawn.length)}
    +
      + {notDrawn.map((c) => ( +
    • + +
    • + ))} +
    +
    + )} + {page && ( + + )} +
    +
    + {textFor && (() => { + const comp = findComponent(draft, textFor)?.node + return comp ? ( + setTextFor(null)} + onSave={(props) => { apply({ op: 'app_update', id: comp.id, props }); setTextFor(null) }} /> + ) : null + })()} + {adding && page && ( + setAdding(false)} onPick={(c) => { + const comp = freshComponent(latest.current, c.component) + if (apply({ op: 'app_add', parent: page.id, component: comp })) { + setSelected(comp.id) + toast.ok(T.added(c.label)) + } + setAdding(false) + }} /> + )} + {asking && setAsking(null)} onConfirm={(d) => void publish(d)} />} +
    + ) +} + +/** 选中组件的小工具条:浮在组件右上角,组件太靠上时放进组件里面 */ +function Toolbar({ box, comp, entry, first, last, onText, onHide, onMove, onRemove }: { + box: Box; comp: AppComponent; entry?: CatalogComponent; first: boolean; last: boolean + onText: () => void; onHide: () => void; onMove: (delta: -1 | 1) => void; onRemove: () => void +}) { + const label = labelOf(comp, entry) + const texts = textPropsOf(entry?.props) + const top = box.top >= 34 ? box.top - 32 : box.top + 4 + return ( +
    +
    + {label} + + + + + +
    +
    + ) +} + +/** 改文字:只改目录里标了 x-text 的属性;清空的去掉这一项(同属性面板) */ +function TextDialog({ comp, entry, onClose, onSave }: { + comp: AppComponent; entry?: CatalogComponent; onClose: () => void; onSave: (props: Record) => void +}) { + const fields = textPropsOf(entry?.props) + const initial = (key: string) => (typeof comp.props?.[key] === 'string' ? comp.props[key] as string : '') + const [values, setValues] = useState>(() => Object.fromEntries(fields.map((f) => [f.key, initial(f.key)]))) + const changedKeys = fields.filter((f) => (values[f.key] ?? '') !== initial(f.key)).map((f) => f.key) + const save = () => onSave(Object.fromEntries(changedKeys.map((k) => [k, values[k].trim() ? values[k] : null]))) + return ( + 0} + footer={<> + + + }> +

    {T.editTextHint}

    + {!fields.length ? : ( +
    + {fields.map((f) => { + const id = `page-edit-text-${f.key}` + return ( +
    + + {f.long + ?