mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-10 15:12:40 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d97b2968 | ||
|
|
35abd080be | ||
|
|
2fa93a1eeb | ||
|
|
ff7eb13187 | ||
|
|
ed9090c3d0 | ||
|
|
d430254868 | ||
|
|
a8870f80da | ||
|
|
14ef2a4ccc | ||
|
|
dd41941b04 | ||
|
|
01a259bae0 | ||
|
|
ef5ef2730c | ||
|
|
8b8772b064 | ||
|
|
5393a973eb | ||
|
|
cc1f130099 | ||
|
|
c8b3817805 | ||
|
|
b1ea181f96 | ||
|
|
078709b871 |
46
README.md
46
README.md
@@ -8,16 +8,17 @@
|
||||
|
||||
**A highly extensible private cloud storage solution for individuals and teams, featuring AI-powered semantic search.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
<blockquote>
|
||||
<em><strong>The ocean of data is boundless, let the eye of insight guide the voyage, yet its intricate connections lie deep, not fully discernible from the surface.</strong></em>
|
||||
</blockquote>
|
||||
<img src="https://foxel.cc/image/ad-min.png" alt="UI Screenshot">
|
||||
<img src="https://foxel.cc/image/ad-min-en.png" alt="UI Screenshot">
|
||||
</div>
|
||||
|
||||
## 👀 Online Demo
|
||||
@@ -39,36 +40,37 @@
|
||||
|
||||
Using Docker Compose is the most recommended way to start Foxel.
|
||||
|
||||
1. **Create Data Directories**:
|
||||
Create a `data` folder for persistent data:
|
||||
1. **Create Data Directories**
|
||||
|
||||
```bash
|
||||
mkdir -p data/db
|
||||
mkdir -p data/mount
|
||||
chmod 777 data/db data/mount
|
||||
```
|
||||
Create a `data` folder for persistent data:
|
||||
|
||||
2. **Download Docker Compose File**:
|
||||
```bash
|
||||
mkdir -p data/db
|
||||
mkdir -p data/mount
|
||||
chmod 777 data/db data/mount
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
|
||||
```
|
||||
2. **Download Docker Compose File**
|
||||
|
||||
After downloading, it is **strongly recommended** to modify the environment variables in the `compose.yaml` file to ensure security:
|
||||
```bash
|
||||
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
|
||||
```
|
||||
|
||||
- Modify `SECRET_KEY` and `TEMP_LINK_SECRET_KEY`: Replace the default keys with randomly generated strong keys.
|
||||
After downloading, it is **strongly recommended** to modify the environment variables in the `compose.yaml` file to ensure security:
|
||||
|
||||
3. **Start the Services**:
|
||||
- Modify `SECRET_KEY` and `TEMP_LINK_SECRET_KEY`: Replace the default keys with randomly generated strong keys.
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
3. **Start the Services**
|
||||
|
||||
4. **Access the Application**:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Once the services are running, open the page in your browser.
|
||||
4. **Access the Application**
|
||||
|
||||
> On the first launch, please follow the setup guide to initialize the administrator account.
|
||||
Once the services are running, open the page in your browser.
|
||||
|
||||
> On the first launch, please follow the setup guide to initialize the administrator account.
|
||||
|
||||
## 🤝 How to Contribute
|
||||
|
||||
|
||||
47
README_zh.md
47
README_zh.md
@@ -8,17 +8,17 @@
|
||||
|
||||
**一个面向个人和团队的、高度可扩展的私有云盘解决方案,支持 AI 语义搜索。**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
<blockquote>
|
||||
<em><strong>数据之洋浩瀚无涯,当以洞察之目引航,然其脉络深隐,非表象所能尽窥。</strong></em><br>
|
||||
<em><strong>The ocean of data is boundless, let the eye of insight guide the voyage, yet its intricate connections lie deep, not fully discernible from the surface.</strong></em>
|
||||
</blockquote>
|
||||
<img src="https://foxel.cc/image/ad-min.png" alt="UI Screenshot">
|
||||
<img src="https://foxel.cc/image/ad-min-zh.png" alt="UI Screenshot">
|
||||
</div>
|
||||
|
||||
## 👀 在线体验
|
||||
@@ -40,36 +40,37 @@
|
||||
|
||||
使用 Docker Compose 是启动 Foxel 最推荐的方式。
|
||||
|
||||
1. **创建数据目录**:
|
||||
新建 `data` 文件夹用于持久化数据:
|
||||
1. **创建数据目录**
|
||||
|
||||
```bash
|
||||
mkdir -p data/db
|
||||
mkdir -p data/mount
|
||||
chmod 777 data/db data/mount
|
||||
```
|
||||
新建 `data` 文件夹用于持久化数据:
|
||||
|
||||
2. **下载 Docker Compose 文件**:
|
||||
```bash
|
||||
mkdir -p data/db
|
||||
mkdir -p data/mount
|
||||
chmod 777 data/db data/mount
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
|
||||
```
|
||||
2. **下载 Docker Compose 文件**
|
||||
|
||||
下载完成后,**强烈建议**修改 `compose.yaml` 文件中的环境变量以确保安全:
|
||||
```bash
|
||||
curl -L -O https://github.com/DrizzleTime/Foxel/raw/main/compose.yaml
|
||||
```
|
||||
|
||||
- 修改 `SECRET_KEY` 和 `TEMP_LINK_SECRET_KEY`:将默认的密钥替换为随机生成的强密钥
|
||||
下载完成后,**强烈建议**修改 `compose.yaml` 文件中的环境变量以确保安全:
|
||||
|
||||
3. **启动服务**:
|
||||
- 修改 `SECRET_KEY` 和 `TEMP_LINK_SECRET_KEY`:将默认的密钥替换为随机生成的强密钥
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
3. **启动服务**
|
||||
|
||||
4. **访问应用**:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
服务启动后,在浏览器中打开页面。
|
||||
4. **访问应用**
|
||||
|
||||
> 首次启动,请根据引导页面完成管理员账号的初始化设置。
|
||||
服务启动后,在浏览器中打开页面。
|
||||
|
||||
> 首次启动,请根据引导页面完成管理员账号的初始化设置。
|
||||
|
||||
## 🤝 如何贡献
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ from domain.audit import router as audit
|
||||
|
||||
def include_routers(app: FastAPI):
|
||||
app.include_router(adapters.router)
|
||||
app.include_router(virtual_fs.router)
|
||||
app.include_router(search_api.router)
|
||||
app.include_router(virtual_fs.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(processors.router)
|
||||
|
||||
@@ -5,9 +5,10 @@ services:
|
||||
container_name: foxel
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8088:80"
|
||||
- "${FOXEL_HOST_PORT:-8088}:${FOXEL_PORT:-80}"
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- FOXEL_PORT=${FOXEL_PORT:-80}
|
||||
- SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||
- TEMP_LINK_SECRET_KEY=EnsRhL9NFPxgFVc+7t96/y70DIOR+9SpntcIqQa90TU=
|
||||
volumes:
|
||||
|
||||
411
domain/adapters/providers/foxel.py
Normal file
411
domain/adapters/providers/foxel.py
Normal file
@@ -0,0 +1,411 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator, Dict, List, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from models import StorageAdapter
|
||||
|
||||
|
||||
def _normalize_fs_path(path: str) -> str:
|
||||
path = (path or "").replace("\\", "/").strip()
|
||||
if not path or path == "/":
|
||||
return "/"
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
path = re.sub(r"/{2,}", "/", path)
|
||||
if path != "/" and path.endswith("/"):
|
||||
path = path.rstrip("/")
|
||||
return path or "/"
|
||||
|
||||
|
||||
def _join_fs_path(base: str, rel: str | None) -> str:
|
||||
base = _normalize_fs_path(base)
|
||||
rel_norm = (rel or "").replace("\\", "/").strip().lstrip("/")
|
||||
if not rel_norm:
|
||||
return base
|
||||
if base == "/":
|
||||
return "/" + rel_norm
|
||||
return f"{base}/{rel_norm}"
|
||||
|
||||
|
||||
def _unwrap_success(payload: Any, *, context: str) -> Any:
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
if "data" not in payload:
|
||||
return payload
|
||||
code = payload.get("code")
|
||||
if code not in (None, 0, 200):
|
||||
msg = payload.get("msg") or payload.get("message") or ""
|
||||
raise HTTPException(502, detail=f"Foxel 上游错误({context}): {msg}")
|
||||
return payload.get("data")
|
||||
|
||||
|
||||
class FoxelAdapter:
|
||||
def __init__(self, record: StorageAdapter):
|
||||
self.record = record
|
||||
cfg = record.config or {}
|
||||
|
||||
self.base_url: str = str(cfg.get("base_url", "")).rstrip("/")
|
||||
if not self.base_url.startswith("http"):
|
||||
raise ValueError("foxel requires base_url http/https")
|
||||
|
||||
self.username: str = str(cfg.get("username") or "")
|
||||
self.password: str = str(cfg.get("password") or "")
|
||||
if not self.username or not self.password:
|
||||
raise ValueError("foxel requires username and password")
|
||||
|
||||
self.timeout: float = float(cfg.get("timeout", 15))
|
||||
self.root_path: str = _normalize_fs_path(str(cfg.get("root") or "/"))
|
||||
|
||||
self._token: str | None = None
|
||||
self._login_lock = asyncio.Lock()
|
||||
|
||||
def get_effective_root(self, sub_path: str | None) -> str:
|
||||
base = _normalize_fs_path(self.root_path)
|
||||
if sub_path:
|
||||
return _join_fs_path(base, sub_path)
|
||||
return base
|
||||
|
||||
async def _login(self) -> str:
|
||||
url = self.base_url + "/api/auth/login"
|
||||
body = {"username": self.username, "password": self.password}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.post(url, data=body)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(502, detail="Foxel 登录响应异常")
|
||||
token = payload.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(502, detail="Foxel 登录失败: 缺少 access_token")
|
||||
return str(token)
|
||||
|
||||
async def _ensure_token(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
async with self._login_lock:
|
||||
if self._token:
|
||||
return self._token
|
||||
self._token = await self._login()
|
||||
return self._token
|
||||
|
||||
async def _request_json(self, method: str, path: str, *, params: dict | None = None, json: Any = None) -> Any:
|
||||
url = self.base_url + path
|
||||
for attempt in range(2):
|
||||
token = await self._ensure_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.request(method, url, headers=headers, params=params, json=json)
|
||||
if resp.status_code == 401 and attempt == 0:
|
||||
self._token = None
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
raise HTTPException(502, detail="Foxel 上游请求失败")
|
||||
|
||||
@staticmethod
|
||||
def _encode_path(full_path: str) -> str:
|
||||
return quote(full_path.lstrip("/"), safe="/")
|
||||
|
||||
def _browse_path(self, full_path: str) -> str:
|
||||
full_path = _normalize_fs_path(full_path)
|
||||
if full_path == "/":
|
||||
return "/api/fs/"
|
||||
return "/api/fs/" + self._encode_path(full_path)
|
||||
|
||||
def _stat_path(self, full_path: str) -> str:
|
||||
full_path = _normalize_fs_path(full_path)
|
||||
if full_path == "/":
|
||||
return "/api/fs/stat/"
|
||||
return "/api/fs/stat/" + self._encode_path(full_path)
|
||||
|
||||
def _file_path(self, full_path: str) -> str:
|
||||
full_path = _normalize_fs_path(full_path)
|
||||
if full_path == "/":
|
||||
return "/api/fs/file/"
|
||||
return "/api/fs/file/" + self._encode_path(full_path)
|
||||
|
||||
def _stream_path(self, full_path: str) -> str:
|
||||
full_path = _normalize_fs_path(full_path)
|
||||
if full_path == "/":
|
||||
return "/api/fs/stream/"
|
||||
return "/api/fs/stream/" + self._encode_path(full_path)
|
||||
|
||||
async def list_dir(
|
||||
self,
|
||||
root: str,
|
||||
rel: str,
|
||||
page_num: int = 1,
|
||||
page_size: int = 50,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
) -> Tuple[List[Dict], int]:
|
||||
rel = (rel or "").strip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
self._browse_path(full_path),
|
||||
params={
|
||||
"page": page_num,
|
||||
"page_size": page_size,
|
||||
"sort_by": sort_by,
|
||||
"sort_order": sort_order,
|
||||
},
|
||||
)
|
||||
data = _unwrap_success(payload, context="list_dir")
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(502, detail="Foxel 浏览响应异常")
|
||||
entries = data.get("entries") or []
|
||||
pagination = data.get("pagination") or {}
|
||||
total = pagination.get("total")
|
||||
try:
|
||||
total_int = int(total) if total is not None else len(entries)
|
||||
except Exception:
|
||||
total_int = len(entries)
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
return entries, total_int
|
||||
|
||||
async def stat_file(self, root: str, rel: str):
|
||||
rel = (rel or "").strip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
payload = await self._request_json("GET", self._stat_path(full_path))
|
||||
data = _unwrap_success(payload, context="stat_file")
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(502, detail="Foxel stat 响应异常")
|
||||
return data
|
||||
|
||||
async def exists(self, root: str, rel: str) -> bool:
|
||||
rel = (rel or "").strip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
url = self.base_url + self._stat_path(full_path)
|
||||
for attempt in range(2):
|
||||
token = await self._ensure_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
if resp.status_code == 401 and attempt == 0:
|
||||
self._token = None
|
||||
continue
|
||||
return resp.status_code == 200
|
||||
return False
|
||||
|
||||
async def read_file(self, root: str, rel: str) -> bytes:
|
||||
rel = (rel or "").lstrip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
url = self.base_url + self._file_path(full_path)
|
||||
for attempt in range(2):
|
||||
token = await self._ensure_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
if resp.status_code == 401 and attempt == 0:
|
||||
self._token = None
|
||||
continue
|
||||
if resp.status_code == 404:
|
||||
raise FileNotFoundError(rel)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
raise HTTPException(502, detail="Foxel 读取失败")
|
||||
|
||||
async def _upload_file_path(self, full_path: str, file_path: Path) -> None:
|
||||
url = self.base_url + self._file_path(full_path)
|
||||
filename = Path(full_path).name or file_path.name
|
||||
for attempt in range(2):
|
||||
token = await self._ensure_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with file_path.open("rb") as f:
|
||||
files = {"file": (filename, f, "application/octet-stream")}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.post(url, headers=headers, files=files)
|
||||
if resp.status_code == 401 and attempt == 0:
|
||||
self._token = None
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return
|
||||
raise HTTPException(502, detail="Foxel 上传失败")
|
||||
|
||||
async def write_file(self, root: str, rel: str, data: bytes):
|
||||
rel = (rel or "").lstrip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
url = self.base_url + self._file_path(full_path)
|
||||
filename = Path(rel).name or "file"
|
||||
for attempt in range(2):
|
||||
token = await self._ensure_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
files = {"file": (filename, data, "application/octet-stream")}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.post(url, headers=headers, files=files)
|
||||
if resp.status_code == 401 and attempt == 0:
|
||||
self._token = None
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
raise HTTPException(502, detail="Foxel 写入失败")
|
||||
|
||||
async def write_file_stream(self, root: str, rel: str, data_iter: AsyncIterator[bytes]):
|
||||
rel = (rel or "").lstrip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
suffix = Path(rel).suffix
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf:
|
||||
tmp_path = Path(tf.name)
|
||||
|
||||
size = 0
|
||||
try:
|
||||
with tmp_path.open("wb") as f:
|
||||
async for chunk in data_iter:
|
||||
if not chunk:
|
||||
continue
|
||||
f.write(chunk)
|
||||
size += len(chunk)
|
||||
await self._upload_file_path(full_path, tmp_path)
|
||||
return size
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def mkdir(self, root: str, rel: str):
|
||||
rel = (rel or "").strip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
payload = await self._request_json("POST", "/api/fs/mkdir", json={"path": full_path})
|
||||
_unwrap_success(payload, context="mkdir")
|
||||
return True
|
||||
|
||||
async def delete(self, root: str, rel: str):
|
||||
rel = (rel or "").strip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
url = self.base_url + self._browse_path(full_path)
|
||||
for attempt in range(2):
|
||||
token = await self._ensure_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
||||
resp = await client.delete(url, headers=headers)
|
||||
if resp.status_code == 401 and attempt == 0:
|
||||
self._token = None
|
||||
continue
|
||||
if resp.status_code == 404:
|
||||
return
|
||||
resp.raise_for_status()
|
||||
return
|
||||
raise HTTPException(502, detail="Foxel 删除失败")
|
||||
|
||||
async def move(self, root: str, src_rel: str, dst_rel: str):
|
||||
src_path = _join_fs_path(root, (src_rel or "").lstrip("/"))
|
||||
dst_path = _join_fs_path(root, (dst_rel or "").lstrip("/"))
|
||||
payload = await self._request_json("POST", "/api/fs/move", json={"src": src_path, "dst": dst_path})
|
||||
_unwrap_success(payload, context="move")
|
||||
return True
|
||||
|
||||
async def rename(self, root: str, src_rel: str, dst_rel: str):
|
||||
src_path = _join_fs_path(root, (src_rel or "").lstrip("/"))
|
||||
dst_path = _join_fs_path(root, (dst_rel or "").lstrip("/"))
|
||||
payload = await self._request_json("POST", "/api/fs/rename", json={"src": src_path, "dst": dst_path})
|
||||
_unwrap_success(payload, context="rename")
|
||||
return True
|
||||
|
||||
async def copy(self, root: str, src_rel: str, dst_rel: str, overwrite: bool = False):
|
||||
src_path = _join_fs_path(root, (src_rel or "").lstrip("/"))
|
||||
dst_path = _join_fs_path(root, (dst_rel or "").lstrip("/"))
|
||||
payload = await self._request_json(
|
||||
"POST",
|
||||
"/api/fs/copy",
|
||||
json={"src": src_path, "dst": dst_path},
|
||||
params={"overwrite": overwrite},
|
||||
)
|
||||
_unwrap_success(payload, context="copy")
|
||||
return True
|
||||
|
||||
async def stream_file(self, root: str, rel: str, range_header: str | None):
|
||||
rel = (rel or "").lstrip("/")
|
||||
full_path = _join_fs_path(root, rel)
|
||||
url = self.base_url + self._stream_path(full_path)
|
||||
|
||||
headers = {}
|
||||
if range_header:
|
||||
headers["Range"] = range_header
|
||||
|
||||
for attempt in range(2):
|
||||
token = await self._ensure_token()
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
client = httpx.AsyncClient(timeout=None, follow_redirects=True)
|
||||
stream_cm = client.stream("GET", url, headers=headers)
|
||||
try:
|
||||
resp = await stream_cm.__aenter__()
|
||||
except Exception:
|
||||
await client.aclose()
|
||||
raise
|
||||
|
||||
if resp.status_code == 401 and attempt == 0:
|
||||
try:
|
||||
await resp.aread()
|
||||
finally:
|
||||
await stream_cm.__aexit__(None, None, None)
|
||||
await client.aclose()
|
||||
self._token = None
|
||||
continue
|
||||
|
||||
if resp.status_code == 404:
|
||||
try:
|
||||
await resp.aread()
|
||||
finally:
|
||||
await stream_cm.__aexit__(None, None, None)
|
||||
await client.aclose()
|
||||
raise FileNotFoundError(rel)
|
||||
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
await resp.aread()
|
||||
finally:
|
||||
await stream_cm.__aexit__(None, None, None)
|
||||
await client.aclose()
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("Content-Type") or (
|
||||
mimetypes.guess_type(rel)[0] or "application/octet-stream"
|
||||
)
|
||||
out_headers = {}
|
||||
for key in ("Accept-Ranges", "Content-Range", "Content-Length"):
|
||||
value = resp.headers.get(key)
|
||||
if value:
|
||||
out_headers[key] = value
|
||||
|
||||
async def iterator():
|
||||
try:
|
||||
async for chunk in resp.aiter_bytes():
|
||||
if chunk:
|
||||
yield chunk
|
||||
finally:
|
||||
await stream_cm.__aexit__(None, None, None)
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(
|
||||
iterator(),
|
||||
status_code=resp.status_code,
|
||||
headers=out_headers,
|
||||
media_type=content_type,
|
||||
)
|
||||
|
||||
raise HTTPException(502, detail="Foxel 流式读取失败")
|
||||
|
||||
|
||||
ADAPTER_TYPE = "foxel"
|
||||
CONFIG_SCHEMA = [
|
||||
{"key": "base_url", "label": "节点地址", "type": "string", "required": True, "placeholder": "http://127.0.0.1:8000"},
|
||||
{"key": "username", "label": "用户名", "type": "string", "required": True},
|
||||
{"key": "password", "label": "密码", "type": "password", "required": True},
|
||||
{"key": "root", "label": "远端根目录", "type": "string", "required": False, "default": "/", "placeholder": "/ 或 /drive"},
|
||||
{"key": "timeout", "label": "超时(秒)", "type": "number", "required": False, "default": 60},
|
||||
]
|
||||
|
||||
|
||||
def ADAPTER_FACTORY(rec: StorageAdapter):
|
||||
return FoxelAdapter(rec)
|
||||
@@ -19,6 +19,8 @@ from .vector_providers import (
|
||||
)
|
||||
|
||||
DEFAULT_VECTOR_DIMENSION = 4096
|
||||
VECTOR_COLLECTION_NAME = "vector_collection"
|
||||
FILE_COLLECTION_NAME = "file_collection"
|
||||
|
||||
OPENAI_EMBEDDING_DIMS = {
|
||||
"text-embedding-3-large": 3072,
|
||||
|
||||
@@ -62,7 +62,5 @@ async def clear_audit_logs(
|
||||
):
|
||||
start_dt = _parse_iso(start_time, "start_time")
|
||||
end_dt = _parse_iso(end_time, "end_time")
|
||||
if start_dt is None and end_dt is None:
|
||||
raise HTTPException(status_code=400, detail="start_time 或 end_time 至少提供一个")
|
||||
deleted_count = await AuditService.clear_logs(start_time=start_dt, end_time=end_dt)
|
||||
return response.success({"deleted_count": deleted_count})
|
||||
|
||||
@@ -98,6 +98,11 @@ def _build_request_params(request: Request | None) -> Dict[str, Any] | None:
|
||||
def _get_client_ip(request: Request | None) -> str | None:
|
||||
if not request:
|
||||
return None
|
||||
cf_connecting_ip = request.headers.get("cf-connecting-ip") or request.headers.get("CF-Connecting-IP")
|
||||
if cf_connecting_ip:
|
||||
ip = cf_connecting_ip.strip()
|
||||
if ip:
|
||||
return ip
|
||||
x_real_ip = request.headers.get("x-real-ip") or request.headers.get("X-Real-IP")
|
||||
if x_real_ip:
|
||||
ip = x_real_ip.strip()
|
||||
|
||||
@@ -10,7 +10,7 @@ from models.database import Configuration, UserAccount
|
||||
|
||||
load_dotenv(dotenv_path=".env")
|
||||
|
||||
VERSION = "v1.5.2"
|
||||
VERSION = "v1.5.5"
|
||||
|
||||
|
||||
class ConfigService:
|
||||
|
||||
@@ -9,7 +9,12 @@ from PIL import Image
|
||||
|
||||
from ..base import BaseProcessor
|
||||
from domain.ai.inference import describe_image_base64, get_text_embedding, provider_service
|
||||
from domain.ai.service import VectorDBService, DEFAULT_VECTOR_DIMENSION
|
||||
from domain.ai.service import (
|
||||
VectorDBService,
|
||||
DEFAULT_VECTOR_DIMENSION,
|
||||
VECTOR_COLLECTION_NAME,
|
||||
FILE_COLLECTION_NAME,
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 800
|
||||
@@ -112,18 +117,20 @@ class VectorIndexProcessor:
|
||||
action = config.get("action", "create")
|
||||
index_type = config.get("index_type", "vector")
|
||||
vector_db = VectorDBService()
|
||||
collection_name = "vector_collection"
|
||||
vector_collection = VECTOR_COLLECTION_NAME
|
||||
file_collection = FILE_COLLECTION_NAME
|
||||
|
||||
if action == "destroy":
|
||||
await vector_db.delete_vector(collection_name, path)
|
||||
target_collection = file_collection if index_type == "simple" else vector_collection
|
||||
await vector_db.delete_vector(target_collection, path)
|
||||
return Response(content=f"文件 {path} 的 {index_type} 索引已销毁", media_type="text/plain")
|
||||
|
||||
mime_type = _guess_mime(path)
|
||||
|
||||
if index_type == "simple":
|
||||
await vector_db.ensure_collection(collection_name, vector=False)
|
||||
await vector_db.delete_vector(collection_name, path)
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
await vector_db.ensure_collection(file_collection, vector=False)
|
||||
await vector_db.delete_vector(file_collection, path)
|
||||
await vector_db.upsert_vector(file_collection, {
|
||||
"path": path,
|
||||
"source_path": path,
|
||||
"chunk_id": "filename",
|
||||
@@ -146,8 +153,8 @@ class VectorIndexProcessor:
|
||||
if vector_dim <= 0:
|
||||
vector_dim = DEFAULT_VECTOR_DIMENSION
|
||||
|
||||
await vector_db.ensure_collection(collection_name, vector=True, dim=vector_dim)
|
||||
await vector_db.delete_vector(collection_name, path)
|
||||
await vector_db.ensure_collection(vector_collection, vector=True, dim=vector_dim)
|
||||
await vector_db.delete_vector(vector_collection, path)
|
||||
|
||||
if file_ext in ["jpg", "jpeg", "png", "bmp"]:
|
||||
processed_bytes, compression = _compress_image_for_embedding(input_bytes)
|
||||
@@ -155,7 +162,7 @@ class VectorIndexProcessor:
|
||||
description = await describe_image_base64(base64_image)
|
||||
embedding = await get_text_embedding(description)
|
||||
image_mime = "image/jpeg" if compression else mime_type
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
await vector_db.upsert_vector(vector_collection, {
|
||||
"path": _chunk_key(path, "image"),
|
||||
"source_path": path,
|
||||
"chunk_id": "image",
|
||||
@@ -177,7 +184,7 @@ class VectorIndexProcessor:
|
||||
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
await vector_db.upsert_vector(vector_collection, {
|
||||
"path": _chunk_key(path, "0"),
|
||||
"source_path": path,
|
||||
"chunk_id": "0",
|
||||
@@ -194,7 +201,7 @@ class VectorIndexProcessor:
|
||||
chunk_count = 0
|
||||
for chunk_id, chunk_text, start, end in chunks:
|
||||
embedding = await get_text_embedding(chunk_text)
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
await vector_db.upsert_vector(vector_collection, {
|
||||
"path": _chunk_key(path, str(chunk_id)),
|
||||
"source_path": path,
|
||||
"chunk_id": str(chunk_id),
|
||||
@@ -213,15 +220,15 @@ class VectorIndexProcessor:
|
||||
return Response(content="文本文件已索引", media_type="text/plain")
|
||||
|
||||
# 其他类型暂未支持向量索引,回退为文件名索引
|
||||
await vector_db.delete_vector(collection_name, path)
|
||||
await vector_db.upsert_vector(collection_name, {
|
||||
"path": _chunk_key(path, "fallback"),
|
||||
await vector_db.ensure_collection(file_collection, vector=False)
|
||||
await vector_db.delete_vector(file_collection, path)
|
||||
await vector_db.upsert_vector(file_collection, {
|
||||
"path": path,
|
||||
"source_path": path,
|
||||
"chunk_id": "filename",
|
||||
"mime": mime_type,
|
||||
"type": "filename",
|
||||
"name": os.path.basename(path),
|
||||
"embedding": [0.0] * vector_dim,
|
||||
})
|
||||
return Response(content="暂不支持该类型的向量索引,已创建文件名索引", media_type="text/plain")
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import HTTPException
|
||||
|
||||
from api.response import page
|
||||
from domain.adapters.registry import runtime_registry
|
||||
from domain.ai.service import VectorDBService
|
||||
from domain.ai.service import VectorDBService, VECTOR_COLLECTION_NAME, FILE_COLLECTION_NAME
|
||||
from domain.virtual_fs.thumbnail import is_image_filename, is_video_filename
|
||||
from models import StorageAdapter
|
||||
|
||||
@@ -161,13 +161,19 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
||||
@classmethod
|
||||
async def _gather_vector_index(cls, full_path: str, limit: int = 20):
|
||||
vector_db = VectorDBService()
|
||||
try:
|
||||
raw_results = await vector_db.search_by_path("vector_collection", full_path, max(limit * 2, 20))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
matched = []
|
||||
if raw_results:
|
||||
had_success = False
|
||||
fetch_limit = max(limit * 2, 20)
|
||||
for collection_name in (VECTOR_COLLECTION_NAME, FILE_COLLECTION_NAME):
|
||||
try:
|
||||
raw_results = await vector_db.search_by_path(collection_name, full_path, fetch_limit)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not raw_results:
|
||||
had_success = True
|
||||
continue
|
||||
had_success = True
|
||||
buckets = raw_results if isinstance(raw_results, list) else [raw_results]
|
||||
for bucket in buckets:
|
||||
if not bucket:
|
||||
@@ -193,6 +199,9 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
||||
entry["preview_truncated"] = len(text) > preview_limit
|
||||
matched.append(entry)
|
||||
|
||||
if not had_success:
|
||||
return None
|
||||
|
||||
if not matched:
|
||||
return {"total": 0, "entries": [], "by_type": {}, "has_more": False}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from api.response import success
|
||||
from domain.auth.service import get_current_active_user
|
||||
from domain.auth.types import User
|
||||
from domain.virtual_fs.search.search_service import VirtualFSSearchService
|
||||
@@ -17,10 +18,11 @@ async def search_files(
|
||||
user: User = Depends(get_current_active_user),
|
||||
):
|
||||
if not q.strip():
|
||||
return {"items": [], "query": q}
|
||||
return success({"items": [], "query": q, "mode": mode})
|
||||
|
||||
top_k = max(top_k, 1)
|
||||
page = max(page, 1)
|
||||
page_size = max(min(page_size, 100), 1)
|
||||
|
||||
return await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
|
||||
data = await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
|
||||
return success(data)
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Any, Dict, List, Tuple
|
||||
|
||||
from domain.virtual_fs.types import SearchResultItem
|
||||
from domain.ai.inference import get_text_embedding
|
||||
from domain.ai.service import VectorDBService
|
||||
from domain.ai.service import VectorDBService, VECTOR_COLLECTION_NAME, FILE_COLLECTION_NAME
|
||||
|
||||
|
||||
def _normalize_result(raw: Dict[str, Any], source: str, fallback_score: float = 0.0) -> SearchResultItem:
|
||||
@@ -53,7 +53,7 @@ async def _vector_search(query: str, top_k: int) -> List[SearchResultItem]:
|
||||
return []
|
||||
|
||||
try:
|
||||
raw_results = await vector_db.search_vectors("vector_collection", embedding, max(top_k, 10))
|
||||
raw_results = await vector_db.search_vectors(VECTOR_COLLECTION_NAME, embedding, max(top_k, 10))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -68,12 +68,15 @@ async def _filename_search(query: str, page: int, page_size: int) -> Tuple[List[
|
||||
vector_db = VectorDBService()
|
||||
limit = max(page * page_size + 1, page_size * (page + 2))
|
||||
limit = min(limit, 2000)
|
||||
try:
|
||||
raw_results = await vector_db.search_by_path("vector_collection", query, limit)
|
||||
except Exception:
|
||||
return [], False
|
||||
records: List[Dict[str, Any]] = []
|
||||
for collection_name in (FILE_COLLECTION_NAME, VECTOR_COLLECTION_NAME):
|
||||
try:
|
||||
raw_results = await vector_db.search_by_path(collection_name, query, limit)
|
||||
except Exception:
|
||||
continue
|
||||
if raw_results:
|
||||
records.extend(raw_results[0] or [])
|
||||
|
||||
records = raw_results[0] if raw_results else []
|
||||
deduped: List[SearchResultItem] = []
|
||||
seen_paths: set[str] = set()
|
||||
for record in records or []:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python migrate/run.py
|
||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 1 -b 0.0.0.0:80 main:app
|
||||
port="${FOXEL_PORT:-80}"
|
||||
exec gunicorn -k uvicorn.workers.UvicornWorker -w 1 -b "0.0.0.0:${port}" main:app
|
||||
|
||||
@@ -232,7 +232,7 @@ install_new_foxel() {
|
||||
if ss -tuln | grep -q ":${new_port}\b"; then
|
||||
warn "端口 $new_port 已被占用,请换一个。"
|
||||
else
|
||||
sed -i -E "s|\"[0-9]{1,5}:80\"|\"$new_port:80\"|" compose.yaml
|
||||
sed -i -E "s|(FOXEL_HOST_PORT:-)[0-9]{1,5}|\\1$new_port|" compose.yaml
|
||||
info "端口已成功修改为 $new_port。"
|
||||
break
|
||||
fi
|
||||
|
||||
6
uv.lock
generated
6
uv.lock
generated
@@ -398,7 +398,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.127.0"
|
||||
version = "0.128.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -406,9 +406,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/02/2cbbecf6551e0c1a06f9b9765eb8f7ae126362fbba43babbb11b0e3b7db3/fastapi-0.127.0.tar.gz", hash = "sha256:5a9246e03dcd1fdb19f1396db30894867c1d630f5107dc167dcbc5ed1ea7d259", size = 369269, upload-time = "2025-12-21T16:47:16.393Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fa/6a27e2ef789eb03060abb43b952a7f0bd39e6feaa3805362b48785bcedc5/fastapi-0.127.0-py3-none-any.whl", hash = "sha256:725aa2bb904e2eff8031557cf4b9b77459bfedd63cae8427634744fd199f6a49", size = 112055, upload-time = "2025-12-21T16:47:14.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
32
web/bun.lock
32
web/bun.lock
@@ -27,7 +27,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"typescript-eslint": "^8.51.0",
|
||||
"vite": "^7.3.0",
|
||||
},
|
||||
},
|
||||
@@ -185,7 +185,7 @@
|
||||
|
||||
"@rc-component/async-validator": ["@rc-component/async-validator@5.0.4", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg=="],
|
||||
|
||||
"@rc-component/cascader": ["@rc-component/cascader@1.9.0", "", { "dependencies": { "@rc-component/select": "~1.3.0", "@rc-component/tree": "~1.1.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-2jbthe1QZrMBgtCvNKkJFjZYC3uKl4N/aYm5SsMvO3T+F+qRT1CGsSM9bXnh1rLj7jDk/GK0natShWF/jinhWQ=="],
|
||||
"@rc-component/cascader": ["@rc-component/cascader@1.10.0", "", { "dependencies": { "@rc-component/select": "~1.4.0", "@rc-component/tree": "~1.1.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-D1XOKvbhdo9kX+cG1p8qJOnSq+sMK3L84iVYjGQIx950kJt0ixN+Xac75ykyK/AC8V3GUanjNK14Qkv149RrEw=="],
|
||||
|
||||
"@rc-component/checkbox": ["@rc-component/checkbox@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-08yTH8m+bSm8TOqbybbJ9KiAuIATti6bDs2mVeSfu4QfEnyeF6X0enHVvD1NEAyuBWEAo56QtLe++MYs2D9XiQ=="],
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
|
||||
"@rc-component/segmented": ["@rc-component/segmented@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg=="],
|
||||
|
||||
"@rc-component/select": ["@rc-component/select@1.3.6", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-CzbJ9TwmWcF5asvTMZ9BMiTE9CkkrigeOGRPpzCNmeZP7KBwwmYrmOIiKh9tMG7d6DyGAEAQ75LBxzPx+pGTHA=="],
|
||||
"@rc-component/select": ["@rc-component/select@1.4.0", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-DDCsUkx3lHAO42fyPiBADzZgbqOp3gepjBCusuy6DDN51Vx73cwX0aqsid1asxpIwHPMYGgYg+wXbLi4YctzLQ=="],
|
||||
|
||||
"@rc-component/slider": ["@rc-component/slider@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g=="],
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
|
||||
"@rc-component/tree": ["@rc-component/tree@1.1.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.2.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-HZs3aOlvFgQdgrmURRc/f4IujiNBf4DdEeXUlkS0lPoLlx9RoqsZcF0caXIAMVb+NaWqKtGQDnrH8hqLCN5zlA=="],
|
||||
|
||||
"@rc-component/tree-select": ["@rc-component/tree-select@1.4.0", "", { "dependencies": { "@rc-component/select": "~1.3.0", "@rc-component/tree": "~1.1.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-I3UAlO2hNqy9CSKc8EBaESgnmKk2QaRzuZ2XHZGFCgsSMkGl06mdF97sVfROM02YIb64ocgLKefsjE0Ch4ocwQ=="],
|
||||
"@rc-component/tree-select": ["@rc-component/tree-select@1.5.0", "", { "dependencies": { "@rc-component/select": "~1.4.0", "@rc-component/tree": "~1.1.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-1nBAMreFJXkCIeZlWG0l+6i0jLWzlmmRv/TrtZjLkoq8WmpzSuDhP32YroC7rAhGFR34thpHkvCedPzBXIL/XQ=="],
|
||||
|
||||
"@rc-component/trigger": ["@rc-component/trigger@3.7.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-25x+D2k9SAkaK/MNMNmv2Nlv8FH1D9RtmjoMoLEw1Cid+sMV4pAAT5k49ku59UeXaOA1qwLUVrBUMq4A6gUSsQ=="],
|
||||
|
||||
@@ -347,25 +347,25 @@
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/type-utils": "8.50.1", "@typescript-eslint/utils": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.50.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.50.1", "@typescript-eslint/types": "^8.50.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.51.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.51.0", "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.1", "", { "dependencies": { "@typescript-eslint/types": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1" } }, "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw=="],
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0" } }, "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.50.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.51.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.50.1", "", { "dependencies": { "@typescript-eslint/types": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/utils": "8.50.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.50.1", "", {}, "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA=="],
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.51.0", "", {}, "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.50.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.50.1", "@typescript-eslint/tsconfig-utils": "8.50.1", "@typescript-eslint/types": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.51.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.51.0", "@typescript-eslint/tsconfig-utils": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.50.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ=="],
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.51.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.1", "", { "dependencies": { "@typescript-eslint/types": "8.50.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg=="],
|
||||
|
||||
"@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.19", "", {}, "sha512-AYxzFUBkZrhtExb2QC0C4lFH2+BSx6JVId9iqeGHakBuosqiQHUQaNZCvIBeM97Ucp+nJ22flOh8FBT2pKRRAA=="],
|
||||
|
||||
@@ -385,7 +385,7 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"antd": ["antd@6.1.2", "", { "dependencies": { "@ant-design/colors": "^8.0.0", "@ant-design/cssinjs": "^2.0.1", "@ant-design/cssinjs-utils": "^2.0.2", "@ant-design/fast-color": "^3.0.0", "@ant-design/icons": "^6.1.0", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.9.0", "@rc-component/checkbox": "~1.0.1", "@rc-component/collapse": "~1.1.2", "@rc-component/color-picker": "~3.0.3", "@rc-component/dialog": "~1.5.1", "@rc-component/drawer": "~1.3.0", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.6.0", "@rc-component/image": "~1.5.3", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "~1.1.6", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.0", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.3.6", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.0", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.2.1", "@rc-component/tree": "~1.1.0", "@rc-component/tree-select": "~1.4.0", "@rc-component/trigger": "^3.7.2", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.6.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-pqYaZECL/7TBiNxxz+LieLiPCem6DaEzudqN44EZ3SvJjixLP7K41n6clo0zxe/2HiOUe9KxTMxGN+icOkL6Tw=="],
|
||||
"antd": ["antd@6.1.3", "", { "dependencies": { "@ant-design/colors": "^8.0.0", "@ant-design/cssinjs": "^2.0.1", "@ant-design/cssinjs-utils": "^2.0.2", "@ant-design/fast-color": "^3.0.0", "@ant-design/icons": "^6.1.0", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.10.0", "@rc-component/checkbox": "~1.0.1", "@rc-component/collapse": "~1.1.2", "@rc-component/color-picker": "~3.0.3", "@rc-component/dialog": "~1.5.1", "@rc-component/drawer": "~1.3.0", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.6.0", "@rc-component/image": "~1.5.3", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "~1.1.6", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.0", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.4.0", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.0", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.2.1", "@rc-component/tree": "~1.1.0", "@rc-component/tree-select": "~1.5.0", "@rc-component/trigger": "^3.7.2", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.6.2", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-kvaLtOm0UwCIdtR424/Mo6pyJxN34/6003e1io3GIKWQOdlddplFylv767iGxXLMrxfNoQmxuNJcF1miFbxCZQ=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
@@ -845,7 +845,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.50.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.50.1", "@typescript-eslint/parser": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/utils": "8.50.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ=="],
|
||||
"typescript-eslint": ["typescript-eslint@8.51.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.51.0", "@typescript-eslint/parser": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/utils": "8.51.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Foxel</title>
|
||||
<link rel='stylesheet'
|
||||
href='https://chinese-fonts-cdn.deno.dev/packages/maple-mono-cn/dist/MapleMono-CN-Regular/result.css' />
|
||||
href='https://foxel.cc/fonts/result.css' />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Maple Mono CN';
|
||||
font-family: 'Maple Mono Normal NL NF CN';
|
||||
}
|
||||
</style>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"typescript-eslint": "^8.51.0",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,30 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
|
||||
.fx-grid-item .thumb { height:120px; border-radius:10px; background: var(--ant-color-bg-container, #fff); display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px var(--ant-color-border-secondary, #eee); }
|
||||
.fx-grid-item .thumb img { width:100%; height:100%; object-fit:cover; }
|
||||
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
||||
.fx-grid-item .thumb .score-badge {
|
||||
left:auto;
|
||||
right:8px;
|
||||
top:8px;
|
||||
background: rgba(0,0,0,0.45);
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
color: rgba(255,255,255,0.92);
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.08);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.fx-grid-item:hover .thumb .score-badge,
|
||||
.fx-grid-item.selected .thumb .score-badge {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.fx-grid-item .name { font-weight:600; font-size:13px; }
|
||||
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||
|
||||
|
||||
@@ -499,6 +499,7 @@
|
||||
"/ or /drive": "/ or /drive",
|
||||
"Adapter Config": "Adapter Config",
|
||||
"adapter.type.local": "Local Filesystem",
|
||||
"adapter.type.foxel": "Foxel Node",
|
||||
"adapter.type.dropbox": "Dropbox",
|
||||
"adapter.type.webdav": "WebDAV",
|
||||
"adapter.type.googledrive": "Google Drive",
|
||||
@@ -527,6 +528,7 @@
|
||||
"Status": "Status",
|
||||
"Confirm clear logs?": "Confirm clear logs?",
|
||||
"This will delete logs in selected range irreversibly.": "This will delete logs in selected range irreversibly.",
|
||||
"This will delete all logs irreversibly.": "This will delete all logs irreversibly.",
|
||||
"Cleared {count} logs": "Cleared {count} logs",
|
||||
"Time": "Time",
|
||||
"Level": "Level",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"/ or /drive": "/或/drive",
|
||||
"Adapter Config": "适配器配置",
|
||||
"adapter.type.local": "本地文件系统",
|
||||
"adapter.type.foxel": "Foxel 节点",
|
||||
"adapter.type.dropbox": "Dropbox",
|
||||
"adapter.type.quark": "夸克网盘",
|
||||
"adapter.type.alist": "AList",
|
||||
@@ -509,6 +510,7 @@
|
||||
"Status": "状态",
|
||||
"Confirm clear logs?": "确认清理日志?",
|
||||
"This will delete logs in selected range irreversibly.": "该操作将删除选定时间范围内的所有日志,且不可恢复。",
|
||||
"This will delete all logs irreversibly.": "将删除全部日志且不可恢复",
|
||||
"Cleared {count} logs": "成功清理 {count} 条日志",
|
||||
"Time": "时间",
|
||||
"Level": "级别",
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { Modal, Input, List, Divider, Spin, Space, Tag, Typography, Empty, Flex, Segmented, Pagination, message } from 'antd';
|
||||
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { vfsApi, type SearchResultItem } from '../api/vfs';
|
||||
import { type VfsEntry } from '../api/client';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||
import { Modal, Input, Flex, Segmented } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAppWindows } from '../contexts/AppWindowsContext';
|
||||
import { ContextMenu } from '../pages/FileExplorerPage/components/ContextMenu';
|
||||
import { RenameModal } from '../pages/FileExplorerPage/components/Modals/RenameModal';
|
||||
import { MoveCopyModal } from '../pages/FileExplorerPage/components/Modals/MoveCopyModal';
|
||||
import { ShareModal } from '../pages/FileExplorerPage/components/Modals/ShareModal';
|
||||
import { DirectLinkModal } from '../pages/FileExplorerPage/components/Modals/DirectLinkModal';
|
||||
import { FileDetailModal } from '../pages/FileExplorerPage/components/FileDetailModal';
|
||||
import { ProcessorModal } from '../pages/FileExplorerPage/components/Modals/ProcessorModal';
|
||||
import { useFileActions } from '../pages/FileExplorerPage/hooks/useFileActions.tsx';
|
||||
import { useProcessor } from '../pages/FileExplorerPage/hooks/useProcessor';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean;
|
||||
@@ -23,329 +10,32 @@ interface SearchDialogProps {
|
||||
}
|
||||
|
||||
type SearchMode = 'vector' | 'filename';
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('vector');
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||
const statCacheRef = useRef<Map<string, VfsEntry>>(new Map());
|
||||
|
||||
const [contextMenuState, setContextMenuState] = useState<{ entry: VfsEntry; x: number; y: number; path: string } | null>(null);
|
||||
const [menuEntries, setMenuEntries] = useState<VfsEntry[]>([]);
|
||||
const [selectedEntryNames, setSelectedEntryNames] = useState<string[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string>('/');
|
||||
const [processorTypes, setProcessorTypes] = useState<ProcessorTypeMeta[]>([]);
|
||||
const [renaming, setRenaming] = useState<VfsEntry | null>(null);
|
||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
||||
const [detailData, setDetailData] = useState<any>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenuState(null);
|
||||
setMenuEntries([]);
|
||||
setSelectedEntryNames([]);
|
||||
}, []);
|
||||
|
||||
const noop = useCallback(() => {}, []);
|
||||
|
||||
const handleShare = useCallback((entries: VfsEntry[]) => {
|
||||
setSharingEntries(entries);
|
||||
}, []);
|
||||
|
||||
const handleDirectLink = useCallback((entry: VfsEntry) => {
|
||||
setDirectLinkEntry(entry);
|
||||
}, []);
|
||||
|
||||
const { doDelete, doDownload, doRename, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({
|
||||
path: currentPath,
|
||||
refresh: noop,
|
||||
clearSelection: noop,
|
||||
onShare: handleShare,
|
||||
onGetDirectLink: handleDirectLink,
|
||||
});
|
||||
|
||||
const processorHook = useProcessor({ path: currentPath, processorTypes, refresh: noop });
|
||||
const location = useLocation();
|
||||
const isOnFiles = location.pathname.startsWith('/files');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
statCacheRef.current.clear();
|
||||
setProcessorTypes([]);
|
||||
closeContextMenu();
|
||||
if (!open) return;
|
||||
if (!isOnFiles) {
|
||||
setSearch('');
|
||||
setSearchMode('vector');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await processorsApi.list();
|
||||
if (!cancelled) {
|
||||
setProcessorTypes(list);
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
const msg = e instanceof Error ? e.message : t('Load failed');
|
||||
message.error(msg);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, closeContextMenu, t]);
|
||||
const params = new URLSearchParams(location.search);
|
||||
setSearch(params.get('q') || '');
|
||||
setSearchMode(params.get('mode') === 'filename' ? 'filename' : 'vector');
|
||||
}, [open, isOnFiles, location.search]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearch('');
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setSearchMode('vector');
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
requestIdRef.current = 0;
|
||||
setLoading(false);
|
||||
closeContextMenu();
|
||||
setProcessorTypes([]);
|
||||
setRenaming(null);
|
||||
setSharingEntries([]);
|
||||
setDirectLinkEntry(null);
|
||||
setDetailEntry(null);
|
||||
setDetailData(null);
|
||||
setDetailLoading(false);
|
||||
setMovingEntries([]);
|
||||
setCopyingEntries([]);
|
||||
setCurrentPath('/');
|
||||
statCacheRef.current.clear();
|
||||
onClose();
|
||||
}, [closeContextMenu, onClose]);
|
||||
|
||||
const renderSourceLabel = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'vector':
|
||||
return t('Vector Search');
|
||||
case 'filename':
|
||||
return t('Name Search');
|
||||
case 'text':
|
||||
return t('Text Chunk');
|
||||
case 'image':
|
||||
return t('Image Description');
|
||||
default:
|
||||
return t('Vector Search');
|
||||
}
|
||||
};
|
||||
|
||||
const sourceColor = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'vector':
|
||||
return 'blue';
|
||||
case 'filename':
|
||||
return 'green';
|
||||
case 'image':
|
||||
return 'volcano';
|
||||
case 'text':
|
||||
return 'geekblue';
|
||||
default:
|
||||
return 'purple';
|
||||
}
|
||||
};
|
||||
|
||||
const buildFullPath = useCallback((entryName: string, basePath?: string) => {
|
||||
const dir = basePath ?? currentPath;
|
||||
const prefix = dir === '/' ? '' : dir;
|
||||
const combined = `${prefix}/${entryName}`.replace(/\/{2,}/g, '/');
|
||||
if (!combined) return '/';
|
||||
return combined.startsWith('/') ? combined : `/${combined}`;
|
||||
}, [currentPath]);
|
||||
|
||||
const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
|
||||
if (!targetEntries || targetEntries.length === 0) return '';
|
||||
if (targetEntries.length > 1) {
|
||||
return currentPath || '/';
|
||||
}
|
||||
const entry = targetEntries[0];
|
||||
const base = currentPath === '/' ? '' : currentPath;
|
||||
const segments = [base, entry.name].filter(Boolean);
|
||||
const joined = segments.join('/');
|
||||
if (!joined) return '/';
|
||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||
}, [currentPath]);
|
||||
|
||||
const openDetail = useCallback(async (entry: VfsEntry) => {
|
||||
setDetailEntry(entry);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const stat = await vfsApi.stat(buildFullPath(entry.name));
|
||||
setDetailData(stat);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t('Load failed');
|
||||
setDetailData({ error: msg });
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, [buildFullPath, t]);
|
||||
|
||||
const handleOpenEntry = useCallback((entry: VfsEntry) => {
|
||||
const basePath = contextMenuState?.path ?? currentPath;
|
||||
if (entry.is_dir) {
|
||||
const next = buildFullPath(entry.name, basePath);
|
||||
navigate(`/files${next === '/' ? '' : next}`, { state: { highlight: { name: entry.name } } });
|
||||
closeContextMenu();
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
openFileWithDefaultApp(entry, basePath);
|
||||
closeContextMenu();
|
||||
handleClose();
|
||||
}, [buildFullPath, navigate, closeContextMenu, handleClose, openFileWithDefaultApp, currentPath, contextMenuState]);
|
||||
|
||||
const ensureEntry = useCallback(async (fullPath: string, defaultName: string): Promise<VfsEntry | null> => {
|
||||
const cached = statCacheRef.current.get(fullPath);
|
||||
if (cached) {
|
||||
return { ...cached, name: cached.name || defaultName };
|
||||
}
|
||||
try {
|
||||
const stat = await vfsApi.stat(fullPath);
|
||||
const entry: VfsEntry = {
|
||||
name: (stat as any)?.name || defaultName,
|
||||
is_dir: Boolean((stat as any)?.is_dir),
|
||||
size: Number((stat as any)?.size ?? 0),
|
||||
mtime: Number((stat as any)?.mtime ?? (stat as any)?.mtime_ms ?? 0),
|
||||
type: (stat as any)?.type,
|
||||
has_thumbnail: Boolean((stat as any)?.has_thumbnail),
|
||||
};
|
||||
statCacheRef.current.set(fullPath, entry);
|
||||
return entry;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t('Load failed');
|
||||
message.error(msg);
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleResultContextMenu = useCallback(async (event: React.MouseEvent, item: SearchResultItem) => {
|
||||
event.preventDefault();
|
||||
closeContextMenu();
|
||||
const rawPath = (item.path || '').replace(/\/+$/, '');
|
||||
if (!rawPath) {
|
||||
return;
|
||||
}
|
||||
const normalizedPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
||||
const segments = normalizedPath.split('/').filter(Boolean);
|
||||
const filename = segments.pop() || '';
|
||||
const dir = segments.length ? `/${segments.join('/')}` : '/';
|
||||
const entry = await ensureEntry(normalizedPath, filename);
|
||||
if (!entry) return;
|
||||
setCurrentPath(dir || '/');
|
||||
setMenuEntries([entry]);
|
||||
setSelectedEntryNames([entry.name]);
|
||||
setContextMenuState({
|
||||
entry,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
path: dir || '/',
|
||||
});
|
||||
}, [closeContextMenu, ensureEntry]);
|
||||
|
||||
const performSearch = async (options?: { page?: number; mode?: SearchMode }) => {
|
||||
const query = search.trim();
|
||||
if (!query) {
|
||||
setSearched(false);
|
||||
setResults([]);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMode = options?.mode ?? searchMode;
|
||||
const targetPage = currentMode === 'filename' ? (options?.page ?? (currentMode === searchMode ? page : 1)) : 1;
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
setLoading(true);
|
||||
closeContextMenu();
|
||||
setSearched(true);
|
||||
if (currentMode === 'filename') {
|
||||
setPage(targetPage);
|
||||
} else {
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await vfsApi.searchFiles(
|
||||
query,
|
||||
currentMode === 'filename' ? PAGE_SIZE : 10,
|
||||
currentMode,
|
||||
currentMode === 'filename' ? targetPage : undefined,
|
||||
currentMode === 'filename' ? PAGE_SIZE : undefined,
|
||||
);
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setResults(res.items);
|
||||
if (currentMode === 'filename') {
|
||||
const pagination = res.pagination;
|
||||
setHasMore(Boolean(pagination?.has_more));
|
||||
if (pagination?.page) {
|
||||
setPage(pagination.page);
|
||||
}
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setResults([]);
|
||||
if (currentMode === 'filename') {
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!search.trim()) {
|
||||
closeContextMenu();
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
void performSearch({ page: searchMode === 'filename' ? 1 : undefined });
|
||||
};
|
||||
|
||||
const handleModeChange = (value: string | number) => {
|
||||
const nextMode = value as SearchMode;
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
setSearchMode(nextMode);
|
||||
closeContextMenu();
|
||||
if (search.trim()) {
|
||||
void performSearch({ mode: nextMode, page: nextMode === 'filename' ? 1 : undefined });
|
||||
} else {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalItems = searchMode === 'filename'
|
||||
? (hasMore ? page * PAGE_SIZE + 1 : (page - 1) * PAGE_SIZE + results.length)
|
||||
: results.length;
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -375,7 +65,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
{ label: t('Name Search'), value: 'filename' },
|
||||
]}
|
||||
value={searchMode}
|
||||
onChange={handleModeChange}
|
||||
onChange={(value) => setSearchMode(value as SearchMode)}
|
||||
style={{
|
||||
minWidth: 160,
|
||||
height: 40,
|
||||
@@ -390,18 +80,7 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('Search files / tags / types')}
|
||||
value={search}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setSearch(value);
|
||||
if (!value.trim()) {
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
setHasMore(false);
|
||||
setPage(1);
|
||||
requestIdRef.current += 1;
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{ fontSize: 18, height: 40, flex: 1, minWidth: 240 }}
|
||||
styles={{
|
||||
input: {
|
||||
@@ -409,197 +88,28 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
},
|
||||
}}
|
||||
autoFocus
|
||||
onPressEnter={handleSearch}
|
||||
onPressEnter={() => {
|
||||
const trimmed = search.trim();
|
||||
if (!trimmed) {
|
||||
if (isOnFiles) {
|
||||
navigate(location.pathname);
|
||||
}
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', trimmed);
|
||||
params.set('mode', searchMode);
|
||||
if (searchMode === 'filename') {
|
||||
params.set('page', '1');
|
||||
}
|
||||
const targetPath = isOnFiles ? location.pathname : '/files';
|
||||
navigate(`${targetPath}?${params.toString()}`);
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{!searched ? null : (
|
||||
<Flex vertical style={{ flex: 1, minHeight: 0 }}>
|
||||
<Divider style={{ margin: 0, padding: '0 0 12px' }}>{t('Search Results')}</Divider>
|
||||
{loading ? (
|
||||
<Flex align="center" justify="center" style={{ flex: 1 }}>
|
||||
<Spin />
|
||||
</Flex>
|
||||
) : results.length === 0 ? (
|
||||
<Flex align="center" justify="center" style={{ flex: 1 }}>
|
||||
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Flex>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingRight: 6 }}>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={results}
|
||||
split={false}
|
||||
renderItem={item => {
|
||||
const fullPath = item.path || '';
|
||||
const trimmed = fullPath.replace(/\/+$/, '');
|
||||
const parts = trimmed.split('/');
|
||||
const filename = parts.pop() || '';
|
||||
const dir = parts.length ? '/' + parts.join('/') : '/';
|
||||
const snippet = item.snippet || '';
|
||||
const retrieval = item.metadata?.retrieval_source || item.source_type;
|
||||
const retrievalLabel = renderSourceLabel(retrieval);
|
||||
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{ padding: '10px 12px', borderRadius: 6, background: '#fafafa', marginBottom: 8 }}
|
||||
onContextMenu={(event) => { void handleResultContextMenu(event, item); }}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<FileTextOutlined style={{ fontSize: 18, color: '#8c8c8c' }} />}
|
||||
title={
|
||||
<a
|
||||
onClick={() => {
|
||||
navigate(`/files${dir === '/' ? '' : dir}`, { state: { highlight: { name: filename } } });
|
||||
handleClose();
|
||||
}}
|
||||
style={{ fontSize: 16 }}
|
||||
>
|
||||
{fullPath}
|
||||
</a>
|
||||
}
|
||||
description={(
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{snippet ? (
|
||||
<Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>
|
||||
{snippet}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Space size={10} wrap>
|
||||
{retrieval ? (
|
||||
<Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>
|
||||
{retrievalLabel}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Typography.Text type="secondary">
|
||||
{t('Relevance')}: {scoreText}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{searchMode === 'filename' && results.length > 0 ? (
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={Math.max(totalItems, 1)}
|
||||
showSizeChanger={false}
|
||||
size="small"
|
||||
style={{ marginTop: 12, textAlign: 'right' }}
|
||||
onChange={(nextPage) => {
|
||||
void performSearch({ page: nextPage });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
{contextMenuState ? (
|
||||
<ContextMenu
|
||||
x={contextMenuState.x}
|
||||
y={contextMenuState.y}
|
||||
entry={contextMenuState.entry}
|
||||
entries={menuEntries}
|
||||
selectedEntries={selectedEntryNames}
|
||||
processorTypes={processorTypes}
|
||||
onClose={closeContextMenu}
|
||||
onOpen={handleOpenEntry}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, contextMenuState?.path ?? currentPath)}
|
||||
onDownload={doDownload}
|
||||
onRename={setRenaming}
|
||||
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
||||
onDetail={openDetail}
|
||||
onProcess={(entry, type) => {
|
||||
processorHook.setSelectedProcessor(type);
|
||||
processorHook.openProcessorModal(entry);
|
||||
}}
|
||||
onUploadFile={noop}
|
||||
onUploadDirectory={noop}
|
||||
onCreateDir={noop}
|
||||
onShare={doShare}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
|
||||
onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}
|
||||
/>
|
||||
) : null}
|
||||
<RenameModal
|
||||
entry={renaming}
|
||||
onOk={(entry, newName) => {
|
||||
void doRename(entry, newName);
|
||||
setRenaming(null);
|
||||
}}
|
||||
onCancel={() => setRenaming(null)}
|
||||
/>
|
||||
<FileDetailModal
|
||||
entry={detailEntry}
|
||||
loading={detailLoading}
|
||||
data={detailData}
|
||||
onClose={() => setDetailEntry(null)}
|
||||
/>
|
||||
<MoveCopyModal
|
||||
mode="move"
|
||||
entries={movingEntries}
|
||||
open={movingEntries.length > 0}
|
||||
defaultPath={buildDefaultDestination(movingEntries)}
|
||||
onOk={async (destination) => {
|
||||
if (movingEntries.length > 0) {
|
||||
await doMove(movingEntries, destination);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setMovingEntries([])}
|
||||
/>
|
||||
<MoveCopyModal
|
||||
mode="copy"
|
||||
entries={copyingEntries}
|
||||
open={copyingEntries.length > 0}
|
||||
defaultPath={buildDefaultDestination(copyingEntries)}
|
||||
onOk={async (destination) => {
|
||||
if (copyingEntries.length > 0) {
|
||||
await doCopy(copyingEntries, destination);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setCopyingEntries([])}
|
||||
/>
|
||||
{sharingEntries.length > 0 ? (
|
||||
<ShareModal
|
||||
path={currentPath}
|
||||
entries={sharingEntries}
|
||||
open={sharingEntries.length > 0}
|
||||
onOk={() => setSharingEntries([])}
|
||||
onCancel={() => setSharingEntries([])}
|
||||
/>
|
||||
) : null}
|
||||
<DirectLinkModal
|
||||
entry={directLinkEntry}
|
||||
path={currentPath}
|
||||
open={!!directLinkEntry}
|
||||
onCancel={() => setDirectLinkEntry(null)}
|
||||
/>
|
||||
<ProcessorModal
|
||||
entry={processorHook.processorModal.entry}
|
||||
visible={processorHook.processorModal.visible}
|
||||
loading={processorHook.processorLoading}
|
||||
processorTypes={processorTypes}
|
||||
selectedProcessor={processorHook.selectedProcessor}
|
||||
config={processorHook.processorConfig}
|
||||
savingPath={processorHook.processorSavingPath}
|
||||
overwrite={processorHook.processorOverwrite}
|
||||
onOk={processorHook.handleProcessorOk}
|
||||
onCancel={processorHook.handleProcessorCancel}
|
||||
onSelectedProcessorChange={processorHook.setSelectedProcessor}
|
||||
onConfigChange={processorHook.setProcessorConfig}
|
||||
onSavingPathChange={processorHook.setProcessorSavingPath}
|
||||
onOverwriteChange={processorHook.setProcessorOverwrite}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -95,13 +95,12 @@ const AuditLogsPage = memo(function AuditLogsPage() {
|
||||
}, [fetchList]);
|
||||
|
||||
const handleClearLogs = () => {
|
||||
if (!filters.start_time && !filters.end_time) {
|
||||
message.warning(t('Please select time range'));
|
||||
return;
|
||||
}
|
||||
const hasRange = !!(filters.start_time || filters.end_time);
|
||||
Modal.confirm({
|
||||
title: t('Confirm clear logs?'),
|
||||
content: t('This will delete logs in selected range irreversibly.'),
|
||||
content: hasRange
|
||||
? t('This will delete logs in selected range irreversibly.')
|
||||
: t('This will delete all logs irreversibly.'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const params: any = {};
|
||||
@@ -127,35 +126,38 @@ const AuditLogsPage = memo(function AuditLogsPage() {
|
||||
{
|
||||
title: t('Action'),
|
||||
dataIndex: 'action',
|
||||
width: 140,
|
||||
width: 100,
|
||||
render: (action: string) => <Tag color="blue">{action}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('User'),
|
||||
dataIndex: 'username',
|
||||
width: 160,
|
||||
width: 100,
|
||||
render: (_: any, rec: AuditLogItem) => rec.username || rec.user_id || '-',
|
||||
},
|
||||
{
|
||||
title: t('Path'),
|
||||
dataIndex: 'path',
|
||||
width: 350,
|
||||
ellipsis: true,
|
||||
render: (path: string, rec: AuditLogItem) => (
|
||||
<Space size={4}>
|
||||
{renderHttpMethodTag(rec.method)}
|
||||
<span style={{ maxWidth: 320, display: 'inline-block' }}>{path}</span>
|
||||
<span style={{ maxWidth: 280, display: 'inline-block' }}>{path}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Status Code'),
|
||||
dataIndex: 'status_code',
|
||||
width: 100,
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: t('Duration (ms)'),
|
||||
dataIndex: 'duration_ms',
|
||||
width: 120,
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (ms?: number | null) => (ms !== null && ms !== undefined ? ms : '-'),
|
||||
},
|
||||
{
|
||||
@@ -167,7 +169,8 @@ const AuditLogsPage = memo(function AuditLogsPage() {
|
||||
{
|
||||
title: t('Result'),
|
||||
dataIndex: 'success',
|
||||
width: 100,
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (success: boolean) => (
|
||||
<Tag color={success ? 'green' : 'red'}>
|
||||
{success ? t('Success') : t('Failure')}
|
||||
@@ -176,7 +179,8 @@ const AuditLogsPage = memo(function AuditLogsPage() {
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 100,
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: any, rec: AuditLogItem) => (
|
||||
<Button size="small" onClick={() => setSelectedLog(rec)}>{t('Details')}</Button>
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useFileActions } from './hooks/useFileActions.tsx';
|
||||
import { useAppWindows } from '../../contexts/AppWindowsContext';
|
||||
import { useContextMenu } from './hooks/useContextMenu';
|
||||
import { useProcessor } from './hooks/useProcessor';
|
||||
import { useFileSearch } from './hooks/useFileSearch';
|
||||
import { useThumbnails } from './hooks/useThumbnails';
|
||||
import { useUploader } from './hooks/useUploader';
|
||||
import { Header } from './components/Header';
|
||||
@@ -23,6 +24,7 @@ import { ShareModal } from './components/Modals/ShareModal';
|
||||
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
||||
import { FileDetailModal } from './components/FileDetailModal';
|
||||
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||
import { SearchResultsView } from './components/SearchResultsView';
|
||||
import type { ViewMode } from './types';
|
||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
||||
@@ -39,12 +41,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||
const { doCreateDir, doDelete, doRename, doDownload, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries), onGetDirectLink: (entry) => setDirectLinkEntry(entry) });
|
||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const uploader = useUploader(path, refresh);
|
||||
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
|
||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||
const { thumbs } = useThumbnails(entries, path);
|
||||
|
||||
// --- State for Modals ---
|
||||
@@ -58,6 +58,42 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||
|
||||
// --- Search ---
|
||||
const fileSearch = useFileSearch({
|
||||
currentPath: path,
|
||||
navigateTo,
|
||||
openFileWithDefaultApp,
|
||||
openContextMenu,
|
||||
closeContextMenus,
|
||||
activeEntry: ctxMenu?.entry ?? null,
|
||||
});
|
||||
|
||||
const {
|
||||
isSearching,
|
||||
actionPath,
|
||||
loading: searchLoading,
|
||||
mode: searchMode,
|
||||
query: searchQuery,
|
||||
page: searchPage,
|
||||
pageSize: searchPageSize,
|
||||
displayItems: searchItems,
|
||||
selectedPaths: searchSelectedPaths,
|
||||
selectedNames: searchSelectedNames,
|
||||
contextEntries: searchContextEntries,
|
||||
entrySnapshot: searchEntrySnapshot,
|
||||
showPagination: showSearchPagination,
|
||||
totalItems: searchTotalItems,
|
||||
clearSearchParams,
|
||||
updateSearchPage,
|
||||
refreshSearch,
|
||||
openResult: openSearchResult,
|
||||
selectResult: selectSearchResult,
|
||||
openResultContextMenu: openSearchContextMenu,
|
||||
clearSelection: clearSearchSelection,
|
||||
} = fileSearch;
|
||||
|
||||
const entryBasePath = isSearching ? actionPath : path;
|
||||
|
||||
// --- Effects ---
|
||||
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
|
||||
|
||||
@@ -65,6 +101,14 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||
|
||||
const effectiveRefresh = useCallback(() => {
|
||||
if (isSearching) {
|
||||
refreshSearch();
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
}, [isSearching, refresh, refreshSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skeletonTimerRef.current !== null) {
|
||||
clearTimeout(skeletonTimerRef.current);
|
||||
@@ -89,20 +133,43 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
}, [loading]);
|
||||
|
||||
// --- Handlers ---
|
||||
const clearAllSelection = useCallback(() => {
|
||||
clearSelection();
|
||||
clearSearchSelection();
|
||||
}, [clearSearchSelection, clearSelection]);
|
||||
|
||||
const { doCreateDir: doCreateDirInCurrentDir } = useFileActions({
|
||||
path,
|
||||
refresh,
|
||||
clearSelection,
|
||||
onShare: (entriesToShare) => setSharingEntries(entriesToShare),
|
||||
onGetDirectLink: (entry) => setDirectLinkEntry(entry),
|
||||
});
|
||||
|
||||
const { doDelete, doRename, doDownload, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({
|
||||
path: entryBasePath,
|
||||
refresh: effectiveRefresh,
|
||||
clearSelection: clearAllSelection,
|
||||
onShare: (entriesToShare) => setSharingEntries(entriesToShare),
|
||||
onGetDirectLink: (entry) => setDirectLinkEntry(entry),
|
||||
});
|
||||
|
||||
const processorHook = useProcessor({ path: entryBasePath, processorTypes, refresh: effectiveRefresh });
|
||||
|
||||
const handleOpenEntry = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
const next = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
const next = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
|
||||
navigateTo(next.replace(/\/+/g, '/'));
|
||||
} else {
|
||||
openFileWithDefaultApp(entry, path);
|
||||
}
|
||||
openFileWithDefaultApp(entry, entryBasePath);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (entry: VfsEntry) => {
|
||||
setDetailEntry(entry);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
|
||||
const stat = await vfsApi.stat(fullPath);
|
||||
setDetailData(stat as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
@@ -116,17 +183,19 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
|
||||
if (!targetEntries || targetEntries.length === 0) return '';
|
||||
if (targetEntries.length > 1) {
|
||||
return path || '/';
|
||||
return entryBasePath || '/';
|
||||
}
|
||||
const entry = targetEntries[0];
|
||||
const base = path === '/' ? '' : path;
|
||||
const base = entryBasePath === '/' ? '' : entryBasePath;
|
||||
const segments = [base, entry.name].filter(Boolean);
|
||||
const joined = segments.join('/');
|
||||
if (!joined) {
|
||||
return '/';
|
||||
}
|
||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||
}, [path]);
|
||||
}, [entryBasePath]);
|
||||
const showFsPagination = !isSearching && pagination.total > 0;
|
||||
const shouldReserveBottomBar = showSearchPagination || showFsPagination;
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -179,13 +248,13 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
<Header
|
||||
navKey={navKey}
|
||||
path={path}
|
||||
loading={loading}
|
||||
loading={isSearching ? searchLoading : loading}
|
||||
viewMode={viewMode}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onGoUp={goUp}
|
||||
onNavigate={navigateTo}
|
||||
onRefresh={refresh}
|
||||
onRefresh={effectiveRefresh}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onUploadFile={openFilePicker}
|
||||
onUploadDirectory={openDirectoryPicker}
|
||||
@@ -208,8 +277,22 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onChange={handleDirectoryInputChange}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||
{showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||
{isSearching ? (
|
||||
<SearchResultsView
|
||||
viewMode={viewMode}
|
||||
loading={searchLoading}
|
||||
mode={searchMode}
|
||||
query={searchQuery}
|
||||
items={searchItems}
|
||||
selectedPaths={searchSelectedPaths}
|
||||
entrySnapshot={searchEntrySnapshot}
|
||||
onClearSearch={clearSearchParams}
|
||||
onSelect={selectSearchResult}
|
||||
onOpen={(fullPath) => { void openSearchResult(fullPath); }}
|
||||
onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }}
|
||||
/>
|
||||
) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
|
||||
<LoadingSkeleton mode={viewMode} />
|
||||
) : !loading && entries.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
||||
@@ -239,14 +322,27 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination.total > 0 && (
|
||||
{showFsPagination && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
||||
<Pagination {...pagination} onChange={handlePaginationChange} onShowSizeChange={handlePaginationChange} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSearchPagination && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
||||
<Pagination
|
||||
current={searchPage}
|
||||
pageSize={searchPageSize}
|
||||
total={Math.max(searchTotalItems, 1)}
|
||||
showSizeChanger={false}
|
||||
size="small"
|
||||
onChange={(nextPage) => updateSearchPage(nextPage)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Modals & Context Menus --- */}
|
||||
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
|
||||
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDirInCurrentDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
|
||||
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
|
||||
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
|
||||
<MoveCopyModal
|
||||
@@ -275,7 +371,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
/>
|
||||
{sharingEntries.length > 0 && (
|
||||
<ShareModal
|
||||
path={path}
|
||||
path={entryBasePath}
|
||||
entries={sharingEntries}
|
||||
open={sharingEntries.length > 0}
|
||||
onOk={() => setSharingEntries([])}
|
||||
@@ -284,7 +380,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
)}
|
||||
<DirectLinkModal
|
||||
entry={directLinkEntry}
|
||||
path={path}
|
||||
path={entryBasePath}
|
||||
open={!!directLinkEntry}
|
||||
onCancel={() => setDirectLinkEntry(null)}
|
||||
/>
|
||||
@@ -310,12 +406,12 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
x={ctxMenu?.x || blankCtxMenu!.x}
|
||||
y={ctxMenu?.y || blankCtxMenu!.y}
|
||||
entry={ctxMenu?.entry}
|
||||
entries={entries}
|
||||
selectedEntries={selectedEntries}
|
||||
entries={isSearching ? searchContextEntries : entries}
|
||||
selectedEntries={isSearching ? searchSelectedNames : selectedEntries}
|
||||
processorTypes={processorTypes}
|
||||
onClose={closeContextMenus}
|
||||
onOpen={handleOpenEntry}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, entryBasePath)}
|
||||
onDownload={doDownload}
|
||||
onRename={setRenaming}
|
||||
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
||||
|
||||
192
web/src/pages/FileExplorerPage/components/SearchResultsView.tsx
Normal file
192
web/src/pages/FileExplorerPage/components/SearchResultsView.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import { Empty, Flex, Spin, Tag, Typography, theme } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { ViewMode } from '../types';
|
||||
import type { SearchDisplayItem, SearchMode } from '../hooks/useFileSearch';
|
||||
|
||||
interface SearchResultsViewProps {
|
||||
viewMode: ViewMode;
|
||||
loading: boolean;
|
||||
mode: SearchMode;
|
||||
query: string;
|
||||
items: SearchDisplayItem[];
|
||||
selectedPaths: string[];
|
||||
entrySnapshot: Record<string, VfsEntry>;
|
||||
onClearSearch: () => void;
|
||||
onSelect: (fullPath: string, additive: boolean) => void;
|
||||
onOpen: (fullPath: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, fullPath: string) => void;
|
||||
}
|
||||
|
||||
export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
|
||||
viewMode,
|
||||
loading,
|
||||
mode,
|
||||
query,
|
||||
items,
|
||||
selectedPaths,
|
||||
entrySnapshot,
|
||||
onClearSearch,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onContextMenu,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
|
||||
const renderSourceLabel = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'vector':
|
||||
return t('Vector Search');
|
||||
case 'filename':
|
||||
return t('Name Search');
|
||||
case 'text':
|
||||
return t('Text Chunk');
|
||||
case 'image':
|
||||
return t('Image Description');
|
||||
default:
|
||||
return t('Vector Search');
|
||||
}
|
||||
};
|
||||
|
||||
const sourceColor = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'vector':
|
||||
return 'blue';
|
||||
case 'filename':
|
||||
return 'green';
|
||||
case 'image':
|
||||
return 'volcano';
|
||||
case 'text':
|
||||
return 'geekblue';
|
||||
default:
|
||||
return 'purple';
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSnippet = (rawSnippet: string | undefined, name: string, fullPath: string) => {
|
||||
const snippet = (rawSnippet || '').trim();
|
||||
if (!snippet) return '';
|
||||
if (snippet === name) return '';
|
||||
if (snippet === fullPath) return '';
|
||||
if (snippet === fullPath.replace(/^\/+/, '')) return '';
|
||||
return snippet;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Flex align="center" justify="space-between" style={{ marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
|
||||
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<Typography.Text strong>{t('Search Results')}</Typography.Text>
|
||||
<Tag color={mode === 'filename' ? 'green' : 'blue'}>
|
||||
{mode === 'filename' ? t('Name Search') : t('Smart Search')}
|
||||
</Tag>
|
||||
<Tag closable onClose={(ev) => { ev.preventDefault(); onClearSearch(); }}>
|
||||
{query}
|
||||
</Tag>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Flex align="center" justify="center" style={{ padding: 48 }}>
|
||||
<Spin />
|
||||
</Flex>
|
||||
) : items.length === 0 ? (
|
||||
<Flex align="center" justify="center" style={{ padding: 48 }}>
|
||||
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Flex>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div
|
||||
className="fx-grid"
|
||||
style={{ padding: 0, gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
|
||||
>
|
||||
{items.map(({ item, fullPath, dir, name }) => {
|
||||
const selected = selectedPaths.includes(fullPath);
|
||||
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||
const isDir = Boolean(entrySnapshot[fullPath]?.is_dir);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fullPath}
|
||||
className={['fx-grid-item', selected ? 'selected' : '', 'file'].join(' ')}
|
||||
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)}
|
||||
onDoubleClick={() => onOpen(fullPath)}
|
||||
onContextMenu={(ev) => onContextMenu(ev, fullPath)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
<span className="badge score-badge">{scoreText}</span>
|
||||
{isDir
|
||||
? <Typography.Text style={{ fontSize: 32, color: token.colorPrimary }}>📁</Typography.Text>
|
||||
: <Typography.Text style={{ fontSize: 32, color: token.colorTextTertiary }}>📄</Typography.Text>}
|
||||
</div>
|
||||
<div className="name ellipsis">{name}</div>
|
||||
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
|
||||
{dir}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map(({ item, fullPath, name }) => {
|
||||
const selected = selectedPaths.includes(fullPath);
|
||||
const retrieval = item.metadata?.retrieval_source || item.source_type;
|
||||
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||
const snippet = normalizeSnippet(item.snippet, name, fullPath);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fullPath}
|
||||
className={selected ? 'row-selected' : ''}
|
||||
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)}
|
||||
onDoubleClick={() => onOpen(fullPath)}
|
||||
onContextMenu={(ev) => onContextMenu(ev, fullPath)}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: token.borderRadius,
|
||||
background: token.colorFillTertiary,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Flex vertical style={{ gap: 6 }}>
|
||||
<Typography.Text strong className="ellipsis">
|
||||
{name}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
|
||||
{fullPath}
|
||||
</Typography.Text>
|
||||
{snippet ? (
|
||||
<Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>
|
||||
{snippet}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
{retrieval ? (
|
||||
<Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>
|
||||
{renderSourceLabel(retrieval)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag
|
||||
style={{
|
||||
marginRight: 0,
|
||||
background: token.colorBgContainer,
|
||||
borderColor: token.colorBorderSecondary,
|
||||
color: token.colorText,
|
||||
}}
|
||||
>
|
||||
{scoreText}
|
||||
</Tag>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
314
web/src/pages/FileExplorerPage/hooks/useFileSearch.ts
Normal file
314
web/src/pages/FileExplorerPage/hooks/useFileSearch.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { message } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
import type { SearchResultItem } from '../../../api/vfs';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
export type SearchMode = 'vector' | 'filename';
|
||||
|
||||
export type SearchDisplayItem = {
|
||||
item: SearchResultItem;
|
||||
fullPath: string;
|
||||
dir: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
interface UseFileSearchParams {
|
||||
currentPath: string;
|
||||
navigateTo: (path: string) => void;
|
||||
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
|
||||
openContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||
closeContextMenus: () => void;
|
||||
activeEntry?: VfsEntry | null;
|
||||
}
|
||||
|
||||
export function useFileSearch({
|
||||
currentPath,
|
||||
navigateTo,
|
||||
openFileWithDefaultApp,
|
||||
openContextMenu,
|
||||
closeContextMenus,
|
||||
activeEntry,
|
||||
}: UseFileSearchParams) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const query = (searchParams.get('q') || '').trim();
|
||||
const mode = (searchParams.get('mode') === 'filename' ? 'filename' : 'vector') as SearchMode;
|
||||
const page = Math.max(1, Number(searchParams.get('page') || '1') || 1);
|
||||
const isSearching = Boolean(query);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const entryCacheRef = useRef<Map<string, VfsEntry>>(new Map());
|
||||
const [entrySnapshot, setEntrySnapshot] = useState<Record<string, VfsEntry>>({});
|
||||
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
|
||||
const [actionPath, setActionPath] = useState<string>('/');
|
||||
|
||||
const normalizeFullPath = useCallback((fullPath: string) => {
|
||||
const raw = (fullPath || '').replace(/\/+$/, '');
|
||||
if (!raw) return '/';
|
||||
const leading = raw.startsWith('/') ? raw : `/${raw}`;
|
||||
return leading.replace(/\/{2,}/g, '/');
|
||||
}, []);
|
||||
|
||||
const splitPath = useCallback((fullPath: string) => {
|
||||
const normalized = normalizeFullPath(fullPath);
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
const name = parts.pop() || '';
|
||||
const dir = parts.length > 0 ? `/${parts.join('/')}` : '/';
|
||||
return { fullPath: normalized, dir, name };
|
||||
}, [normalizeFullPath]);
|
||||
|
||||
const runSearch = useCallback(async (requestId: number) => {
|
||||
try {
|
||||
const res = await vfsApi.searchFiles(
|
||||
query,
|
||||
mode === 'filename' ? PAGE_SIZE : 10,
|
||||
mode,
|
||||
mode === 'filename' ? page : undefined,
|
||||
mode === 'filename' ? PAGE_SIZE : undefined,
|
||||
);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setResults(res.items || []);
|
||||
if (mode === 'filename') {
|
||||
setHasMore(Boolean(res.pagination?.has_more));
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : t('Load failed');
|
||||
message.error(msg);
|
||||
setResults([]);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [mode, page, query, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearching) {
|
||||
setResults([]);
|
||||
setHasMore(false);
|
||||
setLoading(false);
|
||||
requestIdRef.current += 1;
|
||||
setSelectedPaths([]);
|
||||
entryCacheRef.current.clear();
|
||||
setEntrySnapshot({});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
setLoading(true);
|
||||
setSelectedPaths([]);
|
||||
closeContextMenus();
|
||||
void runSearch(requestId);
|
||||
}, [closeContextMenus, isSearching, runSearch]);
|
||||
|
||||
const ensureEntry = useCallback(async (fullPath: string, defaultName: string): Promise<VfsEntry> => {
|
||||
const normalized = normalizeFullPath(fullPath);
|
||||
const cached = entryCacheRef.current.get(normalized);
|
||||
if (cached) {
|
||||
return { ...cached, name: cached.name || defaultName };
|
||||
}
|
||||
try {
|
||||
const stat = await vfsApi.stat(normalized);
|
||||
const entry: VfsEntry = {
|
||||
name: (stat as any)?.name || defaultName,
|
||||
is_dir: Boolean((stat as any)?.is_dir),
|
||||
size: Number((stat as any)?.size ?? 0),
|
||||
mtime: Number((stat as any)?.mtime ?? (stat as any)?.mtime_ms ?? 0),
|
||||
type: (stat as any)?.type,
|
||||
has_thumbnail: Boolean((stat as any)?.has_thumbnail),
|
||||
};
|
||||
entryCacheRef.current.set(normalized, entry);
|
||||
setEntrySnapshot(prev => ({ ...prev, [normalized]: entry }));
|
||||
return entry;
|
||||
} catch {
|
||||
const fallback: VfsEntry = { name: defaultName, is_dir: false, size: 0, mtime: 0 };
|
||||
entryCacheRef.current.set(normalized, fallback);
|
||||
setEntrySnapshot(prev => ({ ...prev, [normalized]: fallback }));
|
||||
return fallback;
|
||||
}
|
||||
}, [normalizeFullPath]);
|
||||
|
||||
const refreshSearch = useCallback(() => {
|
||||
if (!isSearching) return;
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
setLoading(true);
|
||||
void runSearch(requestId);
|
||||
}, [isSearching, runSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearching) {
|
||||
setActionPath(currentPath || '/');
|
||||
return;
|
||||
}
|
||||
if (actionPath === '/' && currentPath) {
|
||||
setActionPath(currentPath);
|
||||
}
|
||||
}, [actionPath, currentPath, isSearching]);
|
||||
|
||||
const displayItems = useMemo(() => {
|
||||
if (!isSearching) return [];
|
||||
return (results || []).map((item) => {
|
||||
const { fullPath, dir, name } = splitPath(item.path || '');
|
||||
return { item, fullPath, dir, name };
|
||||
}).filter(it => it.fullPath && it.fullPath !== '/' && it.name);
|
||||
}, [isSearching, results, splitPath]);
|
||||
|
||||
const itemByPath = useMemo(() => {
|
||||
const map = new Map<string, SearchDisplayItem>();
|
||||
for (const it of displayItems) {
|
||||
map.set(it.fullPath, it);
|
||||
}
|
||||
return map;
|
||||
}, [displayItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearching) return;
|
||||
selectedPaths.forEach((p) => {
|
||||
const info = itemByPath.get(p);
|
||||
if (info) {
|
||||
void ensureEntry(info.fullPath, info.name);
|
||||
}
|
||||
});
|
||||
}, [ensureEntry, isSearching, itemByPath, selectedPaths]);
|
||||
|
||||
const updateSearchPage = useCallback((nextPage: number) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (nextPage <= 1) params.delete('page');
|
||||
else params.set('page', String(nextPage));
|
||||
const next = params.toString();
|
||||
navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true });
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
const clearSearchParams = useCallback(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete('q');
|
||||
params.delete('mode');
|
||||
params.delete('page');
|
||||
const next = params.toString();
|
||||
navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true });
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
const openResult = useCallback(async (fullPath: string) => {
|
||||
const info = itemByPath.get(fullPath);
|
||||
if (!info) return;
|
||||
setActionPath(info.dir);
|
||||
const entry = await ensureEntry(info.fullPath, info.name);
|
||||
if (entry.is_dir) {
|
||||
navigateTo(info.fullPath);
|
||||
return;
|
||||
}
|
||||
openFileWithDefaultApp(entry, info.dir);
|
||||
}, [ensureEntry, itemByPath, navigateTo, openFileWithDefaultApp]);
|
||||
|
||||
const selectResult = useCallback((fullPath: string, additive: boolean) => {
|
||||
const info = itemByPath.get(fullPath);
|
||||
if (!info) return;
|
||||
if (actionPath !== info.dir) {
|
||||
setActionPath(info.dir);
|
||||
setSelectedPaths([fullPath]);
|
||||
return;
|
||||
}
|
||||
if (!additive) {
|
||||
setSelectedPaths([fullPath]);
|
||||
return;
|
||||
}
|
||||
setSelectedPaths((prev) => {
|
||||
const exists = prev.includes(fullPath);
|
||||
return exists ? prev.filter(p => p !== fullPath) : [...prev, fullPath];
|
||||
});
|
||||
}, [actionPath, itemByPath]);
|
||||
|
||||
const openResultContextMenu = useCallback(async (e: React.MouseEvent, fullPath: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const info = itemByPath.get(fullPath);
|
||||
if (!info) return;
|
||||
setActionPath(info.dir);
|
||||
setSelectedPaths((prev) => {
|
||||
if (actionPath !== info.dir) {
|
||||
return [fullPath];
|
||||
}
|
||||
return prev.includes(fullPath) ? prev : [fullPath];
|
||||
});
|
||||
const entry = await ensureEntry(info.fullPath, info.name);
|
||||
openContextMenu(e, entry);
|
||||
}, [actionPath, ensureEntry, itemByPath, openContextMenu]);
|
||||
|
||||
const selectedNames = useMemo(() => {
|
||||
const names: string[] = [];
|
||||
for (const p of selectedPaths) {
|
||||
const info = itemByPath.get(p);
|
||||
if (info && info.dir === actionPath) {
|
||||
names.push(info.name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [actionPath, itemByPath, selectedPaths]);
|
||||
|
||||
const contextEntries = useMemo(() => {
|
||||
if (!isSearching) return [];
|
||||
const map = new Map<string, VfsEntry>();
|
||||
for (const p of selectedPaths) {
|
||||
const info = itemByPath.get(p);
|
||||
if (!info || info.dir !== actionPath) continue;
|
||||
const cached = entrySnapshot[info.fullPath];
|
||||
map.set(info.name, cached || { name: info.name, is_dir: false, size: 0, mtime: 0 });
|
||||
}
|
||||
if (activeEntry) {
|
||||
map.set(activeEntry.name, activeEntry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}, [actionPath, activeEntry, entrySnapshot, isSearching, itemByPath, selectedPaths]);
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
if (mode !== 'filename') return results.length;
|
||||
if (hasMore) return page * PAGE_SIZE + 1;
|
||||
return (page - 1) * PAGE_SIZE + results.length;
|
||||
}, [hasMore, mode, page, results.length]);
|
||||
|
||||
const showPagination = isSearching && mode === 'filename' && results.length > 0;
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedPaths([]), []);
|
||||
|
||||
return {
|
||||
isSearching,
|
||||
query,
|
||||
mode,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
loading,
|
||||
displayItems,
|
||||
selectedPaths,
|
||||
selectedNames,
|
||||
contextEntries,
|
||||
entrySnapshot,
|
||||
actionPath,
|
||||
showPagination,
|
||||
totalItems,
|
||||
clearSearchParams,
|
||||
updateSearchPage,
|
||||
refreshSearch,
|
||||
openResult,
|
||||
selectResult,
|
||||
openResultContextMenu,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm, Empty, Skeleton, theme, Divider, Tabs, Select, Pagination } from 'antd';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm, Empty, Skeleton, theme, Divider, Tabs } from 'antd';
|
||||
import { GithubOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { loadPlugin, ensureManifest } from '../plugins/runtime';
|
||||
import { getAppByKey, reloadPluginApps, ensureAppsLoaded, listSystemApps, type AppDescriptor } from '../apps/registry';
|
||||
import { useI18n } from '../i18n';
|
||||
import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter';
|
||||
import { useAppWindows } from '../contexts/AppWindowsContext';
|
||||
|
||||
const PluginsPage = memo(function PluginsPage() {
|
||||
@@ -15,14 +14,6 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [tab, setTab] = useState<'installed' | 'discover'>('installed');
|
||||
const [repoLoading, setRepoLoading] = useState(false);
|
||||
const [repoQ, setRepoQ] = useState('');
|
||||
const [repoSort, setRepoSort] = useState<'createdAt' | 'downloads'>('createdAt');
|
||||
const [repoPage, setRepoPage] = useState(1);
|
||||
const [repoPageSize, setRepoPageSize] = useState(12);
|
||||
const [repoTotal, setRepoTotal] = useState(0);
|
||||
const [repoItems, setRepoItems] = useState<RepoItem[]>([]);
|
||||
const [installingKeys, setInstallingKeys] = useState<Record<string, boolean>>({});
|
||||
const [form] = Form.useForm<{ url: string }>();
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
@@ -42,30 +33,6 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const installedKeySet = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach(p => { if (p.key) set.add(p.key); });
|
||||
return set;
|
||||
}, [data]);
|
||||
|
||||
const reloadRepo = useCallback(async () => {
|
||||
try {
|
||||
setRepoLoading(true);
|
||||
const res = await fetchRepoList({ query: repoQ || undefined, sort: repoSort, page: repoPage, pageSize: repoPageSize });
|
||||
setRepoItems(res.items || []);
|
||||
setRepoTotal(res.total || 0);
|
||||
} catch {
|
||||
setRepoItems([]);
|
||||
setRepoTotal(0);
|
||||
} finally {
|
||||
setRepoLoading(false);
|
||||
}
|
||||
}, [repoPage, repoPageSize, repoQ, repoSort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'discover') reloadRepo();
|
||||
}, [reloadRepo, tab]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
const { url } = await form.validateFields();
|
||||
@@ -246,99 +213,6 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderRepoCard = (item: RepoItem) => {
|
||||
const icon = item.icon || '/plugins/demo-text-viewer.svg';
|
||||
const name = item.name || item.key;
|
||||
const exts = (item.supportedExts || []).slice(0, 6);
|
||||
const more = (item.supportedExts || []).length - exts.length;
|
||||
const installed = installedKeySet.has(item.key);
|
||||
const installing = !!installingKeys[item.key];
|
||||
const title = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<img src={icon} alt={name} style={{ width: 24, height: 24, objectFit: 'contain' }} onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/plugins/demo-text-viewer.svg'; }} />
|
||||
<span>{name}</span>
|
||||
{item.version && <Tag color="blue" style={{ marginLeft: 'auto' }}>{item.version}</Tag>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={item.key + '@' + (item.version || '')}
|
||||
title={title}
|
||||
hoverable
|
||||
size="small"
|
||||
styles={{ body: { padding: 12 } } as any}
|
||||
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
|
||||
actions={[
|
||||
typeof item.downloads === 'number' ? (
|
||||
<span key="dl" style={{ color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
{t('Downloads')}: {item.downloads}
|
||||
</span>
|
||||
) : (
|
||||
<span key="dl-gap" />
|
||||
),
|
||||
<Button
|
||||
key="install"
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={installed || installing}
|
||||
loading={installing}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setInstallingKeys(s => ({ ...s, [item.key]: true }));
|
||||
const url = buildCenterUrl(item.directUrl);
|
||||
const created = await pluginsApi.create({ url });
|
||||
try {
|
||||
const p = await loadPlugin(created);
|
||||
await ensureManifest(created.id, p);
|
||||
} catch { void 0; }
|
||||
await reload();
|
||||
await reloadPluginApps();
|
||||
message.success(t('Installed successfully'));
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || 'Install failed');
|
||||
} finally {
|
||||
setInstallingKeys(s => ({ ...s, [item.key]: false }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{installed ? t('Installed already') : t('Install')}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Typography.Paragraph
|
||||
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{item.description || '(暂无描述)'}
|
||||
</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', overflow: 'hidden', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
|
||||
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e} style={{ flex: 'none' }}>{e}</Tag>)}
|
||||
</div>
|
||||
{more > 0 && <Tag style={{ flex: 'none' }}>+{more}</Tag>}
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{(item.author || item.github || item.website) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
{item.author && <span>{t('Author')}: {item.author}</span>}
|
||||
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
{item.github && (
|
||||
<a href={item.github || undefined} target="_blank" rel="noreferrer" title="GitHub">
|
||||
<GithubOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
|
||||
</a>
|
||||
)}
|
||||
{item.website && (
|
||||
<a href={item.website || undefined} target="_blank" rel="noreferrer" title={t('Website')}>
|
||||
<LinkOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: 'calc(100vh - 88px)', display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
@@ -392,63 +266,8 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
key: 'discover',
|
||||
label: t('Discover'),
|
||||
children: (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder={t('Search apps')}
|
||||
value={repoQ}
|
||||
onChange={e => { setRepoQ(e.target.value); setRepoPage(1); }}
|
||||
allowClear
|
||||
style={{ maxWidth: 360 }}
|
||||
onPressEnter={() => { setRepoPage(1); reloadRepo(); }}
|
||||
/>
|
||||
<Select
|
||||
value={repoSort}
|
||||
style={{ width: 200 }}
|
||||
onChange={(v) => { setRepoSort(v); setRepoPage(1); }}
|
||||
options={[
|
||||
{ value: 'createdAt', label: t('Created (newest)') },
|
||||
{ value: 'downloads', label: t('Downloads') },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
href="https://center.foxel.cc"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Foxel Center
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: 4 }}>
|
||||
{repoLoading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} style={{ borderRadius: 10 }}>
|
||||
<Skeleton active avatar paragraph={{ rows: 3 }} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : repoItems.length === 0 ? (
|
||||
<Empty description={t('No results')} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
|
||||
{repoItems.map(renderRepoCard)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 12 }}>
|
||||
<Pagination
|
||||
current={repoPage}
|
||||
pageSize={repoPageSize}
|
||||
total={repoTotal}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[12, 24, 48].map(String)}
|
||||
onChange={(p, ps) => { setRepoPage(p); setRepoPageSize(ps); }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Empty description={`${t('Coming soon')} v2`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user