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:
@@ -37,6 +37,7 @@ import {
|
|||||||
useVoicePreview,
|
useVoicePreview,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "@/hooks/use-voice-preview";
|
} from "@/hooks/use-voice-preview";
|
||||||
|
import { assistantsApi } from "@/lib/api";
|
||||||
|
|
||||||
type CallView = "video" | "chat";
|
type CallView = "video" | "chat";
|
||||||
|
|
||||||
@@ -452,9 +453,25 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
selectCamera: changeCamera,
|
selectCamera: changeCamera,
|
||||||
} = camera;
|
} = camera;
|
||||||
const [view, setView] = useState<CallView>("chat");
|
const [view, setView] = useState<CallView>("chat");
|
||||||
|
const [assistantName, setAssistantName] = useState("");
|
||||||
const connecting = status === "connecting";
|
const connecting = status === "connecting";
|
||||||
const inCall = !callEnded && (connecting || status === "connected");
|
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 () => {
|
const startCall = useCallback(async () => {
|
||||||
await connect({
|
await connect({
|
||||||
visionEnabled: true,
|
visionEnabled: true,
|
||||||
@@ -474,11 +491,6 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
[changeCamera, selectTransportCamera],
|
[changeCamera, selectTransportCamera],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void startCall();
|
|
||||||
// 首次打开页面时自动发起通话;hooks 会在卸载时各自释放媒体资源。
|
|
||||||
}, [startCall]);
|
|
||||||
|
|
||||||
const error = previewError || cameraError || photoCapture.error;
|
const error = previewError || cameraError || photoCapture.error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -524,12 +536,20 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
? "通话中"
|
? "通话中"
|
||||||
: connecting
|
: connecting
|
||||||
? "正在连接…"
|
? "正在连接…"
|
||||||
: "通话已结束"}
|
: status === "failed"
|
||||||
|
? "连接失败"
|
||||||
|
: "等待开始"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!inCall && (
|
{!inCall && (
|
||||||
<div className="absolute inset-0 z-[2] flex items-center justify-center px-8">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
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"
|
className="h-12 gap-2 rounded-full bg-white px-6 text-[#07101a] shadow-2xl hover:bg-white/90"
|
||||||
>
|
>
|
||||||
<Phone size={19} />
|
<Phone size={19} />
|
||||||
{status === "failed" ? "重新开始通话" : "开始通话"}
|
{status === "failed" || callEnded ? "重新开始通话" : "开始通话"}
|
||||||
</Button>
|
</Button>
|
||||||
{error && <p className="text-xs leading-5 text-white/65">{error}</p>}
|
{error && <p className="text-xs leading-5 text-white/65">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -547,26 +567,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{inCall && (
|
{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))]">
|
<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))]">
|
||||||
<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>
|
|
||||||
{photoCapture.visible && (
|
{photoCapture.visible && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -583,6 +584,29 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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
|
<CallCameraDeviceSelect
|
||||||
value={cameraDeviceId}
|
value={cameraDeviceId}
|
||||||
devices={cameraDevices}
|
devices={cameraDevices}
|
||||||
@@ -590,6 +614,8 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
disabled={connecting}
|
disabled={connecting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -51,6 +51,28 @@ export type DataListProps<T> = {
|
|||||||
pagination?: DataListPagination;
|
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>({
|
export function DataList<T>({
|
||||||
columns,
|
columns,
|
||||||
rows,
|
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 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>{pagination.summary}</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -163,20 +185,29 @@ export function DataList<T>({
|
|||||||
<ChevronLeft size={15} />
|
<ChevronLeft size={15} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{Array.from({ length: pagination.totalPages }, (_, index) => index + 1).map(
|
{buildPageItems(pagination.page, pagination.totalPages).map(
|
||||||
(page) => (
|
(item, index) =>
|
||||||
|
item === "ellipsis" ? (
|
||||||
|
<span
|
||||||
|
key={`ellipsis-${index}`}
|
||||||
|
className="px-1 text-muted-soft"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
key={page}
|
key={item}
|
||||||
variant={page === pagination.page ? "default" : "outline"}
|
variant={item === pagination.page ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 min-w-8 px-2",
|
"h-8 min-w-8 px-2",
|
||||||
page !== pagination.page &&
|
item !== pagination.page &&
|
||||||
"border-hairline-strong text-muted-foreground hover:text-foreground",
|
"border-hairline-strong text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
onClick={() => pagination.onPageChange(page)}
|
onClick={() => pagination.onPageChange(item)}
|
||||||
>
|
>
|
||||||
{page}
|
{item}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user