Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 27 additions & 28 deletions src/components/IconPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,30 +241,28 @@ export const IconPicker: React.FC<IconPickerProps> = ({
{t("iconPicker.myIcons")}
</h3>
<div className={GRID_CLASS}>
<HoverTip content={t("iconPicker.uploadHint")}>
<button
type="button"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className={cn(
"flex w-full flex-col items-center gap-1 p-3 rounded-lg",
"border-2 border-dashed border-border text-fg-2 transition-colors",
"hover:border-primary/50 hover:bg-subtle hover:text-fg-1",
"disabled:cursor-wait disabled:opacity-60",
<button
type="button"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className={cn(
"flex w-full flex-col items-center gap-1 p-3 rounded-lg",
"border-2 border-dashed border-border text-fg-2 transition-colors",
"hover:border-primary/50 hover:bg-subtle hover:text-fg-1",
"disabled:cursor-wait disabled:opacity-60",
)}
>
<span className="flex h-8 w-8 items-center justify-center">
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Plus className="h-5 w-5" />
)}
>
<span className="flex h-8 w-8 items-center justify-center">
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Plus className="h-5 w-5" />
)}
</span>
<span className="text-xs truncate w-full text-center">
{t("iconPicker.upload")}
</span>
</button>
</HoverTip>
</span>
<span className="text-xs truncate w-full text-center">
{t("iconPicker.upload")}
</span>
</button>
<input
ref={fileInputRef}
type="file"
Expand Down Expand Up @@ -335,11 +333,12 @@ export const IconPicker: React.FC<IconPickerProps> = ({
</div>
))}
</div>
{library.length === 0 && !currentUnsaved && (
<p className="mt-2 text-xs text-fg-3">
{t("iconPicker.myIconsEmpty")}
</p>
)}
<p className="mt-2 text-xs text-fg-3">
{library.length === 0 && !currentUnsaved
? `${t("iconPicker.myIconsEmpty")} `
: ""}
{t("iconPicker.uploadHint")}
</p>
</section>

<section>
Expand Down
30 changes: 20 additions & 10 deletions src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
getIconMetadata,
getIconUrl,
isUrlIcon,
isDarkInk,
adaptSvgToTheme,
isTileIcon,
} from "@/icons/extracted";
import { cn } from "@/lib/utils";
import { isCustomIcon } from "@/utils/customIcon";
Expand All @@ -29,11 +32,14 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
fallbackClassName,
}) => {
// 获取内联 SVG 字符串
const iconSvg = useMemo(() => {
const { svg: iconSvg, adapted } = useMemo(() => {
if (icon && !isCustomIcon(icon) && !isUrlIcon(icon) && hasIcon(icon)) {
return getIcon(icon);
const raw = getIcon(icon);
// tile 图标自带底色,里面的深色是底,不能换成文字色
const themed = isTileIcon(icon) ? raw : adaptSvgToTheme(raw);
return { svg: themed, adapted: themed !== raw };
}
return "";
return { svg: "", adapted: false };
}, [icon]);

// 获取图标 URL:内置的大图(URL_ICONS 列表中的 SVG / 光栅图片),或用户上传的 PNG。
Expand Down Expand Up @@ -62,19 +68,23 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
};
}, [size]);

// 获取有效颜色:优先使用传入的有效 color,否则从元数据获取 defaultColor
// 获取有效颜色:优先使用传入的有效 color,否则从元数据获取 defaultColor。
// 近黑色(#000 / #111 这类)的颜色不写死,交给外层文字色,
// 否则深色模式下是黑底上的黑图标。已经把近黑笔画换成 currentColor 的图标
// 也不能再套品牌色,否则眼睛、嘴这些细节会和脸同色
const effectiveColor = useMemo(() => {
if (adapted) return undefined;
let resolved: string | undefined;
if (color && typeof color === "string" && color.trim() !== "") {
return color;
}
if (icon) {
resolved = color;
} else if (icon) {
const metadata = getIconMetadata(icon);
if (metadata?.defaultColor && metadata.defaultColor !== "currentColor") {
return metadata.defaultColor;
resolved = metadata.defaultColor;
}
}
return undefined;
}, [color, icon]);
return isDarkInk(resolved) ? undefined : resolved;
}, [color, icon, adapted]);

// 内联 SVG 渲染(支持 CSS currentColor 着色)
if (iconSvg) {
Expand Down
5 changes: 4 additions & 1 deletion src/components/ProviderIconBox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ProviderIcon } from "@/components/ProviderIcon";
import { isTileIcon } from "@/icons/extracted";
import { isTileIcon, needsLightBacking } from "@/icons/extracted";
import { cn } from "@/lib/utils";
import { isCustomIcon } from "@/utils/customIcon";

