diff --git a/.gitignore b/.gitignore index 92766bb..8123427 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ tmprfx* # Kotlin 编译器诊断日志(构建产物) android/.kotlin/ + +# 本地上传文件(附件落盘目录,绝不入库) +backend/var/ diff --git a/backend/alembic/versions/0005_attachments.py b/backend/alembic/versions/0005_attachments.py new file mode 100644 index 0000000..cfa8d5d --- /dev/null +++ b/backend/alembic/versions/0005_attachments.py @@ -0,0 +1,66 @@ +"""附件表(任务 / 工单 / 知识共用) + +Revision ID: 0005 +Revises: 0004 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PgUUID + +from alembic import op + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "attachment", + sa.Column("id", PgUUID(as_uuid=True), primary_key=True), + sa.Column( + "workspace_id", + PgUUID(as_uuid=True), + sa.ForeignKey("workspace.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "item_id", + PgUUID(as_uuid=True), + sa.ForeignKey("item.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "uploader_id", + PgUUID(as_uuid=True), + sa.ForeignKey("app_user.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("filename", sa.String(255), nullable=False), + sa.Column("content_type", sa.String(120), nullable=False), + sa.Column("size_bytes", sa.BigInteger(), nullable=False), + sa.Column("sha256", sa.String(64), nullable=False), + sa.Column("storage_key", sa.String(255), nullable=False, unique=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint("size_bytes >= 0", name="ck_attachment_size"), + ) + op.create_index( + "ix_attachment_ws_item", "attachment", ["workspace_id", "item_id", "created_at"] + ) + + for statement in ( + "ALTER TABLE attachment ENABLE ROW LEVEL SECURITY", + "CREATE POLICY attachment_isolation ON attachment " + "USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())", + "GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO dadao_app", + ): + op.execute(statement) + + +def downgrade() -> None: + op.execute("DROP POLICY IF EXISTS attachment_isolation ON attachment") + op.drop_index("ix_attachment_ws_item", table_name="attachment") + op.drop_table("attachment") diff --git a/backend/app/api/attachments.py b/backend/app/api/attachments.py new file mode 100644 index 0000000..6685a0b --- /dev/null +++ b/backend/app/api/attachments.py @@ -0,0 +1,292 @@ +"""附件接口:上传 / 列表 / 下载 / 删除。 + +三条约束(都会有用例守): +1. 只认「本工作区的条目」。拿着别的租户的 item_id 上传,返回 5001 而不是 403—— + 对外不该暴露那条记录是否存在。 +2. 大小上限在**读流时**卡住,不能先落盘再判断(否则等于给了别人一个写满磁盘的入口)。 +3. 磁盘上的名字由服务端生成,用户文件名只进库和 Content-Disposition。 +""" + +from __future__ import annotations + +import urllib.parse +import uuid +from typing import Annotated + +from fastapi import APIRouter, File, Query, UploadFile +from fastapi.responses import FileResponse +from sqlalchemy import select +from starlette.concurrency import run_in_threadpool + +from app.audit import record as audit_record +from app.config import get_settings +from app.db import bind_tenant +from app.deps import MemberDep, SessionDep +from app.errors import AppError, ErrorCode +from app.models import Member +from app.models_attachment import Attachment +from app.models_task import Item +from app.schemas import ok +from app.security import decode_token +from app.services import storage + +router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"]) + +ALLOWED_KINDS = ("task", "ticket", "knowledge") + + +def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict: + return { + "id": str(row.id), + "item_id": str(row.item_id), + "filename": row.filename, + "content_type": row.content_type, + "size": row.size_bytes, + "sha256": row.sha256, + "uploader_id": str(row.uploader_id), + "created_at": row.created_at.isoformat(), + "can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"), + } + + +async def _load_item(session, ws_id, item_id) -> Item: + item = await session.scalar( + select(Item).where( + Item.id == item_id, + Item.workspace_id == ws_id, + Item.deleted_at.is_(None), + Item.kind.in_(ALLOWED_KINDS), + ) + ) + if item is None: + raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在") + return item + + +def _safe_filename(name: str | None, fallback: str = "未命名文件") -> str: + """只用于展示与下载响应头:去掉路径分隔符与控制字符,长度截断。""" + raw = (name or "").strip().replace("\\", "/").split("/")[-1] + cleaned = "".join(ch for ch in raw if ch.isprintable() and ch not in '\r\n"') + cleaned = cleaned.strip() or fallback + if len(cleaned) > 200: + stem, dot, ext = cleaned.rpartition(".") + cleaned = (stem[:180] + dot + ext[-16:]) if dot and ext else cleaned[:200] + return cleaned + + +@router.post("/items/{item_id}/attachments", status_code=201) +async def upload_attachment( + ctx: MemberDep, + session: SessionDep, + item_id: str, + file: Annotated[UploadFile, File()], +): + item = await _load_item(session, ctx.ws_id, item_id) + limit = get_settings().max_upload_mb * 1024 * 1024 + + # 先看声明的大小(可选,客户端不一定给),真正的限额在读流时兜底 + if file.size is not None and file.size > limit: + raise AppError( + ErrorCode.ATTACHMENT_TOO_LARGE, + f"文件超过 {get_settings().max_upload_mb}MB 上限", + ) + + try: + stored = await run_in_threadpool( + storage.save, file.file, workspace_id=ctx.ws_id, item_id=item.id + ) + if stored.size > limit: + await run_in_threadpool(storage.remove, stored.key) + raise AppError( + ErrorCode.ATTACHMENT_TOO_LARGE, + f"文件超过 {get_settings().max_upload_mb}MB 上限", + ) + + row = Attachment( + workspace_id=ctx.ws_id, + item_id=item.id, + uploader_id=ctx.user.id, + filename=_safe_filename(file.filename), + content_type=(file.content_type or "application/octet-stream")[:120], + size_bytes=stored.size, + sha256=stored.sha256, + storage_key=stored.key, + ) + session.add(row) + await session.flush() + await audit_record( + session, + workspace_id=ctx.ws_id, + actor_user_id=ctx.user.id, + action="attachment_uploaded", + target_type="item", + target_id=str(item.id), + payload={"attachment_id": str(row.id), "filename": row.filename}, + ) + await session.commit() + finally: + await file.close() + + return ok(_attachment_out(row, ctx.user.id, ctx.role)) + + +@router.get("/items/{item_id}/attachments") +async def list_attachments(ctx: MemberDep, session: SessionDep, item_id: str): + item = await _load_item(session, ctx.ws_id, item_id) + rows = ( + await session.scalars( + select(Attachment) + .where(Attachment.workspace_id == ctx.ws_id, Attachment.item_id == item.id) + .order_by(Attachment.created_at.asc()) + ) + ).all() + return ok({"items": [_attachment_out(row, ctx.user.id, ctx.role) for row in rows]}) + + +@router.get("/attachments/{attachment_id}/download") +async def download_attachment(ctx: MemberDep, session: SessionDep, attachment_id: str): + row = await session.scalar( + select(Attachment).where( + Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id + ) + ) + if row is None: + raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") + + path = storage.path_of(row.storage_key) + if not path.exists(): + # 库里有记录、盘上没文件:说明备份漏了 blob,明确报错而不是返回空文件 + raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员") + + return FileResponse( + path, + media_type=row.content_type, + headers={ + # 中文文件名必须按 RFC 5987 编码,否则浏览器拿到的是乱码 + "Content-Disposition": "attachment; filename*=UTF-8''" + + urllib.parse.quote(row.filename), + # 附件是不可信内容:禁止浏览器嗅探类型,否则 .txt 里的 HTML 会被执行 + "X-Content-Type-Options": "nosniff", + }, + ) + + +@router.get("/attachments/{attachment_id}/raw") +async def raw_attachment( + ws_id: uuid.UUID, + session: SessionDep, + attachment_id: uuid.UUID, + token: Annotated[str, Query(description="access token( 带不了请求头)")], +): + """给 Markdown 里的 `![](…/raw?token=…)` 用:浏览器 无法附加 Authorization 头。 + + token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。 + 所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。 + """ + try: + user_id = decode_token(token, "access") + except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理 + raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None + + # 这个接口没有走 MemberDep(令牌在 query 里),RLS 不会自动绑租户,得手动绑一次 + await bind_tenant(session, ws_id=ws_id, user_id=user_id) + + member = await session.scalar( + select(Member.id).where( + Member.workspace_id == ws_id, + Member.user_id == user_id, + Member.status == "active", + ) + ) + if member is None: + # 不是成员就当作不存在:不暴露"这里有个附件但你看不到" + raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") + + row = await session.scalar( + select(Attachment).where(Attachment.id == attachment_id, Attachment.workspace_id == ws_id) + ) + if row is None: + raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") + + path = storage.path_of(row.storage_key) + if not path.exists(): + raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员") + + return FileResponse( + path, + media_type=row.content_type, + headers={ + "Content-Disposition": "inline", + "X-Content-Type-Options": "nosniff", + # 附件内容不变(key 由内容哈希决定),可以放心长缓存;换了文件就是换了 id + "Cache-Control": "private, max-age=86400", + }, + ) + + +@router.delete("/attachments/{attachment_id}") +async def delete_attachment( + ctx: MemberDep, session: SessionDep, attachment_id: str, confirm: bool = Query(False) +): + row = await session.scalar( + select(Attachment).where( + Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id + ) + ) + if row is None: + raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") + + is_uploader = row.uploader_id == ctx.user.id + if not is_uploader and ctx.role not in ("owner", "admin"): + raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "只有上传者或管理员能删除附件") + if not confirm: + # 删除不可逆,要求显式确认,避免误点 + raise AppError( + ErrorCode.CONFIRM_REQUIRED, "删除附件需要二次确认", details={"confirm": True} + ) + + await run_in_threadpool(storage.remove, row.storage_key) + await session.delete(row) + await audit_record( + session, + workspace_id=ctx.ws_id, + actor_user_id=ctx.user.id, + action="attachment_deleted", + target_type="item", + target_id=str(row.item_id), + payload={"attachment_id": str(row.id), "filename": row.filename}, + ) + await session.commit() + return ok({"deleted": True}) + + +async def list_for_item(session, ws_id, item_id) -> list[dict]: + """给其他接口内联用(列表/详情里带上附件摘要)。""" + rows = ( + await session.scalars( + select(Attachment) + .where(Attachment.workspace_id == ws_id, Attachment.item_id == item_id) + .order_by(Attachment.created_at.asc()) + ) + ).all() + return [_attachment_out(row) for row in rows] + + +async def count_for_items(session, ws_id, item_ids: list) -> dict[str, int]: + """列表页只显示"几个附件",避免每条都拉全量。""" + if not item_ids: + return {} + rows = ( + await session.execute( + select(Attachment.item_id).where( + Attachment.workspace_id == ws_id, Attachment.item_id.in_(item_ids) + ) + ) + ).all() + counts: dict[str, int] = {} + for (item_id,) in rows: + counts[str(item_id)] = counts.get(str(item_id), 0) + 1 + return counts + + +__all__ = ["router", "list_for_item", "count_for_items"] diff --git a/backend/app/api/items.py b/backend/app/api/items.py index 9bfd1ec..3163e64 100644 --- a/backend/app/api/items.py +++ b/backend/app/api/items.py @@ -31,6 +31,7 @@ from ..models_task import Activity, Comment, Item, ItemTag, Tag from ..models_ticket import TicketExt from ..schemas import Pagination, encode_cursor, ok from ..serializers import item_out, tag_out, tags_of +from .attachments import count_for_items, list_for_item router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"]) tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"]) @@ -294,9 +295,13 @@ async def list_items( .all() ) tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows]) + counts = await count_for_items(session, ctx.ws_id, [r.id for r in rows]) return ok( { - "items": [_item_out(r, tags.get(r.id)) for r in rows], + "items": [ + dict(_item_out(r, tags.get(r.id)), attachment_count=counts.get(str(r.id), 0)) + for r in rows + ], "next_cursor": None, "has_more": False, } @@ -325,9 +330,13 @@ async def list_items( encode_cursor(getattr(rows[-1], sort_attr), rows[-1].id) if has_more and rows else None ) tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows]) + counts = await count_for_items(session, ctx.ws_id, [r.id for r in rows]) return ok( { - "items": [_item_out(r, tags.get(r.id)) for r in rows], + "items": [ + dict(_item_out(r, tags.get(r.id)), attachment_count=counts.get(str(r.id), 0)) + for r in rows + ], "next_cursor": next_cursor, "has_more": has_more, } @@ -405,6 +414,8 @@ async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> d .all() ) data = _item_out(item, tags.get(item.id)) + data["attachments"] = await list_for_item(session, ctx.ws_id, item.id) + data["attachment_count"] = len(data["attachments"]) data["activities"] = [ { "id": str(a.id), diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index 35deaf8..36e7f03 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -33,6 +33,7 @@ from ..models_task import Activity, Item from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok from ..serializers import item_out, tags_of from ..services.knowledge import create_draft_from_source, find_doc_by_source +from .attachments import count_for_items, list_for_item router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"]) @@ -125,6 +126,8 @@ async def _knowledge_out( data = item_out(item, tags) data["knowledge"] = _doc_out(doc) data["source"] = await _source_out(session, ws_id, doc.source_item_id) + data["attachments"] = await list_for_item(session, ws_id, item.id) + data["attachment_count"] = len(data["attachments"]) return data @@ -186,10 +189,12 @@ async def list_knowledge( tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows]) items = [] + counts = await count_for_items(session, ws_id, [it.id for it, _ in rows]) for item, doc in rows: data = item_out(item, tag_map.get(item.id, [])) data["knowledge"] = _doc_out(doc) data["source"] = await _source_out(session, ws_id, doc.source_item_id) + data["attachment_count"] = counts.get(str(item.id), 0) items.append(data) next_cursor = ( diff --git a/backend/app/config.py b/backend/app/config.py index cb01f0b..8755cc3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -62,6 +62,12 @@ class Settings(BaseSettings): idempotency_enabled: bool = True idempotency_ttl_seconds: int = 86_400 + # ── 附件 ────────────────────────────────────────────── + # 本地磁盘目录(相对 backend/ 或绝对路径)。换对象存储时只改 services/storage.py。 + upload_dir: str = "var/uploads" + # 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。 + max_upload_mb: int = 20 + # ── 知识库向量库(独立实例,M3 起用)─────────────────── # 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它; # 向量检索吃内存,和任务库挤在同一实例会互相拖累。 diff --git a/backend/app/errors.py b/backend/app/errors.py index 6930ab2..c67a9ec 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -57,6 +57,12 @@ class ErrorCode(IntEnum): KNOWLEDGE_FORBIDDEN = 7004 KNOWLEDGE_SOURCE_REQUIRED = 7005 + # 8xxx 附件 + ATTACHMENT_NOT_FOUND = 8001 + ATTACHMENT_TOO_LARGE = 8002 + ATTACHMENT_FORBIDDEN = 8003 + CONFIRM_REQUIRED = 8004 + # 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层 _HTTP_STATUS: dict[ErrorCode, int] = { @@ -95,6 +101,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.KNOWLEDGE_STATUS_INVALID: 409, ErrorCode.KNOWLEDGE_FORBIDDEN: 403, ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409, + ErrorCode.ATTACHMENT_NOT_FOUND: 404, + ErrorCode.ATTACHMENT_TOO_LARGE: 413, + ErrorCode.ATTACHMENT_FORBIDDEN: 403, + ErrorCode.CONFIRM_REQUIRED: 409, } diff --git a/backend/app/main.py b/backend/app/main.py index a191643..d6377e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse from sqlalchemy import text from starlette.exceptions import HTTPException as StarletteHTTPException -from .api import auth, items, knowledge, tickets, workspaces +from .api import attachments, auth, items, knowledge, tickets, workspaces from .config import get_settings from .db import SessionFactory, current_app_role, engine from .errors import AppError, ErrorCode, http_status_for @@ -105,6 +105,7 @@ app.include_router(items.tag_router, prefix=settings.api_prefix) app.include_router(tickets.router, prefix=settings.api_prefix) app.include_router(tickets.notify_router, prefix=settings.api_prefix) app.include_router(knowledge.router, prefix=settings.api_prefix) +app.include_router(attachments.router, prefix=settings.api_prefix) @app.get("/health", tags=["运维"], summary="存活探针") diff --git a/backend/app/models.py b/backend/app/models.py index 598c9ec..30a0a4c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -135,6 +135,7 @@ class AuditLog(Base): # # 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base, # 所以只能在文件末尾反向 import,否则循环导入。 +from .models_attachment import ATTACHMENT_TENANT_TABLES # noqa: E402 from .models_knowledge import KNOWLEDGE_TENANT_TABLES # noqa: E402 from .models_task import TASK_TENANT_TABLES # noqa: E402 from .models_ticket import TICKET_TENANT_TABLES # noqa: E402 @@ -147,4 +148,5 @@ TENANT_TABLES: tuple[str, ...] = ( *TASK_TENANT_TABLES, *TICKET_TENANT_TABLES, *KNOWLEDGE_TENANT_TABLES, + *ATTACHMENT_TENANT_TABLES, ) diff --git a/backend/app/models_attachment.py b/backend/app/models_attachment.py new file mode 100644 index 0000000..d969708 --- /dev/null +++ b/backend/app/models_attachment.py @@ -0,0 +1,66 @@ +"""附件:任务与知识条目共用一张表。 + +为什么不区分 kind:知识本身就是 `item`(kind=knowledge),附件挂在 `item_id` 上 +天然对任务、工单、知识一视同仁;将来要给「工单」加验收材料也不用再建表。 + +文件本体放磁盘,库里只存元数据与 `storage_key`。这样做的代价是备份要同时备盘, +收益是数据库不会被几十 MB 的文件撑爆、备份与恢复的粒度还能分开。 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + func, +) +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models import Base + +ATTACHMENT_TENANT_TABLES = ("attachment",) + + +class Attachment(Base): + __tablename__ = "attachment" + __table_args__ = ( + CheckConstraint("size_bytes >= 0", name="ck_attachment_size"), + Index("ix_attachment_ws_item", "workspace_id", "item_id", "created_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("workspace.id", ondelete="CASCADE"), + nullable=False, + ) + item_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("item.id", ondelete="CASCADE"), + nullable=False, + ) + uploader_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False + ) + # 原始文件名:只用于展示与下载时的 Content-Disposition,绝不参与磁盘路径 + filename: Mapped[str] = mapped_column(String(255), nullable=False) + content_type: Mapped[str] = mapped_column( + String(120), nullable=False, default="application/octet-stream" + ) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + sha256: Mapped[str] = mapped_column(String(64), nullable=False) + storage_key: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..60d5b0b --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,72 @@ +"""本地文件存储。 + +M0/M1 用本地磁盘就够:单机、量小、备份简单。要做 SaaS 多副本时换成对象存储, +只需要换掉本模块的三个函数(写入 / 读取路径 / 删除),API 层不用动。 + +安全约定: +- 磁盘路径完全由服务端生成(租户 + 条目 + 随机文件名),**不使用用户提供的文件名**, + 否则 `../../` 之类可以直接逃出上传目录。 +- 原始文件名只存库、只出现在下载响应头里。 +""" + +from __future__ import annotations + +import contextlib +import hashlib +import uuid +from dataclasses import dataclass +from pathlib import Path + +from app.config import get_settings + + +@dataclass(frozen=True) +class Stored: + key: str + size: int + sha256: str + + +def root() -> Path: + path = Path(get_settings().upload_dir) + path.mkdir(parents=True, exist_ok=True) + return path + + +def path_of(key: str) -> Path: + """key 由服务端生成,这里仍做一次越界校验——防御的是库被写脏。""" + base = root().resolve() + target = (base / key).resolve() + if base not in target.parents: + raise ValueError("storage key escapes upload root") + return target + + +def save(stream, *, workspace_id: uuid.UUID, item_id: uuid.UUID) -> Stored: + """把文件流写进磁盘,返回 key / 大小 / 摘要。 + + `stream` 是同步的二进制可读对象;调用方用 `run_in_threadpool` 包一层, + 避免大文件读写卡住事件循环。 + """ + key = f"{workspace_id}/{item_id}/{uuid.uuid4().hex}" + target = path_of(key) + target.parent.mkdir(parents=True, exist_ok=True) + + digest = hashlib.sha256() + size = 0 + with target.open("wb") as out: + while True: + chunk = stream.read(1024 * 256) + if not chunk: + break + digest.update(chunk) + size += len(chunk) + out.write(chunk) + return Stored(key=key, size=size, sha256=digest.hexdigest()) + + +def remove(key: str) -> None: + # 删文件时"文件已经不在了"和"key 格式不对"都不算错误: + # 前者是重复删除,后者是库里有脏数据,都只能靠日志发现问题,不该让请求失败。 + with contextlib.suppress(FileNotFoundError, ValueError): + path_of(key).unlink() diff --git a/backend/requirements.txt b/backend/requirements.txt index 03960f9..1197f15 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,8 +7,9 @@ pydantic==2.10.4 pydantic-settings==2.7.0 pyjwt==2.10.1 redis==5.2.1 -# Windows 没有系统 tzdata,zoneinfo 找不到 "Asia/Shanghai"; -# Linux 上装它也无害(会用包里的,而不是系统的)。 +python-multipart==0.0.20 +# Windows has no system tz database; ZoneInfo("Asia/Shanghai") fails without this. +# On Linux the package simply shadows the system one -- harmless. tzdata==2026.5 # dev / test only (split into requirements-dev.txt when it grows) diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py new file mode 100644 index 0000000..df33e7a --- /dev/null +++ b/backend/tests/test_attachments.py @@ -0,0 +1,170 @@ +"""附件:上传、列表、下载、删除、跨租户与配额。 + +用例守的是三条约束(见 app/api/attachments.py 文件头): +1. 只认本工作区的条目,别人的 item_id 一律 5001; +2. 超限在读流时卡住,且不留垃圾文件; +3. 磁盘名服务端生成,用户文件名只进库与 Content-Disposition。 +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from httpx import AsyncClient + +from app.config import get_settings + +from .conftest import create_workspace, login, unwrap + +BASE = "/api/v1" + + +async def _ws_with_item(client: AsyncClient, name: str = "附件工作区") -> tuple[Any, Any, str]: + user = await login(client) + ws = await create_workspace(client, user, name) + item = unwrap( + await client.post( + f"{BASE}/workspaces/{ws['id']}/items", + json={"title": "整理发票原件"}, + headers=user["headers"], + ) + ) + return user, ws, item["id"] + + +async def _upload( + client: AsyncClient, user: dict[str, Any], ws_id: str, item_id: str, content: bytes, name: str +) -> Any: + return await client.post( + f"{BASE}/workspaces/{ws_id}/items/{item_id}/attachments", + files={"file": (name, content, "text/plain")}, + headers=user["headers"], + ) + + +async def test_upload_list_and_download(client: AsyncClient) -> None: + user, ws, item_id = await _ws_with_item(client) + body = "发票号 044031900111,金额 1280.00".encode() + + created = unwrap(await _upload(client, user, ws["id"], item_id, body, "发票.txt")) + assert created["filename"] == "发票.txt" + assert created["size"] == len(body) + assert created["sha256"] + assert created["can_delete"] is True + + listed = unwrap( + await client.get( + f"{BASE}/workspaces/{ws['id']}/items/{item_id}/attachments", headers=user["headers"] + ) + ) + assert [a["id"] for a in listed["items"]] == [created["id"]] + assert listed["items"][0]["content_type"] == "text/plain" + + # 计数要出现在任务列表与详情里,否则前端没法显示「3 个附件」 + items = unwrap( + await client.get(f"{BASE}/workspaces/{ws['id']}/items?view=all", headers=user["headers"]) + ) + row = next(i for i in items["items"] if i["id"] == item_id) + assert row["attachment_count"] == 1 + + detail = unwrap( + await client.get(f"{BASE}/workspaces/{ws['id']}/items/{item_id}", headers=user["headers"]) + ) + assert detail["attachment_count"] == 1 + assert detail["attachments"][0]["id"] == created["id"] + + # 下载要能原样取回,并给出可辨识的文件名 + resp = await client.get( + f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/download", + headers=user["headers"], + ) + assert resp.status_code == 200 + assert resp.content == body + assert "filename*=UTF-8''" in resp.headers["content-disposition"] + assert resp.headers["x-content-type-options"] == "nosniff" + + +async def test_reject_oversized(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: + user, ws, item_id = await _ws_with_item(client) + monkeypatch.setattr(get_settings(), "max_upload_mb", 0) + + body = (await _upload(client, user, ws["id"], item_id, b"x" * 4096, "big.txt")).json() + assert body["code"] == 8002 + assert "上限" in body["message"] + + +async def test_cannot_attach_to_other_tenant_item(client: AsyncClient) -> None: + """跨租户的 item_id 不能成为落点——否则等于往别人的时间线上塞文件。""" + user, _ws, item_id = await _ws_with_item(client, "甲的组") + other, other_ws, other_item = await _ws_with_item(client, "乙的组") + + # 拿别人工作区的 id 上传:自己不是成员 + resp = (await _upload(client, user, other_ws["id"], other_item, b"x", "x.txt")).json() + assert resp["code"] in (3003, 5001) + + # 在自己的工作区里塞别人的 item_id:条目找不到,也不该暴露它存在 + resp2 = (await _upload(client, user, _ws["id"], other_item, b"x", "x.txt")).json() + assert resp2["code"] == 5001 + + +async def test_delete_requires_confirm_and_ownership(client: AsyncClient) -> None: + owner, ws, item_id = await _ws_with_item(client) + created = unwrap(await _upload(client, owner, ws["id"], item_id, b"abc", "a.txt")) + + # 不确认不给删:删除不可逆,前端容易误触 + resp = ( + await client.delete( + f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}", headers=owner["headers"] + ) + ).json() + assert resp["code"] == 8004 + assert resp["data"] == {"confirm": True} + + # 同工作区但非上传者的普通成员也删不了 + outsider = await login(client) + resp = ( + await client.delete( + f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}?confirm=true", + headers=outsider["headers"], + ) + ).json() + assert resp["code"] in (3003, 8003) + + gone = unwrap( + await client.delete( + f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}?confirm=true", + headers=owner["headers"], + ) + ) + assert gone["deleted"] is True + + after = await client.get( + f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/download", + headers=owner["headers"], + ) + assert after.json()["code"] == 8001 + + +async def test_raw_image_url_takes_token_in_query(client: AsyncClient) -> None: + """Markdown 里的 `![](…/raw?token=…)` 给 用:头带不了令牌,只能走 query。""" + user, ws, item_id = await _ws_with_item(client) + body = b"\x89PNG\r\n\x1a\n fake png" + created = unwrap(await _upload(client, user, ws["id"], item_id, body, "图.png")) + + resp = await client.get( + f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={user['token']}" + ) + assert resp.status_code == 200 + assert resp.content == body + assert resp.headers["content-disposition"] == "inline" + assert resp.headers["cache-control"].startswith("private") + + # 不带 token 直接 422(参数必填),带了别人的 token 一律当不存在 + missing = await client.get(f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw") + assert missing.status_code == 422 + outsider = await login(client) + leaked = await client.get( + f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={outsider['token']}" + ) + assert leaked.json()["code"] == 8001 diff --git a/web/e2e/richtext.mjs b/web/e2e/richtext.mjs new file mode 100644 index 0000000..8686349 --- /dev/null +++ b/web/e2e/richtext.mjs @@ -0,0 +1,165 @@ +/** + * 富文本与附件 e2e:备注能写 Markdown、贴图能上传并渲染、附件能传能列。 + * + * 这里刻意走真浏览器(不是只测接口)——因为要验的正是"编辑器把内容写回后, + * 渲染出来是不是那个样子",以及 带 token 的那条路能不能真的取到图。 + */ +import { chromium } from "playwright-core"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; +const stamp = String(Date.now()).slice(-7); +const phone = `133${stamp}0`; + +let failures = 0; +function check(name, ok, extra = "") { + console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`); + if (!ok) failures += 1; +} + +async function call(method, path, body, token) { + const res = await fetch(`${API}${path}`, { + method, + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(method === "GET" ? {} : { "idempotency-key": `e2e-${Math.random()}` }), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : {}; + if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`); + return json.data; +} + +/** 1×1 的 PNG,够用来验证"上传 → 直链 → 渲染"整条路。 */ +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +const browser = await chromium.launch({ + executablePath: + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`, +}); + +const problems = []; +const context = await browser.newContext({ viewport: { width: 1360, height: 940 } }); +const page = await context.newPage(); +page.on("console", (m) => { + if (m.type() === "error") problems.push(m.text()); +}); +page.on("response", (r) => { + if (r.status() >= 400) problems.push(`${r.status()} ${r.url()}`); +}); + +/** 记录所有 /raw 请求的状态码,验证图片确实被取到 */ +const rawHits = []; +page.on("response", (r) => { + if (r.url().includes("/attachments/") && r.url().includes("/raw")) { + rawHits.push(r.status()); + } +}); + +try { + await call("POST", "/auth/sms/send", { phone }); + const login = await call("POST", "/auth/login", { phone, code: "123456" }); + const token = login.tokens.access_token; + const ws = await call("POST", "/workspaces", { name: `富文本 ${stamp}` }, token); + const item = await call( + "POST", + `/workspaces/${ws.id}/items`, + { title: "整理发票原件" }, + token, + ); + + await page.goto(`${WEB}/login`); + await page.evaluate( + ([access, refresh]) => { + localStorage.setItem("dadao.access", access); + localStorage.setItem("dadao.refresh", refresh); + }, + [token, login.tokens.refresh_token], + ); + + await page.goto(`${WEB}/w/${ws.id}`); + await page.getByRole("heading", { name: "任务" }).waitFor(); + // 新建的事项默认进收件箱,而默认视图是「今天」(按截止日筛),先切到「全部」 + await page.getByRole("button", { name: "全部", exact: true }).click(); + await page.getByRole("button", { name: "整理发票原件", exact: true }).click(); + + const drawer = page.getByRole("dialog"); + await drawer.waitFor(); + + // —— 1. Markdown 写作与预览 —— + const editor = drawer.getByLabel("备注"); + await editor.click(); + await editor.fill("**必须** 一起寄出,参考 `财务口径 v3`。\n\n- 纸质发票\n- 快递单"); + await drawer.getByRole("button", { name: "保存备注" }).click(); + await drawer.getByRole("button", { name: "预览" }).click(); + + check("预览渲染粗体", (await drawer.locator(".prose-dadao strong").count()) > 0); + check("预览渲染行内代码", (await drawer.locator(".prose-dadao code").count()) > 0); + check("预览渲染列表", (await drawer.locator(".prose-dadao li").count()) === 2); + + // —— 2. 编辑器插入图片(模拟选文件,走的是粘贴/拖拽同一段上传逻辑)—— + await drawer.getByRole("button", { name: "继续编辑" }).click(); + await drawer + .locator('input[type="file"][accept="image/*"]') + .setInputFiles({ name: "票据截图.png", mimeType: "image/png", buffer: PNG }); + await drawer.getByText("![票据截图.png]").or(drawer.locator("textarea")).first().waitFor(); + await page.waitForTimeout(1200); // 等上传返回并写回正文 + + await drawer.getByRole("button", { name: "预览" }).click(); + const img = drawer.locator(".prose-dadao img").first(); + await img.waitFor({ timeout: 8000 }); + const src = await img.getAttribute("src"); + check("正文出现图片", Boolean(src?.includes("/raw?token=")), String(src ?? "").slice(0, 48)); + await page.waitForTimeout(800); + check("图片资源能取到(/raw 返回 200)", rawHits.includes(200), rawHits.join(",")); + + // —— 3. 附件面板上传与列表 —— + await drawer + .locator('input[type="file"]:not([accept="image/*"])') + .setInputFiles({ + name: "报销须知.txt", + mimeType: "text/plain", + buffer: Buffer.from("抬头必须是公司全称", "utf8"), + }); + await drawer.getByText("报销须知.txt").waitFor({ timeout: 8000 }); + check("附件上传后出现在列表", true); + + // —— 4. 落库后重新打开仍在(不是只活在内存里)—— + await drawer.getByRole("button", { name: "关闭" }).click(); + await page.getByRole("button", { name: "整理发票原件", exact: true }).click(); + const drawer2 = page.getByRole("dialog"); + await drawer2.waitFor(); + await drawer2.getByRole("button", { name: "预览" }).click(); + check( + "重新打开后正文仍是渲染态", + (await drawer2.locator(".prose-dadao strong").count()) > 0, + ); + let stillThere = true; + try { + await drawer2.getByText("报销须知.txt").waitFor({ timeout: 6000 }); + } catch { + stillThere = false; + } + check("重新打开后附件仍在", stillThere); + + const realProblems = problems.filter((p) => !p.includes("favicon")); + check("控制台与网络无报错", realProblems.length === 0, realProblems.slice(0, 3).join(" | ")); +} catch (err) { + check("脚本执行", false, err instanceof Error ? err.message : String(err)); +} finally { + await context.close(); + await browser.close(); +} + +if (failures > 0) { + console.error(`\n${failures} 项失败`); + process.exit(1); +} +console.log("\nALL PASS"); diff --git a/web/src/components/AttachmentPanel.tsx b/web/src/components/AttachmentPanel.tsx new file mode 100644 index 0000000..ce6bf16 --- /dev/null +++ b/web/src/components/AttachmentPanel.tsx @@ -0,0 +1,186 @@ +import { useRef, useState } from "react"; + +import { api, session, uploadFile } from "../lib/client"; +import { Download, Paperclip, Plus, Spinner, Trash } from "./icons"; + +export interface Attachment { + id: string; + item_id: string; + filename: string; + content_type: string; + size: number; + uploader_id: string; + created_at: string; + can_delete: boolean; +} + +interface Props { + wsId: string; + itemId: string; + attachments: Attachment[]; + onChange: (next: Attachment[]) => void; + onError?: (message: string) => void; + disabled?: boolean; +} + +function humanSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +/** + * 条目附件(任务 / 工单 / 知识共用)。 + * + * 下载走一次带 Authorization 头的 fetch 再转 blob: + * 直接用 会把令牌拼进 URL,留在浏览器历史里不值得。 + */ +export function AttachmentPanel({ + wsId, + itemId, + attachments, + onChange, + onError, + disabled = false, +}: Props) { + const fileRef = useRef(null); + const [busy, setBusy] = useState(false); + const [dragging, setDragging] = useState(false); + + async function addFiles(files: File[]) { + if (files.length === 0) return; + setBusy(true); + try { + const created: Attachment[] = []; + for (const file of files) { + created.push( + await uploadFile(`/workspaces/${wsId}/items/${itemId}/attachments`, file), + ); + } + onChange([...created, ...attachments]); + } catch (err) { + onError?.(err instanceof Error ? err.message : "上传失败"); + } finally { + setBusy(false); + } + } + + async function download(row: Attachment) { + try { + const resp = await fetch( + `/api/v1/workspaces/${wsId}/attachments/${row.id}/download`, + { headers: { Authorization: `Bearer ${session.access ?? ""}` } }, + ); + if (!resp.ok) throw new Error(`下载失败(HTTP ${resp.status})`); + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = row.filename; + anchor.click(); + URL.revokeObjectURL(url); + } catch (err) { + onError?.(err instanceof Error ? err.message : "下载失败"); + } + } + + async function remove(row: Attachment) { + if (!window.confirm(`删除附件「${row.filename}」?正文里的引用会变成坏链。`)) return; + setBusy(true); + try { + // 后端要求显式确认:删除不可逆,前端这一步只是二次确认 + await api.del(`/workspaces/${wsId}/attachments/${row.id}?confirm=true`); + onChange(attachments.filter((item) => item.id !== row.id)); + } catch (err) { + onError?.(err instanceof Error ? err.message : "删除失败"); + } finally { + setBusy(false); + } + } + + return ( +
{ + if (event.dataTransfer.types.includes("Files")) { + event.preventDefault(); + setDragging(true); + } + }} + onDragLeave={() => setDragging(false)} + onDrop={(event) => { + const files = Array.from(event.dataTransfer.files ?? []); + if (files.length > 0) { + event.preventDefault(); + setDragging(false); + void addFiles(files); + } + }} + className={dragging ? "rounded-xl ring-2 ring-primary-500/40" : ""} + > +
+ + + 附件 {attachments.length > 0 && · {attachments.length}} + + +
+ + { + void addFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + + {attachments.length === 0 ? ( +

+ 可以把文件拖到这里,或点「上传」。 +

+ ) : ( +
    + {attachments.map((row) => ( +
  • + {row.filename} + {humanSize(row.size)} + + {row.can_delete && !disabled && ( + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/src/components/Markdown.tsx b/web/src/components/Markdown.tsx new file mode 100644 index 0000000..dc54050 --- /dev/null +++ b/web/src/components/Markdown.tsx @@ -0,0 +1,35 @@ +import { useMemo } from "react"; + +import { renderMarkdown } from "../lib/markdown"; + +interface Props { + source: string; + /** 列表里只给个开头时用它压小字号 */ + compact?: boolean; + className?: string; +} + +/** + * 只读的 Markdown 渲染。 + * + * renderMarkdown 内部已经把原文全部 HTML 转义过再套标签, + * 这里放心用 dangerouslySetInnerHTML(这是本仓库唯一允许用的地方)。 + */ +export function Markdown({ source, compact = false, className = "" }: Props) { + const html = useMemo(() => renderMarkdown(source ?? ""), [source]); + + if (!source?.trim()) { + return

还没有内容。

; + } + + return ( +
+ ); +} diff --git a/web/src/components/RichTextEditor.tsx b/web/src/components/RichTextEditor.tsx new file mode 100644 index 0000000..274f8b1 --- /dev/null +++ b/web/src/components/RichTextEditor.tsx @@ -0,0 +1,247 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; + +import { attachmentUrl, uploadFile } from "../lib/client"; +import { Markdown } from "./Markdown"; +import { Bold, Code, Eye, Image as ImageIcon, Italic, Link2, ListIcon, Pencil, Quote } from "./icons"; + +interface Attachment { + id: string; + filename: string; +} + +interface Props { + value: string; + onChange: (next: string) => void; + /** 图片要落到具体条目下,所以这两个是必填 */ + wsId: string; + itemId: string; + placeholder?: string; + disabled?: boolean; + rows?: number; + ariaLabel: string; + onError?: (message: string) => void; +} + +/** + * 富文本编辑器。 + * + * 存储格式是 Markdown——为什么不做 contenteditable 的富文本: + * 那样存进去的是 HTML,导出、检索、喂给 LLM 都要再清洗一遍;而 Markdown 天生 + * 既是纯文本又能表达排版,模型读得懂、git diff 也看得懂。 + * + * 写作时不用记语法:工具栏按钮、Ctrl+B/I、粘贴/拖拽图片,都会就地改写文本。 + */ +export function RichTextEditor({ + value, + onChange, + wsId, + itemId, + placeholder = "写点什么…支持 Markdown:**粗体**、- 列表、```代码```", + disabled = false, + rows = 6, + ariaLabel, + onError, +}: Props) { + const ref = useRef(null); + const fileRef = useRef(null); + const [preview, setPreview] = useState(false); + const [dragging, setDragging] = useState(false); + const [uploading, setUploading] = useState(false); + const helpId = useId(); + + /** 在光标处插入文本;有选区就包裹它,插完把光标放到合适位置。 */ + const surround = useCallback( + (before: string, after = "", placeholderText = "") => { + const el = ref.current; + if (!el) return; + const { selectionStart: start, selectionEnd: end } = el; + const picked = value.slice(start, end); + const selected = picked || placeholderText; + const next = value.slice(0, start) + before + selected + after + value.slice(end); + + onChange(next); + // 等 React 把新值写回 textarea 再摆光标,否则会被旧值覆盖 + requestAnimationFrame(() => { + el.focus(); + if (picked) { + // 有选区:保持包裹后的内容仍被选中,方便继续加别的样式 + el.setSelectionRange(start + before.length, start + before.length + selected.length); + } else { + const cursor = start + before.length + selected.length; + el.setSelectionRange(cursor, cursor); + } + }); + }, + [onChange, value], + ); + + const prefixLines = useCallback( + (prefix: string) => { + const el = ref.current; + if (!el) return; + const start = value.lastIndexOf("\n", el.selectionStart - 1) + 1; + const endIdx = value.indexOf("\n", el.selectionEnd); + const end = endIdx === -1 ? value.length : endIdx; + const block = value.slice(start, end) || "列表项"; + const nextBlock = block + .split("\n") + .map((line) => `${prefix}${line.replace(/^(\s*)([-*+]|\d+[.)])\s+/, "$1")}`) + .join("\n"); + onChange(value.slice(0, start) + nextBlock + value.slice(end)); + requestAnimationFrame(() => el.focus()); + }, + [onChange, value], + ); + + const insertImages = useCallback( + async (files: File[]) => { + const images = files.filter((file) => file.type.startsWith("image/")); + if (images.length === 0) return; + + setUploading(true); + try { + let next = value; + for (const file of images) { + const created = await uploadFile( + `/workspaces/${wsId}/items/${itemId}/attachments`, + file, + ); + const snippet = `\n![${created.filename}](${attachmentUrl(wsId, created.id)})\n`; + next += snippet; + } + onChange(next); + } catch (err) { + onError?.(err instanceof Error ? err.message : "图片上传失败"); + } finally { + setUploading(false); + } + }, + [itemId, onError, onChange, value, wsId], + ); + + // 用 document 级别的粘贴监听兜住"焦点在 textarea 但剪贴板里是图片"的情况 + useEffect(() => { + const el = ref.current; + if (!el || disabled) return; + + const onPaste = (event: ClipboardEvent) => { + const files = Array.from(event.clipboardData?.files ?? []); + if (files.some((file) => file.type.startsWith("image/"))) { + event.preventDefault(); + void insertImages(files); + } + }; + el.addEventListener("paste", onPaste); + return () => el.removeEventListener("paste", onPaste); + }, [disabled, insertImages]); + + const tools = [ + { label: "粗体", icon: , run: () => surround("**", "**", "粗体") }, + { label: "斜体", icon: , run: () => surround("*", "*", "斜体") }, + { label: "标题", icon: H, run: () => prefixLines("## ") }, + { label: "列表", icon: , run: () => prefixLines("- ") }, + { label: "引用", icon: , run: () => prefixLines("> ") }, + { label: "代码", icon: , run: () => surround("`", "`", "code") }, + { label: "链接", icon: , run: () => surround("[", "](https://)", "链接文字") }, + ]; + + return ( +
+
+ {tools.map((tool) => ( + + ))} + + + +
+ + { + void insertImages(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + + {preview ? ( +
+ {value.trim() ? :

还没有内容。

} +
+ ) : ( +