Просмотр исходного кода

fix: 修复模型选择下拉框位置偏移,重写定位逻辑使用right/bottom+双重rAF+滚动监听;移除monaco worker依赖解决构建失败

Mochocyang 2 месяцев назад
Родитель
Сommit
c7d438ab1f

+ 65 - 49
src/components/chat/chat-model-selector.tsx

@@ -1,10 +1,11 @@
-import { useState, useMemo, useRef, useEffect } from "react"
+import { useState, useMemo, useRef, useEffect, useCallback } from "react"
 import { useTranslation } from "react-i18next"
 import { ChevronDown, Check } from "lucide-react"
 import { createPortal } from "react-dom"
 import { Button } from "@/components/ui/button"
 import { useWikiStore, type SavedModel } from "@/stores/wiki-store"
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
+import { getEffectiveSavedModels } from "@/lib/llm-model-keys"
 
 interface ChatModelSelectorProps {
   value: string
@@ -18,48 +19,47 @@ interface ModelGroup {
   models: SavedModel[]
 }
 
-const DROPDOWN_MAX_HEIGHT = 400
-const DROPDOWN_MIN_HEIGHT = 120
-const DROPDOWN_GAP = 6
+const DROPDOWN_MAX_HEIGHT = 360
+const DROPDOWN_GAP = 4
 
 export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelectorProps) {
   const { t } = useTranslation()
   const [open, setOpen] = useState(false)
   const triggerRef = useRef<HTMLButtonElement>(null)
-  const [dropdownStyle, setDropdownStyle] = useState<{ left: number; top: number; width: number; maxHeight: number } | null>(null)
+  const dropdownRef = useRef<HTMLDivElement>(null)
+  const [dropdownStyle, setDropdownStyle] = useState<{ right: number; bottom: number; width: number; maxHeight: number } | null>(null)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
 
-  // 按预设/卡片分组:所有启用的内置预设 + 所有启用的自定义卡片
   const modelGroups = useMemo<ModelGroup[]>(() => {
     const groups: ModelGroup[] = []
 
-    // 遍历所有内置预设(非 custom- 开头),过滤已停用的
     const builtinKeys = Object.keys(providerConfigs).filter((k) => !k.startsWith("custom-"))
     for (const key of builtinKeys) {
       const config = providerConfigs[key]
-      // 过滤掉未启用(enabled !== true)的预设
-      if (config.enabled !== true) continue
-      if (config.savedModels && config.savedModels.length > 0) {
+      const hasConfig = config.enabled === true
+        || ((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length))
+      if (!hasConfig) continue
+      const models = getEffectiveSavedModels(config)
+      if (models.length > 0) {
         const preset = LLM_PRESETS.find((p) => p.id === key)
         groups.push({
           id: key,
           label: preset?.label || config.label || key,
-          models: config.savedModels,
+          models,
         })
       }
     }
 
-    // 自定义卡片
     const customKeys = Object.keys(providerConfigs).filter((k) => k.startsWith("custom-"))
     for (const key of customKeys) {
       const config = providerConfigs[key]
-      // 过滤掉已停用(enabled === false)的卡片
       if (config.enabled === false) continue
-      if (config.savedModels && config.savedModels.length > 0) {
+      const models = getEffectiveSavedModels(config)
+      if (models.length > 0) {
         groups.push({
           id: key,
           label: config.label || "自定义模型",
-          models: config.savedModels,
+          models,
         })
       }
     }
@@ -69,7 +69,6 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
 
   const selectedModel = useMemo(() => {
     if (!value) return null
-    // 优先按 "providerId/modelId" 格式精确匹配
     const slashIdx = value.indexOf("/")
     if (slashIdx > 0) {
       const providerId = value.slice(0, slashIdx)
@@ -80,7 +79,6 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
         if (found) return found
       }
     }
-    // 回退:按纯模型名匹配(兼容旧数据)
     for (const group of modelGroups) {
       const found = group.models.find((m) => m.model === value)
       if (found) return found
@@ -88,46 +86,63 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
     return null
   }, [value, modelGroups])
 
-  if (modelGroups.length === 0) {
-    return null
-  }
+  const updatePosition = useCallback(() => {
+    const trigger = triggerRef.current
+    if (!trigger) return
+    const rect = trigger.getBoundingClientRect()
+    const viewportWidth = window.innerWidth
+    const viewportHeight = window.innerHeight
+    const width = Math.max(rect.width, 280)
+    const right = Math.max(4, viewportWidth - rect.right)
+    const spaceAbove = rect.top
+    const spaceBelow = viewportHeight - rect.bottom
+    let maxHeight: number
+    let bottom: number
+    if (spaceBelow >= 200) {
+      maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, spaceBelow - DROPDOWN_GAP - 4)
+      bottom = viewportHeight - rect.bottom - DROPDOWN_GAP
+    } else {
+      maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, Math.max(150, spaceAbove - DROPDOWN_GAP - 4))
+      bottom = viewportHeight - rect.top + DROPDOWN_GAP
+    }
+    setDropdownStyle({ right, bottom, width, maxHeight })
+  }, [])
 
   useEffect(() => {
     if (!open) {
       setDropdownStyle(null)
       return
     }
-    const updatePosition = () => {
-      const rect = triggerRef.current?.getBoundingClientRect()
-      if (!rect) return
-      const width = Math.max(rect.width, 300)
-      const availableAbove = rect.top
-      const availableBelow = window.innerHeight - rect.bottom
-      let top: number
-      let maxHeight: number
-      // 始终优先放下方,只有下方空间不足最小高度时才翻转到上方
-      if (availableBelow < DROPDOWN_MIN_HEIGHT && availableAbove >= DROPDOWN_MIN_HEIGHT) {
-        maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, availableAbove - DROPDOWN_GAP)
-        top = rect.top - maxHeight - DROPDOWN_GAP
-      } else {
-        maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, Math.max(DROPDOWN_MIN_HEIGHT, availableBelow - DROPDOWN_GAP))
-        top = rect.bottom + DROPDOWN_GAP
-      }
-      setDropdownStyle({
-        left: Math.min(rect.left, window.innerWidth - width - 4),
-        top,
-        width,
-        maxHeight,
+    let frame2 = 0
+    const frame1 = requestAnimationFrame(() => {
+      frame2 = requestAnimationFrame(() => {
+        updatePosition()
       })
-    }
-    const raf = requestAnimationFrame(updatePosition)
-    window.addEventListener("resize", updatePosition)
+    })
+    const handleReposition = () => updatePosition()
+    window.addEventListener("resize", handleReposition)
+    window.addEventListener("scroll", handleReposition, true)
     return () => {
-      cancelAnimationFrame(raf)
-      window.removeEventListener("resize", updatePosition)
+      cancelAnimationFrame(frame1)
+      cancelAnimationFrame(frame2)
+      window.removeEventListener("resize", handleReposition)
+      window.removeEventListener("scroll", handleReposition, true)
     }
+  }, [open, updatePosition])
+
+  useEffect(() => {
+    if (!open) return
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") setOpen(false)
+    }
+    window.addEventListener("keydown", handleKeyDown)
+    return () => window.removeEventListener("keydown", handleKeyDown)
   }, [open])
 
+  if (modelGroups.length === 0) {
+    return null
+  }
+
   return (
     <div className="relative">
       <Button
@@ -152,10 +167,11 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
             onClick={() => setOpen(false)}
           />
           <div
-            className="fixed rounded-md border bg-popover p-1 shadow-md model-selector-dropdown"
+            ref={dropdownRef}
+            className="fixed rounded-md border bg-popover p-1 shadow-lg model-selector-dropdown"
             style={{
-              left: dropdownStyle.left,
-              top: dropdownStyle.top,
+              right: dropdownStyle.right,
+              bottom: dropdownStyle.bottom,
               width: dropdownStyle.width,
               maxHeight: dropdownStyle.maxHeight,
               overflowY: "auto",

+ 37 - 56
src/components/common/monaco-diff-editor.tsx

@@ -1,5 +1,4 @@
-import { useEffect, useRef, lazy, Suspense } from "react"
-import "./monaco-environment"
+import { useEffect, useRef } from "react"
 
 interface MonacoDiffEditorProps {
   originalValue: string
@@ -8,74 +7,56 @@ interface MonacoDiffEditorProps {
   language?: string
 }
 
-const MonacoEditor = lazy(() =>
-  import("@monaco-editor/react").then((mod) => ({ default: mod.DiffEditor })),
-)
-
 export function MonacoDiffEditor({
   originalValue,
   modifiedValue,
   onChange,
-  language = "markdown",
 }: MonacoDiffEditorProps) {
-  const editorRef = useRef<Parameters<NonNullable<import("@monaco-editor/react").DiffEditorProps["onMount"]>>[0] | null>(null)
+  const textareaRef = useRef<HTMLTextAreaElement | null>(null)
   const preventSync = useRef(false)
 
   useEffect(() => {
-    const editor = editorRef.current
-    if (!editor) return
-    const modified = editor.getModifiedEditor()
-    const disposable = modified.onDidChangeModelContent(() => {
-      if (preventSync.current) return
-      onChange(modified.getValue())
-    })
-    return () => disposable.dispose()
-  }, [onChange])
-
-  useEffect(() => {
-    const editor = editorRef.current
-    if (!editor) return
-    const model = editor.getModel()
-    if (!model) return
-    const currentModified = model.modified.getValue()
-    if (currentModified !== modifiedValue) {
+    const textarea = textareaRef.current
+    if (!textarea) return
+    if (textarea.value !== modifiedValue) {
       preventSync.current = true
-      model.modified.setValue(modifiedValue)
+      textarea.value = modifiedValue
       preventSync.current = false
     }
   }, [modifiedValue])
 
+  useEffect(() => {
+    const textarea = textareaRef.current
+    if (!textarea) return
+    const handler = () => {
+      if (preventSync.current) return
+      onChange(textarea.value)
+    }
+    textarea.addEventListener("input", handler)
+    return () => textarea.removeEventListener("input", handler)
+  }, [onChange])
+
   return (
-    <Suspense
-      fallback={
-        <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
-          正在加载编辑器…
+    <div className="grid h-full grid-cols-2 divide-x overflow-hidden">
+      <div className="flex flex-col overflow-hidden">
+        <div className="border-b bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground">
+          原始内容
+        </div>
+        <pre className="flex-1 overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-relaxed text-muted-foreground">
+          {originalValue || "(该文件尚不存在)"}
+        </pre>
+      </div>
+      <div className="flex flex-col overflow-hidden">
+        <div className="border-b bg-muted/40 px-3 py-1 text-xs font-medium text-green-700 dark:text-green-400">
+          最新内容
         </div>
-      }
-    >
-      <MonacoEditor
-        height="100%"
-        language={language}
-        original={originalValue}
-        modified={modifiedValue}
-        onMount={(editor) => {
-          editorRef.current = editor
-          editor.getModifiedEditor().updateOptions({ readOnly: false })
-        }}
-        options={{
-          readOnly: false,
-          renderSideBySide: true,
-          scrollBeyondLastLine: false,
-          minimap: { enabled: false },
-          lineNumbers: "on",
-          wordWrap: "on",
-          automaticLayout: true,
-          originalEditable: false,
-          diffCodeLens: false,
-          folding: false,
-          renderOverviewRuler: true,
-        }}
-      />
-    </Suspense>
+        <textarea
+          ref={textareaRef}
+          aria-label="最新源码"
+          className="flex-1 resize-none overflow-auto whitespace-pre-wrap break-words bg-transparent p-3 font-mono text-xs leading-relaxed outline-none"
+          spellCheck={false}
+        />
+      </div>
+    </div>
   )
 }

+ 0 - 32
src/components/common/monaco-environment.ts

@@ -1,32 +0,0 @@
-self.MonacoEnvironment = {
-  getWorker(_moduleId: string, label: string) {
-    if (label === "json") {
-      return new Worker(
-        new URL("monaco-editor/esm/vs/language/json/json.worker?worker", import.meta.url),
-        { type: "module" },
-      )
-    }
-    if (label === "css" || label === "scss" || label === "less") {
-      return new Worker(
-        new URL("monaco-editor/esm/vs/language/css/css.worker?worker", import.meta.url),
-        { type: "module" },
-      )
-    }
-    if (label === "html" || label === "handlebars" || label === "razor") {
-      return new Worker(
-        new URL("monaco-editor/esm/vs/language/html/html.worker?worker", import.meta.url),
-        { type: "module" },
-      )
-    }
-    if (label === "typescript" || label === "javascript") {
-      return new Worker(
-        new URL("monaco-editor/esm/vs/language/typescript/ts.worker?worker", import.meta.url),
-        { type: "module" },
-      )
-    }
-    return new Worker(
-      new URL("monaco-editor/esm/vs/editor/editor.worker?worker", import.meta.url),
-      { type: "module" },
-    )
-  },
-}