137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import type { ImageModelInfo } from "@/lib/types";
|
|
import { fetchModels } from "@/lib/api";
|
|
import { slideUp } from "@/components/ui/motion-presets";
|
|
|
|
import { getStoreUserId } from "@/lib/store";
|
|
|
|
function getModelStorageKey() {
|
|
const uid = getStoreUserId() || "_anonymous";
|
|
return `epeekit-${uid}-selected-image-model`;
|
|
}
|
|
|
|
interface ModelSelectorProps {
|
|
value: string;
|
|
onChange: (modelId: string) => void;
|
|
}
|
|
|
|
export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
|
const [models, setModels] = useState<ImageModelInfo[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
fetchModels()
|
|
.then(({ models: list, default: defaultId }) => {
|
|
setModels(list);
|
|
const saved = localStorage.getItem(getModelStorageKey());
|
|
const validIds = new Set(list.map((m) => m.id));
|
|
if (saved && validIds.has(saved)) {
|
|
onChange(saved);
|
|
} else if (!value || !validIds.has(value)) {
|
|
onChange(defaultId);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, [open]);
|
|
|
|
const selected = models.find((m) => m.id === value);
|
|
|
|
if (models.length === 0) return null;
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(!open)}
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs
|
|
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
|
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
|
hover:border-[var(--accent)]/40 hover:bg-[var(--accent)]/5
|
|
transition-all cursor-pointer btn-hover-lift"
|
|
title="切换生图模型"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
|
<path d="M2 17l10 5 10-5" />
|
|
<path d="M2 12l10 5 10-5" />
|
|
</svg>
|
|
<span className="max-w-[100px] truncate">{selected?.name ?? "模型"}</span>
|
|
<svg
|
|
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2.5"
|
|
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
|
>
|
|
<polyline points="6 9 12 15 18 9" />
|
|
</svg>
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
variants={slideUp}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
|
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-xl
|
|
surface-3 overflow-hidden z-50"
|
|
>
|
|
{models.map((m) => {
|
|
const isActive = m.id === value;
|
|
return (
|
|
<button
|
|
key={m.id}
|
|
onClick={() => {
|
|
onChange(m.id);
|
|
localStorage.setItem(getModelStorageKey(), m.id);
|
|
setOpen(false);
|
|
}}
|
|
className={`w-full text-left px-3.5 py-2.5 flex flex-col gap-0.5
|
|
transition-all cursor-pointer
|
|
${isActive
|
|
? "bg-[var(--accent)]/8 text-[var(--accent)]"
|
|
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
|
}`}
|
|
>
|
|
<span className="text-sm font-medium flex items-center gap-1.5">
|
|
{m.name}
|
|
{m.supports_ref_image && (
|
|
<span className="typo-micro px-1.5 py-0.5 rounded-full
|
|
bg-[var(--accent)]/10 text-[var(--accent)] leading-none
|
|
border border-[var(--accent)]/20"
|
|
style={{ textTransform: "none", fontSize: "10px" }}>
|
|
参考图
|
|
</span>
|
|
)}
|
|
{isActive && (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
<span className="typo-caption">{m.description}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|