Expand Down Expand Up @@ -31,6 +31,8 @@ export function ProviderIconBox({
}: ProviderIconBoxProps) {
// 用户上传的图标在编码时已经按 tile / 透明 logo 留好了白,统一铺满整框
const tile = isCustomIcon(icon) || isTileIcon(icon);
// 透明底的位图 / 上传图标、适配后仍带近黑笔画的图标:深色下垫浅色衬底,否则看不见
const lightBacking = isCustomIcon(icon) || needsLightBacking(icon);
return (
<span
aria-hidden="true"
Expand All @@ -41,6 +43,7 @@ export function ProviderIconBox({
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-black/10 dark:after:ring-white/10"
: "border border-border",
className,
lightBacking && "dark:bg-white dark:text-neutral-900",
)}
>
<ProviderIcon
Expand Down
19 changes: 19 additions & 0 deletions src/components/common/FullScreenPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ interface FullScreenPanelProps {
/** 外壳里的内容区(App 的 <main id="content-area">):二级页只盖住它,侧栏留着 */
const CONTENT_AREA_ID = "content-area";

/** 已打开的面板,后开的在后;Esc 只交给最上面那个(嵌套面板里不能连带关掉外层) */
const openPanelStack: symbol[] = [];

let bodyScrollLockCount = 0;
let bodyOverflowBeforeFirstLock: string | null = null;

Expand Down Expand Up @@ -93,6 +96,18 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
return unlockBodyScroll;
}, [isOpen]);

const panelIdRef = React.useRef<symbol>(Symbol("full-screen-panel"));

React.useEffect(() => {
if (!isOpen) return;
const id = panelIdRef.current;
openPanelStack.push(id);
return () => {
const index = openPanelStack.lastIndexOf(id);
if (index !== -1) openPanelStack.splice(index, 1);
};
}, [isOpen]);

// ESC 键关闭面板
const onCloseRef = React.useRef(onClose);

Expand All @@ -105,6 +120,10 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
// 上面还有别的面板:交给它
if (openPanelStack[openPanelStack.length - 1] !== panelIdRef.current) {
return;
}
// 子组件(例如 Radix 的 Select/Dialog/Dropdown)如果已经消费了 ESC,就不要再关闭整个面板
if (event.defaultPrevented) {
return;
Expand Down
8 changes: 4 additions & 4 deletions src/components/providers/ProviderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,12 @@ export function ProviderCard({
name={provider.name}
color={provider.iconColor}
className={cn(
// 底色固定为白,图标颜色也要固定:单色(currentColor)图标和首字母
// fallback 不能继承深色模式下的浅色文字
"bg-white text-neutral-900",
// 不再垫白底:单色 / 近黑笔画的图标跟随文字色(ProviderIcon 里换成 currentColor),
// 自带底色的 tile 图标铺满整框
"bg-transparent text-fg-1",
presentation.dim && "opacity-60",
)}
fallbackClassName="bg-transparent text-neutral-600"
fallbackClassName="bg-transparent text-fg-2"
/>

<div className="min-w-0 flex-1">
Expand Down
89 changes: 28 additions & 61 deletions src/components/providers/forms/BasicFormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,10 @@ import {
FormMessage,
} from "@/components/ui/form";
import { ImeSafeInput } from "@/components/ui/ime-safe-input";
import { Button } from "@/components/ui/button";
import { HoverTip } from "@/components/ui/hover-tip";
import { ArrowLeft } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { ProviderIconBox } from "@/components/ProviderIconBox";
import { IconPicker } from "@/components/IconPicker";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { getIconMetadata } from "@/icons/extracted/metadata";
import type { UseFormReturn } from "react-hook-form";
import type { ProviderFormData } from "@/lib/schemas/provider";
Expand Down Expand Up @@ -59,7 +52,7 @@ export function BasicFormFields({
};

const iconButton = (
<Dialog open={iconDialogOpen} onOpenChange={setIconDialogOpen}>
<>
<HoverTip
content={
currentIcon
Expand All @@ -71,60 +64,34 @@ export function BasicFormFields({
})
}
>
<DialogTrigger asChild>
<button
type="button"
aria-label={t("providerIcon.change", { defaultValue: "更换图标" })}
className="group shrink-0 rounded-[8px]"
>
<ProviderIconBox
icon={currentIcon}
name={providerName}
color={effectiveIconColor}
className="bg-surface transition-colors group-hover:bg-subtle"
/>
</button>
</DialogTrigger>
<button
type="button"
aria-label={t("providerIcon.change", { defaultValue: "更换图标" })}
className="group shrink-0 rounded-[8px]"
onClick={() => setIconDialogOpen(true)}
>
<ProviderIconBox
icon={currentIcon}
name={providerName}
color={effectiveIconColor}
className="bg-surface transition-colors group-hover:bg-subtle"
/>
</button>
</HoverTip>
<DialogContent
variant="fullscreen"
zIndex="top"
overlayClassName="bg-[hsl(var(--background))] backdrop-blur-0"
className="p-0 sm:rounded-none"
{/* 只盖住右侧内容区,侧栏和窗口按钮保持原位,和其他二级页一致 */}
<FullScreenPanel
isOpen={iconDialogOpen}
title={t("providerIcon.selectIcon", { defaultValue: "选择图标" })}
onClose={() => setIconDialogOpen(false)}
>
<div className="flex h-full flex-col">
<div className="flex-shrink-0 py-4 border-b border-border bg-subtle">
<div className="px-6 flex items-center gap-4">
<DialogClose asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label={t("common.back")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
</DialogClose>
<p className="text-lg font-semibold leading-tight">
{t("providerIcon.selectIcon", {
defaultValue: "选择图标",
})}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="space-y-2 px-6 py-6 w-full">
<IconPicker
value={currentIcon}
onValueChange={handleIconSelect}
color={effectiveIconColor}
defaultName={form.watch("name") ?? ""}
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<IconPicker
value={currentIcon}
onValueChange={handleIconSelect}
color={effectiveIconColor}
defaultName={form.watch("name") ?? ""}
/>
</FullScreenPanel>
</>
);

return (
Expand Down
Loading
Loading