1169 lines
37 KiB
Markdown
1169 lines
37 KiB
Markdown
# 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?**
|