From 542bcb65bb3dd86793f9bd01964cef69e0c28c58 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 20 Sep 2026 14:09:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=AF=B9=E8=AF=9D=E6=9C=89?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E5=9C=B0=E5=9D=80=E4=BA=86=EF=BC=9A?= =?UTF-8?q?/chat/=EF=BC=8C=E8=BF=90=E8=A1=8C=E3=80=81=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E3=80=81=E5=90=84=E9=A1=B5=E6=A0=87=E7=AD=BE=E5=90=8C?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在此之前 react-router 只管到页面一级,「页面内部在看哪个」全在 store 和 localStorage 里,全站一处 useParams / useSearchParams / useNavigate 都没有。 后果: - 一次对话没有地址。想把「这个结论是怎么查出来的」发给同事,只能发截图。 - 刷新回到上次那个对话靠 localStorage,换台机器、换个浏览器就丢。 - 浏览器前进/后退在页面内部彻底失效——切了五个对话,后退一下直接离开问数据页。 - /settings 永远落在「模型接入」,指不到数据源那一屏。 /chat/:conversationId /studio/:workflowId /runs/:runId /knowledge/:tab /tools/:tab /settings/:tab 每页写两条显式路由(带 id / 不带 id)而不是可选参数 `:id?`:「没带 id」是真要 单独处理的一种情况(落到上次那个、或列表第一个),藏在一个问号里下次就没人 记得它存在了。 **URL 驱动 store,不是反过来。** 两边都能写就一定会打架——点一下列表,store 改了、地址栏没动,刷新又跳回旧的。所以 useConversations.currentId 降级成 URL 的派生缓存,唯一写入口是 ChatPage 的那个同步 effect;列表里的切换、新建、删除 一律改成导航。conversations.load() 不再自作主张决定「当前看哪个」, remove() 改成返回「接下来该去哪」交给调用方。 localStorage 留着,但它现在只回答另一个问题:**打开不带 id 的 /chat 该去哪**。 **指向不存在的东西**要优雅:等列表加载完再判(在那之前"找不到"是假的,照着它 跳会让每次深链接进入都先闪一下错误提示),落到一个真实的,并说明为什么换了 地方。RunsPage 特别一点——列表只取前 100 条,而一条老运行的链接必须也能打开, 所以详情按 id 直接取,不在列表里找。 **画布多两道闸。** 选择器里本来就有"未保存改动"的确认,但那道闸在它的 onClick 里,而前进/后退会绕过选择器直接换图——改了一半的画布就这么没了。URL→画布那个 effect 里补一道,取消就把地址退回去。 过程中实测抓到三个问题,都修了: - 同一个坏地址弹三次"那张工作流不在了":effect 在请求回来之前又跑了两遍, 加了在途去重(chat 那边同样的毛病弹两次)。 - 「在画布里打开」现在直接把人送去 /studio,而那是一张还没存、没有 id 的草稿图。 不带 id 就落到第一张图的逻辑会拿第一张把它盖掉,还会先弹一句莫名其妙的 "有未保存的改动"——加了 nodeCount 判断让开。这个坑其实原来就在,只是以前 要手动点导航才踩得到。 - 删掉当前打开的那张图之后地址还指着它,刷新会撞上"不在了"。 顺带:三个 tab 页共用一个 useTabRoute(放在 Tabs 旁边,tab 和它的地址是同一件 事,拆两处迟早各改各的),认不出的 tab 名回落并把地址一起纠正过来。 「在画布里打开」不再是 toast 让用户自己去点导航,直接送过去。 验证:tsc 干净;后端 249 passed(没碰后端,跑一遍确认没带坏); check-decode / check-stream 不回归;check-ui 新增「URL 指得到」一节 19 条, 真浏览器里验了深链接、切换改地址、刷新、后退、坏地址、tab 直达、 草稿图不被盖掉、越过列表上限的老运行。 --- frontend/src/App.tsx | 11 ++ frontend/src/components/ui.tsx | 29 +++++ frontend/src/pages/ChatPage.tsx | 46 +++++++- frontend/src/pages/ConversationList.tsx | 28 ++++- frontend/src/pages/KnowledgePage.tsx | 21 ++-- frontend/src/pages/RunsPage.tsx | 37 ++++-- frontend/src/pages/SettingsPage.tsx | 23 ++-- frontend/src/pages/StudioPage.tsx | 69 +++++++++-- frontend/src/pages/ToolsPage.tsx | 23 ++-- frontend/src/store/conversations.ts | 63 +++++----- scripts/check-ui.mjs | 145 ++++++++++++++++++++++++ 11 files changed, 406 insertions(+), 89 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e4ec260..3c50f32f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -74,15 +74,26 @@ export default function App() {
+ {/* 「在看哪个」属于 URL,不属于 store。 + 每页写成两条显式路由(带 id / 不带 id),而不是可选参数 `:id?`: + 「没带 id」是真要单独处理的一种情况(落到上次那个、或列表第一个), + 藏在一个问号里下次就没人记得它存在了。 + 导航高亮不用动——NavLink 默认前缀匹配,/chat/abc 照样点亮「问数据」。*/} {/* 默认落到对话页:多数人要的是答案,画布留给要自己编排的人 */} } /> } /> + } /> } /> + } /> } /> + } /> } /> + } /> } /> + } /> } /> + } />
diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index afac11fc..844b0c83 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import type { ReactNode } from 'react' import { AlertCircle, CheckCircle2, Info, Loader2, X } from 'lucide-react' import clsx from 'clsx' @@ -101,6 +102,34 @@ export function Empty({ icon, title, hint, action }: { ) } +/** + * 把 Tabs 的选中项接到 URL 的最后一段上:/settings/datasources。 + * + * 和 Tabs 放在一起而不是单开一个 hooks 文件——tab 和它的地址是同一件事, + * 拆到两处迟早各改各的。三个 tab 页(知识 / 工具 / 设置)各接一行。 + * + * 用 replace 而不是 push:切 tab 不该在后退历史里堆一串,按后退是想离开 + * 这一页,不是想把四个标签倒着走一遍。 + * + * 认不出的 tab 名(链接过期、手输错了)回落到 fallback,并把地址一起纠正过来, + * 免得地址栏和眼前这一屏说的不是同一件事。 + */ +export function useTabRoute( + valid: readonly string[], fallback: string, +): [string, (key: string) => void] { + const { tab } = useParams() + const navigate = useNavigate() + const { pathname } = useLocation() + const base = '/' + pathname.split('/').filter(Boolean)[0] + const active = tab && valid.includes(tab) ? tab : fallback + + useEffect(() => { + if (tab !== active) navigate(`${base}/${active}`, { replace: true }) + }, [tab, active, base, navigate]) + + return [active, (key: string) => navigate(`${base}/${key}`, { replace: true })] +} + export function Tabs({ tabs, active, onChange }: { tabs: { key: string; label: string; badge?: number }[] active: string diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 0c5a8fa9..168b0820 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' import { Database, ListTree, MessageSquare, Play, Send, Square } from 'lucide-react' import { api } from '../api/client' import { ApprovalCard } from '../run/RunPanel' @@ -6,7 +7,7 @@ import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantS import { decodeCopilot, decodeRun } from '../run/decode' import { useCatalog } from '../store/catalog' import { useChat, type ChatTurn } from '../store/chat' -import { useConversations } from '../store/conversations' +import { lastVisited, useConversations } from '../store/conversations' import { useStudio } from '../store/studio' import { ConversationList } from './ConversationList' import { useToast } from '../components/ui' @@ -26,14 +27,48 @@ import { useToast } from '../components/ui' */ export function ChatPage() { const { busy, ask, stop, load, loadSteps, runNow, turnsOf } = useChat() + const { conversationId } = useParams() + const navigate = useNavigate() const currentId = useConversations((s) => s.currentId) const create = useConversations((s) => s.create) + const select = useConversations((s) => s.select) + const list = useConversations((s) => s.list) + const listLoading = useConversations((s) => s.loading) const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined)) const [draft, setDraft] = useState('') const sources = useDataSources() const setGraph = useStudio((s) => s.setGraph) const toast = useToast() + // URL → store。这是 currentId 唯一的写入口,别处一律靠导航 + useEffect(() => { select(conversationId ?? null) }, [conversationId, select]) + + // /chat 不带 id:落到上次那个,没有就列表第一条。用 replace 而不是 push, + // 否则按后退会回到这个空壳地址、又被弹回来,人就卡在这儿出不去了 + useEffect(() => { + if (conversationId || listLoading || !list.length) return + const last = lastVisited() + const target = list.find((c) => c.id === last)?.id ?? list[0].id + navigate(`/chat/${target}`, { replace: true }) + }, [conversationId, listLoading, list, navigate]) + + // 地址指着一个不存在的对话(被删了、链接过期了)。不能白屏,也不能一声不响 + // 地跳走——点开一个链接却到了别的地方,得让人知道为什么。 + // + // 必须等列表加载完再判:列表是异步取的,在那之前"找不到"是假的, + // 照着它跳会让每次深链接进入都先闪一下错误提示 + const reported = useRef(null) + useEffect(() => { + if (!conversationId || listLoading || !list.length) return + if (list.some((c) => c.id === conversationId)) return + // 同一个坏地址只说一次。StrictMode 会把 effect 跑两遍,依赖里的 list + // 变一次又会再跑一遍——不记着的话同一句提示会连弹好几条 + if (reported.current === conversationId) return + reported.current = conversationId + toast('那个对话不在了,已经带你回到最近一个', 'info') + navigate(`/chat/${list[0].id}`, { replace: true }) + }, [conversationId, listLoading, list, navigate, toast]) + // 切到哪个会话就把哪个会话的内容取回来。内存里已经有的不会回源—— // 那一份带着这次跑出来的完整步骤,回源拿到的只有问题和答案 useEffect(() => { if (currentId) void load(currentId) }, [currentId, load]) @@ -43,6 +78,8 @@ export function ChatPage() { if (!q || busy) return // 没有会话就先开一个。第一次进来的人不该先被要求"新建对话" const id = currentId ?? (await create()) + // 地址要跟上,否则内容已经在新对话里、地址栏还停在 /chat + if (id !== currentId) navigate(`/chat/${id}`, { replace: true }) ask(id, q) setDraft('') } @@ -81,8 +118,11 @@ export function ChatPage() { /> )} onOpenGraph={(graph) => { + // 以前只把图放过去、让用户自己去点导航。有了路由就直接送过去—— + // "已放到画布,切到「编排」继续改"是在让人替系统完成一次跳转 setGraph(graph) - toast('已放到画布,切到「编排」继续改', 'ok') + navigate('/studio') + toast('已放到画布', 'ok') }} footer={
diff --git a/frontend/src/pages/ConversationList.tsx b/frontend/src/pages/ConversationList.tsx index 4b1fd102..d959ccea 100644 --- a/frontend/src/pages/ConversationList.tsx +++ b/frontend/src/pages/ConversationList.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { Check, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Trash2, X } from 'lucide-react' import clsx from 'clsx' import { useChat } from '../store/chat' @@ -14,9 +15,26 @@ import { useConversations } from '../store/conversations' * "改图指令",和这里的"问题"不是一回事,混进同一个列表只会让两种都更难找。 */ export function ConversationList() { - const { list, currentId, loading, load, create, select, rename, remove } = useConversations() + const { list, currentId, loading, load, create, rename, remove } = useConversations() const forget = useChat((s) => s.forget) const busy = useChat((s) => s.busy) + const navigate = useNavigate() + + // 切会话 = 换地址,不是改 store。store 那个 currentId 是 URL 的派生缓存, + // 在这里直接 set 的话,地址栏不动、刷新就跳回旧的那个,前进后退也全失效 + const open = (id: string) => navigate(`/chat/${id}`) + + const startNew = async () => { + const id = await create() + navigate(`/chat/${id}`) + } + + const drop = async (id: string) => { + forget(id) + const next = await remove(id) + // 删的是当前这个才需要换地方;remove 会把该去哪告诉我们 + if (next !== currentId) navigate(next ? `/chat/${next}` : '/chat', { replace: true }) + } const [collapsed, setCollapsed] = useState(false) const [editing, setEditing] = useState(null) @@ -34,7 +52,7 @@ export function ConversationList() { onClick={() => setCollapsed(false)}> -
@@ -45,7 +63,7 @@ export function ConversationList() {