diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index cac9448..322e87a 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -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", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 50a1009..6538fae 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 3ab75d8..f7b61cb 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -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" ] diff --git a/apps/desktop/src/demo/api.ts b/apps/desktop/src/demo/api.ts index bd7c7ae..e5b8e80 100644 --- a/apps/desktop/src/demo/api.ts +++ b/apps/desktop/src/demo/api.ts @@ -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); diff --git a/apps/desktop/src/features/home/HomePage.tsx b/apps/desktop/src/features/home/HomePage.tsx index de81482..3c6ce72 100644 --- a/apps/desktop/src/features/home/HomePage.tsx +++ b/apps/desktop/src/features/home/HomePage.tsx @@ -57,7 +57,6 @@ export function HomePage() { const { locale } = useI18n(); const [preset, setPreset] = useState("month"); const [quick, setQuick] = useState(null); - const [fourHourBucket, setFourHourBucket] = useState(undefined); const [customRange, setCustomRange] = useState(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) => { 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() { ) => 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(null); const popover = useRef(null); - const fourHourButton = useRef(null); - const granularityMenu = useRef(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
{presets.map((preset) => - : )} + {quickPresets.map((preset) => )}
@@ -180,31 +126,5 @@ export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOp
, document.body)} - {granularityOpen && customOpen && createPortal(, document.body)} ; } diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.tsx b/apps/desktop/src/features/plugins/PluginManagementPage.tsx index a1ca992..1b037ef 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.tsx +++ b/apps/desktop/src/features/plugins/PluginManagementPage.tsx @@ -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 && setSelected(null)} />} {selected?.mode === "settings" && selectedPlugin && } diff --git a/apps/desktop/src/features/settings/CommitSettingsCard.tsx b/apps/desktop/src/features/settings/CommitSettingsCard.tsx index 4a4bcc9..e52d3a0 100644 --- a/apps/desktop/src/features/settings/CommitSettingsCard.tsx +++ b/apps/desktop/src/features/settings/CommitSettingsCard.tsx @@ -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, diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 2b8c65b..c0d2f98 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -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", diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 96eb648..a11b8bd 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -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(path: string, init?: RequestInit): Promise { export const api = { ads: (disabledAdIds: Iterable, locale: Locale) => { const value = [...disabledAdIds].join(","); - return request("/ads", { + return request("/promotions", { headers: { "accept-language": locale, ...(value ? { "disable-ad-ids": value } : {}), }, }); }, - dismissAd: (id: string, reason: string) => request(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }), + dismissAd: (id: string, reason: string) => request(`/promotions/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }), models: () => request("/models"), createModels: (models: ModelInput[]) => request("/models", { method: "POST", body: JSON.stringify({ models }) }), reorderModels: (modelHashes: string[]) => request("/models/order", { method: "PUT", body: JSON.stringify({ model_hashes: modelHashes }) }), diff --git a/apps/desktop/src/shell/ads/AdMenu.tsx b/apps/desktop/src/shell/ads/AdMenu.tsx index 89bd2aa..1d0ca4e 100644 --- a/apps/desktop/src/shell/ads/AdMenu.tsx +++ b/apps/desktop/src/shell/ads/AdMenu.tsx @@ -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(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 {children}; + return + {children} + ; +} + export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) { return
{ads.map((ad) =>
@@ -34,8 +61,8 @@ export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs {!readAdIds.has(ad.id) &&