feat: add creative welcome and external works square links
This commit is contained in:
1 parent
4199cadd1e
commit
f8a7880da3
18 files changed
+428
-41
No files matched your search
@@ -0,0 +1,8 @@
|
||||
import { CommunityLink } from './CommunityLink';
|
||||
|
||||
export function CommunityInvitation() {
|
||||
return <section className="community-invitation" aria-label="作品广场入口" data-testid="community-invitation">
|
||||
<div><h2>还没想到?去找找灵感吧</h2><p>作品广场里,有小伙伴们用麦洛做出的奇思妙想。</p></div>
|
||||
<CommunityLink />
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowUpRight, Compass } from 'lucide-react';
|
||||
import { openCommunity } from '@/lib/community';
|
||||
import './community.css';
|
||||
|
||||
export function CommunityLink({ variant = 'inline', collapsed = false }: {
|
||||
variant?: 'inline' | 'sidebar'; collapsed?: boolean;
|
||||
}) {
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
return <div>
|
||||
<button type="button" className="community-link" data-variant={variant} data-collapsed={collapsed}
|
||||
aria-label={variant === 'sidebar' ? '作品广场' : '去作品广场逛逛'}
|
||||
title="在浏览器打开作品广场" disabled={opening}
|
||||
onClick={() => {
|
||||
setOpening(true); setError(false);
|
||||
void openCommunity().catch(() => setError(true)).finally(() => setOpening(false));
|
||||
}}>
|
||||
<Compass aria-hidden="true" />
|
||||
{!collapsed && <><span>{variant === 'sidebar' ? '作品广场' : '去作品广场逛逛'}</span><ArrowUpRight className="community-link-arrow" aria-hidden="true" /></>}
|
||||
</button>
|
||||
{error && <span className="community-link-error" role="alert">没能打开作品广场,请再点一次试试。</span>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.coding-chat-body { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.coding-chat-body[hidden] { display: none; }
|
||||
.coding-chat-body[data-welcome="true"] { overflow-y: auto; padding: clamp(24px, 4vh, 48px) 24px 28px; background: radial-gradient(ellipse at 45% 18%, #fff8e8 0%, #fffdf8 28%, #fff 67%); }
|
||||
.coding-chat-body[data-welcome="true"] .classroom-chat-view { flex: 0 0 auto; }
|
||||
.coding-chat-body[data-welcome="true"] .classroom-composer-view { width: 100%; max-width: 740px; margin: 0 auto; }
|
||||
.coding-chat-body[data-welcome="true"] [data-testid="coding-message-composer"] { width: 100%; padding: 0 0 28px; }
|
||||
.coding-chat-body[data-welcome="true"] .chat-composer-surface { background: #fff; border-color: #d7dfda; border-radius: 20px; box-shadow: 0 5px 0 #eaf0e8, 0 12px 32px #69774b08; }
|
||||
.coding-start-heading { position: relative; width: 100%; max-width: 740px; margin: 0 auto; padding: 0 12px 24px; text-align: center; }
|
||||
.coding-start-mark { display: inline-grid; position: relative; place-items: center; width: 57px; height: 59px; border-radius: 18px; border: 1px solid #f2d6a7; background: #fff0d7; box-shadow: 0 4px 0 #eeddbd; transform: rotate(-7deg); animation: community-hello 650ms ease-out both; }
|
||||
.coding-start-mark img { width: 35px; height: 35px; }
|
||||
.coding-start-mark > svg { position: absolute; width: 17px; height: 17px; right: -24px; top: -2px; color: #c78b36; }
|
||||
.coding-start-mark > svg:last-child { width: 12px; height: 12px; left: -23px; top: 39px; color: #83a496; }
|
||||
.coding-start-eyebrow { margin: 18px 0 7px; color: #7a7769; font-size: 12px; letter-spacing: .08em; }
|
||||
.coding-start-heading h1 { color: #304968; font-size: clamp(24px, 2.5vw, 34px); line-height: 1.45; font-weight: 650; letter-spacing: -.04em; }
|
||||
.coding-start-heading h1 span { position: relative; white-space: nowrap; background: linear-gradient(transparent 73%, #ffe6a6 73%, #ffe6a6 94%, transparent 94%); }
|
||||
.coding-start-caption { margin-top: 10px; color: #718096; font-size: 13px; line-height: 1.8; }
|
||||
.community-invitation { display: flex; align-items: center; justify-content: space-between; gap: 18px; width: 100%; max-width: 740px; margin: 5px auto 0; padding: 21px 23px; border: 1px dashed #e4d9bc; border-radius: 18px; background: #fffaf0; color: #304968; }
|
||||
.community-invitation h2 { margin-bottom: 5px; font-size: 15px; font-weight: 600; }
|
||||
.community-invitation p { color: #7c806e; font-size: 12px; line-height: 1.7; }
|
||||
.community-link { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; padding: 8px 13px; border: 1px solid #dce5d0; border-radius: 12px; background: #f0f5e8; color: #526b46; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
||||
.community-link:hover:not(:disabled) { background: #e5eed9; border-color: #b7c6a4; }
|
||||
.community-link:disabled { opacity: .6; cursor: wait; }
|
||||
.community-link:focus-visible { outline: 2px solid #547ab0; outline-offset: 3px; }
|
||||
.community-link svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||
.community-link .community-link-arrow { width: 14px; height: 14px; }
|
||||
.community-link[data-variant="sidebar"] { justify-content: flex-start; width: 100%; margin-top: 12px; border-color: transparent; background: transparent; text-align: left; }
|
||||
.community-link[data-variant="sidebar"]:hover { background: #e8f0e7; }
|
||||
.community-link[data-collapsed="true"] { justify-content: center; padding-inline: 0; }
|
||||
.community-link-error { display: block; margin-top: 6px; color: #965a45; font-size: 12px; line-height: 1.6; }
|
||||
@keyframes community-hello { 0% { transform: rotate(-15deg) translateY(5px); opacity: 0; } 70% { transform: rotate(3deg); opacity: 1; } 100% { transform: rotate(-7deg); } }
|
||||
@media (max-width: 600px) {
|
||||
.coding-chat-body[data-welcome="true"] { padding: 24px 16px; }
|
||||
.coding-start-caption { max-width: 230px; margin: 9px auto 0; }
|
||||
.community-invitation { flex-direction: column; align-items: flex-start; gap: 13px; padding: 19px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .coding-start-mark { animation: none; } }
|
||||
@@ -53,6 +53,7 @@ import { useProviderStore } from '@/stores/providers';
|
||||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { toast } from 'sonner';
|
||||
import { CommunityLink } from '@/components/community/CommunityLink';
|
||||
|
||||
type OpenDialogResult = {
|
||||
canceled: boolean;
|
||||
@@ -435,6 +436,7 @@ export function Sidebar({
|
||||
<Plus className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed && '新建项目'}
|
||||
</button>
|
||||
<CommunityLink variant="sidebar" collapsed={sidebarCollapsed} />
|
||||
<div className="classroom-projects mt-3">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { invokeIpc } from './api-client';
|
||||
|
||||
export const COMMUNITY_URL = 'https://square.nianxx.cn/#explore';
|
||||
|
||||
/** Open only after the student explicitly clicks; no community data is fetched. */
|
||||
export async function openCommunity(): Promise<void> {
|
||||
await invokeIpc('shell:openExternal', COMMUNITY_URL);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
/** Keep the same composer mounted while the empty state becomes a conversation. */
|
||||
export function CodingChatBody({ welcome, hidden, bubble, children }: {
|
||||
welcome: boolean; hidden: boolean; bubble?: HTMLElement | null; children: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const body = ref.current;
|
||||
if (!body || !welcome || hidden || !bubble) return;
|
||||
const update = () => {
|
||||
const area = body.getBoundingClientRect();
|
||||
const message = bubble.getBoundingClientRect();
|
||||
const overlaps = message.height > 0 && message.right > area.left && message.left < area.right && message.bottom > area.top;
|
||||
// Wide workspaces put the welcome beside the teachers. Compact ones flow below them.
|
||||
body.style.paddingRight = overlaps && area.width >= 1050 ? `${Math.ceil(area.right - message.left + 24)}px` : '';
|
||||
body.style.paddingTop = overlaps && area.width < 1050 ? `${Math.ceil(message.bottom - area.top + 24)}px` : '';
|
||||
};
|
||||
update();
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
|
||||
observer?.observe(body); observer?.observe(bubble);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
observer?.disconnect(); window.removeEventListener('resize', update);
|
||||
body.style.paddingRight = ''; body.style.paddingTop = '';
|
||||
};
|
||||
}, [welcome, hidden, bubble]);
|
||||
return <div ref={ref} className="coding-chat-body" data-welcome={welcome} hidden={hidden}>{children}</div>;
|
||||
}
|
||||
@@ -47,6 +47,9 @@ import { CodingConversationHeader } from './CodingConversationHeader';
|
||||
import { CodingConversationTimeline } from './CodingConversationTimeline';
|
||||
import { CodingInteractionPanel } from './CodingInteractionPanel';
|
||||
import { CodingWelcomeHero } from './CodingWelcomeHero';
|
||||
import { CodingStartHeading } from './CodingStartHeading';
|
||||
import { CodingChatBody } from './CodingChatBody';
|
||||
import { CommunityInvitation } from '@/components/community/CommunityInvitation';
|
||||
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { TeacherChatPanel } from './TeacherChatPanel';
|
||||
@@ -676,6 +679,8 @@ export function CodingChatPanel({
|
||||
});
|
||||
}, [loginRecovery, activeProject, targetConversationId, entryLoadState, workspaceLoadState, handleRecovery, draftKey, onLoginRecoveryConsumed]);
|
||||
const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]);
|
||||
const showWelcome = editable && !selectedConversation?.archivedAt && !running && runStatus === 'idle'
|
||||
&& (!targetConversationId || (entryLoadState === 'live' && snapshot?.nodes.length === 0));
|
||||
|
||||
if (!activeProject && workspaceLoadState !== 'loading') {
|
||||
return (
|
||||
@@ -807,6 +812,7 @@ export function CodingChatPanel({
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
<CodingChatBody welcome={showWelcome} hidden={agentBrowserOpen} bubble={teacherBubble}>
|
||||
<div id="classroom-chat-view" role="tabpanel" aria-labelledby="classroom-chat-tab" hidden={agentBrowserOpen} className="classroom-chat-view min-h-0 flex-1 flex-col">
|
||||
|
||||
{(workspaceError || conversationMetadataError || connectionError) && (
|
||||
@@ -829,7 +835,7 @@ export function CodingChatPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetConversationId
|
||||
{showWelcome ? <CodingStartHeading /> : targetConversationId
|
||||
? (
|
||||
<CodingConversationTimeline
|
||||
key={targetConversationId}
|
||||
@@ -959,6 +965,8 @@ export function CodingChatPanel({
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
{showWelcome && <CommunityInvitation />}
|
||||
</CodingChatBody>
|
||||
</div>
|
||||
|
||||
{consultationOpen && activeProject && (
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Sparkles, Star } from 'lucide-react';
|
||||
import mark from '@/assets/consultations/mark.svg';
|
||||
|
||||
export function CodingStartHeading() {
|
||||
return <div className="coding-start-heading" data-testid="coding-start-heading">
|
||||
<span className="coding-start-mark"><img src={mark} alt="麦洛" /><Sparkles aria-hidden="true" /><Star aria-hidden="true" /></span>
|
||||
<p className="coding-start-eyebrow">小点子,也能变成大惊喜</p>
|
||||
<h1>今天,你想<span>创造什么?</span></h1>
|
||||
<p className="coding-start-caption">一个游戏、一段故事,或是一个天马行空的想法。</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -471,7 +471,7 @@ export function TeacherChatPanel({
|
||||
(request) => request.status === 'preparing' || request.status === 'running'
|
||||
);
|
||||
const helpUnavailable = busy || !topicEnabled || Boolean(running);
|
||||
const askForSuggestions = () => void send({ text: '不知道问什么,你先帮我看看👀', intent: 'suggestions' });
|
||||
const askForSuggestions = () => void send({ text: '帮我整体看🧠', intent: 'suggestions' });
|
||||
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
|
||||
const removeReferences = () => { setReferences([]); persistDraft(text, [], pending.current); };
|
||||
const questions = definition?.suggested_questions ?? [];
|
||||
@@ -541,7 +541,7 @@ export function TeacherChatPanel({
|
||||
{request.suggestedQuestions?.map((question, index) => <button key={`${request.id}:${index}`} type="button" disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
|
||||
</div>
|
||||
</>}
|
||||
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-retry"><span>不知道问什么,<span className="whitespace-nowrap">你先帮我看看👀</span></span></button>}
|
||||
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-retry">帮我整体看🧠</button>}
|
||||
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{label}正在想…</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
|
||||
{request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label="接着聊">
|
||||
{request.suggestedQuestions?.map((question, index) => <button type="button" key={`${request.id}:${index}`} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
|
||||
@@ -585,7 +585,7 @@ export function TeacherChatPanel({
|
||||
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
|
||||
<div className="mt-2 flex min-h-[68px] items-center justify-between gap-2" data-testid="consultation-composer-actions">
|
||||
<div className="min-w-0 flex-1">
|
||||
{!draftRevision && !text.trim() && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-hint"><span>不知道问什么,<span className="whitespace-nowrap">你先帮我看看👀</span></span></button>}
|
||||
{!draftRevision && !text.trim() && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-hint">帮我整体看🧠</button>}
|
||||
</div>
|
||||
{running ? <button type="button" className="rounded-lg border px-3 py-2 text-xs" onClick={() => void teacherApi.cancel(topicBase, endpointId, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
|
||||
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
|
||||
|
||||
Reference in new issue
Block a user