Merge pull request 'feat: Workbench SaaS视觉升级与视图重置机制' (#26) from feat/workbench-saas-polish-and-reset into master

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-06-08 09:31:55 +00:00
4 changed files with 1755 additions and 16 deletions
+7 -1
View File
@@ -374,6 +374,7 @@ function App() {
})));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
useEffect(() => {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
@@ -459,6 +460,9 @@ function App() {
);
const handleSetView = useCallback((view: WebViewKey) => {
if (view === "workbench" && Boolean(session)) {
setWorkbenchResetToken((token) => token + 1);
}
window.location.hash = `/${view}`;
setView(view);
if (view !== "login") {
@@ -467,7 +471,7 @@ function App() {
if (isWorkspaceView(view)) {
setWorkspaceExpanded(true);
}
}, [setView, setWorkspaceExpanded]);
}, [session, setView, setWorkspaceExpanded]);
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
clearAllUserStorage();
@@ -1313,11 +1317,13 @@ function App() {
case "workbench":
return (
<WorkbenchPage
key={`workbench-${workbenchResetToken}`}
isAuthenticated={Boolean(session)}
session={session}
onRequireLogin={handleRequireTaskLogin}
onOpenResultInCanvas={handleOpenResultInCanvas}
onRefreshUsage={refreshUsage}
resetToken={workbenchResetToken}
/>
);
case "home":
+80 -15
View File
@@ -201,6 +201,7 @@ interface WorkbenchPageProps {
onRequireLogin: (input: CreatePreviewTaskInput) => void;
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
onRefreshUsage?: () => void;
resetToken?: number;
}
// ─── Component ───────────────────────────────────────────────────────────
@@ -226,6 +227,7 @@ function WorkbenchPage({
onRequireLogin,
onOpenResultInCanvas,
onRefreshUsage,
resetToken,
}: WorkbenchPageProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const referenceInputRef = useRef<HTMLInputElement | null>(null);
@@ -244,10 +246,11 @@ function WorkbenchPage({
const activeConversationIdRef = useRef<number | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const conversationMessagesCacheRef = useRef<Map<number, ChatMessage[]>>(new Map());
const skipConversationAutoSelectRef = useRef(false);
const skipConversationAutoSelectRef = useRef(Boolean(resetToken));
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0);
const scrollActionHintTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
const genTracker = useGenerationTasks({ sourceView: "workbench" });
@@ -256,7 +259,7 @@ function WorkbenchPage({
const [activeMode, setActiveMode] = useState<WorkbenchMode>("video");
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(() => readStoredMessages());
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
@@ -279,7 +282,7 @@ function WorkbenchPage({
const [projectError, setProjectError] = useState<string | null>(null);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState<number | null>(() =>
readStoredActiveConversationId(readStoredMessages()),
resetToken ? null : readStoredActiveConversationId(readStoredMessages()),
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
@@ -289,7 +292,9 @@ function WorkbenchPage({
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false);
const [scrollActionHint, setScrollActionHint] = useState<"top" | "bottom" | null>(null);
const [workspaceStarted, setWorkspaceStarted] = useState(false);
const lastResetTokenRef = useRef(resetToken);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
@@ -415,7 +420,6 @@ function WorkbenchPage({
const toolTheme = MODE_META[activeMode];
const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
@@ -443,6 +447,7 @@ function WorkbenchPage({
[conversations],
);
const hasSidebarRecords = conversationRecords.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const activeConversationTitle = useMemo(() => {
if (!activeConversationId) return "";
@@ -496,6 +501,31 @@ function WorkbenchPage({
});
}, []);
const hideScrollActionHint = useCallback(() => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
scrollActionHintTimerRef.current = null;
}
setScrollActionHint(null);
}, []);
const showScrollActionHint = useCallback((direction: "top" | "bottom") => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
}
setScrollActionHint(direction);
scrollActionHintTimerRef.current = window.setTimeout(() => {
setScrollActionHint(null);
scrollActionHintTimerRef.current = null;
}, 1400);
}, []);
useEffect(() => () => {
if (scrollActionHintTimerRef.current !== null) {
window.clearTimeout(scrollActionHintTimerRef.current);
}
}, []);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
{
@@ -1266,6 +1296,12 @@ function WorkbenchPage({
activeConversationIdRef.current = null;
}, [syncActiveGenerationUi]);
useEffect(() => {
if (resetToken === undefined || lastResetTokenRef.current === resetToken) return;
lastResetTokenRef.current = resetToken;
handleNewConversation();
}, [handleNewConversation, resetToken]);
const handleSelectProject = useCallback((id: string) => {
if (!id) {
handleNewConversation();
@@ -1420,6 +1456,7 @@ function WorkbenchPage({
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
shouldFollowNewMessagesRef.current = atBottom;
setComposerHidden(!(atTop || atBottom));
hideScrollActionHint();
lastScrollTopRef.current = top;
};
@@ -1434,24 +1471,27 @@ function WorkbenchPage({
shouldFollowNewMessagesRef.current = atBottom;
if (atTop || atBottom) {
setComposerHidden(false);
hideScrollActionHint();
} else if (Math.abs(delta) > scrollDeltaThreshold) {
setComposerHidden(true);
showScrollActionHint(delta < 0 ? "top" : "bottom");
}
lastScrollTopRef.current = top;
};
surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace]);
}, [hasActivatedWorkspace, hideScrollActionHint, showScrollActionHint]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current;
if (!surface) return;
const top = direction === "top" ? 0 : surface.scrollHeight;
hideScrollActionHint();
setComposerHidden(false);
surface.scrollTo({ top, behavior: "smooth" });
}, []);
}, [hideScrollActionHint]);
const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
@@ -2933,9 +2973,29 @@ function WorkbenchPage({
</div>
) : null;
const renderPromptCaseOverlay = () =>
selectedPromptCase ? (
<div className="wb-prompt-case-modal" role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
const renderPromptCaseOverlay = () => {
if (!selectedPromptCase) return null;
const measuredRatio = promptCaseMeasuredRatios[selectedPromptCase.id];
const ratioParts = selectedPromptCase.ratio.replace(/\s+/g, "").split(":").map(Number);
const declaredRatio =
ratioParts.length === 2 && ratioParts[0] > 0 && ratioParts[1] > 0
? ratioParts[0] / ratioParts[1]
: null;
const caseRatio =
typeof measuredRatio === "number" && Number.isFinite(measuredRatio) && measuredRatio > 0
? measuredRatio
: declaredRatio;
const copyLength = `${selectedPromptCase.summary} ${selectedPromptCase.prompt}`.length;
const modalClassName = [
"wb-prompt-case-modal",
caseRatio && caseRatio < 0.72 ? "is-tall-media" : "",
caseRatio && caseRatio >= 0.72 && caseRatio < 1 ? "is-portrait-media" : "",
copyLength > 260 ? "is-long-copy" : "",
].filter(Boolean).join(" ");
return (
<div className={modalClassName} role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
<button
type="button"
className="wb-prompt-case-modal__backdrop"
@@ -2944,7 +3004,11 @@ function WorkbenchPage({
/>
<section className="wb-prompt-case-modal__panel">
<div className="wb-prompt-case-modal__media">
<img src={selectedPromptCase.imageUrl} alt={selectedPromptCase.title} />
<img
src={selectedPromptCase.imageUrl}
alt={selectedPromptCase.title}
onLoad={(event) => handlePromptCaseImageLoad(selectedPromptCase.id, event)}
/>
</div>
<aside className="wb-prompt-case-modal__sidebar">
<button
@@ -2984,7 +3048,8 @@ function WorkbenchPage({
</aside>
</section>
</div>
) : null;
);
};
if (!hasActivatedWorkspace) {
return (
@@ -3079,8 +3144,8 @@ function WorkbenchPage({
</div>
</div>
{renderConversationSidebar()}
</div>
{renderConversationSidebar()}
{renderMessagePreviewOverlay()}
{renderPromptCaseOverlay()}
{renderDeleteDialog()}
@@ -3227,10 +3292,10 @@ function WorkbenchPage({
{renderComposerToolbar(false, isGenerating)}
</div>
</section>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
<div className={`wb-chat-scroll-actions${scrollActionHint ? ` is-showing-${scrollActionHint}` : ""}`} aria-label="聊天滚动">
<button
type="button"
className="wb-chat-scroll-actions__button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
title="返回聊天顶部"
aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")}
@@ -3239,7 +3304,7 @@ function WorkbenchPage({
</button>
<button
type="button"
className="wb-chat-scroll-actions__button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
title="到达聊天底部"
aria-label="到达聊天底部"
onClick={() => scrollMessagesSurface("bottom")}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -10837,8 +10837,44 @@
}
@media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"] {
--dg-mobile-nav-height: 58px;
--dg-mobile-nav-gap: 12px;
--dg-mobile-nav-space: calc(var(--dg-mobile-nav-height) + var(--dg-mobile-nav-gap));
}
.web-shell[data-ui-theme="dark-green"] .web-topbar {
z-index: 72;
}
.web-shell[data-ui-theme="dark-green"] .web-shell__content,
.web-shell[data-ui-theme="dark-green"] .web-shell__page {
min-height: 0;
padding-bottom: 0;
}
.web-shell[data-ui-theme="dark-green"] .web-shell__page {
overflow: auto;
}
.web-shell[data-ui-theme="dark-green"]:not([data-view="home"]):not([data-view="login"]):not([data-view="workbench"]):not([data-view="agent"]):not([data-view="avatarConsole"]) .web-shell__page {
padding-top: var(--dg-mobile-nav-space);
}
.web-shell[data-ui-theme="dark-green"] .profile-popover {
position: fixed;
top: calc(56px + var(--dg-mobile-nav-space) + env(safe-area-inset-top, 0px));
right: 12px;
z-index: 120;
width: min(288px, calc(100vw - 24px));
max-height: calc(100svh - 56px - var(--dg-mobile-nav-space) - 24px);
overflow-y: auto;
transform-origin: top right;
}
.web-shell[data-ui-theme="dark-green"] .floating-nav {
top: calc(56px + env(safe-area-inset-top, 0px));
z-index: 50;
right: 12px;
bottom: auto;
left: 12px;