Compare commits

...

17 Commits

Author SHA1 Message Date
shiyu
31d97b2968 chore: update version to v1.5.5 2026-01-03 21:19:47 +08:00
shiyu
35abd080be feat: implement file search functionality in FileExplorerPage 2026-01-03 21:16:53 +08:00
shiyu
2fa93a1eeb feat: add vector and file collection constants, update vector index handling 2026-01-03 15:12:20 +08:00
shiyu
ff7eb13187 chore: update Python version in README files to 3.14 2026-01-03 14:09:29 +08:00
shiyu
ed9090c3d0 chore: update version to v1.5.4 2026-01-02 11:42:26 +08:00
shiyu
d430254868 feat: add Foxel adapter support and localization entries 2026-01-01 23:57:27 +08:00
dependabot[bot]
a8870f80da chore(deps): bump fastapi from 0.127.0 to 0.128.0 (#85)
Bumps [fastapi](https://github.com/fastapi/fastapi) from 0.127.0 to 0.128.0.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.127.0...0.128.0)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.128.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 15:48:21 +08:00
dependabot[bot]
14ef2a4ccc chore(deps): bump antd from 6.1.2 to 6.1.3 in /web (#84)
Bumps [antd](https://github.com/ant-design/ant-design) from 6.1.2 to 6.1.3.
- [Release notes](https://github.com/ant-design/ant-design/releases)
- [Changelog](https://github.com/ant-design/ant-design/blob/master/CHANGELOG.en-US.md)
- [Commits](https://github.com/ant-design/ant-design/compare/6.1.2...6.1.3)

---
updated-dependencies:
- dependency-name: antd
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 15:48:14 +08:00
dependabot[bot]
dd41941b04 chore(deps-dev): bump typescript-eslint from 8.50.1 to 8.51.0 in /web (#86)
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.50.1 to 8.51.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.51.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.51.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 15:48:02 +08:00
shiyu
01a259bae0 fix: update UI screenshot links in README files for consistency 2026-01-01 15:47:17 +08:00
shiyu
ef5ef2730c feat(audit): enhance client IP extraction logic in request handling 2026-01-01 15:45:38 +08:00
shiyu
8b8772b064 fix: update font stylesheet and family for consistency in design 2025-12-30 20:43:10 +08:00
shiyu
5393a973eb chore: update version to v1.5.3 2025-12-30 17:36:15 +08:00
shiyu
cc1f130099 feat(audit): adjust column widths and alignments in Audit Logs table for improved readability 2025-12-30 17:25:17 +08:00
shiyu
c8b3817805 feat(plugins): remove unused repository-related code and simplify UI for upcoming features 2025-12-30 15:39:09 +08:00
shiyu
b1ea181f96 feat: support dynamic port configuration in Docker Compose and entrypoint scripts 2025-12-30 15:00:22 +08:00
shiyu
078709b871 feat(audit): update log clearing confirmation message and remove redundant checks 2025-12-30 14:41:21 +08:00
28 changed files with 1253 additions and 848 deletions

View File

@@ -8,16 +8,17 @@
**A highly extensible private cloud storage solution for individuals and teams, featuring AI-powered semantic search.**
![Python Version](https://img.shields.io/badge/Python-3.13+-blue.svg)
![Python Version](https://img.shields.io/badge/Python-3.14+-blue.svg)
![React](https://img.shields.io/badge/React-19.0-blue.svg)
![License](https://img.shields.io/badge/license-MIT-green.svg)
![GitHub stars](https://img.shields.io/github/stars/DrizzleTime/foxel?style=social)
---
<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

View File

@@ -8,17 +8,17 @@
**一个面向个人和团队的、高度可扩展的私有云盘解决方案,支持 AI 语义搜索。**
![Python Version](https://img.shields.io/badge/Python-3.13+-blue.svg)
![Python Version](https://img.shields.io/badge/Python-3.14+-blue.svg)
![React](https://img.shields.io/badge/React-19.0-blue.svg)
![License](https://img.shields.io/badge/license-MIT-green.svg)
![GitHub stars](https://img.shields.io/github/stars/DrizzleTime/foxel?style=social)
---
<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. **访问应用**
> 首次启动,请根据引导页面完成管理员账号的初始化设置
服务启动后,在浏览器中打开页面
> 首次启动,请根据引导页面完成管理员账号的初始化设置。
## 🤝 如何贡献

View File

@@ -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)

View File

@@ -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:

View 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)

View File

@@ -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,

View File

@@ -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})

View File

@@ -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()

View File

@@ -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:

View File

@@ -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")

View File

@@ -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}

View File

@@ -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)

View File

@@ -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 []:

View File

@@ -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

View File

@@ -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
View File

@@ -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]]

View File

@@ -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=="],

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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; }

View File

@@ -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",

View File

@@ -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": "级别",

View File

@@ -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>
);
};

View File

@@ -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>
),

View File

@@ -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)}

View 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>
);
};

View 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,
};
}

View File

@@ -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>
)
}