For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在 OA 审批详情提供独立评论区:文字评论、引用表单附件、本人撤回;Web 管理端先交付,API 可供小程序复用。
Architecture: 新建 oa_instance_comment 表与服务模块 oa_instance_comment.py(附件枚举/校验复用 oa_related_approval 的 upload 遍历能力);API 挂在 /web/admin/oa-approval;详情 get_instance_detail 注入 comments;前端抽 OaCommentPanel.vue 挂到详情页与只读详情弹窗。不改动审批意见 / 进度链。
Tech Stack: FastAPI · SQLAlchemy · pytest/unittest · Vue 3 · Element Plus · MinIO 既有 URL(仅引用)
Spec: docs/superpowers/specs/2026-09-20-oa-instance-comment-design.md
| Path | Responsibility |
|---|---|
Create: docs/sql/migrations/2026-09-20-oa-instance-comment.sql |
建表 DDL |
Modify: backend/app/models/models.py |
在 OaInstanceEvent 后新增 OaInstanceComment |
Create: backend/app/services/oa_instance_comment.py |
附件枚举、校验、列表/发表/撤回、_comment_to_dict |
Create: backend/tests/test_oa_instance_comment.py |
纯函数 + SQLite 集成测 |
Modify: backend/app/services/oa_engine.py |
get_instance_detail 注入 comments |
Modify: backend/app/api/oa_approval.py |
Body + 4 个路由 |
Modify: web-admin/src/api/oaApproval.ts |
类型 + fetch 方法 |
Create: web-admin/src/components/oa-approval/OaCommentPanel.vue |
评论 UI |
Modify: web-admin/src/views/oa-approval/instances/detail.vue |
挂载评论区 |
Modify: web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue |
挂载评论区(只读关联详情也要能看;running 时可评) |
Locked rules (from spec):
OaTask.comment_text。status=running 可发/可撤;结束后只读。status=revoked,保留 content(审计);UI 对 revoked 不展示正文与附件。_get_instance)。Files:
docs/sql/migrations/2026-09-20-oa-instance-comment.sqlModify: backend/app/models/models.py(在 OaInstanceEvent 类之后、OaRelation 之前插入)
[ ] Step 1: 写迁移 SQL
-- OA 审批实例评论
CREATE TABLE IF NOT EXISTS `oa_instance_comment` (
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
`instance_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属实例ID',
`content` varchar(1024) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '评论正文',
`attachment_refs_json` text COLLATE utf8mb4_unicode_ci COMMENT '引用表单附件快照 JSON',
`status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/revoked',
`revoked_time` datetime DEFAULT NULL COMMENT '撤回时间',
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id(评论作者)',
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
PRIMARY KEY (`id`),
KEY `idx_oa_comment_tenant_inst_time` (`tenant_no`,`instance_id`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA审批实例评论';
在 OaInstanceEvent 与 OaRelation 之间插入:
class OaInstanceComment(Base):
"""OA审批实例评论 oa_instance_comment"""
__tablename__ = "oa_instance_comment"
id = Column(String(64), primary_key=True, comment="主键ID")
instance_id = Column(String(64), nullable=False, comment="所属实例ID")
content = Column(String(1024), nullable=False, comment="评论正文")
attachment_refs_json = Column(Text, nullable=True, comment="引用表单附件快照 JSON")
status = Column(String(16), nullable=False, default="normal", comment="normal/revoked")
revoked_time = Column(DateTime, nullable=True, comment="撤回时间")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id(评论作者)")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
确认文件顶部已 import Text(OaInstance 已用,应已有)。
git add docs/sql/migrations/2026-09-20-oa-instance-comment.sql backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat(oa): add oa_instance_comment table and model
EOF
)"
Files:
backend/app/services/oa_instance_comment.pyCreate: backend/tests/test_oa_instance_comment.py
[ ] Step 1: 写失败/行为测试(先写测)
"""OA 实例评论:附件枚举与引用校验"""
import unittest
from app.services.oa_instance_comment import (
enumerate_form_attachments,
normalize_attachment_refs,
)
class TestEnumerateFormAttachments(unittest.TestCase):
def test_groups_upload_fields(self):
schema = {
"rule": [
{
"type": "upload",
"field": "files",
"title": "附件",
},
{"type": "input", "field": "name", "title": "名称"},
]
}
form = {
"files": [
{"url": "https://x/a.pdf", "name": "a.pdf"},
{"url": "https://x/b.pdf", "name": "b.pdf"},
],
"name": "demo",
}
items = enumerate_form_attachments(schema, form)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["fieldKey"], "files")
self.assertEqual(items[0]["fieldLabel"], "附件")
self.assertEqual(len(items[0]["files"]), 2)
self.assertEqual(items[0]["files"][0]["fileName"], "a.pdf")
def test_skips_empty_upload(self):
schema = {"rule": [{"type": "fcUpload", "field": "f", "title": "F"}]}
self.assertEqual(enumerate_form_attachments(schema, {"f": []}), [])
class TestNormalizeAttachmentRefs(unittest.TestCase):
def test_accepts_valid_ref(self):
schema = {"rule": [{"type": "upload", "field": "files", "title": "附件"}]}
form = {"files": [{"url": "https://x/a.pdf", "name": "a.pdf"}]}
out = normalize_attachment_refs(
schema,
form,
[{"fieldKey": "files", "url": "https://x/a.pdf", "fileName": "wrong"}],
)
self.assertEqual(out[0]["fileName"], "a.pdf")
self.assertEqual(out[0]["fieldLabel"], "附件")
def test_rejects_foreign_url(self):
from app.services.oa_engine import OaEngineError
schema = {"rule": [{"type": "upload", "field": "files", "title": "附件"}]}
form = {"files": [{"url": "https://x/a.pdf", "name": "a.pdf"}]}
with self.assertRaises(OaEngineError):
normalize_attachment_refs(
schema,
form,
[{"fieldKey": "files", "url": "https://evil/x.pdf"}],
)
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py -v
Expected: FAIL(模块不存在)
创建 backend/app/services/oa_instance_comment.py:
"""OA 审批实例评论:附件枚举、校验、CRUD"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import OaAppVersion, OaInstance, OaInstanceComment
from app.services.oa_engine import OaEngineError, _get_instance, _loads, _user_name_map
from app.services.oa_related_approval import (
UPLOAD_TYPES,
_extract_upload_urls,
_schema_rules,
_upload_display_name,
)
CONTENT_MAX = 1024
def enumerate_form_attachments(
schema: Any, form_data: Optional[Dict[str, Any]]
) -> List[Dict[str, Any]]:
data = form_data or {}
items: List[Dict[str, Any]] = []
for rule in _schema_rules(schema):
t = str(rule.get("type") or "")
if t not in UPLOAD_TYPES:
continue
field = str(rule.get("field") or rule.get("name") or "").strip()
if not field:
continue
label = str(rule.get("title") or rule.get("label") or field).strip() or field
raw = data.get(field)
files: List[Dict[str, str]] = []
if isinstance(raw, list):
for it in raw:
urls = _extract_upload_urls(it if not isinstance(it, list) else it)
# prefer per-item
if isinstance(it, dict):
u = (_extract_upload_urls(it) or [""])[0]
if not u:
continue
files.append(
{
"url": u,
"fileName": _upload_display_name(it, u),
}
)
else:
for u in _extract_upload_urls(it):
files.append({"url": u, "fileName": _upload_display_name(it, u)})
else:
for u in _extract_upload_urls(raw):
files.append({"url": u, "fileName": _upload_display_name(raw, u)})
if not files:
continue
items.append({"fieldKey": field, "fieldLabel": label, "files": files})
return items
def _allowed_url_index(
schema: Any, form_data: Optional[Dict[str, Any]]
) -> Dict[str, Dict[str, str]]:
"""url -> {fieldKey, fieldLabel, fileName}"""
index: Dict[str, Dict[str, str]] = {}
for group in enumerate_form_attachments(schema, form_data):
for f in group["files"]:
u = str(f.get("url") or "").strip()
if not u:
continue
index[u] = {
"fieldKey": group["fieldKey"],
"fieldLabel": group["fieldLabel"],
"fileName": str(f.get("fileName") or "").strip() or u,
}
return index
def normalize_attachment_refs(
schema: Any,
form_data: Optional[Dict[str, Any]],
refs: Optional[List[Dict[str, Any]]],
) -> List[Dict[str, str]]:
if not refs:
return []
if not isinstance(refs, list):
raise OaEngineError("attachmentRefs 格式无效")
index = _allowed_url_index(schema, form_data)
out: List[Dict[str, str]] = []
seen = set()
for raw in refs:
if not isinstance(raw, dict):
raise OaEngineError("引用的附件不存在或不属于本表单")
url = str(raw.get("url") or "").strip()
if not url or url not in index:
raise OaEngineError("引用的附件不存在或不属于本表单")
if url in seen:
continue
seen.add(url)
meta = index[url]
out.append(
{
"fieldKey": meta["fieldKey"],
"fieldLabel": meta["fieldLabel"],
"url": url,
"fileName": meta["fileName"],
}
)
return out
(CRUD 函数在 Task 3 追加;本任务先保证上述纯函数可测。)
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py::TestEnumerateFormAttachments tests/test_oa_instance_comment.py::TestNormalizeAttachmentRefs -v
Expected: PASS
git add backend/app/services/oa_instance_comment.py backend/tests/test_oa_instance_comment.py
git commit -m "$(cat <<'EOF'
feat(oa): enumerate and validate comment form attachment refs
EOF
)"
Files:
backend/app/services/oa_instance_comment.pyModify: backend/tests/test_oa_instance_comment.py
[ ] Step 1: 追加集成测试
在同测试文件追加(对齐 TestTransferTask 的内存 SQLite 风格):
class TestCommentCrud(unittest.TestCase):
def setUp(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.models import OaAppVersion, OaInstance, OaInstanceComment, SysUser
self.engine = create_engine("sqlite:///:memory:")
OaAppVersion.__table__.create(self.engine)
OaInstance.__table__.create(self.engine)
OaInstanceComment.__table__.create(self.engine)
SysUser.__table__.create(self.engine)
Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
self.db = Session()
now = datetime.now()
schema = {
"rule": [
{"type": "upload", "field": "files", "title": "附件"},
]
}
self.db.add(
OaAppVersion(
id="v1",
app_id="app1",
version_no=1,
status="published",
is_latest_published="1",
form_schema_json=json.dumps(schema, ensure_ascii=False),
flow_tree_json="{}",
advanced_settings_json="{}",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
self.db.add(
OaInstance(
id="i1",
app_id="app1",
app_version_id="v1",
title="t",
status="running",
initiator_user_id="u0",
initiator_dept_id="d1",
form_data_json=json.dumps(
{"files": [{"url": "https://x/a.pdf", "name": "a.pdf"}]},
ensure_ascii=False,
),
tenant_no="zkxw",
create_dept_id="d1",
create_time=now,
update_time=now,
del_flag=0,
current_round=1,
)
)
self.db.add(
SysUser(
id="u1",
tenant_no="zkxw",
user_no="u1",
nick_name="张三",
status="1",
del_flag=0,
create_time=now,
update_time=now,
)
)
self.db.commit()
def test_create_list_revoke(self):
from app.services import oa_instance_comment as svc
row = svc.create_comment(
self.db,
instance_id="i1",
tenant_no="zkxw",
user_id="u1",
dept_id="d1",
content="请看附件",
attachment_refs=[{"fieldKey": "files", "url": "https://x/a.pdf"}],
)
self.assertEqual(row["status"], "normal")
self.assertTrue(row["canRevoke"])
self.assertEqual(len(row["attachmentRefs"]), 1)
listed = svc.list_comments(
self.db, instance_id="i1", tenant_no="zkxw", viewer_user_id="u1"
)
self.assertEqual(len(listed), 1)
revoked = svc.revoke_comment(
self.db,
instance_id="i1",
comment_id=row["id"],
tenant_no="zkxw",
user_id="u1",
)
self.assertEqual(revoked["status"], "revoked")
self.assertFalse(revoked["canRevoke"])
# 幂等
again = svc.revoke_comment(
self.db,
instance_id="i1",
comment_id=row["id"],
tenant_no="zkxw",
user_id="u1",
)
self.assertEqual(again["status"], "revoked")
def test_cannot_comment_when_not_running(self):
from app.services import oa_instance_comment as svc
from app.services.oa_engine import OaEngineError
from app.models.models import OaInstance
inst = self.db.query(OaInstance).filter(OaInstance.id == "i1").one()
inst.status = "completed"
self.db.commit()
with self.assertRaises(OaEngineError):
svc.create_comment(
self.db,
instance_id="i1",
tenant_no="zkxw",
user_id="u1",
dept_id="d1",
content="x",
attachment_refs=None,
)
文件顶部补:import json、from datetime import datetime。
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py::TestCommentCrud -v
Expected: FAIL(缺 create_comment 等)
在 oa_instance_comment.py 追加:
def _fmt_time(dt: Optional[datetime]) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""
def _comment_to_dict(
row: OaInstanceComment,
*,
name_map: Dict[str, str],
viewer_user_id: str,
instance_status: str,
) -> Dict[str, Any]:
refs = _loads(row.attachment_refs_json, [])
if not isinstance(refs, list):
refs = []
status = (row.status or "normal").strip() or "normal"
can_revoke = (
status == "normal"
and instance_status == "running"
and (row.create_by or "") == (viewer_user_id or "")
)
return {
"id": row.id,
"instanceId": row.instance_id,
"content": row.content or "",
"attachmentRefs": refs,
"status": status,
"revokedTime": _fmt_time(row.revoked_time),
"createBy": row.create_by or "",
"createByName": name_map.get(row.create_by or "", row.create_by or ""),
"createTime": _fmt_time(row.create_time),
"canRevoke": can_revoke,
}
def _load_version_schema(db: Session, inst: OaInstance) -> Any:
ver = (
db.query(OaAppVersion)
.filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
.first()
)
if not ver:
return {}
return _loads(ver.form_schema_json, {})
def list_comments(
db: Session,
*,
instance_id: str,
tenant_no: str,
viewer_user_id: str,
) -> List[Dict[str, Any]]:
inst = _get_instance(db, instance_id, tenant_no)
rows = (
db.query(OaInstanceComment)
.filter(
OaInstanceComment.instance_id == instance_id,
OaInstanceComment.tenant_no == tenant_no,
OaInstanceComment.del_flag == 0,
)
.order_by(OaInstanceComment.create_time.asc())
.all()
)
name_map = _user_name_map(db, [r.create_by for r in rows if r.create_by])
return [
_comment_to_dict(
r,
name_map=name_map,
viewer_user_id=viewer_user_id,
instance_status=inst.status or "",
)
for r in rows
]
def list_form_attachments(
db: Session, *, instance_id: str, tenant_no: str
) -> List[Dict[str, Any]]:
inst = _get_instance(db, instance_id, tenant_no)
schema = _load_version_schema(db, inst)
form = _loads(inst.form_data_json, {})
if not isinstance(form, dict):
form = {}
return enumerate_form_attachments(schema, form)
def create_comment(
db: Session,
*,
instance_id: str,
tenant_no: str,
user_id: str,
dept_id: str,
content: str,
attachment_refs: Optional[List[Dict[str, Any]]],
) -> Dict[str, Any]:
inst = _get_instance(db, instance_id, tenant_no)
if (inst.status or "") != "running":
raise OaEngineError("仅审批进行中可评论")
text = (content or "").strip()
if not text:
raise OaEngineError("评论内容不能为空")
if len(text) > CONTENT_MAX:
raise OaEngineError(f"评论内容不能超过{CONTENT_MAX}字")
schema = _load_version_schema(db, inst)
form = _loads(inst.form_data_json, {})
if not isinstance(form, dict):
form = {}
refs = normalize_attachment_refs(schema, form, attachment_refs)
now = datetime.now()
row = OaInstanceComment(
id=generate_uuid(),
instance_id=instance_id,
content=text,
attachment_refs_json=json.dumps(refs, ensure_ascii=False) if refs else None,
status="normal",
revoked_time=None,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
db.commit()
db.refresh(row)
name_map = _user_name_map(db, [user_id])
return _comment_to_dict(
row,
name_map=name_map,
viewer_user_id=user_id,
instance_status=inst.status or "",
)
def revoke_comment(
db: Session,
*,
instance_id: str,
comment_id: str,
tenant_no: str,
user_id: str,
) -> Dict[str, Any]:
inst = _get_instance(db, instance_id, tenant_no)
if (inst.status or "") != "running":
raise OaEngineError("仅审批进行中可撤回评论")
row = (
db.query(OaInstanceComment)
.filter(
OaInstanceComment.id == comment_id,
OaInstanceComment.instance_id == instance_id,
OaInstanceComment.tenant_no == tenant_no,
OaInstanceComment.del_flag == 0,
)
.first()
)
if not row:
raise OaEngineError("评论不存在")
if (row.create_by or "") != (user_id or ""):
raise OaEngineError("只能撤回自己的评论")
now = datetime.now()
if (row.status or "") != "revoked":
row.status = "revoked"
row.revoked_time = now
row.update_by = user_id
row.update_time = now
db.add(row)
db.commit()
db.refresh(row)
name_map = _user_name_map(db, [row.create_by] if row.create_by else [])
return _comment_to_dict(
row,
name_map=name_map,
viewer_user_id=user_id,
instance_status=inst.status or "",
)
注意:_loads / _get_instance / _user_name_map 若为 oa_engine 私有导出,现网其它服务已在用(见 oa_related_approval),保持一致即可。
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py -v
Expected: PASS(若 SysUser 必填列不足,按报错补全 setUp,对齐 test_oa_engine.py 中 SysUser 构造)
git add backend/app/services/oa_instance_comment.py backend/tests/test_oa_instance_comment.py
git commit -m "$(cat <<'EOF'
feat(oa): add instance comment create list and revoke service
EOF
)"
Files:
backend/app/services/oa_engine.py(get_instance_detail 返回前)Modify: backend/app/api/oa_approval.py
[ ] Step 1: 详情注入 comments
在 get_instance_detail 末尾 return data 前:
from app.services import oa_instance_comment as oa_comment
# viewer 在 API 层注入更准;此处无 user_id 时 canRevoke 一律 false
data["comments"] = oa_comment.list_comments(
db,
instance_id=instance_id,
tenant_no=tenant_no,
viewer_user_id="",
)
更好做法:给 get_instance_detail 增加可选参数 viewer_user_id: str = "",并在 get_oa_instance 传入 user.get("user_id") or "",以便 canRevoke 正确。
def get_instance_detail(
db: Session,
instance_id: str,
*,
tenant_no: str,
viewer_user_id: str = "",
) -> Dict[str, Any]:
...
from app.services import oa_instance_comment as oa_comment
data["comments"] = oa_comment.list_comments(
db,
instance_id=instance_id,
tenant_no=tenant_no,
viewer_user_id=viewer_user_id or "",
)
return data
API:
return success_response(
get_instance_detail(
db,
instance_id,
tenant_no=tenant_no,
viewer_user_id=user.get("user_id") or "",
)
)
检查其它调用 get_instance_detail 的地方:新增参数有默认值,无需全改。
在 oa_approval.py 靠近 OaTransferBody 处:
class OaCommentCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
content: str
attachment_refs: Optional[List[Dict[str, Any]]] = Field(
None, alias="attachmentRefs"
)
在 get_oa_instance 附近增加:
@router.get("/instances/{instance_id}/form-attachments")
async def list_oa_form_attachments(
instance_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services import oa_instance_comment as oa_comment
from app.services.oa_engine import OaEngineError
try:
tenant_no = _get_tenant_no(db, user)
items = oa_comment.list_form_attachments(
db, instance_id=instance_id, tenant_no=tenant_no
)
return success_response({"items": items})
except OaEngineError as e:
return error_response(400, str(e))
@router.get("/instances/{instance_id}/comments")
async def list_oa_comments(
instance_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services import oa_instance_comment as oa_comment
from app.services.oa_engine import OaEngineError
try:
tenant_no = _get_tenant_no(db, user)
items = oa_comment.list_comments(
db,
instance_id=instance_id,
tenant_no=tenant_no,
viewer_user_id=user.get("user_id") or "",
)
return success_response({"list": items})
except OaEngineError as e:
return error_response(400, str(e))
@router.post("/instances/{instance_id}/comments")
async def create_oa_comment(
instance_id: str,
body: OaCommentCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services import oa_instance_comment as oa_comment
from app.services.oa_engine import OaEngineError
try:
tenant_no = _get_tenant_no(db, user)
row = oa_comment.create_comment(
db,
instance_id=instance_id,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
dept_id=user.get("dept_id") or "",
content=body.content,
attachment_refs=body.attachment_refs,
)
return success_response(row)
except OaEngineError as e:
return error_response(400, str(e))
@router.post("/instances/{instance_id}/comments/{comment_id}/revoke")
async def revoke_oa_comment(
instance_id: str,
comment_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services import oa_instance_comment as oa_comment
from app.services.oa_engine import OaEngineError
try:
tenant_no = _get_tenant_no(db, user)
row = oa_comment.revoke_comment(
db,
instance_id=instance_id,
comment_id=comment_id,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
)
return success_response(row)
except OaEngineError as e:
return error_response(400, str(e))
确认 user dict 是否含 dept_id:若无,用 "" 或现有 _get_dept_id 辅助(搜同文件其它 create 用法)。
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py tests/test_oa_engine.py -v --tb=line -q
Expected: PASS(或仅既有无关失败)
git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): expose instance comment APIs and embed comments in detail
EOF
)"
Files:
Modify: web-admin/src/api/oaApproval.ts
[ ] Step 1: 增加类型与方法
在 fetchOaInstanceDetail 附近追加:
export type OaCommentAttachmentRef = {
fieldKey: string
fieldLabel?: string
url: string
fileName: string
}
export type OaInstanceComment = {
id: string
instanceId?: string
content: string
attachmentRefs: OaCommentAttachmentRef[]
status: 'normal' | 'revoked' | string
revokedTime?: string
createBy: string
createByName?: string
createTime: string
canRevoke?: boolean
}
export function fetchOaFormAttachments(instanceId: string) {
return request.get<{ items: Array<{ fieldKey: string; fieldLabel: string; files: OaCommentAttachmentRef[] }> }>({
url: `${BASE}/instances/${instanceId}/form-attachments`
})
}
export function fetchOaInstanceComments(instanceId: string) {
return request.get<{ list: OaInstanceComment[] }>({
url: `${BASE}/instances/${instanceId}/comments`
})
}
export function fetchCreateOaComment(
instanceId: string,
data: { content: string; attachmentRefs?: OaCommentAttachmentRef[] }
) {
return request.post<OaInstanceComment>({
url: `${BASE}/instances/${instanceId}/comments`,
data
})
}
export function fetchRevokeOaComment(instanceId: string, commentId: string) {
return request.post<OaInstanceComment>({
url: `${BASE}/instances/${instanceId}/comments/${commentId}/revoke`
})
}
git add web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add OA instance comment API clients
EOF
)"
OaCommentPanel.vueFiles:
Create: web-admin/src/components/oa-approval/OaCommentPanel.vue
[ ] Step 1: 实现组件
Props:
instanceId: stringinstanceStatus: stringcomments: OaInstanceComment[](可由父传入;发送/撤回后 emit('refresh'))行为要点:
running 才显示输入区与撤回。revoked 显示「{createByName} 撤回了一条评论」。el-dialog + el-checkbox-group,数据来自 fetchOaFormAttachments。window.open(url)(对齐 OaFcUpload preview)。ElMessageBox.confirm → fetchRevokeOaComment → emit refresh。fetchCreateOaComment → 清空输入与已选引用 → emit refresh。el-icon(ChatDotRound / Paperclip / Promotion / RefreshLeft 等)。骨架:
<template>
<section class="oa-comment-panel">
<h3 class="oa-comment-panel__title">评论</h3>
<div v-if="!comments.length" class="oa-comment-panel__empty">暂无评论</div>
<ul class="oa-comment-panel__list">
<li v-for="c in comments" :key="c.id" class="oa-comment-panel__item">
<template v-if="c.status === 'revoked'">
<span class="oa-comment-panel__revoked">
{{ c.createByName || c.createBy }} 撤回了一条评论
<span v-if="c.revokedTime" class="oa-comment-panel__time">{{ c.revokedTime }}</span>
</span>
</template>
<template v-else>
<div class="oa-comment-panel__meta">
<strong>{{ c.createByName || c.createBy }}</strong>
<span class="oa-comment-panel__time">{{ c.createTime }}</span>
<el-button
v-if="c.canRevoke"
link
type="danger"
@click="onRevoke(c)"
>
<el-icon><RefreshLeft /></el-icon>
撤回
</el-button>
</div>
<div class="oa-comment-panel__content">{{ c.content }}</div>
<div v-if="c.attachmentRefs?.length" class="oa-comment-panel__refs">
<el-tag
v-for="(f, idx) in c.attachmentRefs"
:key="idx"
class="oa-comment-panel__ref"
size="small"
@click="openUrl(f.url)"
>
{{ f.fileName || f.url }}
</el-tag>
</div>
</template>
</li>
</ul>
<div v-if="instanceStatus === 'running'" class="oa-comment-panel__composer">
<el-input
v-model="draft"
type="textarea"
:rows="3"
maxlength="1024"
show-word-limit
placeholder="填写评论"
/>
<div v-if="picked.length" class="oa-comment-panel__picked">
<el-tag
v-for="(f, idx) in picked"
:key="idx"
closable
size="small"
@close="picked.splice(idx, 1)"
>
{{ f.fileName }}
</el-tag>
</div>
<div class="oa-comment-panel__actions">
<el-button class="btn-ghost" @click="openAttach">
<el-icon><Paperclip /></el-icon>
引用附件
</el-button>
<el-button type="primary" class="btn-main" :loading="sending" @click="onSend">
<el-icon><Promotion /></el-icon>
发送
</el-button>
</div>
</div>
<!-- 引用附件弹窗:多选 field 下 files -->
</section>
</template>
样式:Fresh Mint;section 间距与 oa-detail__section 一致;不要改 OaProgressChain。
git add web-admin/src/components/oa-approval/OaCommentPanel.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add OaCommentPanel for instance discussion comments
EOF
)"
Files:
web-admin/src/views/oa-approval/instances/detail.vueModify: web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue
[ ] Step 1: detail.vue
在表单 section 与 progress section 之间插入:
<section class="oa-detail__section">
<OaCommentPanel
v-if="detail?.id"
:instance-id="detail.id"
:instance-status="detail.status || ''"
:comments="detail.comments || []"
@refresh="load"
/>
</section>
import OaCommentPanel from '@/components/oa-approval/OaCommentPanel.vue'。确认 load 会刷新详情(含 comments)。
同样在表单与进度之间挂载 OaCommentPanel,@refresh 重新 fetchOaInstanceDetail。
pnpm dev。running 审批详情 → 可见评论区。./scripts/update-graph.sh all
git add web-admin/src/views/oa-approval/instances/detail.vue \
web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue \
backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
feat(web-admin): mount instance comment panel on OA detail views
EOF
)"
| Spec 要求 | Task |
|---|---|
| 独立表 + 模型 | T1 |
| 文字评论 ≤1024 | T3 create_comment |
| 引用表单附件 + 服务端校验 | T2–T3 |
| 本人撤回 + 留痕 + 幂等 | T3 |
| 仅 running 可发/撤 | T3 |
| GET form-attachments / comments / POST create / revoke | T4 |
| 详情注入 comments | T4 |
| Web CommentPanel + 详情挂载 | T6–T7 |
| 不混入进度链 / 不改审批意见 | 全任务未触碰 |
| 无通知/@/新上传 | 未实现 |
| 小程序预留 API | T4–T5 无 Web 专用字段 |
无 TBD /「类似 Task N」悬空步骤。
Plan complete and saved to docs/superpowers/plans/2026-09-20-oa-instance-comment.md.
Two execution options:
Which approach?