fix: compact list pagination and refine mobile call UX

Show ellipsis page controls when lists grow large, and make mobile call start manual with clearer hangup layout and assistant name.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Eric Wang
2026-08-04 16:53:25 +08:00
parent 731a372df9
commit d1b05f16c7
2 changed files with 108 additions and 51 deletions

View File

@@ -37,6 +37,7 @@ import {
useVoicePreview,
type ChatMessage,
} from "@/hooks/use-voice-preview";
import { assistantsApi } from "@/lib/api";
type CallView = "video" | "chat";
@@ -452,9 +453,25 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
selectCamera: changeCamera,
} = camera;
const [view, setView] = useState<CallView>("chat");
const [assistantName, setAssistantName] = useState("");
const connecting = status === "connecting";
const inCall = !callEnded && (connecting || status === "connected");
useEffect(() => {
let cancelled = false;
void assistantsApi
.get(assistantId)
.then((assistant) => {
if (!cancelled) setAssistantName(assistant.name.trim());
})
.catch(() => {
if (!cancelled) setAssistantName("");
});
return () => {
cancelled = true;
};
}, [assistantId]);
const startCall = useCallback(async () => {
await connect({
visionEnabled: true,
@@ -474,11 +491,6 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
[changeCamera, selectTransportCamera],
);
useEffect(() => {
void startCall();
// 首次打开页面时自动发起通话hooks 会在卸载时各自释放媒体资源。
}, [startCall]);
const error = previewError || cameraError || photoCapture.error;
return (
@@ -524,12 +536,20 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
? "通话中"
: connecting
? "正在连接…"
: "通话已结束"}
: status === "failed"
? "连接失败"
: "等待开始"}
</div>
{!inCall && (
<div className="absolute inset-0 z-[2] flex items-center justify-center px-8">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex max-w-sm flex-col items-center gap-5 text-center">
<div className="space-y-2">
<p className="text-xs tracking-wide text-white/45">AI </p>
<h1 className="text-2xl font-medium leading-tight text-white">
{assistantName || "助手"}
</h1>
</div>
<Button
type="button"
onClick={() => {
@@ -539,7 +559,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
className="h-12 gap-2 rounded-full bg-white px-6 text-[#07101a] shadow-2xl hover:bg-white/90"
>
<Phone size={19} />
{status === "failed" ? "重新开始通话" : "开始通话"}
{status === "failed" || callEnded ? "重新开始通话" : "开始通话"}
</Button>
{error && <p className="text-xs leading-5 text-white/65">{error}</p>}
</div>
@@ -547,26 +567,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
)}
{inCall && (
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 z-10 flex -translate-x-1/2 items-center gap-3 min-[380px]:gap-5 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
<CallAudioDeviceSelect
micValue={selectedDeviceId}
micDevices={audioInputs}
onMicSelect={selectDevice}
outputValue={selectedOutputDeviceId}
outputDevices={audioOutputs}
onOutputSelect={selectOutputDevice}
outputSupported={supportsOutputSelection}
disabled={connecting}
/>
<button
type="button"
onClick={endCall}
aria-label="挂断电话"
title="挂断电话"
className="flex size-14 items-center justify-center rounded-full bg-red-500 text-white shadow-2xl transition-transform hover:scale-105 hover:bg-red-600 active:scale-95 min-[380px]:size-16"
>
<PhoneOff className="size-6 min-[380px]:size-7" />
</button>
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 z-10 flex -translate-x-1/2 flex-col items-center gap-3 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
{photoCapture.visible && (
<button
type="button"
@@ -583,6 +584,29 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
)}
</button>
)}
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 min-[380px]:gap-5">
<div className="flex justify-end">
<CallAudioDeviceSelect
micValue={selectedDeviceId}
micDevices={audioInputs}
onMicSelect={selectDevice}
outputValue={selectedOutputDeviceId}
outputDevices={audioOutputs}
onOutputSelect={selectOutputDevice}
outputSupported={supportsOutputSelection}
disabled={connecting}
/>
</div>
<button
type="button"
onClick={endCall}
aria-label="挂断电话"
title="挂断电话"
className="flex size-14 items-center justify-center rounded-full bg-red-500 text-white shadow-2xl transition-transform hover:scale-105 hover:bg-red-600 active:scale-95 min-[380px]:size-16"
>
<PhoneOff className="size-6 min-[380px]:size-7" />
</button>
<div className="flex justify-start">
<CallCameraDeviceSelect
value={cameraDeviceId}
devices={cameraDevices}
@@ -590,6 +614,8 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
disabled={connecting}
/>
</div>
</div>
</div>
)}
</div>
</main>

View File

@@ -51,6 +51,28 @@ export type DataListProps<T> = {
pagination?: DataListPagination;
};
/** 页码或省略号。页数少时全部展示;多时固定首尾 + 当前附近,避免按钮撑破布局。 */
type PageItem = number | "ellipsis";
function buildPageItems(current: number, total: number): PageItem[] {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
// 靠近开头: 1 2 3 4 5 … N
if (current <= 3) {
return [1, 2, 3, 4, 5, "ellipsis", total];
}
// 靠近结尾: 1 … N-4 N-3 N-2 N-1 N
if (current >= total - 2) {
return [1, "ellipsis", total - 4, total - 3, total - 2, total - 1, total];
}
// 中间: 1 … c-1 c c+1 … N
return [1, "ellipsis", current - 1, current, current + 1, "ellipsis", total];
}
export function DataList<T>({
columns,
rows,
@@ -151,7 +173,7 @@ export function DataList<T>({
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div>{pagination.summary}</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="icon-sm"
@@ -163,20 +185,29 @@ export function DataList<T>({
<ChevronLeft size={15} />
</Button>
{Array.from({ length: pagination.totalPages }, (_, index) => index + 1).map(
(page) => (
{buildPageItems(pagination.page, pagination.totalPages).map(
(item, index) =>
item === "ellipsis" ? (
<span
key={`ellipsis-${index}`}
className="px-1 text-muted-soft"
aria-hidden
>
</span>
) : (
<Button
key={page}
variant={page === pagination.page ? "default" : "outline"}
key={item}
variant={item === pagination.page ? "default" : "outline"}
size="sm"
className={cn(
"h-8 min-w-8 px-2",
page !== pagination.page &&
item !== pagination.page &&
"border-hairline-strong text-muted-foreground hover:text-foreground",
)}
onClick={() => pagination.onPageChange(page)}
onClick={() => pagination.onPageChange(item)}
>
{page}
{item}
</Button>
),
)}