合并客户端真机预览链路

需求:将待审 Release 扫码验收与项目级真机入口并入登录、发布集成候选。

实现:合并 Main-owned 精确预览与提交映射能力,保留现有登录和一键发布边界。
This commit is contained in:
2026-08-08 17:59:42 +08:00
20 changed files with 1890 additions and 12 deletions

View File

@@ -18,6 +18,7 @@ import { PreviewScene } from './pages/Makelore';
import { ProjectConfiguration } from './pages/ProjectConfiguration';
import { Workbench } from './pages/Workbench';
import { ImageCanvas } from './pages/ImageCanvas';
import { DevicePreview } from './pages/DevicePreview';
import { Settings } from './pages/Settings';
import { Setup } from './pages/Setup';
import { Login } from './pages/Login';
@@ -389,6 +390,7 @@ function App() {
<Route path="/chat" element={<Navigate to="/opencode-chat" replace />} />
<Route path="/deliverables" element={<PreviewScene />} />
<Route path="/image-canvas" element={<ImageCanvas />} />
<Route path="/device-preview" element={<DevicePreview />} />
<Route path="/workbench/:projectId" element={<Workbench />} />
<Route path="/opencode-chat" element={<ProjectChatRoute />} />
<Route path="/projects" element={<Projects />} />

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { Archive, ChevronRight, Pin, Plus, RotateCcw, Settings as SettingsIcon, Trash2 } from 'lucide-react';
import { Archive, ChevronRight, Pin, Plus, RotateCcw, Settings as SettingsIcon, Smartphone, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -47,6 +47,7 @@ type AgentConversationSidebarProps = {
onRestoreSession: (sessionId: string) => Promise<void>;
onDeleteSession: (sessionId: string) => Promise<void>;
onTogglePinAgent: (agent: ProjectAgentConfig) => Promise<void>;
onOpenDevicePreview?: () => void;
onOpenProjectSettings?: () => void;
};
@@ -120,6 +121,7 @@ export function AgentConversationSidebar({
onRestoreSession,
onDeleteSession,
onTogglePinAgent,
onOpenDevicePreview,
onOpenProjectSettings,
}: AgentConversationSidebarProps) {
const [createOpen, setCreateOpen] = useState(false);
@@ -263,11 +265,16 @@ export function AgentConversationSidebar({
return (
<aside className="flex h-full min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-none border-r border-border/80 bg-surface-tertiary text-foreground lg:w-[19rem]" data-testid="agent-conversation-sidebar">
<div className="relative flex shrink-0 items-center border-b border-border/70 bg-background/70 px-3 py-3 pr-24">
<div className="relative flex shrink-0 items-center border-b border-border/70 bg-background/70 px-3 py-3 pr-28">
<h2 className="min-w-0 truncate text-left text-base font-semibold"></h2>
<div className="absolute right-3 flex items-center gap-1">
{onOpenDevicePreview ? (
<Button type="button" size="icon" variant="ghost" className="h-10 w-10 rounded-full text-muted-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]" aria-label="真机预览" title="真机预览" onClick={onOpenDevicePreview}>
<Smartphone className="h-4 w-4" />
</Button>
) : null}
{onOpenProjectSettings ? (
<Button type="button" size="icon" variant="ghost" className="h-8 w-8 rounded-full text-muted-foreground hover:bg-surface-subtle hover:text-foreground" aria-label="项目设置" title="项目设置" onClick={onOpenProjectSettings}>
<Button type="button" size="icon" variant="ghost" className="h-10 w-10 rounded-full text-muted-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]" aria-label="项目设置" title="项目设置" onClick={onOpenProjectSettings}>
<SettingsIcon className="h-4 w-4" />
</Button>
) : null}

25
src/lib/device-preview.ts Normal file
View File

@@ -0,0 +1,25 @@
import { hostApiFetch } from '@/lib/host-api';
import type { DevicePreviewSnapshot } from '../../shared/device-preview';
type DevicePreviewResponse = {
success: boolean;
preview?: DevicePreviewSnapshot;
error?: string;
};
export class DevicePreviewApiError extends Error {
constructor(message: string) {
super(message);
this.name = 'DevicePreviewApiError';
}
}
export async function fetchProjectDevicePreview(projectId: string): Promise<DevicePreviewSnapshot> {
const response = await hostApiFetch<DevicePreviewResponse>(
`/api/opencode/projects/${encodeURIComponent(projectId)}/device-preview`,
);
if (!response.success || !response.preview) {
throw new DevicePreviewApiError(response.error || '无法读取真机预览状态');
}
return response.preview;
}

View File

@@ -809,13 +809,14 @@ function formatElapsedSeconds(elapsedSeconds: number): string {
type OpencodeChatPanelProps = {
variant?: 'sidebar' | 'main';
navigationDraft?: string;
onOpenDevicePreview?: () => void;
onOpenProjectSettings?: () => void;
};
const COMPOSER_STOP_ARM_DELAY_MS = 650;
const EMPTY_CONVERSATION_STATE = { schemaVersion: 1 as const, sessions: [], updatedAt: '' };
export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenProjectSettings }: OpencodeChatPanelProps) {
export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenDevicePreview, onOpenProjectSettings }: OpencodeChatPanelProps) {
const status = useOpencodeStore((state) => state.status);
const activeProject = useOpencodeStore((state) => state.activeProject);
const sessions = useOpencodeStore((state) => state.sessions);
@@ -2639,6 +2640,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
onRestoreSession={handleRestoreConversation}
onDeleteSession={handleDeleteConversation}
onTogglePinAgent={handleToggleAgentPin}
onOpenDevicePreview={onOpenDevicePreview}
onOpenProjectSettings={onOpenProjectSettings}
/>
) : null}

View File

@@ -22,7 +22,12 @@ export function Chat() {
return (
<div data-testid="chat-operation-page" className="flex h-[calc(100%_+_3rem)] min-h-0 -m-6 overflow-hidden bg-background">
<OpencodeChatPanel variant="main" navigationDraft={navigationDraft} onOpenProjectSettings={() => navigate('/project-config')} />
<OpencodeChatPanel
variant="main"
navigationDraft={navigationDraft}
onOpenDevicePreview={() => navigate('/device-preview')}
onOpenProjectSettings={() => navigate('/project-config')}
/>
</div>
);
}

View File

@@ -0,0 +1,355 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft,
CheckCircle2,
Copy,
ExternalLink,
Loader2,
RefreshCw,
ShieldCheck,
Smartphone,
TriangleAlert,
} from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { fetchProjectDevicePreview } from '@/lib/device-preview';
import { invokeIpc } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { useOpencodeStore } from '@/stores/opencode';
import type { DevicePreviewSnapshot, DevicePreviewState } from '../../../shared/device-preview';
const POLL_INTERVAL_MS = 4_000;
const READY_REVALIDATE_INTERVAL_MS = 60_000;
const previewStatePresentation: Record<DevicePreviewState, {
label: string;
Icon: typeof Smartphone;
className: string;
}> = {
not_deployed: {
label: '尚未部署',
Icon: Smartphone,
className: 'bg-surface-subtle text-muted-foreground',
},
building: {
label: '生成中',
Icon: Loader2,
className: 'bg-amber-50 text-amber-700',
},
ready: {
label: '可预览',
Icon: CheckCircle2,
className: 'bg-emerald-50 text-emerald-700',
},
unavailable: {
label: '暂不可用',
Icon: TriangleAlert,
className: 'bg-red-50 text-red-700',
},
};
function formatUpdatedAt(value: string | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function PreviewStatus({ preview }: { preview: DevicePreviewSnapshot }) {
const presentation = previewStatePresentation[preview.state];
const StatusIcon = presentation.Icon;
return (
<span
data-testid="device-preview-status"
role="status"
aria-live="polite"
aria-atomic="true"
className={cn('inline-flex min-h-8 items-center gap-1.5 rounded-full px-3 text-xs font-semibold', presentation.className)}
>
<StatusIcon className={cn('h-3.5 w-3.5', preview.state === 'building' && 'animate-spin')} aria-hidden="true" />
{presentation.label}
</span>
);
}
export function DevicePreview() {
const navigate = useNavigate();
const activeProject = useOpencodeStore((state) => state.activeProject);
const [preview, setPreview] = useState<DevicePreviewSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestIdRef = useRef(0);
const activeProjectId = activeProject?.id;
const loadPreview = useCallback(async (quiet = false) => {
if (!activeProjectId) return;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
if (quiet) setRefreshing(true);
else setLoading(true);
try {
const next = await fetchProjectDevicePreview(activeProjectId);
if (requestId !== requestIdRef.current) return;
setPreview(next);
setError(null);
} catch (loadError) {
if (requestId !== requestIdRef.current) return;
setPreview(null);
setError(loadError instanceof Error ? loadError.message : String(loadError));
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
setRefreshing(false);
}
}
}, [activeProjectId]);
useEffect(() => {
requestIdRef.current += 1;
setPreview(null);
setError(null);
if (activeProjectId) void loadPreview();
return () => {
requestIdRef.current += 1;
};
}, [activeProjectId, loadPreview]);
useEffect(() => {
const delay = preview?.state === 'building'
? POLL_INTERVAL_MS
: preview?.state === 'ready'
? READY_REVALIDATE_INTERVAL_MS
: null;
if (delay === null) return undefined;
let disposed = false;
let timer: number | null = null;
const scheduleNext = () => {
timer = window.setTimeout(async () => {
await loadPreview(true);
if (!disposed) scheduleNext();
}, delay);
};
scheduleNext();
return () => {
disposed = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [loadPreview, preview?.state]);
const handleCopy = async () => {
if (!preview?.launchUrl) return;
try {
await navigator.clipboard.writeText(preview.launchUrl);
toast.success('预览链接已复制');
} catch {
toast.error('复制失败,请手动选择链接');
}
};
const handleOpenExternal = async () => {
if (!preview?.launchUrl) return;
try {
await invokeIpc('shell:openExternal', preview.launchUrl);
} catch (openError) {
toast.error('无法打开系统浏览器', {
description: openError instanceof Error ? openError.message : String(openError),
});
}
};
if (!activeProject) {
return (
<div data-testid="device-preview-page" className="flex min-h-full items-center justify-center p-6">
<div className="surface-card max-w-md rounded-3xl bg-card p-8 text-center">
<Smartphone className="mx-auto h-10 w-10 text-brand" aria-hidden="true" />
<h1 className="mt-4 text-balance text-2xl font-semibold"></h1>
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground"> AI </p>
<Button className="mt-6 active:scale-[0.96]" onClick={() => navigate('/opencode-chat')}> AI </Button>
</div>
</div>
);
}
const ready = preview?.state === 'ready' && Boolean(preview.launchUrl);
return (
<div data-testid="device-preview-page" className="mx-auto min-h-full w-full max-w-6xl pb-8">
<header className="flex flex-wrap items-start justify-between gap-4 pb-6">
<div className="flex min-w-0 items-start gap-3">
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 active:scale-[0.96]"
aria-label="返回 AI 对话"
onClick={() => navigate('/opencode-chat')}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="min-w-0">
<p className="text-xs font-semibold text-brand">Makelore Code</p>
<h1 className="mt-1 text-balance text-2xl font-semibold tracking-tight"></h1>
<p className="mt-1 truncate text-sm text-muted-foreground" title={activeProject.path}>{activeProject.name}</p>
</div>
</div>
{preview ? <PreviewStatus preview={preview} /> : null}
</header>
{error ? (
<div role="alert" className="mb-5 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
{error}
</div>
) : null}
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="surface-card min-h-[34rem] rounded-[2rem] bg-card p-3">
<div className="flex min-h-[32rem] flex-col items-center justify-center rounded-[1.25rem] bg-surface-subtle px-6 py-10 text-center">
{loading && !preview ? (
<>
<Loader2 className="h-10 w-10 animate-spin text-brand" aria-hidden="true" />
<h2 className="mt-5 text-balance text-xl font-semibold"></h2>
<p className="mt-2 text-pretty text-sm text-muted-foreground"></p>
</>
) : error && !preview ? (
<>
<span className="flex h-20 w-20 items-center justify-center rounded-[1.75rem] bg-red-50 text-red-700 shadow-soft">
<TriangleAlert className="h-9 w-9" aria-hidden="true" />
</span>
<h2 className="mt-6 text-balance text-xl font-semibold"></h2>
<p className="mt-2 max-w-md text-pretty text-sm leading-6 text-muted-foreground">
</p>
</>
) : ready && preview?.launchUrl ? (
<>
<div className="rounded-[1.75rem] bg-white p-2 shadow-float">
<div className="rounded-[1.25rem] bg-white p-5 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.06)]">
<QRCodeSVG
value={preview.launchUrl}
size={220}
level="M"
marginSize={1}
bgColor="#FFFFFF"
fgColor="#27313F"
role="img"
aria-label={`${activeProject.name} 真机预览二维码`}
data-testid="device-preview-qr"
/>
</div>
</div>
<h2 className="mt-6 text-balance text-xl font-semibold"></h2>
<p className="mt-2 max-w-md text-pretty text-sm leading-6 text-muted-foreground">
</p>
<code className="mt-5 block w-full max-w-md break-all rounded-lg bg-background px-3 py-2 text-left text-xs leading-5 text-muted-foreground" title={preview.launchUrl}>
{preview.launchUrl}
</code>
</>
) : (
<>
<span className="flex h-20 w-20 items-center justify-center rounded-[1.75rem] bg-background text-brand shadow-soft">
{preview?.state === 'unavailable'
? <TriangleAlert className="h-9 w-9" aria-hidden="true" />
: preview?.state === 'building'
? <Loader2 className="h-9 w-9 animate-spin" aria-hidden="true" />
: <Smartphone className="h-9 w-9" aria-hidden="true" />}
</span>
<h2 className="mt-6 text-balance text-xl font-semibold">
{preview?.state === 'building' ? '预览版本正在准备' : '还没有可扫描的预览'}
</h2>
<p className="mt-2 max-w-md text-pretty text-sm leading-6 text-muted-foreground">
{preview?.message || '服务端部署并开放当前版本后,这里会出现真机二维码。'}
</p>
</>
)}
</div>
</section>
<aside className="space-y-5">
<section className="surface-card rounded-3xl bg-card p-5">
<div className="flex items-center gap-2">
<Smartphone className="h-4 w-4 text-brand" aria-hidden="true" />
<h2 className="text-base font-semibold"></h2>
</div>
<dl className="mt-5 space-y-4 text-sm">
<div className="flex items-start justify-between gap-4">
<dt className="text-muted-foreground"></dt>
<dd className="min-w-0 truncate text-right font-medium" title={preview?.versionName}>{preview?.versionName || '—'}</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="text-muted-foreground"></dt>
<dd className="min-w-0 truncate text-right font-medium" title={preview?.reviewStatus}>{preview?.reviewStatus || '—'}</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="text-muted-foreground"></dt>
<dd className="tabular-nums text-right font-medium">{formatUpdatedAt(preview?.updatedAt)}</dd>
</div>
</dl>
<div className="mt-6 grid gap-2">
{ready ? (
<>
<Button
type="button"
className="active:scale-[0.96]"
disabled={refreshing}
aria-busy={refreshing}
onClick={() => void handleCopy()}
>
<Copy className="mr-2 h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
className="active:scale-[0.96]"
disabled={refreshing}
aria-busy={refreshing}
onClick={() => void handleOpenExternal()}
>
<ExternalLink className="mr-2 h-4 w-4" />
</Button>
</>
) : (
<Button type="button" variant="outline" className="active:scale-[0.96]" onClick={() => navigate('/opencode-chat')}>
AI
</Button>
)}
<Button
type="button"
variant="ghost"
className="active:scale-[0.96]"
disabled={loading || refreshing}
aria-busy={loading || refreshing}
onClick={() => void loadPreview(true)}
>
<RefreshCw className={cn('mr-2 h-4 w-4', refreshing && 'animate-spin')} />
{refreshing ? '正在核验…' : '刷新状态'}
</Button>
</div>
</section>
<section className="rounded-3xl bg-brand-soft p-5 text-sm">
<div className="flex items-center gap-2 font-semibold">
<ShieldCheck className="h-4 w-4 text-brand" aria-hidden="true" />
</div>
<p className="mt-2 text-pretty leading-6 text-muted-foreground">
HTTPS
</p>
</section>
</aside>
</div>
</div>
);
}
export default DevicePreview;