feat(desktop): add promotion marquee and proxy fix

This commit is contained in:
leokun
2026-09-04 23:07:41 +08:00
parent 900364229e
commit 8fbbcd5ae9
20 changed files with 145 additions and 154 deletions
+10
View File
@@ -23,6 +23,7 @@
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-css-marquee": "^1.0.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
@@ -2978,6 +2979,15 @@
"node": ">=0.10.0"
}
},
"node_modules/react-css-marquee": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/react-css-marquee/-/react-css-marquee-1.0.1.tgz",
"integrity": "sha512-jPHydXILF3oIP2gOvabfbEY3PUdbkHtDVA4mqYQiYvEdLWk9oqNJkUwDBxHK1RtltvQY3zkyuxTaGnw+G8x48w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+1
View File
@@ -34,6 +34,7 @@
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-css-marquee": "^1.0.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
+1 -1
View File
@@ -12,7 +12,7 @@
"app": {
"windows": [],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"dangerousDisableAssetCspModification": [
"style-src"
]
+1 -1
View File
@@ -108,7 +108,7 @@ export function installDemoApi() {
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
const body = await readBody(input, init);
if (path === "/ads") return json({ slots: [] });
if (path === "/promotions") return json({ slots: [] });
if (path === "/models" && method === "GET") return json(models);
if (path === "/models" && method === "POST") return json(models);
if (path === "/models/order") return json(models);
+4 -9
View File
@@ -57,7 +57,6 @@ export function HomePage() {
const { locale } = useI18n();
const [preset, setPreset] = useState<OverviewRangePreset>("month");
const [quick, setQuick] = useState<QuickPreset | null>(null);
const [fourHourBucket, setFourHourBucket] = useState<number | undefined>(undefined);
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const [customStart, setCustomStart] = useState("");
@@ -76,14 +75,13 @@ export function HomePage() {
void api.overview({
...selectedRange,
modelHashes: appliedModels,
bucketMs: fourHourBucket,
}).then((next) => {
if (active) setRangeOverview(next);
}).finally(() => {
if (active) setRangeBusy(false);
});
return () => { active = false; };
}, [preset, customRange, overview, refreshVersion, appliedModels, fourHourBucket]);
}, [preset, customRange, overview, refreshVersion, appliedModels]);
const filteredOverview = rangeOverview ?? overview;
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
@@ -118,11 +116,10 @@ export function HomePage() {
setCustomRange({ startMs, endMs });
setAppliedModels(selectedModels);
setQuick(null);
setFourHourBucket(undefined);
setPreset("custom");
setCustomOpen(false);
};
const selectQuick = (durationMs: number, bucketMs?: number) => {
const selectQuick = (durationMs: number) => {
const end = new Date();
const start = new Date(end.getTime() - durationMs);
setCustomStart(formatTimeInput(start));
@@ -130,13 +127,12 @@ export function HomePage() {
setCustomRange({ startMs: start.getTime(), endMs: end.getTime() });
setAppliedModels(selectedModels);
setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours");
setFourHourBucket(durationMs === 4 * 60 * 60_000 ? bucketMs : undefined);
setPreset("custom");
setCustomOpen(false);
};
const selectPreset = (value: Exclude<OverviewRangePreset, "custom">) => {
setPreset(value);
setQuick(null);
setFourHourBucket(undefined);
setCustomOpen(false);
};
const refresh = async () => {
@@ -152,7 +148,7 @@ export function HomePage() {
icon: iconFor(model.type),
})),
...plugins.flatMap((plugin) => plugin.providers.flatMap((provider) =>
provider.configured ? provider.models.map((model) => ({
provider.configured ? provider.models.filter((model) => model.enabled).map((model) => ({
value: model.id,
label: model.displayName,
group: pluginText(provider.displayName, locale) || model.pluginName,
@@ -187,7 +183,6 @@ export function HomePage() {
<PageActions><OverviewTimeRangeFilter
value={preset}
quick={quick}
fourHourBucket={fourHourBucket}
customOpen={customOpen}
customStart={customStart}
customEnd={customEnd}
@@ -95,31 +95,3 @@
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); border-color: transparent; }
}
}
.granularityMenu {
position: fixed;
z-index: 15000;
min-width: 96px;
padding: 4px;
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 6px;
box-shadow: var(--oa-dropdown-shadow);
button {
width: 100%;
min-height: 28px;
display: block;
padding: 4px 8px;
color: var(--vscode-dropdown-foreground);
text-align: left;
background: transparent;
border: 0;
border-radius: 4px;
font-size: type.$font-size-xs;
white-space: nowrap;
&:hover { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); }
&[aria-pressed="true"] { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); }
}
}
@@ -12,10 +12,9 @@ import styles from "./OverviewTimeRangeFilter.module.scss";
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
export type QuickPreset = "four-hours" | "twenty-four-hours";
export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset;
quick: QuickPreset | null;
fourHourBucket: number | undefined;
customOpen: boolean;
customStart: string;
customEnd: string;
@@ -23,7 +22,7 @@ export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOp
selectedModels: string[];
busy: boolean;
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
onQuickSelect: (durationMs: number, bucketMs?: number) => void;
onQuickSelect: (durationMs: number) => void;
onCustomOpenChange: (open: boolean) => void;
onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void;
@@ -42,21 +41,10 @@ export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOp
{ value: "four-hours", label: t("近4小时") },
{ value: "twenty-four-hours", label: t("近24小时") },
];
const granularityOptions = [
{ bucketMs: 60_000, label: t("1分钟") },
{ bucketMs: 15 * 60_000, label: t("15分钟") },
{ bucketMs: 30 * 60_000, label: t("30分钟") },
{ bucketMs: 60 * 60_000, label: t("1小时") },
];
const customButton = useRef<HTMLButtonElement>(null);
const popover = useRef<HTMLDivElement>(null);
const fourHourButton = useRef<HTMLButtonElement>(null);
const granularityMenu = useRef<HTMLDivElement>(null);
const popoverId = useId();
const granularityMenuId = useId();
const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 });
const [menuPosition, setMenuPosition] = useState({ left: 0, top: 0 });
const [granularityOpen, setGranularityOpen] = useState(false);
useLayoutEffect(() => {
if (!customOpen || !customButton.current || !popover.current) return;
@@ -72,28 +60,11 @@ export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOp
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
}, [customOpen]);
useLayoutEffect(() => {
if (!granularityOpen || !fourHourButton.current || !granularityMenu.current) return;
return autoUpdate(fourHourButton.current, granularityMenu.current, () =>
void computePosition(fourHourButton.current!, granularityMenu.current!, {
placement: "right-start",
middleware: [offset(4), flip({ padding: 10 }), shift({ padding: 10 })],
}).then(({ x, y }) => setMenuPosition({ left: x, top: y })));
}, [granularityOpen]);
useEffect(() => {
if (!customOpen) setGranularityOpen(false);
}, [customOpen]);
useEffect(() => {
if (!customOpen) return;
const closeOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (
!customButton.current?.contains(target)
&& !popover.current?.contains(target)
&& !granularityMenu.current?.contains(target)
) onCustomOpenChange(false);
if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false);
};
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
@@ -102,11 +73,6 @@ export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOp
const parsedStart = parseTimeInput(customStart);
const parsedEnd = parseTimeInput(customEnd);
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
const closeGranularity = () => {
setGranularityOpen(false);
fourHourButton.current?.focus();
};
return <div className={styles.root} aria-label={t("概览时间范围")}>
<div className={styles.presets}>
{presets.map((preset) => <button
@@ -135,42 +101,22 @@ export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOp
role="dialog"
aria-label={t("自定义概览筛选")}
style={position}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
if (granularityOpen) {
closeGranularity();
return;
}
onCustomOpenChange(false);
customButton.current?.focus();
}
}}
>
<div className={styles.quickPresets} aria-label={t("快捷时间范围")}>
{quickPresets.map((preset) => preset.value === "four-hours"
? <button
key={preset.value}
ref={fourHourButton}
type="button"
aria-haspopup="menu"
aria-controls={granularityOpen ? granularityMenuId : undefined}
aria-expanded={granularityOpen}
aria-pressed={quick === "four-hours"}
onClick={() => {
onQuickSelect(4 * 60 * 60_000, fourHourBucket);
setGranularityOpen((current) => !current);
}}
>{preset.label}</button>
: <button
key={preset.value}
type="button"
aria-pressed={quick === "twenty-four-hours"}
onClick={() => {
setGranularityOpen(false);
onQuickSelect(24 * 60 * 60_000);
}}
>{preset.label}</button>)}
{quickPresets.map((preset) => <button
key={preset.value}
type="button"
aria-pressed={quick === preset.value}
onClick={() => onQuickSelect(preset.value === "four-hours" ? 4 * 60 * 60_000 : 24 * 60 * 60_000)}
>{preset.label}</button>)}
</div>
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
@@ -180,31 +126,5 @@ export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOp
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
</div>
</div>, document.body)}
{granularityOpen && customOpen && createPortal(<div
id={granularityMenuId}
ref={granularityMenu}
className={styles.granularityMenu}
role="menu"
aria-label={t("显示粒度")}
style={menuPosition}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
event.preventDefault();
closeGranularity();
}
}}
>
{granularityOptions.map((option) => <button
key={option.bucketMs}
type="button"
role="menuitem"
aria-pressed={quick === "four-hours" && fourHourBucket === option.bucketMs}
onClick={() => {
setGranularityOpen(false);
onQuickSelect(4 * 60 * 60_000, option.bucketMs);
}}
>{option.label}</button>)}
</div>, document.body)}
</div>;
}
@@ -78,6 +78,8 @@ export function PluginManagementPage() {
? t("{name} 账号管理", { name: selectedPlugin?.name ?? "" })
: t("添加 {name} 账号", { name: selectedPlugin?.name ?? "" })}
onClose={() => setSelected(null)}
onSubmit={() => setSelected(null)}
submitLabel={t("确定")}
>
{selected?.mode === "add" && selectedPlugin && <PluginAddPanel plugin={selectedPlugin} onConfigured={() => setSelected(null)} />}
{selected?.mode === "settings" && selectedPlugin && <PluginSettingsPanel plugin={selectedPlugin} />}
@@ -71,7 +71,7 @@ export function CommitSettingsCard() {
for (const provider of plugin.providers) {
if (!provider.configured) continue;
const group = pluginText(provider.displayName, locale) || plugin.name;
for (const model of provider.models) {
for (const model of provider.models.filter((model) => model.enabled)) {
seen.add(model.id);
options.push({
value: model.id,
+1 -1
View File
@@ -409,7 +409,7 @@
"f9aa11dbb15ce647": "Saturday",
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.",
"fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
"fac2a67ad87807c4": "",
"fac2a67ad87807c4": "Confirm",
"fad86bf65f72c747": "Download progress",
"fb11aa6f29827095": "Checking…",
"fbe8778fa8b9bab5": "Initialize the local CA first",
+3 -3
View File
@@ -303,7 +303,7 @@ export interface PluginImportResult {
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
return plugins.flatMap((plugin) =>
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
plugin.providers.flatMap((provider) => provider.configured ? provider.models.filter((model) => model.enabled) : []));
}
export interface OverviewMetrics {
@@ -437,14 +437,14 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
ads: (disabledAdIds: Iterable<string>, locale: Locale) => {
const value = [...disabledAdIds].join(",");
return request<AdRuntime>("/ads", {
return request<AdRuntime>("/promotions", {
headers: {
"accept-language": locale,
...(value ? { "disable-ad-ids": value } : {}),
},
});
},
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
dismissAd: (id: string, reason: string) => request<void>(`/promotions/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
models: () => request<Model[]>("/models"),
createModels: (models: ModelInput[]) => request<Model[]>("/models", { method: "POST", body: JSON.stringify({ models }) }),
reorderModels: (modelHashes: string[]) => request<Model[]>("/models/order", { method: "PUT", body: JSON.stringify({ model_hashes: modelHashes }) }),
+30 -3
View File
@@ -1,4 +1,5 @@
import type { RefObject } from "react";
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
import { Marquee } from "react-css-marquee";
import { Icon } from "../../shared/ui/Icon";
import { windowCloseIcon } from "../../shared/ui/icons";
import type { AdSlot } from "./types";
@@ -14,6 +15,32 @@ type AdMenuProps = {
onDismiss: (ad: AdSlot) => void;
};
type OverflowTextProps = {
children: string;
className: string;
};
function OverflowText({ children, className }: OverflowTextProps) {
const text = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const element = text.current;
if (!element) return;
const update = () => setOverflowing(element.scrollWidth > element.clientWidth + 1);
update();
const observer = new ResizeObserver(update);
observer.observe(element);
if (element.parentElement) observer.observe(element.parentElement);
return () => observer.disconnect();
}, [children]);
if (!overflowing) return <span ref={text} className={className}>{children}</span>;
return <Marquee className={styles.menuMarquee} repeat loop={0} speed={4} gap={24}>
<span ref={text} className={className}>{children}</span>
</Marquee>;
}
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
@@ -34,8 +61,8 @@ export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
<span className={styles.menuCopy}>
<span className={styles.menuTitle}>{ad.target.title}</span>
<span className={styles.menuSubtitle}>{ad.target.description}</span>
<OverflowText className={styles.menuTitle}>{ad.target.title}</OverflowText>
<OverflowText className={styles.menuSubtitle}>{ad.target.description}</OverflowText>
</span>
</button>
<button
+26 -10
View File
@@ -25,18 +25,13 @@
color: inherit;
text-align: left;
background: color-mix(in srgb, var(--vscode-foreground) 4%, transparent);
border: 1px solid var(--vscode-sideBar-border);
border: 2px solid #be8d180d;
border-radius: 6px;
&:hover,
&[aria-expanded="true"] {
background: color-mix(in srgb, var(--vscode-foreground) 9%, transparent);
border-color: var(--vscode-sideBar-border);
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
border-color: #be8d180d;
}
}
@@ -47,7 +42,7 @@
width: 16px;
height: 16px;
padding: 0;
display: flex;
display: none;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
@@ -66,6 +61,10 @@
}
}
.menuSlotContainer:hover .menuDismiss {
display: flex;
}
.unreadDot {
position: absolute;
top: 5px;
@@ -93,11 +92,24 @@
.menuTitle,
.menuSubtitle {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.menuMarquee {
min-width: 0;
overflow: hidden;
}
.menuMarquee .menuTitle,
.menuMarquee .menuSubtitle {
width: max-content;
overflow: visible;
text-overflow: clip;
}
.menuTitle {
font-size: type.$font-size-xs;
font-weight: 500;
@@ -121,9 +133,9 @@
flex-direction: column;
overflow: hidden;
color: var(--vscode-foreground);
background: var(--vscode-editorWidget-background);
background: color-mix(in srgb, var(--vscode-editorWidget-background) 92%, var(--vscode-foreground) 8%);
border-radius: 15px;
border: none;
border: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
animation: ad-enter 180ms ease-out;
@@ -132,6 +144,10 @@
}
}
:global(:root[data-theme="default-light"]) .floatingAd {
background: var(--vscode-editorWidget-background);
}
.hero {
flex: 0 0 112px;
position: relative;