0.1.02思维导图全屏/lightrag修复/luckysheet修复
This commit is contained in:
+2
-5
@@ -41,8 +41,5 @@ cankao/
|
||||
**/test/
|
||||
pw-tests/**
|
||||
pw-tests/
|
||||
wolai-frontend\public\documents
|
||||
wolai-frontend/public/documents/d7561119-ff9f-44ee-8fa9-003a657d43e6/index.md
|
||||
wolai-frontend/public/documents/f8d926b4-1351-4d0d-b768-3356f18b0281/index.md
|
||||
wolai-frontend/public/documents/d7561119-ff9f-44ee-8fa9-003a657d43e6/mindmap.json
|
||||
wolai-frontend/public/documents/f8d926b4-1351-4d0d-b768-3356f18b0281/mindmap.json
|
||||
wolai-frontend/public/documents/
|
||||
wolai-frontend/public/documents/**
|
||||
@@ -0,0 +1,36 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# 说明:
|
||||
# 1) LightRAG Server 启动时会在控制台打印 Emoji/彩色字符,Windows 默认编码(GBK)可能导致启动失败。
|
||||
# 2) 本脚本强制使用 UTF-8,并确保存在一个到 supabase-db 的端口转发(host:15433 -> supabase-db:5432)。
|
||||
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$env:PYTHONUTF8 = "1"
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
$env:PYTHONLEGACYWINDOWSSTDIO = "1"
|
||||
|
||||
Set-Location -Path $PSScriptRoot
|
||||
|
||||
function Ensure-SupabaseDbForwarder {
|
||||
$name = "supabase-db-forward"
|
||||
$exists = (docker ps -a --format "{{.Names}}" | Where-Object { $_ -eq $name }) -ne $null
|
||||
|
||||
if (-not $exists) {
|
||||
Write-Host "创建端口转发容器:$name(15433 -> supabase-db:5432)"
|
||||
docker run -d --name $name --restart unless-stopped --network supabase_default -p 15433:5432 alpine/socat -v TCP-LISTEN:5432,fork,reuseaddr TCP:supabase-db:5432 | Out-Null
|
||||
} else {
|
||||
$running = (docker ps --format "{{.Names}}" | Where-Object { $_ -eq $name }) -ne $null
|
||||
if (-not $running) {
|
||||
Write-Host "启动端口转发容器:$name"
|
||||
docker start $name | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ensure-SupabaseDbForwarder
|
||||
|
||||
Write-Host "启动 LightRAG Server(http://127.0.0.1:7777)"
|
||||
Write-Host "提示:API-Key 使用 X-API-Key: aimnote-local(可在 .env 中调整 LIGHTRAG_API_KEY)"
|
||||
|
||||
& ".\.venv\Scripts\lightrag-server.exe"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pydantic import BaseSettings, Field
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""FastAPI 配置,统一读取 .env 或系统环境变量"""
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
|
||||
|
||||
app_name: str = Field("AIMNOTE Ingest Service", env="INGEST_APP_NAME")
|
||||
environment: str = Field("development", env="INGEST_ENV")
|
||||
@@ -12,16 +14,13 @@ class Settings(BaseSettings):
|
||||
supabase_url: str = Field("http://127.0.0.1:54321", env="SUPABASE_URL")
|
||||
supabase_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY")
|
||||
lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL")
|
||||
lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY")
|
||||
ollama_base_url: str = Field("http://127.0.0.1:11434/v1", env="OLLAMA_BASE_URL")
|
||||
ollama_api_key: str = Field("ollama", env="OLLAMA_API_KEY")
|
||||
embeddings_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL")
|
||||
llm_model: str = Field("qwen3:32b", env="DEFAULT_LLM_MODEL")
|
||||
rerank_model: str = Field("qwen3-reranker-4b", env="RERANK_MODEL")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -1,42 +1,13 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.job import IngestJob
|
||||
from app.services.embeddings import EmbeddingGenerator
|
||||
from app.services.job_store import InMemoryJobStore
|
||||
from app.services.scheduler import SchedulerManager
|
||||
from app.services.tasks import IngestWorker
|
||||
from app.services.webhooks import LightRAGWebhook
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.append(str(ROOT / "packages" / "siyuan_ingest" / "src"))
|
||||
from siyuan_ingest import SiYuanClient, SupabaseSync, SupabaseWriter # type: ignore # noqa: E402
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
job_store = InMemoryJobStore()
|
||||
scheduler = SchedulerManager()
|
||||
siyuan_client = SiYuanClient()
|
||||
webhook = LightRAGWebhook()
|
||||
embedding_generator = EmbeddingGenerator(
|
||||
api_base=settings.ollama_base_url,
|
||||
api_key=settings.ollama_api_key,
|
||||
model=settings.embeddings_model,
|
||||
)
|
||||
supabase_sync = SupabaseSync(
|
||||
supabase_url=settings.supabase_url + "/rest/v1",
|
||||
supabase_key=settings.supabase_key,
|
||||
)
|
||||
supabase_writer = SupabaseWriter(
|
||||
supabase_url=settings.supabase_url + "/rest/v1",
|
||||
supabase_key=settings.supabase_key,
|
||||
)
|
||||
worker = IngestWorker(
|
||||
job_store=job_store,
|
||||
siyuan_client=siyuan_client,
|
||||
webhook=webhook,
|
||||
supabase_sync=supabase_sync,
|
||||
supabase_writer=supabase_writer,
|
||||
embedding_generator=embedding_generator,
|
||||
)
|
||||
worker = IngestWorker(job_store=job_store, webhook=webhook)
|
||||
|
||||
|
||||
@@ -1,49 +1,26 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from app.models.job import IngestJob
|
||||
from app.services.embeddings import EmbeddingGenerator
|
||||
from app.services.job_store import InMemoryJobStore
|
||||
from app.services.webhooks import LightRAGWebhook
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.append(str(ROOT / "packages" / "siyuan_ingest" / "src"))
|
||||
from siyuan_ingest import ( # type: ignore # noqa: E402
|
||||
IngestState,
|
||||
SiYuanClient,
|
||||
SiYuanContentExtractor,
|
||||
SupabaseSync,
|
||||
SupabaseWriter,
|
||||
chunk_blocks,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IngestWorker:
|
||||
"""处理 ingest 队列,串联 SiYuan → Supabase → LightRAG"""
|
||||
"""处理 ingest 队列:将外部文本推送到 LightRAG。
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job_store: InMemoryJobStore,
|
||||
siyuan_client: SiYuanClient,
|
||||
webhook: LightRAGWebhook,
|
||||
supabase_sync: Optional[SupabaseSync] = None,
|
||||
supabase_writer: Optional[SupabaseWriter] = None,
|
||||
embedding_generator: Optional[EmbeddingGenerator] = None,
|
||||
) -> None:
|
||||
说明:历史版本依赖 `siyuan_ingest` 从思源笔记拉取块数据,但该包当前不在仓库内,
|
||||
回档后会导致服务无法启动。此处改为“由调用方直接提供 blocks 文本列表”。
|
||||
"""
|
||||
|
||||
def __init__(self, job_store: InMemoryJobStore, webhook: LightRAGWebhook) -> None:
|
||||
self.job_store = job_store
|
||||
self.siyuan_client = siyuan_client
|
||||
self.extractor = SiYuanContentExtractor(siyuan_client)
|
||||
self.webhook = webhook
|
||||
self.supabase_sync = supabase_sync
|
||||
self.supabase_writer = supabase_writer
|
||||
self.embedding_generator = embedding_generator
|
||||
self.queue: asyncio.Queue[IngestJob] = asyncio.Queue()
|
||||
self._consumer_task: Optional[asyncio.Task[None]] = None
|
||||
|
||||
@@ -57,13 +34,19 @@ class IngestWorker:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._consumer_task
|
||||
|
||||
async def enqueue(self, notebook_id: Optional[str], doc_id: Optional[str], source: str, blocks: List[str]) -> IngestJob:
|
||||
async def enqueue(
|
||||
self,
|
||||
notebook_id: Optional[str],
|
||||
doc_id: Optional[str],
|
||||
source: str,
|
||||
blocks: List[str],
|
||||
) -> IngestJob:
|
||||
job = IngestJob(
|
||||
id=str(uuid4()),
|
||||
notebook_id=notebook_id,
|
||||
doc_id=doc_id,
|
||||
source=source,
|
||||
blocks=blocks,
|
||||
blocks=blocks or [],
|
||||
)
|
||||
await self.job_store.save(job)
|
||||
await self.queue.put(job)
|
||||
@@ -80,12 +63,19 @@ class IngestWorker:
|
||||
try:
|
||||
job.mark_running()
|
||||
await self.job_store.save(job)
|
||||
blocks = await self.extractor.list_blocks(job.notebook_id or "")
|
||||
chunks = chunk_blocks(blocks)
|
||||
logger.info("任务 %s 产生 %s 个 chunk", job.id, len(chunks))
|
||||
await self._maybe_embed(chunks)
|
||||
await self._persist_to_supabase(job, chunks)
|
||||
await self.webhook.notify_ingest(job.doc_id, [c["block_id"] for c in chunks if c.get("block_id")])
|
||||
|
||||
texts = [b.strip() for b in job.blocks if b and b.strip()]
|
||||
if not texts:
|
||||
raise ValueError("blocks 为空:当前 ingest_service 仅支持由调用方提供文本列表")
|
||||
|
||||
# 约定:doc_id 存在时使用 doc:// 作为 file_source,便于后续统一引用
|
||||
file_source = f"doc://{job.doc_id}" if job.doc_id else f"ingest://{job.id}"
|
||||
merged_text = "\n\n".join(texts)
|
||||
track_id = await self.webhook.ingest_text(file_source=file_source, text=merged_text)
|
||||
job.payload["file_source"] = file_source
|
||||
if track_id:
|
||||
job.payload["lightrag_track_id"] = track_id
|
||||
|
||||
job.mark_done()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("任务 %s 失败: %s", job.id, exc)
|
||||
@@ -93,33 +83,3 @@ class IngestWorker:
|
||||
finally:
|
||||
await self.job_store.save(job)
|
||||
|
||||
async def _persist_to_supabase(self, job: IngestJob, chunks: List[Dict[str, str]]) -> None:
|
||||
await self._maybe_sync_state(job, chunks)
|
||||
if not self.supabase_writer:
|
||||
logger.debug("未配置 SupabaseWriter,跳过落库")
|
||||
return
|
||||
await self.supabase_writer.ingest_chunks(job.notebook_id, chunks)
|
||||
|
||||
async def _maybe_sync_state(self, job: IngestJob, chunks: List[Dict[str, str]]) -> None:
|
||||
if not self.supabase_sync:
|
||||
return
|
||||
if not chunks:
|
||||
return
|
||||
merged_checksum = chunks[-1]["checksum"]
|
||||
state = await self.supabase_sync.fetch_state(job.notebook_id, job.doc_id)
|
||||
if state and not state.should_ingest(merged_checksum):
|
||||
logger.info("任务 %s 未发生变化,跳过写入 Supabase", job.id)
|
||||
return
|
||||
state = state or IngestState(notebook_id=job.notebook_id, doc_id=job.doc_id)
|
||||
state.update(merged_checksum)
|
||||
await self.supabase_sync.upsert_state(state)
|
||||
|
||||
async def _maybe_embed(self, chunks: List[Dict[str, str]]) -> None:
|
||||
if not self.embedding_generator:
|
||||
return
|
||||
texts = [c.get("text") or "" for c in chunks]
|
||||
if not texts:
|
||||
return
|
||||
embeddings = await self.embedding_generator.embed_texts(texts)
|
||||
for chunk, embedding in zip(chunks, embeddings):
|
||||
chunk["embedding"] = embedding
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -9,18 +9,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LightRAGWebhook:
|
||||
"""封装与 LightRAG 的交互,默认调用 /ingest"""
|
||||
"""封装与 LightRAG 的交互。
|
||||
|
||||
说明:当前 LightRAG(>=1.4.x) 的“文本入库”接口为 `/documents/text` / `/documents/texts`,
|
||||
旧版项目里使用的 `/ingest` 已不存在(回档后代码仍在调用旧接口)。
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: Optional[str] = None):
|
||||
settings = get_settings()
|
||||
self.base_url = base_url or settings.lightrag_url.rstrip("/")
|
||||
self.api_key = settings.lightrag_api_key
|
||||
|
||||
async def notify_ingest(self, doc_id: Optional[str], block_ids: List[str]) -> None:
|
||||
if not block_ids:
|
||||
logger.info("无可用 block 推送给 LightRAG")
|
||||
return
|
||||
payload = {"doc_id": doc_id, "block_ids": block_ids}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(f"{self.base_url}/ingest", json=payload)
|
||||
def _headers(self) -> dict:
|
||||
headers: dict = {}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
return headers
|
||||
|
||||
async def ingest_text(self, file_source: str, text: str) -> Optional[str]:
|
||||
"""向 LightRAG 提交一段文本,触发后台索引。
|
||||
|
||||
返回 track_id(如果服务端返回),便于后续排查。
|
||||
"""
|
||||
if not text.strip():
|
||||
logger.info("空文本,跳过推送到 LightRAG(file_source=%s)", file_source)
|
||||
return None
|
||||
payload = {"text": text, "file_source": file_source}
|
||||
async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client:
|
||||
resp = await client.post(f"{self.base_url}/documents/text", json=payload)
|
||||
resp.raise_for_status()
|
||||
logger.info("LightRAG ingest 完成,返回 %s", resp.status_code)
|
||||
data = resp.json()
|
||||
track_id = data.get("track_id") if isinstance(data, dict) else None
|
||||
logger.info("LightRAG 入库请求已提交(file_source=%s, track_id=%s)", file_source, track_id)
|
||||
return track_id
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
from functools import lru_cache
|
||||
from pydantic import BaseSettings, Field
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
|
||||
app_name: str = Field("AIMNOTE RAG Gateway", env="RAG_GATEWAY_APP_NAME")
|
||||
api_prefix: str = "/rag"
|
||||
environment: str = Field("development", env="RAG_GATEWAY_ENV")
|
||||
lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL")
|
||||
lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY")
|
||||
supabase_rest_url: str = Field("http://127.0.0.1:54321/rest/v1", env="SUPABASE_REST_URL")
|
||||
supabase_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY")
|
||||
embedding_base_url: str = Field("http://127.0.0.1:11434/v1", env="EMBEDDING_BASE_URL")
|
||||
embedding_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_gateway_settings() -> Settings:
|
||||
|
||||
@@ -1,37 +1,54 @@
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_gateway_settings
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.append(str(ROOT / "siyuan-rag-llm-main"))
|
||||
from utils.rag.rag_knowledge_base import HybridRAGKnowledgeBase # type: ignore # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RagService:
|
||||
"""包装 HybridRAGKnowledgeBase,供路由调用"""
|
||||
"""RAG 网关:直接调用 LightRAG 独立服务。
|
||||
|
||||
说明:历史版本依赖 `siyuan-rag-llm-main`(回档后目录缺失),导致服务无法启动。
|
||||
当前实现改为通过 HTTP 调用 LightRAG 的官方 API(/query、/query/data 等)。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_gateway_settings()
|
||||
self.kb = HybridRAGKnowledgeBase(
|
||||
lightrag_url=settings.lightrag_url,
|
||||
supabase_rest_url=settings.supabase_rest_url,
|
||||
supabase_service_key=settings.supabase_key,
|
||||
embedding_base_url=settings.embedding_base_url,
|
||||
embedding_model=settings.embedding_model,
|
||||
)
|
||||
self.base_url = settings.lightrag_url.rstrip("/")
|
||||
self.api_key = settings.lightrag_api_key
|
||||
|
||||
def _headers(self) -> dict:
|
||||
headers: dict = {}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
return headers
|
||||
|
||||
async def query(self, query: str, top_k: int = 8) -> Dict[str, Any]:
|
||||
return await self.kb.query(query=query, top_k=top_k)
|
||||
"""调用 LightRAG /query,返回生成回答与引用。"""
|
||||
payload = {"query": query, "top_k": top_k}
|
||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=60.0, headers=self._headers()) as client:
|
||||
resp = await client.post("/query", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
return {"response": str(data), "references": []}
|
||||
return data
|
||||
|
||||
async def graph(self, query: str) -> Dict[str, Any]:
|
||||
result = await self.kb.lightrag.graph(query=query)
|
||||
await self.kb.sync_graph_metadata(result)
|
||||
return result
|
||||
async def graph(self, query: str, top_k: int = 60) -> Dict[str, Any]:
|
||||
"""调用 LightRAG /query/data 获取结构化检索结果(实体/关系/分块/引用)。"""
|
||||
payload = {"query": query, "top_k": top_k, "mode": "mix"}
|
||||
async with httpx.AsyncClient(base_url=self.base_url, timeout=60.0, headers=self._headers()) as client:
|
||||
resp = await client.post("/query/data", json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {"status": "failure", "message": "invalid response", "data": data}
|
||||
|
||||
async def path(self, source: str, target: str) -> Dict[str, Any]:
|
||||
return await self.kb.lightrag.path(source=source, target=target)
|
||||
"""路径查询:当前 LightRAG API 未提供等价 /path 接口,先返回明确错误。"""
|
||||
message = "当前 LightRAG API 未提供 /path 等价接口(rag_gateway:path 暂不可用)"
|
||||
logger.warning("%s: source=%s target=%s", message, source, target)
|
||||
return {"status": "not_supported", "message": message, "source": source, "target": target}
|
||||
|
||||
|
||||
@@ -44,4 +44,4 @@ next-env.d.ts
|
||||
**/test/
|
||||
pw-tests/**
|
||||
pw-tests/
|
||||
wolai-frontend\public\documents
|
||||
wolai-frontend/public/documents/**
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import "simple-mind-map/dist/simpleMindMap.esm.css";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
@@ -230,6 +238,9 @@ const MindmapBlockView = ({
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
|
||||
const mindmapReadyRef = useRef(false);
|
||||
const mindmapRef = useRef<MindMapInstance | null>(null);
|
||||
const hasLocalEditsRef = useRef(false);
|
||||
const applyingRemoteRef = useRef(false);
|
||||
const [canBack, setCanBack] = useState(false);
|
||||
const [canForward, setCanForward] = useState(false);
|
||||
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
|
||||
@@ -238,6 +249,12 @@ const MindmapBlockView = ({
|
||||
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
|
||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||
const [noteContent, setNoteContent] = useState("");
|
||||
const [localFullscreen, setLocalFullscreen] = useState(false);
|
||||
const effectiveFullscreen = fullscreen || localFullscreen;
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const hotkeyScopeRef = useRef(false);
|
||||
const recentNodeDblclickRef = useRef(false);
|
||||
const [fullscreenApiActive, setFullscreenApiActive] = useState(false);
|
||||
|
||||
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
||||
const instance = mm ?? mindmap;
|
||||
@@ -254,6 +271,11 @@ const MindmapBlockView = ({
|
||||
[block.props.docId, currentDocumentId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
hasLocalEditsRef.current = false;
|
||||
applyingRemoteRef.current = false;
|
||||
}, [docId]);
|
||||
|
||||
const autosaveKey = useMemo(
|
||||
() => `${STORAGE_PREFIX}${docId || block.id}`,
|
||||
[block.id, docId],
|
||||
@@ -283,10 +305,19 @@ const MindmapBlockView = ({
|
||||
const payload = await resp.json().catch(() => null);
|
||||
const data = payload?.data;
|
||||
if (!data || cancelled) return;
|
||||
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
|
||||
if (hasLocalEditsRef.current) return;
|
||||
initialDataRef.current = data;
|
||||
if (mindmap) {
|
||||
applyingRemoteRef.current = true;
|
||||
try {
|
||||
mindmap.setData(data);
|
||||
mindmap.command.clearHistory();
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
applyingRemoteRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("加载本地/远端思维导图失败", error);
|
||||
@@ -297,6 +328,220 @@ const MindmapBlockView = ({
|
||||
};
|
||||
}, [docId, mindmap]);
|
||||
|
||||
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
|
||||
useEffect(() => {
|
||||
const onPointerDownCapture = (e: Event) => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const target = e.target as Node | null;
|
||||
hotkeyScopeRef.current = !!(target && wrapper.contains(target));
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDownCapture, true);
|
||||
document.addEventListener("mousedown", onPointerDownCapture, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
||||
document.removeEventListener("mousedown", onPointerDownCapture, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 标记“节点双击”事件,避免和“画布双击进入全屏”产生冲突
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
const mark = () => {
|
||||
recentNodeDblclickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
recentNodeDblclickRef.current = false;
|
||||
}, 0);
|
||||
};
|
||||
mindmap.on?.("node_dblclick", mark);
|
||||
return () => {
|
||||
mindmap.off?.("node_dblclick", mark);
|
||||
};
|
||||
}, [mindmap]);
|
||||
|
||||
const exitLocalFullscreen = useCallback(() => {
|
||||
setActiveSidebar(null);
|
||||
if (fullscreen) return;
|
||||
if (typeof document === "undefined") {
|
||||
setLocalFullscreen(false);
|
||||
return;
|
||||
}
|
||||
if (document.fullscreenElement) {
|
||||
document
|
||||
.exitFullscreen()
|
||||
.catch(() => {
|
||||
// ignore
|
||||
})
|
||||
.finally(() => setLocalFullscreen(false));
|
||||
return;
|
||||
}
|
||||
setLocalFullscreen(false);
|
||||
}, [fullscreen]);
|
||||
|
||||
const enterLocalFullscreen = useCallback(() => {
|
||||
if (fullscreen) return;
|
||||
setLocalFullscreen(true);
|
||||
setActiveSidebar(null);
|
||||
if (typeof document === "undefined") return;
|
||||
if (!document.fullscreenEnabled) return;
|
||||
if (document.fullscreenElement) return;
|
||||
// 必须在“用户手势回调”中同步调用,避免 Fullscreen API 被浏览器拒绝
|
||||
try {
|
||||
const target = document.documentElement as any;
|
||||
const p = target?.requestFullscreen?.();
|
||||
if (p && typeof p.catch === "function") {
|
||||
p.catch(() => {
|
||||
// ignore:失败则保持 Portal 伪全屏
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore:失败则保持 Portal 伪全屏
|
||||
}
|
||||
}, [fullscreen]);
|
||||
|
||||
// 沉浸式全屏:锁定页面滚动、支持 ESC 退出(仅对内嵌全屏生效)
|
||||
useEffect(() => {
|
||||
if (!localFullscreen) return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
// 进入全屏后尽量把焦点放到思维导图容器上,保证快捷键立即生效
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (fullscreen) return; // 外部传入的 fullscreen 不在这里处理
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
exitLocalFullscreen();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, true);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [localFullscreen, fullscreen, exitLocalFullscreen]);
|
||||
|
||||
// “真全屏”:使用 Fullscreen API 隐藏浏览器/桌面窗口的外层 UI(Electron/Web 都可用)
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const onFsChange = () => {
|
||||
const active = Boolean(document.fullscreenElement);
|
||||
setFullscreenApiActive(active);
|
||||
// 用户按 ESC 退出浏览器全屏时,同步退出沉浸式全屏界面
|
||||
if (!active && localFullscreen) {
|
||||
setLocalFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", onFsChange);
|
||||
};
|
||||
}, [localFullscreen]);
|
||||
|
||||
// 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键)
|
||||
useLayoutEffect(() => {
|
||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||
if (!hotkeyScopeRef.current) return;
|
||||
const mm = mindmapRef.current;
|
||||
if (!mm || !mindmapReadyRef.current) return;
|
||||
|
||||
const target = e.target as HTMLElement | null;
|
||||
const editClasses = (mm as any)?.editNodeClassList as string[] | undefined;
|
||||
const wrapper = wrapperRef.current;
|
||||
const isInWrapper = Boolean(wrapper && target && wrapper.contains(target));
|
||||
if (target && isInWrapper) {
|
||||
const tag = target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
// contenteditable 输入中不拦截:避免破坏节点文本编辑/粘贴
|
||||
if ((target as any).isContentEditable) return;
|
||||
const editableAncestor = target.closest?.('[contenteditable="true"]');
|
||||
if (editableAncestor) return;
|
||||
if (editClasses && target.classList) {
|
||||
for (const cls of editClasses) {
|
||||
if (cls && target.classList.contains(cls)) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isMod = e.ctrlKey || e.metaKey;
|
||||
const key = e.key;
|
||||
const lower = key.toLowerCase();
|
||||
const stop = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const pickTargetNode = (inst: MindMapInstance) => {
|
||||
const renderer = inst.renderer as any;
|
||||
const active: any[] = renderer?.activeNodeList ?? [];
|
||||
const last: any[] = renderer?.lastActiveNodeList ?? [];
|
||||
const root = renderer?.root ?? renderer?.renderTree?._node ?? null;
|
||||
return (active && active[0]) || (last && last[0]) || root || null;
|
||||
};
|
||||
|
||||
if (key === "Enter" && !e.shiftKey && !e.altKey && !isMod) {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
const node = pickTargetNode(inst);
|
||||
if (!node) return;
|
||||
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
||||
inst.execCommand?.("INSERT_NODE", false, [node]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Tab" && !e.shiftKey && !e.altKey && !isMod) {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
const node = pickTargetNode(inst);
|
||||
if (!node) return;
|
||||
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
||||
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMod && lower === "c") {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
const renderer = inst.renderer as any;
|
||||
if (typeof renderer?.copy === "function") renderer.copy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMod && lower === "v") {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
const renderer = inst.renderer as any;
|
||||
const copyData = renderer?.beingCopyData ?? null;
|
||||
if (copyData) {
|
||||
inst.execCommand?.("PASTE_NODE", copyData);
|
||||
return;
|
||||
}
|
||||
if (typeof renderer?.paste === "function") renderer.paste();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDownCapture, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistData = useCallback(
|
||||
(data: unknown) => {
|
||||
if (!editor) return;
|
||||
@@ -328,7 +573,7 @@ const MindmapBlockView = ({
|
||||
|
||||
const debouncedPersist = useDebouncedCallback((data: unknown) => {
|
||||
persistData(data);
|
||||
}, 500);
|
||||
}, 800);
|
||||
|
||||
const initialSyncDone = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -559,6 +804,7 @@ const MindmapBlockView = ({
|
||||
]),
|
||||
}) as MindMapInstance;
|
||||
createdInstance = instance;
|
||||
mindmapRef.current = instance;
|
||||
// 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用
|
||||
//(render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。
|
||||
mindmapReadyRef.current = true;
|
||||
@@ -629,7 +875,6 @@ const MindmapBlockView = ({
|
||||
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root);
|
||||
}
|
||||
mindmapReadyRef.current = true;
|
||||
console.log("[mindmap] render_end activeLen", renderer?.activeNodeList?.length ?? 0);
|
||||
centerAndFit();
|
||||
};
|
||||
|
||||
@@ -673,6 +918,9 @@ const MindmapBlockView = ({
|
||||
instance.on?.("painter_start", () => setPainterMode(true));
|
||||
instance.on?.("painter_end", () => setPainterMode(false));
|
||||
instance.on?.("data_change", (data: unknown) => {
|
||||
if (!applyingRemoteRef.current) {
|
||||
hasLocalEditsRef.current = true;
|
||||
}
|
||||
debouncedPersist(data);
|
||||
});
|
||||
const renderer = instance.renderer;
|
||||
@@ -682,7 +930,6 @@ const MindmapBlockView = ({
|
||||
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
|
||||
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(rootNode, true);
|
||||
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(rootNode);
|
||||
console.log("[mindmap] init select root", instance.renderer?.activeNodeList?.length ?? 0);
|
||||
setActiveNodes([rootNode]);
|
||||
}
|
||||
// 若首次渲染时 root 仍未就绪,设置兜底延迟激活
|
||||
@@ -692,7 +939,6 @@ const MindmapBlockView = ({
|
||||
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
|
||||
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(root, true);
|
||||
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(root);
|
||||
console.log("[mindmap] delayed select root", instance.renderer?.activeNodeList?.length ?? 0);
|
||||
setActiveNodes([root]);
|
||||
}, 120);
|
||||
|
||||
@@ -704,6 +950,32 @@ const MindmapBlockView = ({
|
||||
return () => {
|
||||
destroyed = true;
|
||||
try {
|
||||
// 切换“内嵌/全屏”会导致实例重建:这里尽量在销毁前同步一次数据,避免丢失最后一次编辑
|
||||
if (createdInstance && typeof window !== "undefined") {
|
||||
const data =
|
||||
createdInstance.getData?.(true) ?? createdInstance.getData?.();
|
||||
if (data) {
|
||||
try {
|
||||
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
editor?.updateBlock(block, { props: { ...block.props, data } });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (docId) {
|
||||
fetch(`/api/mindmap/${docId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data }),
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
createdInstance?.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -716,10 +988,11 @@ const MindmapBlockView = ({
|
||||
}
|
||||
}
|
||||
setMindmap(null);
|
||||
mindmapRef.current = null;
|
||||
mindmapReadyRef.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [block.id]);
|
||||
}, [block.id, effectiveFullscreen]);
|
||||
|
||||
const getInstanceCandidate = () =>
|
||||
mindmap ??
|
||||
@@ -1463,16 +1736,36 @@ const MindmapBlockView = ({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (fullscreen) {
|
||||
return (
|
||||
<div className="relative h-screen w-screen overflow-hidden bg-white">
|
||||
<div className="fixed left-1/2 top-4 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
|
||||
if (effectiveFullscreen) {
|
||||
const fullscreenView = (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
data-testid="mindmap-fullscreen"
|
||||
tabIndex={0}
|
||||
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
|
||||
>
|
||||
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
思维导图编辑{fullscreenApiActive ? "(全屏)" : ""}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="退出全屏"
|
||||
className="rounded p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
onClick={exitLocalFullscreen}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="fixed left-1/2 top-14 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
|
||||
<MindmapToolbar {...toolbarProps} />
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<div className="relative h-full w-full pt-12">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
data-testid="mindmap-canvas"
|
||||
contentEditable={false}
|
||||
/>
|
||||
|
||||
@@ -1489,7 +1782,15 @@ const MindmapBlockView = ({
|
||||
/>
|
||||
<MindmapNavigator
|
||||
mindmap={mindmap}
|
||||
fullscreen={fullscreen}
|
||||
fullscreen={effectiveFullscreen}
|
||||
toggleFullscreen={
|
||||
fullscreen
|
||||
? undefined
|
||||
: () => {
|
||||
if (localFullscreen) exitLocalFullscreen();
|
||||
else enterLocalFullscreen();
|
||||
}
|
||||
}
|
||||
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
|
||||
miniMapOpen={showMiniMap}
|
||||
/>
|
||||
@@ -1503,19 +1804,53 @@ const MindmapBlockView = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(fullscreenView, document.body);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm">
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
data-testid="mindmap-embed"
|
||||
tabIndex={0}
|
||||
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3">
|
||||
<div className="w-full">
|
||||
<MindmapToolbar {...toolbarProps} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-[520px] w-full overflow-hidden bg-white">
|
||||
<div
|
||||
data-testid="mindmap-stage"
|
||||
className="relative h-[520px] w-full overflow-hidden bg-white"
|
||||
onPointerDown={() => {
|
||||
hotkeyScopeRef.current = true;
|
||||
// 点击后把焦点落到容器上,避免快捷键被编辑器抢走
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
hotkeyScopeRef.current = true;
|
||||
// 兼容:某些环境下 pointer 事件不触发
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (recentNodeDblclickRef.current) return;
|
||||
enterLocalFullscreen();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
data-testid="mindmap-canvas"
|
||||
contentEditable={false}
|
||||
/>
|
||||
|
||||
@@ -1532,7 +1867,15 @@ const MindmapBlockView = ({
|
||||
/>
|
||||
<MindmapNavigator
|
||||
mindmap={mindmap}
|
||||
fullscreen={fullscreen}
|
||||
fullscreen={effectiveFullscreen}
|
||||
toggleFullscreen={
|
||||
fullscreen
|
||||
? undefined
|
||||
: () => {
|
||||
if (localFullscreen) exitLocalFullscreen();
|
||||
else enterLocalFullscreen();
|
||||
}
|
||||
}
|
||||
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
|
||||
miniMapOpen={showMiniMap}
|
||||
/>
|
||||
|
||||
@@ -15,16 +15,35 @@ export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => {
|
||||
className={`absolute top-1/2 z-30 flex -translate-y-1/2 transition-all duration-300 ${
|
||||
activeSidebar ? "right-[300px]" : "right-0"
|
||||
}`}
|
||||
contentEditable={false}
|
||||
onPointerDownCapture={(e) => e.stopPropagation()}
|
||||
onMouseDownCapture={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="absolute -left-4 top-1/2 flex h-12 w-4 -translate-y-1/2 cursor-pointer items-center justify-center rounded-l-md bg-blue-500 text-white shadow-md hover:w-6 hover:-left-6 transition-all"
|
||||
onClick={() => setShow(!show)}
|
||||
style={{ display: show ? "flex" : "none" }}
|
||||
data-testid="mindmap-sidebar-collapse-handle"
|
||||
className="absolute -left-4 top-1/2 flex h-12 w-4 -translate-y-1/2 cursor-pointer items-center justify-center rounded-l-md bg-blue-500 text-white shadow-md transition-all hover:-left-6 hover:w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShow((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) {
|
||||
// 避免在 setShow 的 updater 内触发父组件 setState,导致 React 警告
|
||||
queueMicrotask(() => onSelect(null));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 transition-transform ${show ? "" : "rotate-180"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col overflow-hidden rounded-l-lg border border-gray-200 bg-white shadow-lg">
|
||||
{show ? (
|
||||
<div
|
||||
data-testid="mindmap-sidebar-trigger-panel"
|
||||
className="flex flex-col overflow-hidden rounded-l-lg border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{sidebarTriggers
|
||||
.filter((item) => item.visible !== false)
|
||||
.map((item) => {
|
||||
@@ -33,17 +52,27 @@ export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => {
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
onClick={() => onSelect(isActive ? null : target)}
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
onMouseDown={(ev) => ev.stopPropagation()}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onSelect(isActive ? null : target);
|
||||
}}
|
||||
className={`flex h-16 w-16 flex-col items-center justify-center gap-1 border-b border-gray-100 p-2 text-gray-600 transition-colors hover:bg-gray-50 last:border-0 ${
|
||||
isActive ? "bg-blue-50 text-blue-600 font-medium" : ""
|
||||
isActive ? "bg-blue-50 font-medium text-blue-600" : ""
|
||||
}`}
|
||||
>
|
||||
<i className={`iconfont ${item.iconClass} text-[18px] leading-none`} />
|
||||
<span className="text-xs whitespace-nowrap">{item.label}</span>
|
||||
<i
|
||||
className={`iconfont ${item.iconClass} text-[18px] leading-none`}
|
||||
/>
|
||||
<span className="whitespace-nowrap text-xs">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,14 +52,16 @@ export interface OnlineTableBlockProps {
|
||||
// Global declarations for Luckysheet (minimal definition)
|
||||
declare global {
|
||||
interface Window {
|
||||
luckysheet: {
|
||||
luckysheet?: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
create: (options: any) => void;
|
||||
destroy: (id: string) => void;
|
||||
create?: (options: any) => void;
|
||||
destroy?: (id: string) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getluckysheetfile: () => any;
|
||||
getluckysheetfile?: () => any;
|
||||
enterEditMode?: () => void;
|
||||
getluckysheet_select_save?: () => unknown;
|
||||
// 实际功能会通过动态加载的脚本注入
|
||||
// 最小化声明以通过 TypeScript 检查
|
||||
// 最小化声明以通过 TypeScript 检查(必须允许 undefined)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user