Files
zkxw/docs/superpowers/plans/2026-09-20-oa-instance-comment.md
T
2026-09-24 09:59:26 +08:00

1169 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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`
})
}
```
- [ ] **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
<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`。
- [ ] **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
<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`)。
- [ ] **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?**