refactor: align project directory architecture

This commit is contained in:
leookun
2026-08-29 20:45:51 +08:00
parent 88f258f184
commit 279e6bb07c
236 changed files with 1982 additions and 57487 deletions
@@ -0,0 +1,16 @@
.shell {
--app-edge: 8px;
--app-menu-width: 204px;
--window-safe-top: var(--window-header-height);
--window-menu-top: var(--window-safe-top);
--window-corner-safe-bottom: 0px;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--vscode-editor-background);
}
.windows {
--window-corner-safe-bottom: 6px;
}
+20
View File
@@ -0,0 +1,20 @@
import { Outlet } from "react-router-dom";
import { desktopPlatform } from "../shared/native/platform";
import styles from "./AppFrame.module.scss";
import { AppHeader } from "./AppHeader";
const currentPlatform = desktopPlatform();
document.documentElement.dataset.platform = currentPlatform;
export function AppFrame() {
const platform = currentPlatform;
const platformClasses = platform === "windows" ? [styles.windows] : [];
const nativeDesktop = "__TAURI_INTERNALS__" in window;
return (
<div className={[styles.shell, ...platformClasses].filter(Boolean).join(" ")}>
<AppHeader platform={platform} nativeDesktop={nativeDesktop} />
<Outlet />
</div>
);
}
@@ -0,0 +1,49 @@
@use "../styles/typography" as type;
.root {
position: fixed;
z-index: 12000;
top: 0;
right: 0;
left: 0;
height: var(--window-header-height);
background: transparent;
-webkit-user-select: none;
user-select: none;
}
.dragLayer {
position: absolute;
inset: 0;
}
.uiLayer {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
padding-right: 0px;
pointer-events: none;
}
.identity {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
color: var(--vscode-foreground);
font-size: type.$font-size-base;
font-weight: 500;
pointer-events: none;
img {
width: 20px;
height: 20px;
flex: 0 0 auto;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import appIcon from "../../src-tauri/icons/32x32.png";
import { currentAppVersion } from "../shared/native/appLifecycle";
import type { DesktopPlatform } from "../shared/native/platform";
import { WindowControls } from "./WindowControls";
import { MacTrafficLights } from "./MacTrafficLights";
import styles from "./AppHeader.module.scss";
type AppHeaderProps = {
platform: DesktopPlatform;
nativeDesktop: boolean;
};
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
const showMacTrafficLights = !nativeDesktop && platform === "macos";
const showNativeUi = nativeDesktop && platform !== "macos";
const [version, setVersion] = useState("…");
useEffect(() => {
let disposed = false;
void currentAppVersion().then((next) => {
if (!disposed) setVersion(next);
});
return () => { disposed = true; };
}, []);
return <header className={styles.root}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div className={styles.uiLayer}>
{showMacTrafficLights && <MacTrafficLights />}
{showNativeUi && <>
<div className={styles.identity} aria-label="Cursor BYOK">
<img src={appIcon} alt="" />
<span>{t("Cursor 助手 v{version}", { version })}</span>
</div>
<WindowControls />
</>}
</div>
</header>;
}
@@ -0,0 +1,177 @@
@use "../styles/typography" as type;
.root {
--app-region-gap: 16px;
--app-toolbar-height: 36px;
--app-page-max-width: 1280px;
--app-page-padding: 18px;
--app-content-left: calc(var(--app-edge) + var(--app-menu-width) );
--app-content-top: calc(var(--window-safe-top) + var(--app-toolbar-height) + 10px );
background: var(--vscode-editor-background);
}
.menuCard {
position: fixed;
z-index: 10;
top: calc(var(--window-menu-top));
bottom: var(--app-edge);
left: var(--app-edge);
width: var(--app-menu-width);
overflow: hidden;
}
.navigation {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
padding: 6px;
-webkit-user-select: none;
user-select: none;
}
.navigationList {
--scroll-shadow-bottom-size: 28px;
flex: 1 1 auto;
width: 100%;
min-height: 0;
}
.navigationGroup {
display: flex;
align-items: center;
color: color-mix(in srgb, var(--vscode-descriptionForeground) 65%, transparent);
font-size: type.$font-size-xs;
font-weight: normal;
padding: 5px 9px 2px;
margin-top: 0;
}
.navigationRow {
flex: 0 0 36px;
display: flex;
align-items: stretch;
a,
button {
width: 100%;
height: 36px;
display: flex;
align-items: center;
gap: 5px;
padding: 0 11px;
color: inherit;
text-decoration: none;
border-radius: 6px;
&:hover {
background: var(--vscode-list-hoverBackground);
}
&[aria-current="page"] {
color: var(--vscode-list-activeSelectionForeground);
background: var(--vscode-list-activeSelectionBackground);
}
}
button {
font: inherit;
text-align: left;
background: transparent;
border: 0;
}
}
.menuIndicatorDot {
width: 7px;
height: 7px;
flex: 0 0 7px;
margin-left: auto;
background: #ef4444;
border-radius: 50%;
}
.dismissReason {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 14px;
font-size: type.$font-size-base;
textarea {
min-height: 78px;
padding: 8px 10px;
resize: vertical;
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
border-radius: 6px;
outline: none;
&:focus {
border-color: var(--vscode-focusBorder);
}
&:disabled {
opacity: 0.6;
}
}
}
.content {
width: calc(100% - var(--app-content-left));
height: 100%;
position: relative;
min-width: 0;
min-height: 0;
margin-left: var(--app-content-left);
overflow: hidden;
}
.keepAliveContainer,
.keepAlivePage {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.actionRegion {
position: absolute;
z-index: 12500;
top: var(--window-safe-top);
right: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
height: calc(var(--app-content-top) - var(--window-safe-top));
display: flex;
align-items: center;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
-webkit-app-region: drag;
}
.actions {
height: var(--app-toolbar-height);
min-width: var(--app-toolbar-height);
display: flex;
align-items: center;
justify-content: center;
padding: 6px 6px;
gap: 3px;
box-shadow: 0 0px 6px 4px var(--vscode-editor-background);
transform: translateX(6px);
}
.pageActions {
display: flex;
align-items: center;
gap: 3px;
&:empty {
display: none;
}
}
+278
View File
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { IconifyIcon } from "@iconify/react/offline";
import KeepAliveRouteOutlet from "keepalive-for-react-router";
import { NavLink, useLocation } from "react-router-dom";
import cursorIconUrl from "../shared/assets/icons/cursor.svg";
import { api } from "../shared/api";
import { AdMenu } from "./ads/AdMenu";
import { FloatingAd } from "./ads/FloatingAd";
import { AdActionType, type AdAction, type AdSlot } from "./ads/types";
import { PageLayout } from "./layout/PageLayout";
import { Card } from "../shared/ui/Card";
import { ConfirmDialog } from "../shared/ui/ConfirmDialog";
import controls from "../shared/ui/Controls.module.scss";
import { Icon } from "../shared/ui/Icon";
import { TooltipTrigger } from "../shared/ui/TooltipTrigger";
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
import { useMessage } from "../shared/ui/message";
import { VirtualList } from "../shared/virtual/VirtualList";
import { useI18n } from "../i18n/store";
import { appStore, useAppStore } from "../shared/store/appStore";
import { useUpdateStore } from "../shared/store/updateStore";
import styles from "./AppLayout.module.scss";
import { PageActionsTarget } from "./PageActions";
type MenuItem =
| { kind: "page"; path: string; label: string; icon: IconifyIcon | string }
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
| { kind: "group"; label: string };
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"];
const readAdStorageKey = "cursor-byok:read-ad-ids";
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
const tutorialReadStorageKey = "cursor-byok:tutorial-read";
const tutorialUrl = "https://docs.leokun.cn";
function loadStoredAdIds(key: string): Set<string> {
try {
const value: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
return new Set(Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : []);
} catch {
return new Set();
}
}
export function AppLayout() {
const { busy } = useAppStore();
const { availableVersion } = useUpdateStore();
const { locale } = useI18n();
const message = useMessage();
const location = useLocation();
const [leftActionTarget, setLeftActionTarget] = useState<HTMLDivElement | null>(null);
const [rightActionTarget, setRightActionTarget] = useState<HTMLDivElement | null>(null);
const [ads, setAds] = useState<AdSlot[]>([]);
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
const [dismissReason, setDismissReason] = useState("");
const [confirmTutorial, setConfirmTutorial] = useState(false);
const [tutorialRead, setTutorialRead] = useState(() => {
try {
return localStorage.getItem(tutorialReadStorageKey) === "true";
} catch {
return false;
}
});
const [readAdIds, setReadAdIds] = useState(() => loadStoredAdIds(readAdStorageKey));
const [dismissedAdIds, setDismissedAdIds] = useState(() => loadStoredAdIds(dismissedAdStorageKey));
const dismissedAdIdsRef = useRef(dismissedAdIds);
dismissedAdIdsRef.current = dismissedAdIds;
const adTriggers = useRef(new Map<string, HTMLButtonElement>());
const closeAd = useCallback(() => setActiveAd(null), []);
const visibleAds = ads.filter((ad) => ad.enabled && ad.placement === "menu" && !dismissedAdIds.has(ad.id));
const menuItems: MenuItem[] = [
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
{ kind: "group", label: "Harness" },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
];
const openTutorial = useCallback(() => {
setConfirmTutorial(false);
void api.openExternalUrl(tutorialUrl)
.then(() => {
setTutorialRead(true);
try {
localStorage.setItem(tutorialReadStorageKey, "true");
} catch {
// Read state remains valid for the current session when storage is unavailable.
}
})
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]);
useEffect(() => {
let disposed = false;
let pending = false;
let lastRequestedAt = 0;
const refreshAds = () => {
const now = Date.now();
if (pending || now - lastRequestedAt < 10_000) return;
pending = true;
lastRequestedAt = now;
void api.ads(dismissedAdIdsRef.current, locale)
.then((runtime) => {
if (disposed) return;
setAds(runtime.slots);
setActiveAd((current) => current
? runtime.slots.find((ad) => ad.id === current.id) ?? null
: null);
})
.catch(() => {
if (disposed) return;
setAds([]);
setActiveAd(null);
})
.finally(() => { pending = false; });
};
const refreshVisibleAds = () => {
if (document.visibilityState === "visible") refreshAds();
};
refreshAds();
window.addEventListener("focus", refreshAds);
document.addEventListener("visibilitychange", refreshVisibleAds);
return () => {
disposed = true;
window.removeEventListener("focus", refreshAds);
document.removeEventListener("visibilitychange", refreshVisibleAds);
};
}, [locale]);
const openAd = useCallback((ad: AdSlot) => {
setReadAdIds((current) => {
if (current.has(ad.id)) return current;
const next = new Set(current).add(ad.id);
try {
localStorage.setItem(readAdStorageKey, JSON.stringify([...next]));
} catch {
// Read state remains valid for the current session when storage is unavailable.
}
return next;
});
setActiveAd((current) => current?.id === ad.id ? null : ad);
}, []);
const performAdAction = useCallback((action: AdAction) => {
if (action.type !== AdActionType.OpenBrowser) return;
void api.openExternalUrl(action.url)
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]);
const openDismissAd = useCallback((ad: AdSlot) => {
setDismissReason("");
setDismissCandidate(ad);
}, []);
const closeDismissAd = useCallback(() => {
setDismissCandidate(null);
setDismissReason("");
}, []);
const dismissAd = useCallback(() => {
if (!dismissCandidate) return;
const dismissedId = dismissCandidate.id;
const reason = dismissReason.trim();
setDismissedAdIds((current) => {
const next = new Set(current).add(dismissedId);
try {
localStorage.setItem(dismissedAdStorageKey, JSON.stringify([...next]));
} catch {
// Dismissed state remains valid for the current session when storage is unavailable.
}
return next;
});
setActiveAd((current) => current?.id === dismissedId ? null : current);
setDismissCandidate(null);
setDismissReason("");
void api.dismissAd(dismissedId, reason).catch(() => {
// Dismissal is intentionally fire-and-forget so reporting never blocks the UI.
});
}, [dismissCandidate, dismissReason]);
return <PageLayout className={styles.root}>
<Card as="aside" className={styles.menuCard}>
<nav className={styles.navigation} aria-label={t("主菜单")}>
<VirtualList
items={menuItems}
itemKey={(item) => item.kind === "group" ? `group-${item.label}` : item.kind === "external" ? `external-${item.id}` : item.path}
estimatedItemHeight={36}
itemGap={3}
className={`${styles.navigationList} scroll-shadow-bottom`}
>
{(item) => item.kind === "group"
? <div className={styles.navigationGroup} key={`group-${item.label}`}>{item.label}</div>
: item.kind === "external"
? <div className={styles.navigationRow} key={item.id}>
<button
type="button"
aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`}
onClick={() => setConfirmTutorial(true)}
>
{typeof item.icon === "string"
? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span>
{!tutorialRead && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
</button>
</div>
: <div className={styles.navigationRow} key={item.path}>
<NavLink to={item.path} end={item.path === "/"}>
{typeof item.icon === "string"
? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span>
{item.path === "/settings" && availableVersion && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
</NavLink>
</div>}
</VirtualList>
<AdMenu ads={visibleAds} activeAdId={activeAd?.id} dismissingAdId={dismissCandidate?.id} readAdIds={readAdIds} triggerRefs={adTriggers} onOpen={openAd} onDismiss={openDismissAd} />
</nav>
</Card>
{activeAd && <FloatingAd ad={activeAd} trigger={adTriggers.current.get(activeAd.id) ?? null} onClose={closeAd} onAction={performAdAction} />}
<ConfirmDialog
id="open-tutorial-dialog"
open={confirmTutorial}
title={t("打开使用教程?")}
cancelLabel={t("取消")}
confirmLabel={t("打开教程")}
onCancel={() => setConfirmTutorial(false)}
onConfirm={openTutorial}
>
<p>{t("将在系统浏览器中打开使用教程,是否继续?")}</p>
</ConfirmDialog>
<ConfirmDialog
id="dismiss-ad-dialog"
open={dismissCandidate !== null}
title={t("不再显示此广告")}
cancelLabel={t("取消")}
confirmLabel={t("确认")}
onCancel={closeDismissAd}
onConfirm={() => void dismissAd()}
>
<p>{t("你确认不想再看到此广告吗?")}</p>
<label className={styles.dismissReason}>
<span>{t("原因(可选)")}</span>
<textarea
value={dismissReason}
maxLength={2000}
rows={4}
placeholder={t("可以告诉我们原因")}
onChange={(event) => setDismissReason(event.target.value)}
/>
</label>
</ConfirmDialog>
<main className={styles.content}>
<div className={styles.actionRegion}>
<Card className={styles.actions}>
<div ref={setLeftActionTarget} className={styles.pageActions} />
{location.pathname !== "/" && <TooltipTrigger label={t("刷新")}><button className={controls.iconButton} aria-label={t("刷新")} disabled={busy} onClick={() => void appStore.refresh()}>
<Icon className={busy ? controls.spin : ""} icon={refreshIcon} size="1.1em" />
</button></TooltipTrigger>}
<div ref={setRightActionTarget} className={styles.pageActions} />
</Card>
</div>
<PageActionsTarget.Provider value={{ left: leftActionTarget, right: rightActionTarget }}>
<KeepAliveRouteOutlet
activeCacheKey={location.pathname}
include={keptAlivePages}
max={keptAlivePages.length}
enableActivity
containerClassName={styles.keepAliveContainer}
cacheNodeClassName={styles.keepAlivePage}
/>
</PageActionsTarget.Provider>
</main>
</PageLayout>;
}
@@ -0,0 +1,30 @@
.root {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
padding-left: 4px;
pointer-events: none;
}
.light {
width: 12px;
height: 12px;
flex: 0 0 12px;
border: 0.5px solid rgb(0 0 0 / 18%);
border-radius: 50%;
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 10%);
}
.close {
background: #ff5f57;
}
.minimize {
background: #febc2e;
}
.zoom {
background: #28c840;
}
@@ -0,0 +1,9 @@
import styles from "./MacTrafficLights.module.scss";
export function MacTrafficLights() {
return <div className={styles.root} aria-hidden="true">
<span className={[styles.light, styles.close].join(" ")} />
<span className={[styles.light, styles.minimize].join(" ")} />
<span className={[styles.light, styles.zoom].join(" ")} />
</div>;
}
+17
View File
@@ -0,0 +1,17 @@
import { createContext, useContext, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useKeepAliveContext } from "keepalive-for-react";
type PageActionsTargets = {
left: HTMLElement | null;
right: HTMLElement | null;
};
export const PageActionsTarget = createContext<PageActionsTargets>({ left: null, right: null });
export function PageActions({ children, position = "right" }: { children: ReactNode; position?: "left" | "right" }) {
const targets = useContext(PageActionsTarget);
const { active } = useKeepAliveContext();
const target = targets[position];
return active && target ? createPortal(children, target) : null;
}
@@ -0,0 +1,36 @@
.root {
position: relative;
z-index: 2;
height: 30px;
display: flex;
align-items: center;
gap: 1px;
pointer-events: auto;
-webkit-app-region: no-drag;
}
.button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 0px;
color: var(--vscode-descriptionForeground);
background: transparent;
&:hover {
color: var(--vscode-foreground);
background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground));
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
}
.close:hover {
color: var(--vscode-foreground);
}
+69
View File
@@ -0,0 +1,69 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
import { Icon } from "../shared/ui/Icon";
import {
windowCloseIcon,
windowMaximizeIcon,
windowMinimizeIcon,
windowRestoreIcon,
} from "../shared/ui/icons";
import styles from "./WindowControls.module.scss";
export function WindowControls() {
const [maximized, setMaximized] = useState(false);
useEffect(() => {
const appWindow = getCurrentWindow();
let disposed = false;
let unlisten: (() => void) | undefined;
const updateMaximized = async () => {
const next = await appWindow.isMaximized();
if (!disposed) setMaximized(next);
};
void updateMaximized();
void appWindow.onResized(() => void updateMaximized()).then((stop) => {
if (disposed) stop();
else unlisten = stop;
});
return () => {
disposed = true;
unlisten?.();
};
}, []);
const appWindow = getCurrentWindow();
const maximizeLabel = maximized ? t("还原窗口") : t("最大化窗口");
return <div className={styles.root}>
<button
type="button"
className={styles.button}
aria-label={t("最小化窗口")}
title={t("最小化窗口")}
onClick={() => void appWindow.minimize()}
>
<Icon icon={windowMinimizeIcon} size="1.1em" />
</button>
<button
type="button"
className={styles.button}
aria-label={maximizeLabel}
title={maximizeLabel}
onClick={() => void appWindow.toggleMaximize()}
>
<Icon icon={maximized ? windowRestoreIcon : windowMaximizeIcon} size="1.1em" />
</button>
<button
type="button"
className={[styles.button, styles.close].join(" ")}
aria-label={t("关闭窗口")}
title={t("关闭窗口")}
onClick={() => void appWindow.close()}
>
<Icon icon={windowCloseIcon} size="1.1em" />
</button>
</div>;
}
+54
View File
@@ -0,0 +1,54 @@
import type { RefObject } from "react";
import { Icon } from "../../shared/ui/Icon";
import { windowCloseIcon } from "../../shared/ui/icons";
import type { AdSlot } from "./types";
import styles from "./Ads.module.scss";
type AdMenuProps = {
ads: AdSlot[];
activeAdId?: string;
dismissingAdId?: string;
readAdIds: ReadonlySet<string>;
triggerRefs: RefObject<Map<string, HTMLButtonElement>>;
onOpen: (ad: AdSlot) => void;
onDismiss: (ad: AdSlot) => void;
};
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}>
<button
ref={(node) => {
if (node) triggerRefs.current.set(ad.id, node);
else triggerRefs.current.delete(ad.id);
}}
type="button"
className={styles.menuSlot}
title={`${ad.target.title}\n${ad.target.description}`}
aria-label={`${ad.target.title},${ad.target.description}${readAdIds.has(ad.id) ? "" : `,${t("未读")}`}`}
aria-haspopup="dialog"
aria-expanded={activeAdId === ad.id}
aria-controls={activeAdId === ad.id ? "menu-ad-dialog" : undefined}
onClick={() => onOpen(ad)}
>
{!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>
</span>
</button>
<button
type="button"
className={styles.menuDismiss}
aria-label={`${t("不再显示广告")}:${ad.target.title}`}
aria-haspopup="dialog"
aria-expanded={dismissingAdId === ad.id}
aria-controls={dismissingAdId === ad.id ? "dismiss-ad-dialog" : undefined}
onClick={() => onDismiss(ad)}
>
<Icon icon={windowCloseIcon} size="1em" />
</button>
</div>)}
</div>;
}
+272
View File
@@ -0,0 +1,272 @@
@use "../../styles/typography" as type;
.menuSlots {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.menuSlotContainer {
position: relative;
height: 42px;
}
.menuSlot {
position: relative;
width: 100%;
min-width: 0;
height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 0 8px;
color: inherit;
text-align: left;
background: transparent;
border: 1px solid var(--vscode-sideBar-border);
border-radius: 6px;
&:hover,
&[aria-expanded="true"] {
background: var(--vscode-list-hoverBackground);
border: 1px solid var(--vscode-sideBar-border);
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
}
.menuDismiss {
position: absolute;
top: 5px;
right: 5px;
width: 16px;
height: 16px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
background: transparent;
border: 0;
border-radius: 4px;
&:hover {
color: color-mix(in srgb, var(--vscode-foreground) 72%, transparent);
background: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
}
.unreadDot {
position: absolute;
top: 5px;
right: 25px;
width: 7px;
height: 7px;
background: var(--vscode-errorForeground);
border-radius: 50%;
box-shadow: 0 0 0 1px var(--vscode-editorWidget-background);
}
.menuImage {
flex: 0 0 30px;
width: 30px;
height: 30px;
display: block;
}
.menuCopy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.menuTitle,
.menuSubtitle {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.menuTitle {
font-size: type.$font-size-xs;
font-weight: 500;
}
.menuSubtitle {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
}
.floatingAd {
position: fixed;
z-index: 20000;
left: 16px;
bottom: 16px;
width: 320px;
max-width: calc(100vw - 32px);
height: min(360px, calc(100vh - 32px));
max-height: 360px;
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--vscode-foreground);
background: var(--vscode-editorWidget-background);
border-radius: 15px;
border: none;
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
animation: ad-enter 180ms ease-out;
&:focus {
outline: none;
}
}
.hero {
flex: 0 0 112px;
position: relative;
height: 112px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
overflow: hidden;
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
img {
object-fit: cover;
}
}
.promotionLabel {
position: absolute;
top: 10px;
left: 10px;
padding: 3px 7px;
color: white;
font-size: type.$font-size-xs;
line-height: 1;
background: rgb(0 0 0 / 48%);
border-radius: 4px;
}
.close {
position: absolute;
top: 12px;
right: 12px;
width: 30px;
height: 30px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: type.$font-size-xl;
line-height: 1;
background: rgb(0 0 0 / 16%);
border: 0;
border-radius: 50%;
&:hover {
background: rgb(0 0 0 / 30%);
}
}
.body {
--scroll-shadow-bottom-size: 16px;
flex: 1 1 auto;
min-height: 0;
h2,
h3,
p {
margin: 0;
}
}
.bodyContent {
padding: 14px 16px 18px;
}
.intro {
h2,
p {
margin: 0;
}
h2 {
font-size: type.$font-size-base;
font-weight: 600;
}
p {
margin-top: 6px;
color: var(--vscode-descriptionForeground);
}
}
.detail {
h3,
p {
margin: 0;
}
h3 {
font-size: type.$font-size-base;
font-weight: 500;
}
p {
margin-top: 4px;
color: var(--vscode-descriptionForeground);
}
}
.footer {
flex: 0 0 auto;
padding: 10px 12px 12px;
border-top: 1px solid var(--vscode-sideBar-border);
button {
width: 100%;
height: 34px;
color: #202020;
background: #f2f2f2;
border: 0;
border-radius: 8px;
&:focus-visible {
outline: 2px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
}
@keyframes ad-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
}
@media (prefers-reduced-motion: reduce) {
.floatingAd {
animation: none;
}
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../shared/ui/Icon";
import { windowCloseIcon } from "../../shared/ui/icons";
import { VirtualList } from "../../shared/virtual/VirtualList";
import type { AdAction, AdSlot } from "./types";
import styles from "./Ads.module.scss";
type AdTextSection =
| { key: "intro"; kind: "intro" }
| { key: string; kind: "detail"; label: string; value: string };
type FloatingAdProps = {
ad: AdSlot;
trigger: HTMLButtonElement | null;
onClose: () => void;
onAction: (action: AdAction) => void;
};
export function FloatingAd({ ad, trigger, onClose, onAction }: FloatingAdProps) {
const dialog = useRef<HTMLDivElement>(null);
const textSections = useMemo<AdTextSection[]>(() => [
{ key: "intro", kind: "intro" },
...ad.content.details.map((detail, index) => ({
key: `detail-${index}-${detail.label}`,
kind: "detail" as const,
label: detail.label,
value: detail.value,
})),
], [ad.content.details]);
useEffect(() => {
dialog.current?.focus();
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (!dialog.current?.contains(target) && !trigger?.contains(target)) onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
onClose();
trigger?.focus();
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, trigger]);
return createPortal(<div
id="menu-ad-dialog"
ref={dialog}
className={styles.floatingAd}
role="dialog"
aria-modal="false"
aria-label={ad.content.title}
tabIndex={-1}
>
<div className={styles.hero}>
<img src={ad.content.imageUrl} alt="" />
<span className={styles.promotionLabel}>{t("推广")}</span>
<button type="button" className={styles.close} aria-label={t("关闭广告")} onClick={() => { onClose(); trigger?.focus(); }}>
<Icon icon={windowCloseIcon} size="1.1em" />
</button>
</div>
<VirtualList
items={textSections}
itemKey="key"
estimatedItemHeight={58}
itemGap={12}
className={`${styles.body} scroll-shadow-bottom`}
contentClassName={styles.bodyContent}
>
{(section) => section.kind === "intro"
? <div className={styles.intro}>
<h2>{ad.content.title}</h2>
<p>{ad.content.description}</p>
</div>
: <div className={styles.detail}>
<h3>{section.label}</h3>
<p>{section.value}</p>
</div>}
</VirtualList>
<div className={styles.footer}>
<button type="button" onClick={() => { onClose(); onAction(ad.content.button.action); }}>{ad.content.button.label}</button>
</div>
</div>, document.body);
}
+36
View File
@@ -0,0 +1,36 @@
export const AdActionType = {
OpenBrowser: "open_browser",
} as const;
export type AdAction = {
type: typeof AdActionType.OpenBrowser;
url: string;
};
export type AdSlot = {
id: string;
enabled: boolean;
placement: "menu";
target: {
title: string;
description: string;
imageUrl: string;
};
content: {
title: string;
description: string;
imageUrl: string;
details: Array<{
label: string;
value: string;
}>;
button: {
label: string;
action: AdAction;
};
};
};
export type AdRuntime = {
slots: AdSlot[];
};
@@ -0,0 +1,49 @@
@use "../../styles/typography" as type;
.root {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.title {
position: absolute;
z-index: 21;
top: var(--app-content-top);
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
margin: 0;
padding-left:0;
font-size: type.$font-size-xxl;
font-weight: bolder;
transform: translateY(-150%);
-webkit-user-select: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
-webkit-app-region: drag;
}
.fixedContent {
width: calc(100% - var(--app-page-padding) * 2);
max-width: var(--app-page-max-width);
padding-top: var(--app-content-top);
margin-right: auto;
margin-left: auto;
}
.fixedContent,
.fixedSection {
height: 100%;
min-width: 0;
min-height: 0;
}
.fixedSection {
width: 100%;
}
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import type { VirtualPageSection } from "./VirtualPage";
import { VirtualPage } from "./VirtualPage";
import styles from "./PageContent.module.scss";
export function PageContent({ title, sections, contentClassName, fixed = false }: { title?: ReactNode; sections: VirtualPageSection[]; contentClassName?: string; fixed?: boolean }) {
return <div className={styles.root}>
{fixed && title != null && <div className={styles.title}>{title}</div>}
{fixed
? <div className={[styles.fixedContent, contentClassName].filter(Boolean).join(" ")}>{sections.map((section) => <section className={styles.fixedSection} key={section.key}>{section.content}</section>)}</div>
: <VirtualPage title={title} sections={sections} contentClassName={contentClassName} />}
</div>;
}
@@ -0,0 +1,22 @@
.root {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header,
.footer {
flex: 0 0 auto;
min-width: 0;
}
.body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
}
@@ -0,0 +1,18 @@
import type { ElementType, ReactNode } from "react";
import styles from "./PageLayout.module.scss";
type PageLayoutProps = {
as?: ElementType;
className?: string;
header?: ReactNode;
footer?: ReactNode;
children: ReactNode;
};
export function PageLayout({ as: Component = "div", className, header, footer, children }: PageLayoutProps) {
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>
{header && <div className={styles.header}>{header}</div>}
<div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>}
</Component>;
}
@@ -0,0 +1,33 @@
@use "../../styles/typography" as type;
.root {
width: 100%;
height: 100%;
min-height: 0;
}
.content {
width: calc(100% - var(--app-page-padding) * 2);
max-width: var(--app-page-max-width);
padding-top: var(--app-content-top);
padding-bottom: var(--app-page-padding);
margin-right: auto;
margin-left: auto;
}
.section {
position: relative;
width: 100%;
}
.title {
position: absolute;
z-index: 21;
top: 0;
margin: 0;
padding-left: 0;
font-size: type.$font-size-xxl;
font-weight: bolder;
transform: translateY(-150%);
}
@@ -0,0 +1,31 @@
import { useCallback, type ReactNode } from "react";
import { VirtualList } from "../../shared/virtual/VirtualListEngine";
import styles from "./VirtualPage.module.scss";
export type VirtualPageSection = {
key: string;
estimatedHeight: number;
content: ReactNode;
};
export function VirtualPage({ title, sections, className, contentClassName }: { title?: ReactNode; sections: VirtualPageSection[]; className?: string; contentClassName?: string }) {
const getKey = useCallback((section: VirtualPageSection) => section.key, []);
const estimateSize = useCallback((section: VirtualPageSection) => section.estimatedHeight, []);
const renderItem = useCallback((section: VirtualPageSection, { index }: { index: number }) => <section className={styles.section}>
{index === 0 && title != null && <div className={styles.title}>{title}</div>}
{section.content}
</section>, [title]);
return <VirtualList
items={sections}
getKey={getKey}
estimateSize={estimateSize}
renderItem={renderItem}
overscan={2}
itemGap={16}
scrollbarSize={7}
scrollbarInsetTop="var(--app-content-top)"
className={[styles.root, "scroll-shadow-top", className].filter(Boolean).join(" ")}
contentClassName={[styles.content, contentClassName].filter(Boolean).join(" ")}
/>;
}