Compare commits

...
2 Commits
Author SHA1 Message Date
dadao 800c2726e2 docs(api): 补附件契约、Markdown 存储理由与富文本路由
CI / backend (push) Canceled after 0s
2026-10-29 01:53:47 +08:00
dadao a24a493403 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,全绿。
2026-10-29 01:53:46 +08:00
27 changed files with 1928 additions and 27 deletions
+3
View File
@@ -43,3 +43,6 @@ tmprfx*
# Kotlin 编译器诊断日志(构建产物) # Kotlin 编译器诊断日志(构建产物)
android/.kotlin/ android/.kotlin/
# 本地上传文件(附件落盘目录,绝不入库)
backend/var/
+52 -1
View File
@@ -389,7 +389,58 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
--- ---
## 6. 健康与版本 ## 6. 附件与富文本
### 6.1 存储格式:Markdown + 附件表
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
图片不是 base64 塞进正文,而是走附件接口拿一个稳定地址:
```markdown
![票据截图.png](/api/v1/workspaces/{ws_id}/attachments/{attachment_id}/raw?token=...)
```
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/workspaces/{ws_id}/items/{item_id}/attachments` | multipart,字段名 `file`;返回 201 + 附件对象 |
| GET | `/workspaces/{ws_id}/items/{item_id}/attachments` | `{items: [...]}` |
| GET | `/workspaces/{ws_id}/attachments/{id}/download` | 下载(`Content-Disposition: attachment`) |
| GET | `/workspaces/{ws_id}/attachments/{id}/raw?token=<access>` | 直链,给 `<img src>` 用 |
| DELETE | `/workspaces/{ws_id}/attachments/{id}?confirm=true` | 上传者或管理员可删;不传 confirm 报 `8004` |
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / can_delete`。
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
### 6.2 为什么图片直链要带 token
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文会膨胀几十倍,且破坏检索),
或者给一个生命周期短的签名地址。
当前实现取的是"query 里带 access token",**并且只允许本工作区成员访问**(非成员一律 `8001`,
不区分"不存在"与"没权限",避免探测他人附件)。这是本地/内网阶段的折中,上生产前应换成
**带过期时间的一次性签名 URL**,见 `ROADMAP` 的 M7 安全清单。
### 6.3 附件错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
| 8002 | 413 | 超过 `MAX_UPLOAD_MB`(默认 20) |
| 8003 | 403 | 不是上传者也不是管理员 |
| 8004 | 409 | 需要 `?confirm=true`(删除是破坏性动作) |
### 6.4 前端约定
- 编辑器写 Markdown,工具栏/快捷键 `Ctrl+B`、`Ctrl+I` 只管插入语法;预览用 `renderMarkdown()`
渲染(先转义再上标签,链接协议白名单 `http(s)/mailto//`)。
- 粘贴或拖入图片时:先上传 → 拿到 `id` → 在光标处插入 `![文件名](attachmentUrl(...))`。
- 上传接口自己带 `Idempotency-Key`,**不要**手动设 `Content-Type`(浏览器要自己带 boundary)。
---
## 7. 健康与版本
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
+31
View File
@@ -349,3 +349,34 @@
- [x] 模拟器(API 35)实跑:登录 → 工作区 → 任务勾选 → 工单 → 知识 → 成员,截图存 `2.Design/screens/android-*.png` - [x] 模拟器(API 35)实跑:登录 → 工作区 → 任务勾选 → 工单 → 知识 → 成员,截图存 `2.Design/screens/android-*.png`
- [~] 看板不做拖拽:手机屏幕上一列已占 76% 宽,拖拽命中率太差;改为点卡片选目标列,功能等价 - [~] 看板不做拖拽:手机屏幕上一列已占 76% 宽,拖拽命中率太差;改为点卡片选目标列,功能等价
- [ ] 离线缓存(Room)、图片附件、消息推送:均未做,M5 再议 - [ ] 离线缓存(Room)、图片附件、消息推送:均未做,M5 再议
---
## 5. 富文本与附件(M2.5)
### 5.1 决策:正文存 Markdown,不存 HTML
- [x] 任务备注、工单说明与验收结论、知识正文、评论,统一 `*_md` 字段存 Markdown
- [x] 渲染器自研零依赖(`web/src/lib/markdown.ts`),**先转义再上标签**,链接协议白名单 `http(s)/mailto//`
- [x] 不做 contenteditable 富文本:它产出 HTML,导出/检索/喂 LLM 都要再清洗,且 `dangerouslySetInnerHTML` 面积更大
- [ ] 表格与 HTML 片段:暂不支持,按原样当文本(等真有需求再加)
### 5.2 附件
- [x] 后端:`attachment` 表 + RLS + 迁移 0005;`POST/GET/DELETE` 与 `download`/`raw` 两个读取口
- [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件
- [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002`
- [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走 `raw?token=`,只允许本工作区成员
- [ ] 图片直链改一次性签名 URL(当前 token 放 query,上生产前必须换)
- [ ] 缩略图、图片压缩、病毒扫描:未做
### 5.3 编辑器
- [x] `RichTextEditor`:工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片)+ `Ctrl+B`/`Ctrl+I` + 预览切换
- [x] 粘贴与拖拽上传图片,自动在光标处插入 `![文件名](attachmentUrl(...))`
- [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`)
- [x] 安卓:本轮未做富文本编辑,只做只读渲染 + 附件下载(M5 再议)
### 5.4 验证
- [x] 后端 `pytest` → **70 passed**(新增附件 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 直链鉴权)
- [x] `web/e2e/richtext.mjs` → 9 项全绿(渲染、贴图上传后 `/raw` 返回 200、附件落库后重开仍在、控制台无报错)
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
- [x] `tsc -b` 0 错;`vite build` 255.07 kB │ gzip 80.85 kB
+2 -1
View File
@@ -28,7 +28,8 @@
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 | | 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 | | 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 | | 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
| 后端测试 | ✅ 66 passed,ruff 干净 | | 富文本:备注 / 工单说明 / 知识正文支持 Markdown 格式、图片、附件 | ✅ 后端 + Web 已通(附件接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节,e2e 9 项全绿);安卓暂只读 |
| 后端测试 | ✅ 70 passed,ruff 干净 |
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 | | 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
| LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 | | LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 |
@@ -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")
+292
View File
@@ -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(<img src> 带不了请求头)")],
):
"""给 Markdown 里的 `![](…/raw?token=…)` 用:浏览器 <img> 无法附加 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"]
+13 -2
View File
@@ -31,6 +31,7 @@ from ..models_task import Activity, Comment, Item, ItemTag, Tag
from ..models_ticket import TicketExt from ..models_ticket import TicketExt
from ..schemas import Pagination, encode_cursor, ok from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tag_out, tags_of 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=["任务"]) router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"]) tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"])
@@ -294,9 +295,13 @@ async def list_items(
.all() .all()
) )
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows]) 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( 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, "next_cursor": None,
"has_more": False, "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 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]) 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( 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, "next_cursor": next_cursor,
"has_more": has_more, "has_more": has_more,
} }
@@ -405,6 +414,8 @@ async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> d
.all() .all()
) )
data = _item_out(item, tags.get(item.id)) 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"] = [ data["activities"] = [
{ {
"id": str(a.id), "id": str(a.id),
+5
View File
@@ -33,6 +33,7 @@ from ..models_task import Activity, Item
from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
from ..serializers import item_out, tags_of from ..serializers import item_out, tags_of
from ..services.knowledge import create_draft_from_source, find_doc_by_source 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=["知识库"]) router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"])
@@ -125,6 +126,8 @@ async def _knowledge_out(
data = item_out(item, tags) data = item_out(item, tags)
data["knowledge"] = _doc_out(doc) data["knowledge"] = _doc_out(doc)
data["source"] = await _source_out(session, ws_id, doc.source_item_id) 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 return data
@@ -186,10 +189,12 @@ async def list_knowledge(
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows]) tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
items = [] items = []
counts = await count_for_items(session, ws_id, [it.id for it, _ in rows])
for item, doc in rows: for item, doc in rows:
data = item_out(item, tag_map.get(item.id, [])) data = item_out(item, tag_map.get(item.id, []))
data["knowledge"] = _doc_out(doc) data["knowledge"] = _doc_out(doc)
data["source"] = await _source_out(session, ws_id, doc.source_item_id) data["source"] = await _source_out(session, ws_id, doc.source_item_id)
data["attachment_count"] = counts.get(str(item.id), 0)
items.append(data) items.append(data)
next_cursor = ( next_cursor = (
+6
View File
@@ -62,6 +62,12 @@ class Settings(BaseSettings):
idempotency_enabled: bool = True idempotency_enabled: bool = True
idempotency_ttl_seconds: int = 86_400 idempotency_ttl_seconds: int = 86_400
# ── 附件 ──────────────────────────────────────────────
# 本地磁盘目录(相对 backend/ 或绝对路径)。换对象存储时只改 services/storage.py。
upload_dir: str = "var/uploads"
# 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。
max_upload_mb: int = 20
# ── 知识库向量库(独立实例,M3 起用)─────────────────── # ── 知识库向量库(独立实例,M3 起用)───────────────────
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它; # 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
# 向量检索吃内存,和任务库挤在同一实例会互相拖累。 # 向量检索吃内存,和任务库挤在同一实例会互相拖累。
+10
View File
@@ -57,6 +57,12 @@ class ErrorCode(IntEnum):
KNOWLEDGE_FORBIDDEN = 7004 KNOWLEDGE_FORBIDDEN = 7004
KNOWLEDGE_SOURCE_REQUIRED = 7005 KNOWLEDGE_SOURCE_REQUIRED = 7005
# 8xxx 附件
ATTACHMENT_NOT_FOUND = 8001
ATTACHMENT_TOO_LARGE = 8002
ATTACHMENT_FORBIDDEN = 8003
CONFIRM_REQUIRED = 8004
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层 # 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
_HTTP_STATUS: dict[ErrorCode, int] = { _HTTP_STATUS: dict[ErrorCode, int] = {
@@ -95,6 +101,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409, ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
ErrorCode.KNOWLEDGE_FORBIDDEN: 403, ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409, ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409,
ErrorCode.ATTACHMENT_NOT_FOUND: 404,
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
ErrorCode.CONFIRM_REQUIRED: 409,
} }
+2 -1
View File
@@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy import text from sqlalchemy import text
from starlette.exceptions import HTTPException as StarletteHTTPException 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 .config import get_settings
from .db import SessionFactory, current_app_role, engine from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for 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.router, prefix=settings.api_prefix)
app.include_router(tickets.notify_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(knowledge.router, prefix=settings.api_prefix)
app.include_router(attachments.router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针") @app.get("/health", tags=["运维"], summary="存活探针")
+2
View File
@@ -135,6 +135,7 @@ class AuditLog(Base):
# #
# 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base, # 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base,
# 所以只能在文件末尾反向 import,否则循环导入。 # 所以只能在文件末尾反向 import,否则循环导入。
from .models_attachment import ATTACHMENT_TENANT_TABLES # noqa: E402
from .models_knowledge import KNOWLEDGE_TENANT_TABLES # noqa: E402 from .models_knowledge import KNOWLEDGE_TENANT_TABLES # noqa: E402
from .models_task import TASK_TENANT_TABLES # noqa: E402 from .models_task import TASK_TENANT_TABLES # noqa: E402
from .models_ticket import TICKET_TENANT_TABLES # noqa: E402 from .models_ticket import TICKET_TENANT_TABLES # noqa: E402
@@ -147,4 +148,5 @@ TENANT_TABLES: tuple[str, ...] = (
*TASK_TENANT_TABLES, *TASK_TENANT_TABLES,
*TICKET_TENANT_TABLES, *TICKET_TENANT_TABLES,
*KNOWLEDGE_TENANT_TABLES, *KNOWLEDGE_TENANT_TABLES,
*ATTACHMENT_TENANT_TABLES,
) )
+66
View File
@@ -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
)
+72
View File
@@ -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()
+3 -2
View File
@@ -7,8 +7,9 @@ pydantic==2.10.4
pydantic-settings==2.7.0 pydantic-settings==2.7.0
pyjwt==2.10.1 pyjwt==2.10.1
redis==5.2.1 redis==5.2.1
# Windows 没有系统 tzdata,zoneinfo 找不到 "Asia/Shanghai"; python-multipart==0.0.20
# Linux 上装它也无害(会用包里的,而不是系统的)。 # 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 tzdata==2026.5
# dev / test only (split into requirements-dev.txt when it grows) # dev / test only (split into requirements-dev.txt when it grows)
+170
View File
@@ -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=…)` 给 <img> 用:头带不了令牌,只能走 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
+165
View File
@@ -0,0 +1,165 @@
/**
* 富文本与附件 e2e:备注能写 Markdown、贴图能上传并渲染、附件能传能列。
*
* 这里刻意走真浏览器(不是只测接口)——因为要验的正是"编辑器把内容写回后,
* 渲染出来是不是那个样子",以及 <img src> 带 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");
+186
View File
@@ -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:
* 直接用 <a href> 会把令牌拼进 URL,留在浏览器历史里不值得。
*/
export function AttachmentPanel({
wsId,
itemId,
attachments,
onChange,
onError,
disabled = false,
}: Props) {
const fileRef = useRef<HTMLInputElement>(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<Attachment>(`/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 (
<div
onDragOver={(event) => {
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" : ""}
>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 text-2xs font-medium text-ink-500">
<Paperclip size={13} />
附件 {attachments.length > 0 && <span className="text-ink-400">· {attachments.length}</span>}
</span>
<button
type="button"
disabled={disabled || busy}
onClick={() => fileRef.current?.click()}
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-2xs text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
>
{busy ? <Spinner size={12} /> : <Plus size={12} />}
上传
</button>
</div>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
void addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
{attachments.length === 0 ? (
<p className="mt-1.5 rounded-lg border border-dashed border-line-200 px-3 py-2 text-2xs text-ink-400">
可以把文件拖到这里,或点「上传」。
</p>
) : (
<ul className="mt-1.5 space-y-1">
{attachments.map((row) => (
<li
key={row.id}
className="group flex items-center gap-2 rounded-lg px-2 py-1.5 transition hover:bg-paper-200"
>
<span className="min-w-0 flex-1 truncate text-xs text-ink-700">{row.filename}</span>
<span className="shrink-0 text-2xs text-ink-400">{humanSize(row.size)}</span>
<button
type="button"
onClick={() => void download(row)}
title="下载"
aria-label={`下载 ${row.filename}`}
className="grid h-6 w-6 place-items-center rounded text-ink-400 opacity-0 transition hover:bg-paper-300 hover:text-ink-700 group-hover:opacity-100"
>
<Download size={13} />
</button>
{row.can_delete && !disabled && (
<button
type="button"
onClick={() => void remove(row)}
title="删除"
aria-label={`删除 ${row.filename}`}
className="grid h-6 w-6 place-items-center rounded text-ink-400 opacity-0 transition hover:bg-rust-50 hover:text-rust-600 group-hover:opacity-100"
>
<Trash size={13} />
</button>
)}
</li>
))}
</ul>
)}
</div>
);
}
+35
View File
@@ -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 <p className={`text-sm text-ink-400 ${className}`}>还没有内容。</p>;
}
return (
<div
className={[
"prose-dadao text-sm leading-6 text-ink-800",
compact ? "prose-compact" : "",
className,
].join(" ")}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+247
View File
@@ -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<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(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<Attachment>(
`/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: <Bold size={15} />, run: () => surround("**", "**", "粗体") },
{ label: "斜体", icon: <Italic size={15} />, run: () => surround("*", "*", "斜体") },
{ label: "标题", icon: <span className="text-[13px] font-semibold">H</span>, run: () => prefixLines("## ") },
{ label: "列表", icon: <ListIcon size={15} />, run: () => prefixLines("- ") },
{ label: "引用", icon: <Quote size={15} />, run: () => prefixLines("> ") },
{ label: "代码", icon: <Code size={15} />, run: () => surround("`", "`", "code") },
{ label: "链接", icon: <Link2 size={15} />, run: () => surround("[", "](https://)", "链接文字") },
];
return (
<div className={dragging ? "rounded-xl ring-2 ring-primary-500/40" : ""}>
<div className="flex flex-wrap items-center gap-0.5 border-b border-line-100 pb-1.5">
{tools.map((tool) => (
<button
key={tool.label}
type="button"
title={tool.label}
aria-label={tool.label}
disabled={disabled || preview}
onClick={tool.run}
className="grid h-7 w-7 place-items-center rounded-md text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
>
{tool.icon}
</button>
))}
<button
type="button"
title="插入图片"
aria-label="插入图片"
disabled={disabled || preview || uploading}
onClick={() => fileRef.current?.click()}
className="grid h-7 w-7 place-items-center rounded-md text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
>
<ImageIcon size={15} />
</button>
<span className="mx-1 h-4 w-px bg-line-100" />
<button
type="button"
onClick={() => setPreview((prev) => !prev)}
className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-2xs font-medium text-ink-500 transition hover:bg-paper-200 hover:text-ink-800"
aria-pressed={preview}
>
{preview ? <Pencil size={13} /> : <Eye size={13} />}
{preview ? "继续编辑" : "预览"}
</button>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void insertImages(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
{preview ? (
<div className="min-h-[96px] rounded-lg border border-line-100 bg-paper-50 px-3 py-2.5">
{value.trim() ? <Markdown source={value} /> : <p className="text-sm text-ink-400">还没有内容。</p>}
</div>
) : (
<textarea
ref={ref}
aria-label={ariaLabel}
aria-describedby={helpId}
className="input min-h-[96px] resize-y border-0 bg-transparent px-0 py-2 font-mono text-[13px] leading-6 focus:ring-0"
rows={rows}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onDragOver={(event) => {
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 insertImages(files);
}
}}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
const key = event.key.toLowerCase();
if (key === "b") {
event.preventDefault();
surround("**", "**", "粗体");
} else if (key === "i") {
event.preventDefault();
surround("*", "*", "斜体");
}
}}
/>
)}
<p id={helpId} className="mt-1 text-2xs text-ink-400">
{uploading ? "正在上传图片…" : "可直接粘贴或拖入图片;Ctrl+B 粗体、Ctrl+I 斜体"}
</p>
</div>
);
}
+53 -12
View File
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../App"; import { useAuth } from "../App";
import { ApiError, api } from "../lib/client"; import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse"; import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
import type { Activity, Item, ItemDetail, Member, Page, Role, TicketInfo } from "../lib/types"; import type {
Activity,
Attachment,
Item,
ItemDetail,
Member,
Page,
Role,
TicketInfo,
} from "../lib/types";
import { import {
Book, Book,
Clock, Clock,
@@ -14,6 +23,9 @@ import {
Trash, Trash,
Users, Users,
} from "./icons"; } from "./icons";
import { AttachmentPanel } from "./AttachmentPanel";
import { Markdown } from "./Markdown";
import { RichTextEditor } from "./RichTextEditor";
import { Button } from "./ui"; import { Button } from "./ui";
import { Sheet } from "./ui"; import { Sheet } from "./ui";
@@ -75,6 +87,7 @@ export function TaskDetail({
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [due, setDue] = useState(""); const [due, setDue] = useState("");
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
@@ -94,6 +107,8 @@ export function TaskDetail({
setBody(next.body_md); setBody(next.body_md);
setTags(next.tags.map((t) => t.name).join(" ")); setTags(next.tags.map((t) => t.name).join(" "));
setDue(toLocalInput(next.due_at)); setDue(toLocalInput(next.due_at));
// 附件可能为空数组,兜一下:接口在某些分支(如 PATCH 回包)不带这个字段
setAttachments(next.attachments ?? []);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -386,7 +401,9 @@ export function TaskDetail({
{ticket.resolution_md ? ( {ticket.resolution_md ? (
<div className="mt-3 rounded-lg border border-line-200 bg-white p-3"> <div className="mt-3 rounded-lg border border-line-200 bg-white p-3">
<p className="text-2xs text-ink-400">验收结论</p> <p className="text-2xs text-ink-400">验收结论</p>
<p className="mt-1 whitespace-pre-wrap text-sm text-ink-800">{ticket.resolution_md}</p> <div className="mt-1">
<Markdown source={ticket.resolution_md} compact />
</div>
</div> </div>
) : null} ) : null}
@@ -471,17 +488,41 @@ export function TaskDetail({
) : null} ) : null}
</label> </label>
<label className="block"> <div>
<span className="label">备注</span> <span className="label">备注</span>
<textarea <div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
className="input min-h-32 resize-y leading-relaxed" <RichTextEditor
aria-label="备注" ariaLabel="备注"
value={body} wsId={wsId}
onChange={(e) => setBody(e.target.value)} itemId={item.id}
onBlur={() => void saveBody()} value={body}
placeholder="背景、约束、链接…写下来,将来才想得起来" onChange={setBody}
onError={(message) => setError(message)}
rows={8}
disabled={busy}
/>
</div>
<div className="mt-1 flex items-center gap-2">
{body !== item.body_md ? (
<Button size="sm" variant="subtle" disabled={busy} onClick={() => void saveBody()}>
保存备注
</Button>
) : (
<span className="text-2xs text-ink-400">修改后自动提示保存</span>
)}
</div>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
<AttachmentPanel
wsId={wsId}
itemId={item.id}
attachments={attachments}
onChange={setAttachments}
onError={(message) => setError(message)}
disabled={busy}
/> />
</label> </div>
{item.kind === "task" && !["done", "canceled"].includes(item.status) ? ( {item.kind === "task" && !["done", "canceled"].includes(item.status) ? (
<section className="rounded-xl border border-clay-200 bg-clay-50/50 p-4"> <section className="rounded-xl border border-clay-200 bg-clay-50/50 p-4">
@@ -565,7 +606,7 @@ export function TaskDetail({
)} )}
</span> </span>
<div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2"> <div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2">
<p className="whitespace-pre-wrap text-sm text-ink-800">{c.body_md}</p> <Markdown source={c.body_md} />
<p className="mt-0.5 text-2xs text-ink-400"> <p className="mt-0.5 text-2xs text-ink-400">
{new Date(c.created_at).toLocaleString("zh-CN")} {new Date(c.created_at).toLocaleString("zh-CN")}
</p> </p>
+63
View File
@@ -23,6 +23,69 @@ export const Check = (p: IconProps) => (
</svg> </svg>
); );
export const Bold = (p: IconProps) => (
<svg {...base(p)}>
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />
</svg>
);
export const Italic = (p: IconProps) => (
<svg {...base(p)}>
<path d="M15 5h-5M14 19H9M14.5 5 10 19" />
</svg>
);
export const Quote = (p: IconProps) => (
<svg {...base(p)}>
<path d="M7 7h4v4c0 2.2-1.3 3.7-3.5 4.5M15 7h4v4c0 2.2-1.3 3.7-3.5 4.5" />
</svg>
);
export const Code = (p: IconProps) => (
<svg {...base(p)}>
<path d="m9 8-4 4 4 4M15 8l4 4-4 4" />
</svg>
);
export const ListIcon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01" />
</svg>
);
export const Image = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3.5" y="4.5" width="17" height="15" rx="2.5" />
<circle cx="9" cy="10" r="1.6" />
<path d="m4 17 4.5-4.5 3.5 3.5 3-3L20 17" />
</svg>
);
export const Eye = (p: IconProps) => (
<svg {...base(p)}>
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
<circle cx="12" cy="12" r="2.6" />
</svg>
);
export const Pencil = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17z" />
</svg>
);
export const Paperclip = (p: IconProps) => (
<svg {...base(p)}>
<path d="M20 11.5 12.3 19a4.6 4.6 0 0 1-6.5-6.5l7.4-7.4a3.1 3.1 0 1 1 4.4 4.4l-7.4 7.4a1.5 1.5 0 1 1-2.2-2.2l6.6-6.6" />
</svg>
);
export const Download = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M4.5 19h15" />
</svg>
);
export const CheckCircle = (p: IconProps) => ( export const CheckCircle = (p: IconProps) => (
<svg {...base(p)}> <svg {...base(p)}>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
+115
View File
@@ -162,3 +162,118 @@
background-size: 32px 32px; background-size: 32px 32px;
} }
} }
/* Markdown 渲染样式:不引 @tailwindcss/typography(它会把行距和字重改得不像我们的界面),
这里只针对我们真正会渲染的标签写一遍,保持与组件库同一套色板与节奏。 */
.prose-dadao > * + * {
margin-top: 0.6em;
}
.prose-dadao h1,
.prose-dadao h2,
.prose-dadao h3,
.prose-dadao h4 {
font-family: theme("fontFamily.display");
color: theme("colors.ink.900");
line-height: 1.35;
margin-top: 1.1em;
}
.prose-dadao h1 {
font-size: 1.25rem;
}
.prose-dadao h2 {
font-size: 1.1rem;
}
.prose-dadao h3,
.prose-dadao h4 {
font-size: 1rem;
}
.prose-dadao strong {
font-weight: 600;
color: theme("colors.ink.900");
}
.prose-dadao a {
color: theme("colors.primary.700");
text-decoration: underline;
text-decoration-color: theme("colors.primary.300");
text-underline-offset: 2px;
}
.prose-dadao a:hover {
color: theme("colors.primary.800");
}
.prose-dadao ul,
.prose-dadao ol {
padding-left: 1.15rem;
}
.prose-dadao ul {
list-style: disc;
}
.prose-dadao ol {
list-style: decimal;
}
.prose-dadao li::marker {
color: theme("colors.ink.300");
}
.prose-dadao li + li {
margin-top: 0.2em;
}
.prose-dadao blockquote {
border-left: 2px solid theme("colors.primary.300");
padding-left: 0.75rem;
color: theme("colors.ink.600");
}
.prose-dadao code {
font-family: theme("fontFamily.mono");
font-size: 0.86em;
background: theme("colors.paper.200");
border-radius: 4px;
padding: 0.1em 0.35em;
}
.prose-dadao pre {
background: theme("colors.ink.900");
color: theme("colors.paper.50");
border-radius: 10px;
padding: 0.7rem 0.85rem;
overflow-x: auto;
font-size: 0.82em;
line-height: 1.6;
}
.prose-dadao pre code {
background: transparent;
padding: 0;
color: inherit;
}
.prose-dadao hr {
border: 0;
border-top: 1px solid theme("colors.line.200");
}
.prose-dadao img {
max-width: 100%;
border-radius: 8px;
border: 1px solid theme("colors.line.100");
}
.prose-dadao table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.prose-dadao th,
.prose-dadao td {
border: 1px solid theme("colors.line.200");
padding: 0.35rem 0.5rem;
text-align: left;
}
.prose-compact {
font-size: 0.8rem;
line-height: 1.5;
}
+45
View File
@@ -104,3 +104,48 @@ export const api = {
patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }), patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }),
del: <T>(path: string) => raw<T>(path, { method: "DELETE" }), del: <T>(path: string) => raw<T>(path, { method: "DELETE" }),
}; };
/**
* 上传文件(multipart)。
*
* 不能用 api.post:它固定发 JSON。这里也不能自己设 Content-Type,
* 必须让浏览器带 boundary,否则 FastAPI 解析不出文件。
*/
export async function uploadFile<T>(path: string, file: File): Promise<T> {
const form = new FormData();
form.append("file", file, file.name);
const headers: Record<string, string> = { Accept: "application/json" };
const token = session.access;
if (token) headers.Authorization = `Bearer ${token}`;
headers["Idempotency-Key"] = crypto.randomUUID();
const resp = await fetch(`${BASE}${path}`, { method: "POST", headers, body: form });
let body: { code: number; message: string; data: T; request_id: string } | null = null;
try {
body = await resp.json();
} catch {
body = null;
}
if (!body) {
throw new ApiError(resp.status, `上传失败:服务返回了非 JSON 响应(HTTP ${resp.status})`);
}
if (!resp.ok || body.code !== 0) {
if (resp.status === 401) {
session.clear();
authExpired.dispatchEvent(new Event("expired"));
}
throw new ApiError(body.code, body.message, body.request_id);
}
return body.data;
}
/**
* 附件直链:`<img>` 带不了 Authorization 头,所以令牌走 query。
* 只在渲染 Markdown 图片时用,不要拿它去做普通的下载链接(那样 token 会留在浏览器历史里)。
*/
export function attachmentUrl(wsId: string, attachmentId: string): string {
const token = session.access ?? "";
return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw?token=${encodeURIComponent(token)}`;
}
+180
View File
@@ -0,0 +1,180 @@
/**
* 极简 Markdown 渲染器。
*
* 为什么不用 react-markdown:我们的编辑场景只需要"能读懂的一小撮语法",
* 引一个大依赖还得处理它的插件体系;而 XSS 这类问题,自己渲染反倒更容易
* 把"先转义、再套标签"这条铁律钉死在一个文件里。
*
* 支持的语法刻意很小:标题、粗体/斜体/删除线、行内代码、代码块、有序/无序
* 列表、引用、分隔线、链接、图片、(空行分隔的)段落。
* 不支持的(表格、脚注、HTML 片段)会原样当文本显示 —— 显示不出来比显示一半安全。
*/
const ESCAPE_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]);
}
/** 只放行 http/https/mailto 与站内相对地址,挡掉 javascript: 这类伪协议。 */
function safeUrl(url: string): string | null {
const trimmed = url.trim();
if (!trimmed) return null;
if (/^(https?:\/\/|mailto:|\/)/i.test(trimmed)) return trimmed;
return null;
}
/** 行内语法。先转义,再按"从最不可能被误伤"的顺序套标签。 */
function inline(raw: string): string {
let text = escapeHtml(raw);
// 行内代码先摘出来,避免里面的 * 和 _ 被当成强调
const codes: string[] = [];
text = text.replace(/`([^`]+)`/g, (_m, code: string) => {
codes.push(code);
return `\u0000CODE${codes.length - 1}\u0000`;
});
text = text
// ![alt](url) 与 [text](url)
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt: string, url: string) => {
const href = safeUrl(url);
if (!href) return match;
return `<img src="${href}" alt="${alt}" loading="lazy" />`;
})
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label: string, url: string) => {
const href = safeUrl(url);
if (!href) return match;
const external = /^https?:\/\//i.test(href);
const rel = external ? ' target="_blank" rel="noreferrer noopener"' : "";
return `<a href="${href}"${rel}>${label}</a>`;
})
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(?:^|\s)__([^_]+)__(?=\s|$)/g, " <em>$1</em>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/(?:^|\s)_([^_]+)_(?=\s|$)/g, " <em>$1</em>")
.replace(/~~([^~]+)~~/g, "<del>$1</del>");
// 还原行内代码(内容已在上面转义过,这里只负责套标签)
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, idx: string) => `<code>${codes[Number(idx)]}</code>`);
}
/** 渲染成可直接塞进 dangerouslySetInnerHTML 的字符串。 */
export function renderMarkdown(source: string): string {
const lines = source.replace(/\r\n?/g, "\n").split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// 代码块:内容原样保留,只做转义,不做任何行内解析
const fence = line.match(/^\s*```(\w*)\s*$/);
if (fence) {
const lang = fence[1];
const body: string[] = [];
i += 1;
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
body.push(lines[i]);
i += 1;
}
i += 1; // 跳过结束围栏(文件末尾没闭合也能渲染)
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : "";
out.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
continue;
}
if (/^\s*$/.test(line)) {
i += 1;
continue;
}
const heading = line.match(/^(#{1,4})\s+(.*)$/);
if (heading) {
const level = heading[1].length;
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
i += 1;
continue;
}
if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
out.push("<hr />");
i += 1;
continue;
}
// 引用:连续以 > 开头的行合并成一个 blockquote
if (/^\s*>\s?/.test(line)) {
const body: string[] = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
body.push(lines[i].replace(/^\s*>\s?/, ""));
i += 1;
}
out.push(`<blockquote>${inline(body.join(" "))}</blockquote>`);
continue;
}
// 列表:同类型的连续行归一组,嵌套只支持一层(够用,且不会渲染错)
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
const ordered = line.match(/^(\s*)\d+[.)]\s+(.*)$/);
if (bullet || ordered) {
const isOrdered = Boolean(ordered);
const items: string[] = [];
const pattern = isOrdered ? /^(\s*)\d+[.)]\s+(.*)$/ : /^(\s*)[-*+]\s+(.*)$/;
while (i < lines.length) {
const m = lines[i].match(pattern);
if (!m) break;
const [, padding, content] = m;
// 缩进两格以上视为子项,用一段加粗缩进表示(不生成嵌套 ol/ul,避免打错标签)
items.push(
padding.length >= 2
? `<li class="ml-4">${inline(content)}</li>`
: `<li>${inline(content)}</li>`,
);
i += 1;
}
const tag = isOrdered ? "ol" : "ul";
out.push(`<${tag}>${items.join("")}</${tag}>`);
continue;
}
// 段落:吃到下一个空行或下一个块级语法为止
const paragraph: string[] = [];
while (i < lines.length) {
const current = lines[i];
if (
/^\s*$/.test(current) ||
/^\s*```/.test(current) ||
/^(#{1,4})\s+/.test(current) ||
/^\s*>\s?/.test(current) ||
/^\s*[-*+]\s+/.test(current) ||
/^\s*\d+[.)]\s+/.test(current)
) {
break;
}
paragraph.push(current);
i += 1;
}
out.push(`<p>${inline(paragraph.join("\n")).replace(/\n/g, "<br />")}</p>`);
}
return out.join("\n");
}
/** 纯文本摘要,用于列表页显示(去掉语法符号)。 */
export function plainSummary(source: string, limit = 90): string {
const text = source
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[#>*_~`-]/g, " ")
.replace(/\s+/g, " ")
.trim();
return text.length > limit ? `${text.slice(0, limit)}…` : text;
}
+15
View File
@@ -81,7 +81,20 @@ export interface Activity {
created_at: string; created_at: string;
} }
export interface Attachment {
id: string;
item_id: string;
filename: string;
content_type: string;
size: number;
uploader_id: string;
created_at: string;
can_delete: boolean;
}
export interface ItemDetail extends Item { export interface ItemDetail extends Item {
attachments?: Attachment[];
attachment_count?: number;
activities: Activity[]; activities: Activity[];
ticket?: TicketInfo; ticket?: TicketInfo;
} }
@@ -138,5 +151,7 @@ export interface KnowledgeSource {
export interface Knowledge extends Item { export interface Knowledge extends Item {
knowledge: KnowledgeInfo; knowledge: KnowledgeInfo;
source: KnowledgeSource | null; source: KnowledgeSource | null;
attachments?: Attachment[];
attachment_count?: number;
activities?: Activity[]; activities?: Activity[];
} }
+29 -8
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell"; import { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel";
import { RichTextEditor } from "../components/RichTextEditor";
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons"; import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
import { import {
Button, Button,
@@ -12,7 +14,7 @@ import {
StatusPill, StatusPill,
} from "../components/ui"; } from "../components/ui";
import { ApiError, api } from "../lib/client"; import { ApiError, api } from "../lib/client";
import type { Knowledge, KnowledgeStatus } from "../lib/types"; import type { Attachment, Knowledge, KnowledgeStatus } from "../lib/types";
const TABS = [ const TABS = [
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] }, { key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
@@ -299,6 +301,7 @@ function KnowledgeDrawer({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -307,6 +310,7 @@ function KnowledgeDrawer({
setDoc(data); setDoc(data);
setTitle(data.title); setTitle(data.title);
setBody(data.body_md); setBody(data.body_md);
setAttachments(data.attachments ?? []);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败"); setError(err instanceof ApiError ? err.message : "加载失败");
} }
@@ -447,13 +451,30 @@ function KnowledgeDrawer({
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
aria-label="标题" aria-label="标题"
/> />
<textarea
className="input min-h-[320px] resize-y font-mono text-xs leading-relaxed" <div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
value={body} <RichTextEditor
disabled={!canEdit} ariaLabel="正文"
onChange={(e) => setBody(e.target.value)} wsId={wsId}
aria-label="正文" itemId={doc.id}
/> value={body}
onChange={setBody}
onError={(message) => setError(message)}
rows={16}
disabled={!canEdit}
/>
</div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
<AttachmentPanel
wsId={wsId}
itemId={doc.id}
attachments={attachments}
onChange={setAttachments}
onError={(message) => setError(message)}
disabled={!canEdit}
/>
</div>
{error && <InlineAlert>{error}</InlineAlert>} {error && <InlineAlert>{error}</InlineAlert>}
</div> </div>