实现项目真机预览与待审版本扫码验收
需求:接管客户端未提交 WIP,保留真机预览,移除旧登录原型并维持 2.0.0。 实现:由 Main 核对项目与精确 Release,签发短时 Owner preview;补充一键提交映射能力及 Windows ZIP 预检兼容。
This commit is contained in:
@@ -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';
|
||||
@@ -358,6 +359,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 />} />
|
||||
|
||||
@@ -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
25
src/lib/device-preview.ts
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
355
src/pages/DevicePreview/index.tsx
Normal file
355
src/pages/DevicePreview/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user