feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件

写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。
这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**——
能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。

后端
- attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定,
  换对象存储只改 services/storage.py 一个文件
- 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头
- 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求
- raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员,
  非成员一律 8001 不区分「不存在」与「没权限」
- 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权

前端
- 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单
- RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用
- AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob
- 任务备注、工单说明与验收结论、知识正文、评论全部接入

顺手修的
- TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的)

71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB;
e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
This commit is contained in:
dadao
2026-10-29 01:53:46 +08:00
parent 0a3b0f3ca0
commit a24a493403
24 changed files with 1843 additions and 25 deletions
@@ -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")