Compare commits

...
2 Commits
Author SHA1 Message Date
amb ff3e2a7273 回收站,软删除(三天自动清空),以及默认链接三十分钟失效 2026-09-02 18:59:42 +08:00
amb bfb7a1a8a2 细节优化 2026-09-02 18:32:56 +08:00
18 changed files with 863 additions and 88 deletions
@@ -0,0 +1,35 @@
"""add_deleted_at_recycle_bin
Revision ID: a3f8c21d94b7
Revises: 5bb03575e2e7
Create Date: 2026-09-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3f8c21d94b7'
down_revision: Union[str, None] = '5bb03575e2e7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('documents', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
op.add_column('document_categories', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
op.add_column('knowledge_bases', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间'))
op.create_index('ix_documents_deleted_at', 'documents', ['deleted_at'])
op.create_index('ix_document_categories_deleted_at', 'document_categories', ['deleted_at'])
op.create_index('ix_knowledge_bases_deleted_at', 'knowledge_bases', ['deleted_at'])
def downgrade() -> None:
op.drop_index('ix_knowledge_bases_deleted_at', table_name='knowledge_bases')
op.drop_index('ix_document_categories_deleted_at', table_name='document_categories')
op.drop_index('ix_documents_deleted_at', table_name='documents')
op.drop_column('knowledge_bases', 'deleted_at')
op.drop_column('document_categories', 'deleted_at')
op.drop_column('documents', 'deleted_at')
+35 -17
View File
@@ -52,10 +52,13 @@ def get_category_tree(
"""获取完整目录树(含文档数量)。""" """获取完整目录树(含文档数量)。"""
_check_kb_owner(kb_id, user, db) _check_kb_owner(kb_id, user, db)
# 获取所有分类 # 获取所有未删除的分类
stmt = ( stmt = (
select(DocumentCategory) select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb_id) .where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.deleted_at.is_(None),
)
.order_by(DocumentCategory.sort_order, DocumentCategory.name) .order_by(DocumentCategory.sort_order, DocumentCategory.name)
) )
all_cats = list(db.scalars(stmt).all()) all_cats = list(db.scalars(stmt).all())
@@ -110,7 +113,10 @@ def list_categories_flat(
stmt = ( stmt = (
select(DocumentCategory) select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb_id) .where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.deleted_at.is_(None),
)
.order_by(DocumentCategory.path, DocumentCategory.sort_order) .order_by(DocumentCategory.path, DocumentCategory.sort_order)
) )
cats = list(db.scalars(stmt).all()) cats = list(db.scalars(stmt).all())
@@ -236,32 +242,44 @@ def delete_category(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> None: ) -> None:
"""删除分类(同时删除子分类,关联文档的 category_id 置 NULL)。""" """删除分类 → 进回收站(含全部子分类及其下文档,3 天后自动彻底删除)。"""
from datetime import datetime
from app.models.document import Document
_check_kb_owner(kb_id, user, db) _check_kb_owner(kb_id, user, db)
cat = db.get(DocumentCategory, cat_id) cat = db.get(DocumentCategory, cat_id)
if cat is None or cat.knowledge_base_id != kb_id: if cat is None or cat.knowledge_base_id != kb_id or cat.deleted_at:
raise NotFoundError("分类不存在。") raise NotFoundError("分类不存在。")
# 删除所有子分类 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 收集本分类 + 所有存活的后代分类
target_ids = [cat_id]
if cat.path: if cat.path:
stmt = select(DocumentCategory).where( stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id, DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path.startswith(cat.path), DocumentCategory.path.startswith(cat.path),
DocumentCategory.id != cat_id, DocumentCategory.deleted_at.is_(None),
) )
children = list(db.scalars(stmt).all()) for c in db.scalars(stmt).all():
for child in children: if c.id != cat_id:
db.delete(child) target_ids.append(c.id)
# 关联文档的 category_id 置 NULL # 软删除分类树
from app.models.document import Document for cid in target_ids:
c = db.get(DocumentCategory, cid)
c.deleted_at = now
doc_stmt = select(Document).where(Document.category_id == cat_id) # 软删除目录下的文档(同一批次时间戳,便于整组恢复)
docs = list(db.scalars(doc_stmt).all()) doc_stmt = select(Document).where(
for doc in docs: Document.category_id.in_(target_ids),
doc.category_id = None Document.status != "DELETED",
)
for doc in db.scalars(doc_stmt).all():
doc.status = "DELETED"
doc.deleted_at = now
db.delete(cat)
db.commit() db.commit()
+4 -3
View File
@@ -87,9 +87,10 @@ def regenerate_token(
) -> KbTokenResponse: ) -> KbTokenResponse:
svc = KnowledgeBaseService(db) svc = KnowledgeBaseService(db)
kb, token = svc.regenerate_token(kb_id, user) kb, token = svc.regenerate_token(kb_id, user)
# 重新生成 = 全新链接,有效期重置为长期 return KbTokenResponse(
svc.set_expiry(kb_id, user, None) token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint,
return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint) expires_at=kb.token_expires_at, is_expired=False,
)
@router.post("/{kb_id}/enable", response_model=KbResponse) @router.post("/{kb_id}/enable", response_model=KbResponse)
+87
View File
@@ -0,0 +1,87 @@
"""回收站 API 路由。"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.user import User
from app.services.recycle_bin_service import RecycleBinService
router = APIRouter(prefix="/recycle-bin", tags=["recycle-bin"])
@router.get("")
def list_recycle_bin(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""回收站内容列表。"""
return RecycleBinService(db).list_items(user)
@router.post("/documents/{doc_id}/restore", status_code=204)
def restore_document(
doc_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
RecycleBinService(db).restore_document(doc_id, user)
@router.delete("/documents/{doc_id}", status_code=204)
def purge_document(
doc_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""彻底删除单个文档(文件+记录)。"""
RecycleBinService(db).purge_document(doc_id, user)
@router.post("/categories/{cat_id}/restore", status_code=204)
def restore_category(
cat_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""恢复目录(连同同批删除的子目录与文档)。"""
RecycleBinService(db).restore_category(cat_id, user)
@router.delete("/categories/{cat_id}", status_code=204)
def purge_category(
cat_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""彻底删除目录(连同同批内容)。"""
RecycleBinService(db).purge_category(cat_id, user)
@router.post("/knowledge-bases/{kb_id}/restore", status_code=204)
def restore_kb(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
RecycleBinService(db).restore_kb(kb_id, user)
@router.delete("/knowledge-bases/{kb_id}", status_code=204)
def purge_kb(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""彻底删除知识库(全部文件+记录)。"""
RecycleBinService(db).purge_kb(kb_id, user)
@router.post("/purge-expired")
def purge_expired(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""手动触发:彻底清理所有已过保留期的内容。"""
count = RecycleBinService(db).purge_expired()
return {"purged": count}
+2
View File
@@ -41,6 +41,8 @@ class Settings(BaseSettings):
# --- 数据保留 --- # --- 数据保留 ---
# 访问日志保留天数,超过自动清理(0 = 永久保留,不推荐) # 访问日志保留天数,超过自动清理(0 = 永久保留,不推荐)
access_log_retention_days: int = 90 access_log_retention_days: int = 90
# 回收站保留天数,超期自动彻底删除(含物理文件)
recycle_bin_retention_days: int = 3
# --- CORS --- # --- CORS ---
frontend_origin: str = "http://localhost:5173" frontend_origin: str = "http://localhost:5173"
+24 -5
View File
@@ -15,6 +15,7 @@ from app.api.categories import router as cat_router
from app.api.documents import router as doc_router from app.api.documents import router as doc_router
from app.api.health import router as health_router from app.api.health import router as health_router
from app.api.knowledge_bases import router as kb_router from app.api.knowledge_bases import router as kb_router
from app.api.recycle_bin import router as recycle_router
from app.public.routes import router as public_router from app.public.routes import router as public_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.db import dispose_engine, get_session_factory from app.core.db import dispose_engine, get_session_factory
@@ -65,16 +66,29 @@ def _cleanup_access_logs() -> int:
return count return count
async def _periodic_log_cleanup() -> None: async def _periodic_cleanup() -> None:
""" 24 小时清理一次过期访问日志(在线程池执行同步 DB 操作)""" """小时执行一次数据清理:访问日志 + 回收站过期内容"""
import asyncio import asyncio
while True: while True:
await asyncio.sleep(24 * 3600) await asyncio.sleep(3600)
try: try:
await asyncio.to_thread(_cleanup_access_logs) await asyncio.to_thread(_cleanup_access_logs)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.exception("定期清理访问日志失败") logger.exception("定期清理访问日志失败")
try:
await asyncio.to_thread(_purge_recycle_bin)
except Exception: # noqa: BLE001
logger.exception("定期清理回收站失败")
def _purge_recycle_bin() -> int:
"""彻底删除回收站中超过保留期的内容。"""
from app.services.recycle_bin_service import RecycleBinService
factory = get_session_factory()
with factory() as session:
return RecycleBinService(session).purge_expired()
@asynccontextmanager @asynccontextmanager
@@ -90,14 +104,18 @@ async def lifespan(app: FastAPI):
# Seed:确保 free plan 存在 # Seed:确保 free plan 存在
_seed_free_plan() _seed_free_plan()
# 启动时清理过期访问日志(不阻塞启动) # 启动时清理过期数据:访问日志 + 回收站(不阻塞启动)
import asyncio import asyncio
cleanup_task = asyncio.create_task(_periodic_log_cleanup()) cleanup_task = asyncio.create_task(_periodic_cleanup())
try: try:
await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15) await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.warning("启动时清理访问日志未完成(首次部署属正常)") logger.warning("启动时清理访问日志未完成(首次部署属正常)")
try:
await asyncio.wait_for(asyncio.to_thread(_purge_recycle_bin), timeout=15)
except Exception: # noqa: BLE001
logger.warning("启动时清理回收站未完成(首次部署属正常)")
yield yield
@@ -138,6 +156,7 @@ def create_app() -> FastAPI:
app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"]) app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"])
app.include_router(doc_router, prefix="/api", tags=["documents"]) app.include_router(doc_router, prefix="/api", tags=["documents"])
app.include_router(cat_router, prefix="/api", tags=["categories"]) app.include_router(cat_router, prefix="/api", tags=["categories"])
app.include_router(recycle_router, prefix="/api", tags=["recycle-bin"])
app.include_router(public_router, tags=["public"]) app.include_router(public_router, tags=["public"])
return app return app
+6
View File
@@ -126,6 +126,12 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=False, nullable=False,
comment="状态 (PENDING/PROCESSING/READY/FAILED/DELETED)", comment="状态 (PENDING/PROCESSING/READY/FAILED/DELETED)",
) )
deleted_at: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
index=True,
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
)
error_code: Mapped[str | None] = mapped_column( error_code: Mapped[str | None] = mapped_column(
String(64), String(64),
nullable=True, nullable=True,
+6
View File
@@ -52,6 +52,12 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=False, nullable=False,
comment="排序序号", comment="排序序号",
) )
deleted_at: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
index=True,
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
)
# 关系 # 关系
knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin") knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin")
+6
View File
@@ -65,6 +65,12 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=True, nullable=True,
comment="链接过期时间,NULL=长期有效", comment="链接过期时间,NULL=长期有效",
) )
deleted_at: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
index=True,
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
)
# 关系 # 关系
user = relationship("User", back_populates="knowledge_bases", lazy="selectin") user = relationship("User", back_populates="knowledge_bases", lazy="selectin")
+6 -4
View File
@@ -1,6 +1,7 @@
"""文档服务:上传、删除、配额管理。""" """文档服务:上传、删除、配额管理。"""
import hashlib import hashlib
from datetime import datetime
from pathlib import Path from pathlib import Path
import filetype import filetype
@@ -153,16 +154,17 @@ class DocumentService:
return self._doc_repo.list_by_knowledge_base(kb_id, category_id=category_id, page=page, page_size=page_size) return self._doc_repo.list_by_knowledge_base(kb_id, category_id=category_id, page=page, page_size=page_size)
def delete(self, doc_id: str, user: User) -> None: def delete(self, doc_id: str, user: User) -> None:
"""删除文档 → 进回收站(3 天后自动彻底删除)。文件暂保留以支持恢复。"""
doc = self.get_or_404(doc_id, user) doc = self.get_or_404(doc_id, user)
file_size = doc.file_size file_size = doc.file_size
self._doc_repo.delete(doc) self._doc_repo.delete(doc)
self._delete_doc_files(doc) doc.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 回补配额 # 配额立即回补(用户预期删除即释放空间)
self._restore_quota(user, file_size) self._restore_quota(user, file_size)
self._session.commit() self._session.commit()
def _delete_doc_files(self, doc: Document) -> None: def purge_files(self, doc: Document) -> None:
"""物理删除文档的原始文件与 Markdown 文件(软删后调用)。""" """物理删除文档的原始文件与 Markdown 文件(彻底删除时调用)。"""
from app.storage.local_storage import get_storage from app.storage.local_storage import get_storage
storage = get_storage() storage = get_storage()
+5 -2
View File
@@ -56,10 +56,13 @@ class KbPublicService:
return datetime.now() > expires return datetime.now() > expires
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]: def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
"""获取目录树(含文档数量)。""" """获取目录树(含文档数量),排除已删除目录"""
stmt = ( stmt = (
select(DocumentCategory) select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb.id) .where(
DocumentCategory.knowledge_base_id == kb.id,
DocumentCategory.deleted_at.is_(None),
)
.order_by(DocumentCategory.sort_order, DocumentCategory.name) .order_by(DocumentCategory.sort_order, DocumentCategory.name)
) )
all_cats = list(self._session.scalars(stmt).all()) all_cats = list(self._session.scalars(stmt).all())
+12 -30
View File
@@ -1,5 +1,7 @@
"""知识库服务:CRUD + Token 管理 + 默认目录树。""" """知识库服务:CRUD + Token 管理 + 默认目录树。"""
from datetime import datetime, timedelta
from app.core.errors import NotFoundError, PermissionDeniedError from app.core.errors import NotFoundError, PermissionDeniedError
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.document_category import DocumentCategory from app.models.document_category import DocumentCategory
@@ -33,6 +35,10 @@ class KnowledgeBaseService:
token_encrypted=token_encrypted, token_encrypted=token_encrypted,
token_hint=token_hint, token_hint=token_hint,
) )
# AI 链接默认 30 分钟有效期
from datetime import datetime, timedelta
kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
self._seed_default_categories(kb.id) self._seed_default_categories(kb.id)
self._session.commit() self._session.commit()
return kb, token return kb, token
@@ -40,7 +46,7 @@ class KnowledgeBaseService:
def _seed_default_categories(self, kb_id: str) -> None: def _seed_default_categories(self, kb_id: str) -> None:
"""创建默认目录树结构。""" """创建默认目录树结构。"""
default_tree = [ default_tree = [
("01 公司层", ["公司基本信息", "经营理念", "四大价值", "A/M/B三态"]), ("01 公司层", ["公司基本信息", "经营理念", "四大价值"]),
("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]), ("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]),
("03 部门层", ["企划", "技术", "交付", "市场"]), ("03 部门层", ["企划", "技术", "交付", "市场"]),
("04 岗位/AI角色", []), ("04 岗位/AI角色", []),
@@ -97,39 +103,14 @@ class KnowledgeBaseService:
return kb return kb
def delete(self, kb_id: str, user: User) -> None: def delete(self, kb_id: str, user: User) -> None:
"""删除知识库 → 进回收站(3 天后自动彻底删除)。"""
kb = self.get_or_404(kb_id, user) kb = self.get_or_404(kb_id, user)
self._kb_repo.delete(kb) self._kb_repo.delete(kb)
self._cleanup_kb_files(kb) kb.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._session.commit() self._session.commit()
def _cleanup_kb_files(self, kb: KnowledgeBase) -> None:
"""物理删除知识库下所有文档的原始文件与 Markdown 文件(软删后调用)。
文件删除失败不阻塞删除流程(记录日志,可由后续清理兜底)。
"""
from sqlalchemy import select
from app.models.document import Document
from app.storage.local_storage import get_storage
stmt = select(Document).where(Document.knowledge_base_id == kb.id)
docs = list(self._session.scalars(stmt).all())
storage = get_storage()
removed = 0
for doc in docs:
for key in (doc.storage_path, doc.markdown_path):
if not key:
continue
try:
storage.delete(key)
removed += 1
except Exception as exc: # noqa: BLE001
logger.warning("清理文件失败 key=%s: %s", key, exc)
if removed:
logger.info("KB %s 软删,已物理清理 %d 个文件", kb.id, removed)
def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]: def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]:
"""重新生成 Token。旧链接立即失效。""" """重新生成 Token。旧链接立即失效,新链接默认 30 分钟有效期"""
kb = self.get_or_404(kb_id, user) kb = self.get_or_404(kb_id, user)
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair() token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
self._kb_repo.update( self._kb_repo.update(
@@ -138,6 +119,7 @@ class KnowledgeBaseService:
token_encrypted=token_encrypted, token_encrypted=token_encrypted,
token_hint=token_hint, token_hint=token_hint,
) )
kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
self._session.commit() self._session.commit()
return kb, token return kb, token
@@ -149,7 +131,7 @@ class KnowledgeBaseService:
def set_expiry(self, kb_id: str, user: User, expires_in_minutes: int | None) -> KnowledgeBase: def set_expiry(self, kb_id: str, user: User, expires_in_minutes: int | None) -> KnowledgeBase:
"""设置链接有效期。None = 长期有效;负数表示已过期(测试用)。""" """设置链接有效期。None = 长期有效;负数表示已过期(测试用)。"""
from datetime import datetime, timedelta from datetime import timedelta
kb = self.get_or_404(kb_id, user) kb = self.get_or_404(kb_id, user)
if expires_in_minutes is None: if expires_in_minutes is None:
+338
View File
@@ -0,0 +1,338 @@
"""回收站服务:目录/文档/知识库删除后的暂存、恢复与彻底删除。
- 删除 = 软删除(deleted_at 时间戳),文件保留
- 恢复 = 清除删除标记(目录恢复时整批恢复同批文档)
- 彻底删除 = 物理删除文件 + 删除数据库记录
- 超过保留期(默认 3 天)由定时任务自动彻底删除
"""
from datetime import datetime, timedelta
from sqlalchemy import delete as sa_delete
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.errors import NotFoundError
from app.core.logging import get_logger
from app.models.document import Document
from app.models.document_category import DocumentCategory
from app.models.knowledge_base import KnowledgeBase
from app.models.user import User
from app.services.doc_service import DocumentService
logger = get_logger(__name__)
_TIME_FMT = "%Y-%m-%d %H:%M:%S"
_DOC_RESTORE_STATUS = "READY" # 恢复文档时的状态(文件未删,可直接就绪)
class RecycleBinService:
def __init__(self, session: Session) -> None:
self._session = session
self._doc_svc = DocumentService(session)
# ------------------------------------------------------------------
# 查询
# ------------------------------------------------------------------
def list_items(self, user: User) -> dict:
"""列出回收站内容(按用户隔离),分三类返回。"""
settings = get_settings()
now = datetime.now()
deadline = now - timedelta(days=settings.recycle_bin_retention_days)
def days_left(deleted_at: str) -> int:
try:
d = datetime.strptime(deleted_at, _TIME_FMT)
except (ValueError, TypeError):
return 0
return max(0, (d - deadline).days)
# 文档
doc_stmt = (
select(Document)
.join(KnowledgeBase, Document.knowledge_base_id == KnowledgeBase.id)
.where(
Document.status == "DELETED",
Document.deleted_at.is_not(None),
Document.user_id == user.id,
)
.order_by(Document.deleted_at.desc())
)
documents = []
for d in self._session.scalars(doc_stmt).all():
documents.append({
"id": d.id,
"name": d.title or d.original_filename,
"kb_id": d.knowledge_base_id,
"kb_name": d.knowledge_base.name if d.knowledge_base else "",
"deleted_at": d.deleted_at,
"days_left": days_left(d.deleted_at),
"file_size": d.file_size,
})
# 分类
cat_stmt = (
select(DocumentCategory)
.join(KnowledgeBase, DocumentCategory.knowledge_base_id == KnowledgeBase.id)
.where(
DocumentCategory.deleted_at.is_not(None),
KnowledgeBase.user_id == user.id,
)
.order_by(DocumentCategory.deleted_at.desc())
)
categories = []
for c in self._session.scalars(cat_stmt).all():
categories.append({
"id": c.id,
"name": c.name,
"kb_id": c.knowledge_base_id,
"kb_name": c.knowledge_base.name if c.knowledge_base else "",
"deleted_at": c.deleted_at,
"days_left": days_left(c.deleted_at),
})
# 知识库
kb_stmt = (
select(KnowledgeBase)
.where(
KnowledgeBase.status == "DELETED",
KnowledgeBase.deleted_at.is_not(None),
KnowledgeBase.user_id == user.id,
)
.order_by(KnowledgeBase.deleted_at.desc())
)
knowledge_bases = []
for kb in self._session.scalars(kb_stmt).all():
knowledge_bases.append({
"id": kb.id,
"name": kb.name,
"deleted_at": kb.deleted_at,
"days_left": days_left(kb.deleted_at),
})
return {
"documents": documents,
"categories": categories,
"knowledge_bases": knowledge_bases,
"retention_days": settings.recycle_bin_retention_days,
}
# ------------------------------------------------------------------
# 恢复
# ------------------------------------------------------------------
def restore_document(self, doc_id: str, user: User) -> None:
doc = self._get_deleted_doc(doc_id, user)
doc.status = _DOC_RESTORE_STATUS
doc.deleted_at = None
self._session.commit()
def restore_category(self, cat_id: str, user: User) -> None:
"""恢复分类(连同同批删除的后代分类与文档)。"""
cat = self._get_deleted_category(cat_id, user)
batch_time = cat.deleted_at
# 后代分类中同批删除的一并恢复
if cat.path:
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == cat.knowledge_base_id,
DocumentCategory.path.startswith(cat.path),
DocumentCategory.deleted_at == batch_time,
)
for c in self._session.scalars(stmt).all():
c.deleted_at = None
cat.deleted_at = None
# 同批删除的文档一并恢复
doc_stmt = select(Document).where(
Document.deleted_at == batch_time,
Document.user_id == user.id,
)
for d in self._session.scalars(doc_stmt).all():
if d.category_id and self._in_category_tree(d.category_id, cat):
d.status = _DOC_RESTORE_STATUS
d.deleted_at = None
self._session.commit()
def restore_kb(self, kb_id: str, user: User) -> None:
kb = self._get_deleted_kb(kb_id, user)
kb.status = "active"
kb.deleted_at = None
self._session.commit()
# ------------------------------------------------------------------
# 彻底删除
# ------------------------------------------------------------------
def purge_document(self, doc_id: str, user: User) -> None:
doc = self._get_deleted_doc(doc_id, user)
self._purge_doc_rows(doc)
self._session.commit()
def _purge_doc_rows(self, doc: Document) -> None:
"""物理删除文件 + 关联访问日志 + 记录。"""
from app.models.access_log import AccessLog
self._doc_svc.purge_files(doc)
self._session.execute(
sa_delete(AccessLog).where(AccessLog.document_id == doc.id)
)
self._session.delete(doc)
def purge_category(self, cat_id: str, user: User) -> None:
"""彻底删除分类:连同同批删除的后代分类与文档(文件+记录)。"""
cat = self._get_deleted_category(cat_id, user)
batch_time = cat.deleted_at
kb_id = cat.knowledge_base_id
# 收集同批删除的分类 ID 集合(自身 + 后代)
cat_ids = [cat_id]
if cat.path:
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path.startswith(cat.path),
DocumentCategory.deleted_at == batch_time,
)
cat_ids += [c.id for c in self._session.scalars(stmt).all() if c.id != cat_id]
# 同批删除的文档:物理删文件 + 删记录
doc_stmt = select(Document).where(
Document.deleted_at == batch_time,
Document.category_id.in_(cat_ids),
Document.user_id == user.id,
)
for d in self._session.scalars(doc_stmt).all():
self._purge_doc_rows(d)
# 其他仍引用这些分类的文档(各自单独删除的):解除关联,留在回收站
ref_docs = select(Document).where(Document.category_id.in_(cat_ids))
for d in self._session.scalars(ref_docs).all():
d.category_id = None
# 删除分类记录
for cid in cat_ids:
c = self._session.get(DocumentCategory, cid)
if c:
self._session.delete(c)
self._session.commit()
def purge_kb(self, kb_id: str, user: User) -> None:
"""彻底删除知识库:其下所有文档文件 + 全部记录。"""
from app.models.access_log import AccessLog
kb = self._get_deleted_kb(kb_id, user)
doc_stmt = select(Document).where(Document.knowledge_base_id == kb_id)
for d in self._session.scalars(doc_stmt).all():
self._purge_doc_rows(d)
cat_stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id
)
for c in self._session.scalars(cat_stmt).all():
self._session.delete(c)
# 访问日志
self._session.execute(sa_delete(AccessLog).where(AccessLog.knowledge_base_id == kb_id))
self._session.delete(kb)
self._session.commit()
# ------------------------------------------------------------------
# 自动清理(定时任务调用)
# ------------------------------------------------------------------
def purge_expired(self) -> int:
"""彻底删除所有超过保留期的回收站内容。返回清理的条目数。"""
settings = get_settings()
cutoff = (datetime.now() - timedelta(days=settings.recycle_bin_retention_days)).strftime(_TIME_FMT)
count = 0
# 过期文档
doc_stmt = select(Document).where(
Document.status == "DELETED",
Document.deleted_at.is_not(None),
Document.deleted_at < cutoff,
)
for d in self._session.scalars(doc_stmt).all():
self._purge_doc_rows(d)
count += 1
# 过期分类(解除所有仍引用它的文档关联,再删除)
cat_stmt = select(DocumentCategory).where(
DocumentCategory.deleted_at.is_not(None),
DocumentCategory.deleted_at < cutoff,
)
expired_cats = list(self._session.scalars(cat_stmt).all())
for c in expired_cats:
ref_docs = select(Document).where(Document.category_id == c.id)
for d in self._session.scalars(ref_docs).all():
d.category_id = None
self._session.delete(c)
count += 1
# 过期知识库
from app.models.access_log import AccessLog
kb_stmt = select(KnowledgeBase).where(
KnowledgeBase.status == "DELETED",
KnowledgeBase.deleted_at.is_not(None),
KnowledgeBase.deleted_at < cutoff,
)
for kb in self._session.scalars(kb_stmt).all():
doc_all = select(Document).where(Document.knowledge_base_id == kb.id)
for d in self._session.scalars(doc_all).all():
self._purge_doc_rows(d)
cat_all = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb.id
)
for c in self._session.scalars(cat_all).all():
self._session.delete(c)
self._session.execute(
sa_delete(AccessLog).where(AccessLog.knowledge_base_id == kb.id)
)
self._session.delete(kb)
count += 1
if count:
self._session.commit()
logger.info("回收站自动清理:%d 项已彻底删除", count)
return count
# ------------------------------------------------------------------
# 内部工具
# ------------------------------------------------------------------
def _get_deleted_doc(self, doc_id: str, user: User) -> Document:
doc = self._session.get(Document, doc_id)
if doc is None or doc.user_id != user.id or doc.status != "DELETED":
raise NotFoundError("回收站中不存在该文档。")
return doc
def _get_deleted_category(self, cat_id: str, user: User) -> DocumentCategory:
cat = self._session.get(DocumentCategory, cat_id)
if cat is None or cat.deleted_at is None:
raise NotFoundError("回收站中不存在该目录。")
kb = self._session.get(KnowledgeBase, cat.knowledge_base_id)
if kb is None or kb.user_id != user.id:
raise NotFoundError("回收站中不存在该目录。")
return cat
def _get_deleted_kb(self, kb_id: str, user: User) -> KnowledgeBase:
kb = self._session.get(KnowledgeBase, kb_id)
if kb is None or kb.user_id != user.id or kb.status != "DELETED":
raise NotFoundError("回收站中不存在该知识库。")
return kb
def _in_category_tree(self, category_id: str, root: DocumentCategory) -> bool:
"""判断分类是否位于 root 的子树内(含自身)。"""
if category_id == root.id:
return True
c = self._session.get(DocumentCategory, category_id)
if c is None or not c.path or not root.path:
return False
return c.path.startswith(root.path)
+4
View File
@@ -57,6 +57,10 @@ def _isolate_db(tmp_path, monkeypatch):
from app.core import session as session_module from app.core import session as session_module
session_module._store.clear() session_module._store.clear()
# 重置存储服务单例(否则上一个测试的 tmp_path 被复用)
import app.storage.local_storage as ls
monkeypatch.setattr(ls, "_instance", None)
yield yield
engine.dispose() engine.dispose()
+84 -26
View File
@@ -23,14 +23,14 @@ def _setup_kb(client: TestClient) -> tuple[str, str]:
return kb_id, resp.json()["token"] return kb_id, resp.json()["token"]
def test_default_permanent() -> None: def test_default_30min() -> None:
"""新建知识库链接默认 30 分钟有效期。"""
with _client() as client: with _client() as client:
kb_id, token = _setup_kb(client) kb_id, token = _setup_kb(client)
resp = client.get(f"/k/{token}") resp = client.get(f"/k/{token}")
assert resp.status_code == 200 assert resp.status_code == 200
# link 接口返回长期有效
resp = client.get(f"/api/knowledge-bases/{kb_id}/link") resp = client.get(f"/api/knowledge-bases/{kb_id}/link")
assert resp.json()["expires_at"] is None assert resp.json()["expires_at"] is not None
assert resp.json()["is_expired"] is False assert resp.json()["is_expired"] is False
@@ -106,13 +106,14 @@ def test_regenerate_resets_expiry() -> None:
kb_id, old_token = _setup_kb(client) kb_id, old_token = _setup_kb(client)
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry", client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
json={"expires_in_minutes": -1}) json={"expires_in_minutes": -1})
# 重新生成 → 新链接长期有效 # 重新生成 → 新链接默认 30 分钟有效
resp = client.post(f"/api/knowledge-bases/{kb_id}/regenerate-token") resp = client.post(f"/api/knowledge-bases/{kb_id}/regenerate-token")
new_token = resp.json()["token"] new_token = resp.json()["token"]
assert new_token != old_token assert new_token != old_token
assert client.get(f"/k/{new_token}").status_code == 200 assert client.get(f"/k/{new_token}").status_code == 200
resp = client.get(f"/api/knowledge-bases/{kb_id}/link") resp = client.get(f"/api/knowledge-bases/{kb_id}/link")
assert resp.json()["expires_at"] is None assert resp.json()["expires_at"] is not None
assert resp.json()["is_expired"] is False
def _make_docx_bytes() -> bytes: def _make_docx_bytes() -> bytes:
@@ -124,8 +125,8 @@ def _make_docx_bytes() -> bytes:
return buf.getvalue() return buf.getvalue()
def test_doc_delete_removes_files() -> None: def test_doc_delete_goes_to_recycle_bin() -> None:
"""删除文档后物理文件应被清理。""" """删除文档 → 进回收站,文件保留;彻底删除后文件清理。"""
with _client() as client: with _client() as client:
client.post("/api/auth/register", json={ client.post("/api/auth/register", json={
"username": "cleanup_user", "email": "cleanup@example.com", "password": "password123", "username": "cleanup_user", "email": "cleanup@example.com", "password": "password123",
@@ -138,31 +139,37 @@ def test_doc_delete_removes_files() -> None:
client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files) client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files)
resp = client.get(f"/api/documents?kb_id={kb_id}") resp = client.get(f"/api/documents?kb_id={kb_id}")
doc = resp.json()["items"][0] doc_id = resp.json()["items"][0]["id"]
storage_path = doc["storage_path"] if "storage_path" in doc else None
doc_id = doc["id"]
# 删除文档
resp = client.delete(f"/api/documents/{doc_id}")
assert resp.status_code == 204
# 验证物理文件已删除
from app.core.config import get_settings
from pathlib import Path from pathlib import Path
# 通过数据库查询 storage_path(响应里没有)
from app.core.config import get_settings
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models.document import Document from app.models.document import Document
factory = get_session_factory() factory = get_session_factory()
with factory() as session: with factory() as session:
d = session.get(Document, doc_id) d = session.get(Document, doc_id)
storage_path = d.storage_path storage_path = d.storage_path
if storage_path:
# 删除 → 进回收站,文件仍存在
resp = client.delete(f"/api/documents/{doc_id}")
assert resp.status_code == 204
full = Path(get_settings().storage_root_path) / storage_path full = Path(get_settings().storage_root_path) / storage_path
assert not full.exists(), f"文件未被清理: {full}" assert full.exists(), "回收站阶段文件不应被删除"
# 回收站列表可见
resp = client.get("/api/recycle-bin")
assert any(item["id"] == doc_id for item in resp.json()["documents"])
# 彻底删除 → 文件清理
resp = client.delete(f"/api/recycle-bin/documents/{doc_id}")
assert resp.status_code == 204
assert not full.exists(), "彻底删除后文件应被清理"
def test_kb_delete_removes_doc_files() -> None: def test_kb_delete_goes_to_recycle_bin() -> None:
"""删除知识库后其下所有文档文件应被清理。""" """删除知识库 → 进回收站;彻底删除后文件清理。"""
with _client() as client: with _client() as client:
client.post("/api/auth/register", json={ client.post("/api/auth/register", json={
"username": "kb_cleanup_user", "email": "kbcleanup@example.com", "password": "password123", "username": "kb_cleanup_user", "email": "kbcleanup@example.com", "password": "password123",
@@ -174,20 +181,71 @@ def test_kb_delete_removes_doc_files() -> None:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")} "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files) client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files)
from pathlib import Path
from app.core.config import get_settings
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models.document import Document from app.models.document import Document
from app.core.config import get_settings
from pathlib import Path
factory = get_session_factory() factory = get_session_factory()
with factory() as session: with factory() as session:
docs = list(session.query(Document).filter_by(knowledge_base_id=kb_id).all()) docs = list(session.query(Document).filter_by(knowledge_base_id=kb_id).all())
paths = [d.storage_path for d in docs if d.storage_path] paths = [d.storage_path for d in docs if d.storage_path]
# 删除知识库 # 删除知识库 → 回收站,文件保留
resp = client.delete(f"/api/knowledge-bases/{kb_id}") resp = client.delete(f"/api/knowledge-bases/{kb_id}")
assert resp.status_code == 204 assert resp.status_code == 204
root = Path(get_settings().storage_root_path) root = Path(get_settings().storage_root_path)
for p in paths: for p in paths:
assert not (root / p).exists(), f"文件未被清理: {p}" assert (root / p).exists(), "回收站阶段文件不应被删除"
# 回收站可见
resp = client.get("/api/recycle-bin")
assert any(item["id"] == kb_id for item in resp.json()["knowledge_bases"])
# 彻底删除 → 文件清理
resp = client.delete(f"/api/recycle-bin/knowledge-bases/{kb_id}")
assert resp.status_code == 204
for p in paths:
assert not (root / p).exists(), "彻底删除后文件应被清理"
def test_category_delete_and_restore() -> None:
"""删除目录 → 目录+子目录+文档进回收站;恢复后全部回来。"""
with _client() as client:
client.post("/api/auth/register", json={
"username": "cat_rb_user", "email": "catrb@example.com", "password": "password123",
})
resp = client.post("/api/knowledge-bases", json={"name": "Cat RB KB"})
kb_id = resp.json()["id"]
# 在"01 公司层"下创建文本文档
cats = client.get(f"/api/knowledge-bases/{kb_id}/categories").json()
target = next(c for c in cats if c["name"] == "公司基本信息")
client.post("/api/documents/create-text", json={
"kb_id": kb_id, "title": "Cat Doc", "content": "hello",
"category_id": target["id"],
})
# 删除目录
resp = client.delete(f"/api/knowledge-bases/{kb_id}/categories/{target['id']}")
assert resp.status_code == 204
# 文档不可见
docs = client.get(f"/api/documents?kb_id={kb_id}").json()
assert docs["total"] == 0
# 回收站里有目录和文档
rb = client.get("/api/recycle-bin").json()
assert any(c["name"] == "公司基本信息" for c in rb["categories"])
assert any(d["name"] == "Cat Doc" for d in rb["documents"])
# 恢复目录 → 同批文档恢复
cat_id = next(c["id"] for c in rb["categories"] if c["name"] == "公司基本信息")
resp = client.post(f"/api/recycle-bin/categories/{cat_id}/restore")
assert resp.status_code == 204
docs = client.get(f"/api/documents?kb_id={kb_id}").json()
assert docs["total"] == 1
assert docs["items"][0]["title"] == "Cat Doc"
+5
View File
@@ -61,6 +61,10 @@ function navigateTo(path: string) {
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></el-icon> <el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></el-icon>
<template #title>设置</template> <template #title>设置</template>
</el-menu-item> </el-menu-item>
<el-menu-item index="/recycle-bin">
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg></el-icon>
<template #title>回收站</template>
</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
@@ -89,6 +93,7 @@ function navigateTo(path: string) {
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div> <div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div> <div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')"> 设置</div> <div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')"> 设置</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/recycle-bin')">🗑 回收站</div>
</div> </div>
</div> </div>
+5
View File
@@ -37,6 +37,11 @@ const router = createRouter({
name: 'KbDetail', name: 'KbDetail',
component: () => import('@/views/KbDetail.vue'), component: () => import('@/views/KbDetail.vue'),
}, },
{
path: 'recycle-bin',
name: 'RecycleBin',
component: () => import('@/views/RecycleBin.vue'),
},
{ {
path: 'settings', path: 'settings',
name: 'Settings', name: 'Settings',
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import apiClient from '@/api/client'
const data = ref<{
documents: any[]
categories: any[]
knowledge_bases: any[]
retention_days: number
}>({
documents: [],
categories: [],
knowledge_bases: [],
retention_days: 3,
})
const loading = ref(false)
const isMobile = ref(window.innerWidth <= 768)
onMounted(() => {
load()
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 768
})
})
async function load() {
loading.value = true
try {
const { data: d } = await apiClient.get('/recycle-bin')
data.value = d
} catch { /* ignore */ }
loading.value = false
}
async function restoreDoc(item: any) {
try {
await apiClient.post(`/recycle-bin/documents/${item.id}/restore`)
ElMessage.success('文档已恢复。')
load()
} catch { /* ignore */ }
}
async function purgeDoc(item: any) {
try {
await ElMessageBox.confirm(`彻底删除「${item.name}」?文件与记录将不可恢复。`, '彻底删除', { type: 'warning' })
await apiClient.delete(`/recycle-bin/documents/${item.id}`)
ElMessage.success('已彻底删除。')
load()
} catch { /* ignore */ }
}
async function restoreCategory(item: any) {
try {
await apiClient.post(`/recycle-bin/categories/${item.id}/restore`)
ElMessage.success('目录及同批内容已恢复。')
load()
} catch { /* ignore */ }
}
async function purgeCategory(item: any) {
try {
await ElMessageBox.confirm(`彻底删除目录「${item.name}」及其同批删除的全部内容?`, '彻底删除', { type: 'warning' })
await apiClient.delete(`/recycle-bin/categories/${item.id}`)
ElMessage.success('已彻底删除。')
load()
} catch { /* ignore */ }
}
async function restoreKb(item: any) {
try {
await apiClient.post(`/recycle-bin/knowledge-bases/${item.id}/restore`)
ElMessage.success('知识库已恢复。')
load()
} catch { /* ignore */ }
}
async function purgeKb(item: any) {
try {
await ElMessageBox.confirm(`彻底删除知识库「${item.name}」及其全部文档?不可恢复。`, '彻底删除', { type: 'warning' })
await apiClient.delete(`/recycle-bin/knowledge-bases/${item.id}`)
ElMessage.success('已彻底删除。')
load()
} catch { /* ignore */ }
}
function statusText(item: any) {
if (item.days_left <= 0) return '即将自动清理'
return `${item.days_left} 天后自动清理`
}
function formatSize(bytes: number) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
<template>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px">
<h1 style="margin: 0; font-size: 22px">🗑 回收站</h1>
<span style="color: #999; font-size: 13px">
删除的内容保留 {{ data.retention_days }} 超期自动彻底删除
</span>
</div>
<!-- 文档 -->
<el-card shadow="hover" style="margin-bottom: 16px">
<template #header><span style="font-weight: bold">📄 文档{{ data.documents.length }}</span></template>
<el-empty v-if="!data.documents.length" description="暂无" :image-size="48" />
<div v-for="item in data.documents" :key="item.id" class="rb-item">
<div class="rb-info">
<div class="rb-name">{{ item.name }}</div>
<div class="rb-meta">
所属{{ item.kb_name || '未知知识库' }} · {{ formatSize(item.file_size) }} ·
删除于 {{ item.deleted_at }} ·
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
</div>
</div>
<div class="rb-actions">
<el-button size="small" type="primary" @click="restoreDoc(item)">恢复</el-button>
<el-button size="small" type="danger" @click="purgeDoc(item)">彻底删除</el-button>
</div>
</div>
</el-card>
<!-- 目录 -->
<el-card shadow="hover" style="margin-bottom: 16px">
<template #header><span style="font-weight: bold">📁 目录{{ data.categories.length }}</span></template>
<el-empty v-if="!data.categories.length" description="暂无" :image-size="48" />
<div v-for="item in data.categories" :key="item.id" class="rb-item">
<div class="rb-info">
<div class="rb-name">{{ item.name }}</div>
<div class="rb-meta">
所属{{ item.kb_name || '未知知识库' }} · 删除于 {{ item.deleted_at }} ·
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
</div>
</div>
<div class="rb-actions">
<el-button size="small" type="primary" @click="restoreCategory(item)">恢复</el-button>
<el-button size="small" type="danger" @click="purgeCategory(item)">彻底删除</el-button>
</div>
</div>
</el-card>
<!-- 知识库 -->
<el-card shadow="hover">
<template #header><span style="font-weight: bold">📚 知识库{{ data.knowledge_bases.length }}</span></template>
<el-empty v-if="!data.knowledge_bases.length" description="暂无" :image-size="48" />
<div v-for="item in data.knowledge_bases" :key="item.id" class="rb-item">
<div class="rb-info">
<div class="rb-name">{{ item.name }}</div>
<div class="rb-meta">
删除于 {{ item.deleted_at }} ·
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
</div>
</div>
<div class="rb-actions">
<el-button size="small" type="primary" @click="restoreKb(item)">恢复</el-button>
<el-button size="small" type="danger" @click="purgeKb(item)">彻底删除</el-button>
</div>
</div>
</el-card>
</div>
</template>
<style scoped>
.rb-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
}
.rb-item:last-child {
border-bottom: none;
}
.rb-name {
font-weight: 500;
font-size: 14px;
}
.rb-meta {
color: #999;
font-size: 12px;
margin-top: 4px;
}
.rb-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
</style>