Improve mobile recorder focus mode
这个提交包含在:
@@ -27,6 +27,8 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
FlipHorizontal,
|
FlipHorizontal,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
MonitorUp,
|
MonitorUp,
|
||||||
Scissors,
|
Scissors,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@@ -95,7 +97,10 @@ function waitForIceGathering(peer: RTCPeerConnection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMobileDevice() {
|
function isMobileDevice() {
|
||||||
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
return (
|
||||||
|
/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||||
|
window.matchMedia("(max-width: 768px)").matches
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getArchiveProgress(session: MediaSession | null) {
|
function getArchiveProgress(session: MediaSession | null) {
|
||||||
@@ -164,6 +169,7 @@ export default function Recorder() {
|
|||||||
const [mediaSession, setMediaSession] = useState<MediaSession | null>(null);
|
const [mediaSession, setMediaSession] = useState<MediaSession | null>(null);
|
||||||
const [markers, setMarkers] = useState<MediaMarker[]>([]);
|
const [markers, setMarkers] = useState<MediaMarker[]>([]);
|
||||||
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
|
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
|
||||||
|
const [immersivePreview, setImmersivePreview] = useState(false);
|
||||||
|
|
||||||
const mobile = useMemo(() => isMobileDevice(), []);
|
const mobile = useMemo(() => isMobileDevice(), []);
|
||||||
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
const mimeType = useMemo(() => pickRecorderMimeType(), []);
|
||||||
@@ -712,7 +718,40 @@ export default function Recorder() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
playbackVideoRef.current.load();
|
playbackVideoRef.current.load();
|
||||||
}, [currentPlaybackUrl, mode]);
|
}, [currentPlaybackUrl, immersivePreview, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "archived") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveVideo = liveVideoRef.current;
|
||||||
|
if (!liveVideo || !streamRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveVideo.srcObject !== streamRef.current) {
|
||||||
|
liveVideo.srcObject = streamRef.current;
|
||||||
|
}
|
||||||
|
liveVideo.play().catch(() => {});
|
||||||
|
}, [cameraActive, immersivePreview, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobile) {
|
||||||
|
setImmersivePreview(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!immersivePreview) {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [immersivePreview, mobile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -751,6 +790,156 @@ export default function Recorder() {
|
|||||||
|
|
||||||
const previewTitle = mode === "archived" ? "归档回放" : "实时取景";
|
const previewTitle = mode === "archived" ? "归档回放" : "实时取景";
|
||||||
const StatusIcon = statusBadge.icon;
|
const StatusIcon = statusBadge.icon;
|
||||||
|
const previewVideoClassName = immersivePreview && mobile
|
||||||
|
? "h-full w-full object-contain"
|
||||||
|
: "h-full w-full object-contain";
|
||||||
|
const railButtonClassName =
|
||||||
|
"h-16 w-16 flex-col gap-1 rounded-[20px] px-2 text-[11px] leading-tight shadow-lg shadow-black/20";
|
||||||
|
|
||||||
|
const renderPrimaryActions = (layout: "toolbar" | "rail" = "toolbar") => {
|
||||||
|
const rail = layout === "rail";
|
||||||
|
const labelFor = (full: string, short: string) => (rail ? short : full);
|
||||||
|
const buttonClass = (tone: "default" | "outline" | "destructive" | "record" = "default") => {
|
||||||
|
if (!rail) {
|
||||||
|
switch (tone) {
|
||||||
|
case "outline":
|
||||||
|
return "h-12 rounded-2xl px-4";
|
||||||
|
case "destructive":
|
||||||
|
return "h-12 rounded-2xl px-5";
|
||||||
|
case "record":
|
||||||
|
return "h-12 rounded-2xl bg-red-600 px-5 hover:bg-red-700";
|
||||||
|
default:
|
||||||
|
return "h-12 rounded-2xl px-5";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tone) {
|
||||||
|
case "outline":
|
||||||
|
return `${railButtonClassName} border-white/15 bg-white/8 text-white hover:bg-white/14`;
|
||||||
|
case "destructive":
|
||||||
|
return `${railButtonClassName} bg-red-600 text-white hover:bg-red-700`;
|
||||||
|
case "record":
|
||||||
|
return `${railButtonClassName} bg-red-600 text-white hover:bg-red-700`;
|
||||||
|
default:
|
||||||
|
return `${railButtonClassName} bg-white text-slate-950 hover:bg-white/90`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconClass = rail ? "h-5 w-5" : "mr-2 h-4 w-4";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mode === "idle" && (
|
||||||
|
<>
|
||||||
|
{!cameraActive ? (
|
||||||
|
<Button
|
||||||
|
data-testid="recorder-start-camera-button"
|
||||||
|
onClick={() => void startCamera()}
|
||||||
|
className={buttonClass()}
|
||||||
|
>
|
||||||
|
<Camera className={iconClass} />
|
||||||
|
{labelFor("启动摄像头", "启动")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
data-testid="recorder-start-recording-button"
|
||||||
|
onClick={() => void beginRecording()}
|
||||||
|
className={buttonClass("record")}
|
||||||
|
>
|
||||||
|
<Circle className={`${iconClass} ${rail ? "fill-current" : "fill-current"}`} />
|
||||||
|
{labelFor("开始录制", "录制")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={stopCamera} className={buttonClass("outline")}>
|
||||||
|
<VideoOff className={iconClass} />
|
||||||
|
{labelFor("关闭摄像头", "关闭")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasMultipleCameras && (
|
||||||
|
<Button variant="outline" onClick={() => void flipCamera()} className={buttonClass("outline")}>
|
||||||
|
<FlipHorizontal className={iconClass} />
|
||||||
|
{labelFor("切换摄像头", "切换")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(mode === "recording" || mode === "reconnecting") && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
data-testid="recorder-marker-button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void createManualMarker("manual", "手动剪辑点")}
|
||||||
|
className={buttonClass("outline")}
|
||||||
|
>
|
||||||
|
<Scissors className={iconClass} />
|
||||||
|
{labelFor("标记剪辑点", "标记")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="recorder-finish-button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void finishRecording()}
|
||||||
|
className={buttonClass("destructive")}
|
||||||
|
>
|
||||||
|
<CloudUpload className={iconClass} />
|
||||||
|
{labelFor("结束并归档", "完成")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "archived" && (
|
||||||
|
<>
|
||||||
|
{currentPlaybackUrl && (
|
||||||
|
<Button asChild variant="outline" className={buttonClass("outline")}>
|
||||||
|
<a href={currentPlaybackUrl} download target="_blank" rel="noreferrer">
|
||||||
|
<Download className={iconClass} />
|
||||||
|
{labelFor("下载回放", "下载")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
data-testid="recorder-reset-button"
|
||||||
|
onClick={() => void resetRecorder()}
|
||||||
|
className={buttonClass()}
|
||||||
|
>
|
||||||
|
{rail ? null : null}
|
||||||
|
{labelFor("重新录制", "重来")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "finalizing" && (
|
||||||
|
<Button disabled className={rail ? `${railButtonClassName} bg-white/15 text-white` : "h-12 rounded-2xl px-5"}>
|
||||||
|
<Loader2 className={rail ? "h-5 w-5 animate-spin" : "mr-2 h-4 w-4 animate-spin"} />
|
||||||
|
{labelFor("正在生成回放", "处理中")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPreviewMedia = () => (
|
||||||
|
<>
|
||||||
|
{mode === "archived" && currentPlaybackUrl ? (
|
||||||
|
<video
|
||||||
|
ref={playbackVideoRef}
|
||||||
|
className={previewVideoClassName}
|
||||||
|
src={currentPlaybackUrl}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
ref={liveVideoRef}
|
||||||
|
className={previewVideoClassName}
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="no-overscroll mobile-safe-bottom mobile-safe-inline mobile-bottom-spacing space-y-4">
|
<div className="no-overscroll mobile-safe-bottom mobile-safe-inline mobile-bottom-spacing space-y-4">
|
||||||
@@ -804,23 +993,7 @@ export default function Recorder() {
|
|||||||
<Card className="overflow-hidden border-0 shadow-lg">
|
<Card className="overflow-hidden border-0 shadow-lg">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="relative aspect-[16/10] overflow-hidden bg-black sm:aspect-video">
|
<div className="relative aspect-[16/10] overflow-hidden bg-black sm:aspect-video">
|
||||||
{mode === "archived" && currentPlaybackUrl ? (
|
{!immersivePreview && renderPreviewMedia()}
|
||||||
<video
|
|
||||||
ref={playbackVideoRef}
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
src={currentPlaybackUrl}
|
|
||||||
controls
|
|
||||||
playsInline
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<video
|
|
||||||
ref={liveVideoRef}
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
autoPlay
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!cameraActive && mode === "idle" && (
|
{!cameraActive && mode === "idle" && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-[radial-gradient(circle_at_center,_rgba(16,185,129,0.12),_rgba(0,0,0,0.75))] px-6 text-center text-white/80">
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-[radial-gradient(circle_at_center,_rgba(16,185,129,0.12),_rgba(0,0,0,0.75))] px-6 text-center text-white/80">
|
||||||
@@ -858,6 +1031,19 @@ export default function Recorder() {
|
|||||||
{formatRecordingTime(durationMs)}
|
{formatRecordingTime(durationMs)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mobile && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
data-testid="recorder-mobile-focus-button"
|
||||||
|
onClick={() => setImmersivePreview(true)}
|
||||||
|
className="absolute right-3 top-3 z-20 h-11 w-11 rounded-full border border-white/10 bg-black/60 text-white shadow-lg hover:bg-black/75"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 bg-card/80 p-4">
|
<div className="border-t border-border/60 bg-card/80 p-4">
|
||||||
@@ -869,69 +1055,7 @@ export default function Recorder() {
|
|||||||
className="h-12 rounded-2xl border-border/60"
|
className="h-12 rounded-2xl border-border/60"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{mode === "idle" && (
|
{renderPrimaryActions()}
|
||||||
<>
|
|
||||||
{!cameraActive ? (
|
|
||||||
<Button data-testid="recorder-start-camera-button" onClick={() => void startCamera()} className="h-12 rounded-2xl px-5">
|
|
||||||
<Camera className="mr-2 h-4 w-4" />
|
|
||||||
启动摄像头
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button data-testid="recorder-start-recording-button" onClick={() => void beginRecording()} className="h-12 rounded-2xl bg-red-600 px-5 hover:bg-red-700">
|
|
||||||
<Circle className="mr-2 h-4 w-4 fill-current" />
|
|
||||||
开始录制
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={stopCamera} className="h-12 rounded-2xl px-4">
|
|
||||||
<VideoOff className="mr-2 h-4 w-4" />
|
|
||||||
关闭摄像头
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{hasMultipleCameras && (
|
|
||||||
<Button variant="outline" onClick={() => void flipCamera()} className="h-12 rounded-2xl px-4">
|
|
||||||
<FlipHorizontal className="mr-2 h-4 w-4" />
|
|
||||||
切换摄像头
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(mode === "recording" || mode === "reconnecting") && (
|
|
||||||
<>
|
|
||||||
<Button data-testid="recorder-marker-button" variant="outline" onClick={() => void createManualMarker("manual", "手动剪辑点")} className="h-12 rounded-2xl px-4">
|
|
||||||
<Scissors className="mr-2 h-4 w-4" />
|
|
||||||
标记剪辑点
|
|
||||||
</Button>
|
|
||||||
<Button data-testid="recorder-finish-button" variant="destructive" onClick={() => void finishRecording()} className="h-12 rounded-2xl px-5">
|
|
||||||
<CloudUpload className="mr-2 h-4 w-4" />
|
|
||||||
结束并归档
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "archived" && (
|
|
||||||
<>
|
|
||||||
{currentPlaybackUrl && (
|
|
||||||
<Button asChild variant="outline" className="h-12 rounded-2xl px-4">
|
|
||||||
<a href={currentPlaybackUrl} download target="_blank" rel="noreferrer">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
下载回放
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button data-testid="recorder-reset-button" onClick={() => void resetRecorder()} className="h-12 rounded-2xl px-5">
|
|
||||||
重新录制
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "finalizing" && (
|
|
||||||
<Button disabled className="h-12 rounded-2xl px-5">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
正在生成回放
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1088,6 +1212,66 @@ export default function Recorder() {
|
|||||||
</Card>
|
</Card>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mobile && immersivePreview && (
|
||||||
|
<div
|
||||||
|
className="mobile-safe-top mobile-safe-bottom mobile-safe-inline fixed inset-0 z-[80] bg-black/95 px-3 py-4"
|
||||||
|
data-testid="recorder-mobile-focus-shell"
|
||||||
|
>
|
||||||
|
<div className="grid h-full grid-cols-[minmax(0,1fr)_72px] gap-3">
|
||||||
|
<div className="relative flex min-h-0 items-center justify-center">
|
||||||
|
<div className="relative h-full w-full overflow-hidden rounded-[32px] border border-white/10 bg-black shadow-2xl shadow-black/40">
|
||||||
|
{renderPreviewMedia()}
|
||||||
|
|
||||||
|
{!cameraActive && mode === "idle" && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-[radial-gradient(circle_at_center,_rgba(16,185,129,0.12),_rgba(0,0,0,0.8))] px-6 text-center text-white/80">
|
||||||
|
<VideoOff className="h-14 w-14" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-xl font-medium">竖屏专注录制</h2>
|
||||||
|
<p className="text-sm text-white/60">右侧按钮可直接启动摄像头、录制、标记和结束归档。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
|
||||||
|
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{previewTitle}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
|
||||||
|
<Zap className="h-3.5 w-3.5" />
|
||||||
|
{QUALITY_PRESETS[qualityPreset].subtitle}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(mode === "recording" || mode === "reconnecting") && (
|
||||||
|
<div className="absolute bottom-3 left-3 flex items-center gap-2 rounded-full bg-black/65 px-3 py-2 text-sm text-white shadow-lg">
|
||||||
|
<span className="relative flex h-3 w-3">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
|
||||||
|
</span>
|
||||||
|
{formatRecordingTime(durationMs)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setImmersivePreview(false)}
|
||||||
|
className="absolute right-3 top-3 z-20 h-11 w-11 rounded-full border border-white/10 bg-black/60 text-white shadow-lg hover:bg-black/75"
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3">
|
||||||
|
{renderPrimaryActions("rail")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,18 +46,21 @@ test("recorder flow archives a session and exposes it in videos", async ({ page
|
|||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto("/recorder");
|
await page.goto("/recorder");
|
||||||
await expect(page.getByTestId("recorder-title")).toBeVisible();
|
await expect(page.getByTestId("recorder-title")).toBeVisible();
|
||||||
|
await page.getByTestId("recorder-mobile-focus-button").click();
|
||||||
|
const focusShell = page.getByTestId("recorder-mobile-focus-shell");
|
||||||
|
await expect(focusShell).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("recorder-start-camera-button").click();
|
await focusShell.getByTestId("recorder-start-camera-button").click();
|
||||||
await expect(page.getByTestId("recorder-start-recording-button")).toBeVisible();
|
await expect(focusShell.getByTestId("recorder-start-recording-button")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("recorder-start-recording-button").click();
|
await focusShell.getByTestId("recorder-start-recording-button").click();
|
||||||
await expect(page.getByTestId("recorder-marker-button")).toBeVisible();
|
await expect(focusShell.getByTestId("recorder-marker-button")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("recorder-marker-button").click();
|
await focusShell.getByTestId("recorder-marker-button").click();
|
||||||
await expect(page.getByText("手动标记")).toBeVisible();
|
await expect(page.getByText("手动标记")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("recorder-finish-button").click();
|
await focusShell.getByTestId("recorder-finish-button").click();
|
||||||
await expect(page.getByTestId("recorder-reset-button")).toBeVisible({ timeout: 8_000 });
|
await expect(focusShell.getByTestId("recorder-reset-button")).toBeVisible({ timeout: 8_000 });
|
||||||
|
|
||||||
await page.goto("/videos");
|
await page.goto("/videos");
|
||||||
await expect(page.getByTestId("video-card")).toHaveCount(1);
|
await expect(page.getByTestId("video-card")).toHaveCount(1);
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户