# OA 审批实例评论 Implementation Plan > **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` --- ## File map | 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`)。 --- ### Task 1: SQL + Model **Files:** - Create: `docs/sql/migrations/2026-09-20-oa-instance-comment.sql` - Modify: `backend/app/models/models.py`(在 `OaInstanceEvent` 类之后、`OaRelation` 之前插入) - [ ] **Step 1: 写迁移 SQL** ```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审批实例评论'; ``` - [ ] **Step 2: 新增 SQLAlchemy 模型** 在 `OaInstanceEvent` 与 `OaRelation` 之间插入: ```python 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` 已用,应已有)。 - [ ] **Step 3: Commit** ```bash 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 )" ``` --- ### Task 2: 附件枚举与引用校验(纯函数 TDD) **Files:** - Create: `backend/app/services/oa_instance_comment.py` - Create: `backend/tests/test_oa_instance_comment.py` - [ ] **Step 1: 写失败/行为测试(先写测)** ```python """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"}], ) ``` - [ ] **Step 2: Run 确认失败** ```bash cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py -v ``` Expected: FAIL(模块不存在) - [ ] **Step 3: 实现纯函数** 创建 `backend/app/services/oa_instance_comment.py`: ```python """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 追加;本任务先保证上述纯函数可测。) - [ ] **Step 4: Run 确认通过** ```bash cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py::TestEnumerateFormAttachments tests/test_oa_instance_comment.py::TestNormalizeAttachmentRefs -v ``` Expected: PASS - [ ] **Step 5: Commit** ```bash 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 )" ``` --- ### Task 3: 评论 CRUD 服务 + 集成测 **Files:** - Modify: `backend/app/services/oa_instance_comment.py` - Modify: `backend/tests/test_oa_instance_comment.py` - [ ] **Step 1: 追加集成测试** 在同测试文件追加(对齐 `TestTransferTask` 的内存 SQLite 风格): ```python 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`。 - [ ] **Step 2: Run 确认失败** ```bash cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py::TestCommentCrud -v ``` Expected: FAIL(缺 create_comment 等) - [ ] **Step 3: 实现 CRUD** 在 `oa_instance_comment.py` 追加: ```python 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`),保持一致即可。 - [ ] **Step 4: Run 全文件测试** ```bash cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py -v ``` Expected: PASS(若 SysUser 必填列不足,按报错补全 setUp,对齐 `test_oa_engine.py` 中 SysUser 构造) - [ ] **Step 5: Commit** ```bash 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 )" ``` --- ### Task 4: 详情注入 + HTTP API **Files:** - Modify: `backend/app/services/oa_engine.py`(`get_instance_detail` 返回前) - Modify: `backend/app/api/oa_approval.py` - [ ] **Step 1: 详情注入 comments** 在 `get_instance_detail` 末尾 `return data` 前: ```python 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` 正确。 ```python 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: ```python return success_response( get_instance_detail( db, instance_id, tenant_no=tenant_no, viewer_user_id=user.get("user_id") or "", ) ) ``` 检查其它调用 `get_instance_detail` 的地方:新增参数有默认值,无需全改。 - [ ] **Step 2: 增加 Body 与路由** 在 `oa_approval.py` 靠近 `OaTransferBody` 处: ```python 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` 附近增加: ```python @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 用法)。 - [ ] **Step 3: 跑引擎相关测确保未破** ```bash cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py tests/test_oa_engine.py -v --tb=line -q ``` Expected: PASS(或仅既有无关失败) - [ ] **Step 4: Commit** ```bash 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 )" ``` --- ### Task 5: 前端 API **Files:** - Modify: `web-admin/src/api/oaApproval.ts` - [ ] **Step 1: 增加类型与方法** 在 `fetchOaInstanceDetail` 附近追加: ```typescript 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({ url: `${BASE}/instances/${instanceId}/comments`, data }) } export function fetchRevokeOaComment(instanceId: string, commentId: string) { return request.post({ url: `${BASE}/instances/${instanceId}/comments/${commentId}/revoke` }) } ``` - [ ] **Step 2: Commit** ```bash git add web-admin/src/api/oaApproval.ts git commit -m "$(cat <<'EOF' feat(web-admin): add OA instance comment API clients EOF )" ``` --- ### Task 6: `OaCommentPanel.vue` **Files:** - Create: `web-admin/src/components/oa-approval/OaCommentPanel.vue` - [ ] **Step 1: 实现组件** Props: - `instanceId: string` - `instanceStatus: string` - `comments: OaInstanceComment[]`(可由父传入;发送/撤回后 `emit('refresh')`) 行为要点: - `running` 才显示输入区与撤回。 - 列表升序展示;`revoked` 显示「{createByName} 撤回了一条评论」。 - 「引用附件」:`el-dialog` + `el-checkbox-group`,数据来自 `fetchOaFormAttachments`。 - 引用芯片点击:`window.open(url)`(对齐 `OaFcUpload` preview)。 - 撤回:`ElMessageBox.confirm` → `fetchRevokeOaComment` → emit refresh。 - 发送:trim 非空 → `fetchCreateOaComment` → 清空输入与已选引用 → emit refresh。 - 按钮带 `el-icon`(ChatDotRound / Paperclip / Promotion / RefreshLeft 等)。 骨架: ```vue ``` 样式:Fresh Mint;section 间距与 `oa-detail__section` 一致;**不要**改 `OaProgressChain`。 - [ ] **Step 2: Commit** ```bash 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 )" ``` --- ### Task 7: 挂到详情页与只读弹窗 **Files:** - Modify: `web-admin/src/views/oa-approval/instances/detail.vue` - Modify: `web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue` - [ ] **Step 1: detail.vue** 在表单 section 与 progress section **之间**插入: ```vue
``` `import OaCommentPanel from '@/components/oa-approval/OaCommentPanel.vue'`。确认 `load` 会刷新详情(含 `comments`)。 - [ ] **Step 2: OaInstanceDetailDialog.vue** 同样在表单与进度之间挂载 `OaCommentPanel`,`@refresh` 重新 `fetchOaInstanceDetail`。 - [ ] **Step 3: 手工验收清单** 1. 本地执行迁移 SQL 后启动 backend + `pnpm dev`。 2. 打开一张 `running` 审批详情 → 可见评论区。 3. 发纯文字评论 → 列表出现。 4. 引用表单附件 → 评论下可点开。 5. 本人撤回 → 变灰条;他人无撤回按钮。 6. 完成后的单 → 无输入区、不可撤,历史仍可见。 7. 同意/拒绝意见与进度链无回归。 - [ ] **Step 4: 更新图谱(改了 backend + web-admin)** ```bash ./scripts/update-graph.sh all ``` - [ ] **Step 5: Commit** ```bash 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 coverage check | 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 专用字段 | ## Placeholder scan 无 TBD /「类似 Task N」悬空步骤。 --- ## Execution handoff Plan complete and saved to `docs/superpowers/plans/2026-09-20-oa-instance-comment.md`. **Two execution options:** 1. **Subagent-Driven(推荐)** — 每个 Task 开新子代理,任务间复审,迭代快 2. **Inline Execution** — 本会话按 executing-plans 连续执行,设检查点 **Which approach?**