Author SHA1 Message Date
zlwu 802985dd99 docs(oa): record H5 acceptance and approved test database migration 2026-10-30 21:43:41 +08:00
zlwu 0e64b413ce feat(oa): add permission-controlled shared finance remarks to done center
Share one finance remark per approval instance across Web and mobile. Require the explicit permission and the current tenant user's own done task for every read and write; keep general instance responses free of financial remarks.

Add idempotent MySQL migration without automatic role grants, cross-end editors, isolated backend and frontend tests, and rollout requirements. Refs #7; platform validation and target database migration remain pending.
2026-10-30 20:42:05 +08:00
cykj 86df360f35 docs(agents): confirm application scope before implementation (#6)
Require clarification of unclear application scope before business changes; reuse confirmed scope and confirm shared API impact. Refs #4.
2026-10-30 10:33:05 +00:00
zlwu 0e98593671 docs(agents): confirm application scope before implementation
Ask which applications to change when task scope is unclear, reuse confirmed scope, and confirm shared API impact before expanding it. Refs #4.
2026-10-30 18:32:04 +08:00
cykj f12980a053 docs(agents): adopt test ownership and automatic issue completion (#5)
Record default Agent verification and explicit user acceptance, followed by authorized merge, push and issue closure. Refs #4.
2026-10-30 10:17:12 +00:00
zlwu 6356bdecc0 docs(agents): adopt test ownership and automatic issue completion
Default to agent validation followed by merge/push/close. Respect explicit user testing without running tests or builds, provide branch and runtime handoff, and resume completion on acceptance. Preserve separate release and service permissions. Refs #4.
2026-10-30 18:14:05 +08:00
cykj 05de8924b0 fix: 合并采集协议单地区经理负责人筛选修复 (#2)
用户已确认 Web 与小程序测试通过并授权合入 V2.0。Refs #1;合并后执行集成回归再关闭工单。
2026-10-30 10:02:59 +00:00
16 changed files with 1193 additions and 80 deletions

No files matched your search

+5 -2
View File
@@ -20,7 +20,10 @@
- **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。
- 提交使用 Conventional Commits,完整说明“改了什么、为什么”。
- 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。
- 修复后回写工单,附提交、PR、验证结果及未完成项,标注“已修复”;待验收、未合并或未发布须分别说明,不能以“已修复”代替验收和关闭条件。
- 修改端不明确时,先查当前任务和工单的已确认范围;仍不明确则询问修改 Web、小程序、移动 OA、后端中的哪些端,还是所有相关端。确认前仅只读定位、回写工单待确认范围,不修改业务代码;截图不能代替确认。已有明确范围直接沿用,答复“所有端”须记录具体应用清单;共享后端/API 影响范围外的端时,说明影响并确认扩展后再实施。
- 默认由 Agent 测试:自动进入任务分支/worktree,核对实际运行版本,完成测试与审查后合入 `V2.0`、提交推送、同步本地主分支并关闭 Gitea 工单。用户已授予这条常规收尾链路持续授权,不逐次询问合并;打 tag、部署与服务启停仍分别受原有授权限制。
- 用户明确“我来测试”时,该任务由用户测试:Agent 不运行测试或构建,仍完成代码审查和 Git 核对;交付时提醒切换到具体分支,提供 worktree 路径、提交、前后端配套入口和验证要点。用户确认通过后直接合并推送、关闭工单;未确认或失败时不合并、不关闭。
- 合并前核对验收提交与最新目标;目标更新或冲突处理改变代码时,按原测试责任补验,用户模式仍交用户验证。合并后代码树与验收版本一致不重复测试;确认远端同步成功后回写验收结果、合并提交和 PR 并关单。
- 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。
- 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。
@@ -45,7 +48,7 @@
## 验证与交付
- 执行与改动范围相符的检查,报告实际结果和未执行项;文档变更检查链接与引用,业务变更检查受影响测试及构建。
- 按任务测试责任验证,报告实际结果和未执行项:Agent 模式按改动范围检查文档链接或业务测试与构建;用户模式记录用户验收结论,不由 Agent 运行测试和构建。
- CRUD 字段一致性按 [开发指南](docs/dev-field-consistency.md) 检查;根目录命令是 `python backend/scripts/check_field_consistency.py`。该工具仅覆盖注册实体,已知失效路径见该指南。
- 使用源码、引用检索和测试核对影响范围。已移除的 graphify 不再是开发依赖或交付步骤,不安装、运行或恢复其自动 hook。
+49 -3
View File
@@ -2,7 +2,7 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
@@ -15,6 +15,7 @@ from app.crud import oa_instance_draft as oid_crud
from app.crud import oa_receive_account as ora_crud
from app.models.models import SysUser
from app.services.oa_receive_account import normalize_account_fields
from app.services import oa_finance_remark as finance_remark_svc
router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"])
@@ -1054,6 +1055,44 @@ async def list_oa_instance_form_attachments(
return error_response(400, str(e))
class OaFinanceRemarkBody(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="forbid")
finance_remark: str = Field(..., alias="financeRemark", max_length=512, strict=True)
@router.get("/instances/{instance_id}/finance-remark")
async def get_oa_finance_remark(
instance_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
access = finance_remark_svc.resolve_access(db, user)
return success_response(finance_remark_svc.get_finance_remark(db, access, instance_id))
except finance_remark_svc.OaFinanceRemarkError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
@router.put("/instances/{instance_id}/finance-remark")
async def update_oa_finance_remark(
instance_id: str,
body: OaFinanceRemarkBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
access = finance_remark_svc.resolve_access(db, user)
result = finance_remark_svc.update_finance_remark(db, access, instance_id, body.finance_remark)
db.commit()
return success_response(result, message="财务备注已保存")
except finance_remark_svc.OaFinanceRemarkError as exc:
db.rollback()
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
except Exception:
db.rollback()
raise
@router.get("/instances/{instance_id}/bank-receipts")
async def list_oa_bank_receipts(
instance_id: str,
@@ -1365,7 +1404,8 @@ async def list_oa_tasks(
from app.services.oa_engine import OaEngineError, list_tasks
try:
tenant_no = _get_tenant_no(db, user)
access = finance_remark_svc.resolve_access(db, user) if kind == "done" else None
tenant_no = access.tenant_no if access else _get_tenant_no(db, user)
data = list_tasks(
db,
tenant_no=tenant_no,
@@ -1381,7 +1421,13 @@ async def list_oa_tasks(
page=page,
page_size=pageSize,
)
return success_response({"list": data.get("list") or [], "total": int(data.get("total") or 0)})
result = {"list": data.get("list") or [], "total": int(data.get("total") or 0)}
if access is not None:
finance_remark_svc.add_list_remarks(db, access, result["list"])
result["canManageFinanceRemark"] = access.can_manage
return success_response(result)
except finance_remark_svc.OaFinanceRemarkError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
except OaEngineError as e:
return error_response(400, str(e))
+1
View File
@@ -920,6 +920,7 @@ class OaInstance(Base):
default="running",
comment="running/returned/completed/rejected/cancelled",
)
finance_remark = Column(String(512), nullable=True, comment="已办财务备注(专用权限控制)")
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID")
current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
+100
View File
@@ -0,0 +1,100 @@
"""已办中心实例级财务备注;权限与本人已办范围同时满足才可访问。"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List
from sqlalchemy.orm import Session
from app.models.models import OaInstance, OaTask, SysUser
from app.services.web_auth_roles import iter_user_menu_perm_codes
PERMISSION = "oa:done:finance-remark"
DONE_STATUSES = ("approved", "rejected", "returned", "transferred")
class OaFinanceRemarkError(ValueError):
def __init__(self, status_code: int, message: str):
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class FinanceRemarkAccess:
user_id: str
tenant_no: str
can_manage: bool
def resolve_access(db: Session, user: dict) -> FinanceRemarkAccess:
# 不使用缺省租户、客户端租户或角色名称作为敏感字段的授权依据。
uid = str(user.get("user_id") or "").strip()
identity = db.query(SysUser.id, SysUser.tenant_no).filter(
SysUser.id == uid, SysUser.del_flag == 0, SysUser.status == "1"
).first()
if not identity or not str(identity.tenant_no or "").strip():
raise OaFinanceRemarkError(403, "用户不可用")
tenant = str(identity.tenant_no).strip()
granted = iter_user_menu_perm_codes(db, uid, tenant_no=tenant)
return FinanceRemarkAccess(uid, tenant, PERMISSION in granted)
def _accessible_instances(db: Session, access: FinanceRemarkAccess):
own_done = db.query(OaTask.id).filter(
OaTask.instance_id == OaInstance.id,
OaTask.tenant_no == access.tenant_no,
OaTask.assignee_user_id == access.user_id,
OaTask.task_type == "approval",
OaTask.status.in_(DONE_STATUSES),
OaTask.del_flag == 0,
).exists()
return db.query(OaInstance).filter(
OaInstance.tenant_no == access.tenant_no,
OaInstance.del_flag == 0,
own_done,
)
def _require_instance(db: Session, access: FinanceRemarkAccess, instance_id: str) -> OaInstance:
if not access.can_manage:
raise OaFinanceRemarkError(403, "无权查看或修改财务备注")
instance = _accessible_instances(db, access).filter(OaInstance.id == instance_id).first()
if instance is None:
raise OaFinanceRemarkError(404, "审批单不在本人已办范围内")
return instance
def _remark_data(instance: OaInstance) -> Dict[str, str]:
return {"instanceId": instance.id, "financeRemark": instance.finance_remark or ""}
def get_finance_remark(db: Session, access: FinanceRemarkAccess, instance_id: str) -> Dict[str, str]:
return _remark_data(_require_instance(db, access, instance_id))
def update_finance_remark(
db: Session, access: FinanceRemarkAccess, instance_id: str, finance_remark: str
) -> Dict[str, str]:
instance = _require_instance(db, access, instance_id)
if not isinstance(finance_remark, str) or len(finance_remark) > 512:
raise OaFinanceRemarkError(422, "财务备注最多 512 字")
instance.finance_remark = finance_remark
instance.update_by = access.user_id
instance.update_time = datetime.now()
db.flush()
return _remark_data(instance)
def add_list_remarks(db: Session, access: FinanceRemarkAccess, rows: List[Dict[str, Any]]) -> None:
# 即使调用方未来调整序列化,也先移除敏感字段,再按权限补充。
for row in rows:
row.pop("financeRemark", None)
if not access.can_manage:
return
ids = {row.get("instanceId") for row in rows if row.get("instanceId")}
if not ids:
return
instances = _accessible_instances(db, access).filter(OaInstance.id.in_(ids)).all()
remarks = {inst.id: inst.finance_remark or "" for inst in instances}
for row in rows:
if row.get("instanceId") in remarks:
row["financeRemark"] = remarks[row["instanceId"]]
+195
View File
@@ -0,0 +1,195 @@
"""OA 财务备注:真实隔离数据库和 HTTP 路由的读写/权限契约。"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.oa_approval import router
from app.core.auth import get_current_user
from app.core.database import Base, get_db
from app.models.models import (
OaApp, OaAppVersion, OaInstance, OaTask,
SysUser, SysRole, SysUserRole, SysMenu, SysRoleMenu,
SysPost, SysPostRole, SysUserPost,
)
PERM = 'oa:done:finance-remark'
PREFIX = '/web/admin/oa-approval'
URL = PREFIX + '/instances/i1/finance-remark'
@pytest.fixture
def case():
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
tables = (OaApp, OaAppVersion, OaInstance, OaTask, SysUser, SysRole,
SysUserRole, SysMenu, SysRoleMenu, SysPost, SysPostRole, SysUserPost)
Base.metadata.create_all(engine, tables=[m.__table__ for m in tables])
db = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
for uid, tenant in [('u1', 'a'), ('u2', 'a'), ('outsider', 'a'), ('other', 'b')]:
db.add(SysUser(id=uid, user_no=uid, nick_name=uid, password='unused', tenant_no=tenant, status='1'))
db.add_all([
OaApp(id='app', app_name='报销', tenant_no='a'),
OaAppVersion(id='v1', app_id='app', version_no=1, tenant_no='a'),
SysRole(id='finance', role_name='财务', tenant_no='a', status='1'),
SysMenu(id='menu', menu_name='财务备注', parent_id='done', menu_type='F', perms=PERM, tenant_no='a', status='1'),
SysRoleMenu(id='rm', role_id='finance', menu_id='menu', tenant_no='a'),
])
for uid in ['u1', 'u2', 'outsider']:
db.add(SysUserRole(id='ur-'+uid, user_id=uid, role_id='finance', tenant_no='a'))
for iid, tenant in [('i1', 'a'), ('i2', 'a'), ('foreign', 'b')]:
inst = OaInstance(id=iid, app_id='app', app_version_id='v1', title=iid,
initiator_user_id='u1', tenant_no=tenant, status='completed', form_data_json='{}')
inst.finance_remark = '原备注'
db.add(inst)
for tid, uid, iid, tenant, status in [
('t1', 'u1', 'i1', 'a', 'approved'), ('t2', 'u2', 'i1', 'a', 'rejected'),
('pending', 'u1', 'i2', 'a', 'pending'), ('foreign-task', 'u1', 'foreign', 'b', 'approved'),
]:
db.add(OaTask(id=tid, instance_id=iid, assignee_user_id=uid, tenant_no=tenant,
task_type='approval', status=status))
db.commit()
user = {'user_id': 'u1', 'tenant_no': 'a', 'role': 'admin'}
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[get_db] = lambda: db
with TestClient(app) as client:
yield db, client, user
db.close()
engine.dispose()
def test_shared_save_reload_and_clear(case):
db, client, user = case
result = client.get(URL)
assert result.status_code == 200
assert result.json()['data'] == {'instanceId': 'i1', 'financeRemark': '原备注'}
for text in ['财务核对完成\n<标签按原文显示>', '汉' * 512, '']:
user['user_id'] = 'u1'
response = client.put(URL, json={'financeRemark': text})
assert response.status_code == 200
assert response.json()['code'] == 200
db.expire_all()
assert db.get(OaInstance, 'i1').finance_remark == text
assert db.get(OaInstance, 'i1').update_by == 'u1'
user['user_id'] = 'u2'
assert client.get(URL).json()['data']['financeRemark'] == text
rows = client.get(PREFIX + '/tasks/done').json()['data']
assert rows['canManageFinanceRemark'] is True
assert rows['list'][0]['financeRemark'] == text
@pytest.mark.parametrize('role', ['admin', 'super_admin'])
def test_no_permission_hides_list_and_denies_both_routes(case, role):
db, client, user = case
db.get(SysRoleMenu, 'rm').del_flag = 1
db.commit()
user['role'] = role
data = client.get(PREFIX + '/tasks/done').json()['data']
assert data['canManageFinanceRemark'] is False
assert len(data['list']) == 1
assert 'financeRemark' not in data['list'][0]
assert client.get(URL).status_code == 403
assert client.put(URL, json={'financeRemark': '泄漏'}).status_code == 403
@pytest.mark.parametrize('target', ['i2', 'foreign', 'missing'])
def test_only_own_done_instances_are_accessible(case, target):
_, client, _ = case
url = PREFIX + '/instances/' + target + '/finance-remark'
assert client.get(url).status_code == 404
assert client.put(url, json={'financeRemark': '越权'}).status_code == 404
def test_authorized_outsider_cannot_read_or_write(case):
_, client, user = case
user['user_id'] = 'outsider'
assert client.get(URL).status_code == 404
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404
@pytest.mark.parametrize('model,key', [(OaInstance, 'i1'), (OaTask, 't1')])
def test_deleted_instance_or_done_task_denies_access(case, model, key):
db, client, _ = case
db.get(model, key).del_flag = 1
db.commit()
assert client.get(URL).status_code == 404
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404
data = client.get(PREFIX + '/tasks/done').json()['data']
assert all('financeRemark' not in row for row in data['list'])
@pytest.mark.parametrize('status', ['approved', 'rejected', 'returned', 'transferred'])
def test_all_existing_done_statuses_can_manage(case, status):
db, client, _ = case
db.get(OaTask, 't1').status = status
db.commit()
assert client.get(URL).status_code == 200
@pytest.mark.parametrize('status', ['pending', 'done', 'cancelled'])
def test_non_done_statuses_cannot_manage(case, status):
db, client, _ = case
db.get(OaTask, 't1').status = status
db.commit()
assert client.get(URL).status_code == 404
def test_forged_tenant_uses_real_user_tenant(case):
_, client, user = case
user['tenant_no'] = 'b'
assert client.get(URL).json()['data']['financeRemark'] == '原备注'
data = client.get(PREFIX + '/tasks/done').json()['data']
assert [r['instanceId'] for r in data['list']] == ['i1']
assert client.get(PREFIX + '/instances/foreign/finance-remark').status_code == 404
@pytest.mark.parametrize('model,key,field,value', [
(SysUser, 'u1', 'status', '0'), (SysUser, 'u1', 'del_flag', 1),
(SysRole, 'finance', 'status', '0'), (SysRole, 'finance', 'tenant_no', 'b'),
(SysMenu, 'menu', 'status', '0'), (SysMenu, 'menu', 'tenant_no', 'b'),
(SysUserRole, 'ur-u1', 'tenant_no', 'b'),
])
def test_inactive_or_cross_tenant_grants_deny_access(case, model, key, field, value):
db, client, _ = case
setattr(db.get(model, key), field, value)
db.commit()
assert client.get(URL).status_code == 403
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 403
def test_post_role_permission_union(case):
db, client, _ = case
db.get(SysUserRole, 'ur-u1').del_flag = 1
db.add_all([
SysPost(id='post', post_name='财务岗', post_code='finance', tenant_no='a', status='1'),
SysUserPost(id='up', user_id='u1', post_id='post', tenant_no='a'),
SysPostRole(id='pr', post_id='post', role_id='finance', tenant_no='a'),
])
db.commit()
assert client.get(URL).status_code == 200
db.get(SysPost, 'post').status = '0'
db.commit()
assert client.get(URL).status_code == 403
@pytest.mark.parametrize('body', [
{}, {'financeRemark': None}, {'financeRemark': 123}, {'financeRemark': 'a' * 513},
{'financeRemark': 'note', 'tenantNo': 'b'}, {'financeRemark': 'note', 'status': 'running'},
])
def test_invalid_body_rejected_without_writes(case, body):
db, client, _ = case
assert client.put(URL, json=body).status_code == 422
db.expire_all()
assert db.get(OaInstance, 'i1').finance_remark == '原备注'
def test_generic_instance_serialization_does_not_expose_finance_remark(case):
db, client, _ = case
from app.services.oa_engine import _instance_to_dict
assert 'financeRemark' not in _instance_to_dict(db.get(OaInstance, 'i1'))
for kind in ['mine', 'pending']:
data = client.get(PREFIX + '/tasks/' + kind).json()['data']
assert all('financeRemark' not in row for row in data['list'])
+2
View File
@@ -13,6 +13,8 @@
| 数据库 | [SQL 初始化与迁移](sql/) |
| 部署 | [部署导航](../deploy/README.md) |
OA 已办财务备注上线前先执行 [2026-10-30 迁移](sql/migrations/2026-10-30-oa-done-finance-remark.sql),再更新应用;迁移不自动赋权。通过角色菜单配置授予已办中心下“财务备注(查看和修改)”权限(`oa:done:finance-remark`),同一权限作用于 Web 和移动端,仍限本人已办单据。
`docs/sql/zkxw.sql` 仍被交付打包和 Compose 引用,但当前初始化基线不完整,不能据此宣称可从空库部署。SOP HTML 是导入脚本的输入资源,继续保留。
历史细胞产品导入必须显式指定 `--file`,不再自带默认 TSV。实际业务测试保留在各应用源码目录;删除历史报告不代表现有测试已经执行或通过。
@@ -0,0 +1,50 @@
-- OA 已办中心财务备注(Gitea #7),MySQL 5.7,先于应用代码部署。
-- 在目标业务库执行;结构与菜单插入均可重复执行,不自动授予任何角色权限。
-- 权限配置:在已办中心下勾选「财务备注(查看和修改)」;同一权限码作用于两端。
-- 菜单按当前租户中既有 OA 已办页面定位,不挂载到旧工作流已办中心。
SET @oa_finance_column_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'oa_instance'
AND column_name = 'finance_remark'
);
SET @oa_finance_ddl = IF(
@oa_finance_column_exists = 0,
'ALTER TABLE oa_instance ADD COLUMN finance_remark VARCHAR(512) NULL COMMENT ''已办财务备注(专用权限控制)'' AFTER status',
'SELECT ''oa_instance.finance_remark already exists'' AS migration_status'
);
PREPARE oa_finance_stmt FROM @oa_finance_ddl;
EXECUTE oa_finance_stmt;
DEALLOCATE PREPARE oa_finance_stmt;
-- 由租户及父菜单生成稳定的 UUID 形状 ID,避免多行 SELECT 中 UUID 表达式被复用。
-- 已有软删/停用权限节点不自动复活,由管理员决定是否恢复。
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('oa-finance-remark:', parent.tenant_no, ':', parent.id)),
13, 1, '3'), 17, 1, '8'),
'财务备注(查看和修改)', parent.id, 'F', '', NULL,
'oa:done:finance-remark', 1, '1', '1', parent.tenant_no, 90,
'控制本人已办审批单的共享财务备注;Web 与移动端共用权限',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu AS parent
WHERE parent.del_flag = 0 AND parent.status = '1' AND parent.menu_type = 'C'
AND (
parent.perms IN ('oa:done', 'mini:oa:done')
OR TRIM(BOTH '/' FROM parent.component) = 'oa-approval/done/index'
OR TRIM(BOTH '/' FROM parent.path) IN ('pages/oa/done/index', 'oa/done/index')
)
AND NOT EXISTS (
SELECT 1 FROM sys_menu AS existing
WHERE existing.parent_id = parent.id
AND existing.tenant_no = parent.tenant_no
AND existing.perms = 'oa:done:finance-remark'
);
-- 验证必须至少存在当前租户的权限节点;若无结果,先核对该租户 OA 已办菜单配置。
SELECT tenant_no, parent_id, id, menu_name, perms, status
FROM sys_menu WHERE perms = 'oa:done:finance-remark' AND del_flag = 0;
+222 -36
View File
@@ -32,11 +32,7 @@
<view v-else-if="!list.length" class="oa-empty-wrap">
<VanEmpty
:description="keyword ? '未找到相关已办' : '暂无已办'"
:tip="
keyword
? '试试其他关键词,或清空搜索'
: '你处理过的审批会出现在这里'
"
:tip="keyword ? '试试其他关键词,或清空搜索' : '你处理过的审批会出现在这里'"
/>
</view>
@@ -58,23 +54,22 @@
}}</text>
</view>
<view v-if="metaLines(row).length" class="dd-card__meta">
<text
v-for="(line, idx) in metaLines(row)"
:key="idx"
class="dd-card__meta-line"
>
<text v-for="(line, idx) in metaLines(row)" :key="idx" class="dd-card__meta-line">
{{ line }}
</text>
</view>
<view v-if="canManageFinanceRemark" class="finance-remark">
<text class="finance-remark__text">财务备注:{{ row.financeRemark || '—' }}</text>
<button class="finance-remark__edit" @click.stop="openFinanceRemark(row)" @longpress.stop>
编辑备注
</button>
</view>
<view class="dd-card__foot">
<view class="dd-card__user">
<view class="dd-card__avatar">{{ avatarText(row) }}</view>
<text class="dd-card__user-text">{{ submitterText(row) }}</text>
</view>
<text
class="dd-card__status"
:class="'is-' + statusTone(row.instanceStatus)"
>
<text class="dd-card__status" :class="'is-' + statusTone(row.instanceStatus)">
{{ statusText(row.instanceStatus) }}
</text>
</view>
@@ -82,12 +77,52 @@
<view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
<view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
</view>
<view v-if="financeRemarkVisible" class="confirm-dialog" @touchmove.stop.prevent>
<view class="confirm-dialog__mask" />
<view class="confirm-dialog__panel finance-remark-panel">
<view class="confirm-dialog__body">
<view class="confirm-dialog__title">财务备注</view>
<text v-if="financeRemarkLoading" class="finance-remark__hint">正在加载最新备注…</text>
<textarea
v-else
v-model="financeRemarkText"
class="finance-remark__input"
:maxlength="512"
:disabled="financeRemarkSaving"
:adjust-position="true"
placeholder="请输入财务备注,留空可清空"
/>
<text class="finance-remark__hint">{{ financeRemarkText.length }}/512</text>
</view>
<view class="confirm-dialog__actions">
<button
class="confirm-dialog__btn confirm-dialog__btn--cancel finance-remark__button"
:disabled="financeRemarkSaving"
@click="cancelFinanceRemark"
>
取消
</button>
<button
class="confirm-dialog__btn confirm-dialog__btn--confirm finance-remark__button"
:loading="financeRemarkSaving"
:disabled="financeRemarkLoading || financeRemarkSaving"
@click="saveFinanceRemark"
>
保存
</button>
</view>
</view>
</view>
</view>
</template>
<script>
import VanEmpty from '../../../components/van-empty/van-empty.vue'
import { fetchOaTasks } from '../../../utils/oa-api.js'
import {
fetchOaTasks,
fetchOaFinanceRemark,
fetchUpdateOaFinanceRemark
} from '../../../utils/oa-api.js'
import { forceRelogin, getToken } from '../../../utils/auth.js'
import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
import { formatCornerIndex } from '../../../utils/oa-corner-index.js'
@@ -121,6 +156,14 @@
components: { VanEmpty },
data() {
return {
canManageFinanceRemark: false,
financeRemarkVisible: false,
financeRemarkLoading: false,
financeRemarkSaving: false,
financeRemarkText: '',
financeRemarkInstanceId: '',
financeRemarkVersion: 0,
listVersion: 0,
keyword: '',
searchFocused: false,
list: [],
@@ -134,6 +177,7 @@
},
onShow() {
if (!getToken()) {
this.clearFinanceAccess()
forceRelogin('请先登录')
return
}
@@ -150,13 +194,92 @@
if (!this.hasMore || this.loading || this.loadingMore) return
this.loadList(false)
},
onHide() {
this.listVersion++
this.clearFinanceAccess()
if (this._searchTimer) clearTimeout(this._searchTimer)
this._searchTimer = null
},
onUnload() {
this.listVersion++
this.clearFinanceAccess()
if (this._searchTimer) {
clearTimeout(this._searchTimer)
this._searchTimer = null
}
},
methods: {
cancelFinanceRemark() {
this.financeRemarkVersion++
this.financeRemarkVisible = false
this.financeRemarkText = ''
this.financeRemarkInstanceId = ''
this.financeRemarkLoading = false
this.financeRemarkSaving = false
},
clearFinanceAccess() {
this.canManageFinanceRemark = false
this.cancelFinanceRemark()
this.list = this.list.map((row) => {
const sanitized = { ...row }
delete sanitized.financeRemark
return sanitized
})
},
async openFinanceRemark(row) {
if (!this.canManageFinanceRemark || this.financeRemarkSaving) return
const id = String(row.instanceId || '').trim()
if (!id) return
this.cancelFinanceRemark()
const version = this.financeRemarkVersion
this.financeRemarkInstanceId = id
this.financeRemarkVisible = true
this.financeRemarkLoading = true
try {
const result = await fetchOaFinanceRemark(id)
if (version !== this.financeRemarkVersion) return
this.financeRemarkText = result.financeRemark || ''
} catch (e) {
if (version !== this.financeRemarkVersion) return
this.cancelFinanceRemark()
uni.showToast({
title: (e && e.message) || '加载财务备注失败',
icon: 'none'
})
} finally {
if (version === this.financeRemarkVersion) this.financeRemarkLoading = false
}
},
async saveFinanceRemark() {
if (
!this.canManageFinanceRemark ||
!this.financeRemarkInstanceId ||
this.financeRemarkLoading ||
this.financeRemarkSaving
)
return
if (this.financeRemarkText.length > 512) {
uni.showToast({ title: '财务备注最多 512 个字符', icon: 'none' })
return
}
const version = this.financeRemarkVersion
this.financeRemarkSaving = true
try {
await fetchUpdateOaFinanceRemark(this.financeRemarkInstanceId, this.financeRemarkText)
if (version !== this.financeRemarkVersion) return
this.cancelFinanceRemark()
uni.showToast({ title: '财务备注已保存', icon: 'success' })
await this.loadList(true)
} catch (e) {
if (version !== this.financeRemarkVersion) return
uni.showToast({
title: (e && e.message) || '保存财务备注失败',
icon: 'none'
})
} finally {
if (version === this.financeRemarkVersion) this.financeRemarkSaving = false
}
},
cornerIndex(idx) {
return formatCornerIndex(Number(idx) + 1)
},
@@ -188,10 +311,7 @@
for (let i = 0; i < fields.length && i < 3; i++) {
const f = fields[i] || {}
const label = String(f.label || '').trim() || '字段'
const value =
f.value != null && String(f.value).trim() !== ''
? String(f.value)
: '-'
const value = f.value != null && String(f.value).trim() !== '' ? String(f.value) : '-'
lines.push(label + ':' + value)
}
return lines
@@ -200,28 +320,16 @@
const d = parseDateTime(str)
if (!d) return str || '-'
const now = new Date()
const startToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
)
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const dayDiff = Math.round(
(startToday.getTime() - startThat.getTime()) / 86400000
)
const dayDiff = Math.round((startToday.getTime() - startThat.getTime()) / 86400000)
if (dayDiff === 0) {
return pad2(d.getHours()) + ':' + pad2(d.getMinutes())
}
if (dayDiff === 1) return '昨天'
if (dayDiff === 2) return '前天'
if (dayDiff > 2 && dayDiff < 7) return dayDiff + '天前'
return (
d.getFullYear() +
'.' +
pad2(d.getMonth() + 1) +
'.' +
pad2(d.getDate())
)
return d.getFullYear() + '.' + pad2(d.getMonth() + 1) + '.' + pad2(d.getDate())
},
_inputVal(e) {
if (e && e.detail && e.detail.value != null) return String(e.detail.value)
@@ -277,11 +385,14 @@
},
async loadList(reset) {
if (!getToken()) {
this.clearFinanceAccess()
forceRelogin('请先登录')
return
}
const version = ++this.listVersion
const isReset = !!reset
if (isReset) {
this.clearFinanceAccess()
this.page = 1
this.hasMore = true
this.loading = true
@@ -294,23 +405,30 @@
page: this.page,
pageSize: PAGE_SIZE
})
if (version !== this.listVersion) return
this.canManageFinanceRemark = res?.canManageFinanceRemark === true
const rows = (res && res.list) || []
const total = (res && res.total) != null ? Number(res.total) : 0
this.total = total
if (isReset) this.list = rows
else this.list = this.list.concat(rows)
if (!this.canManageFinanceRemark) this.clearFinanceAccess()
const loaded = this.list.length
this.hasMore = loaded < total && rows.length > 0
if (rows.length > 0) this.page += 1
} catch (e) {
if (version !== this.listVersion) return
this.clearFinanceAccess()
if (isReset) this.list = []
uni.showToast({
title: (e && e.message) || '加载失败',
icon: 'none'
})
} finally {
this.loading = false
this.loadingMore = false
if (version === this.listVersion) {
this.loading = false
this.loadingMore = false
}
}
}
}
@@ -318,6 +436,74 @@
</script>
<style scoped>
.finance-remark {
margin-top: 20rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f1f5f9;
}
.finance-remark__text {
display: block;
font-size: 26rpx;
color: #6b7b74;
white-space: pre-wrap;
word-break: break-all;
}
.finance-remark__edit {
margin: 8rpx 0 0;
padding: 0 12rpx;
min-height: 88rpx;
line-height: 88rpx;
font-size: 26rpx;
color: #0a9f6d;
background: transparent;
text-align: right;
}
.finance-remark__edit::after,
.finance-remark__button::after {
border: 0;
}
.finance-remark-panel {
max-height: calc(100vh - 100px);
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
}
.finance-remark__input {
width: 100%;
height: 240rpx;
padding: 16rpx;
box-sizing: border-box;
border: 1rpx solid #dcebe4;
border-radius: 12rpx;
font-size: 28rpx;
text-align: left;
}
.finance-remark__hint {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: #6b7b74;
text-align: right;
}
.finance-remark__button {
margin: 0;
border-radius: 0;
min-height: 88rpx;
line-height: 88rpx;
padding: 0;
}
.finance-remark__button.confirm-dialog__btn--confirm {
background: #0a9f6d;
color: #fff;
}
.page {
min-height: 100vh;
padding-bottom: 48rpx;
+16
View File
@@ -158,3 +158,19 @@ export function fetchReturnOaTask(taskId, comment) {
authHeader()
)
}
export function fetchOaFinanceRemark(instanceId) {
return get(
'/web/admin/oa-approval/instances/' + encodeURIComponent(instanceId) + '/finance-remark',
authHeader()
)
}
export function fetchUpdateOaFinanceRemark(instanceId, financeRemark) {
return request({
url: '/web/admin/oa-approval/instances/' + encodeURIComponent(instanceId) + '/finance-remark',
method: 'PUT',
data: { financeRemark },
header: authHeader()
})
}
@@ -0,0 +1,12 @@
# 设计
`oa_instance.finance_remark VARCHAR(512)` 保存实例级备注,通用实例序列化不输出该字段。专用服务按真实用户租户、显式菜单权限及本人有效已办任务查询实例;审批任务状态沿用 approved/rejected/returned/transferred,软删任务/实例和其他租户不可访问。
GET `/web/admin/oa-approval/tasks/done` 在 data 增加 `canManageFinanceRemark`。有权限时只给可访问实例行增加 `financeRemark`;无权限不返回字段。GET/PUT `/web/admin/oa-approval/instances/{id}/finance-remark` 返回 `{instanceId, financeRemark}`,PUT 接收 `{financeRemark: string}`,拒绝额外字段、超长和非字符串输入,允许空串清空。权限不足 403、实例不在本人已办范围 404。更新记录 update_by/update_time 并保留既有请求审计。
各端采用后端能力标记,不依赖角色名称或仅前端隐藏。打开弹窗时重新读取最新备注,保存后刷新列表;取消不写入,失败保留编辑内容。文字按纯文本渲染。单字段最后成功保存值生效。
简单列表字段和编辑交互沿用既有 OA/通用弹窗,无需另造复杂 UI 或 Figma 原型。权限菜单放在已办中心下,Web/移动对应菜单可用同一权限码,不自动授予角色。
## 验证
SQLite 隔离集成测试真实角色/菜单/任务:允许、拒绝、跨租户、伪造租户、无本人已办、软删/停用、角色/职位并集、保存读取清空、超长、非字符串、其他中心不泄露。各端 UI 行为检查和构建;uni-app HBuilderX 编译及可用平台检查。缺失环境如实记录,必要验证未完成前不关单。
@@ -0,0 +1,15 @@
# OA 已办中心财务备注
关联 Gitea #7。用户已确认 Web 和所有相关移动端、单一权限控制读写、同一审批实例共用备注、仍限本人已办单据。
## Why
已办列表缺少受控的财务补充说明入口。
## What Changes
- 审批实例新增独立财务备注(最多 512 字,可清空),不复用表单内容、审批意见或其他业务财务字段。
- 权限 `oa:done:finance-remark` 控制读写,使用有效角色并集;无权限不返回内容。
- Web 列表列和编辑弹窗、小程序/uni-app 已办卡片和编辑入口共用后端契约。mobile-oa-h5 当前只承载审批详情,不新增需求外的详情字段。
- 新增 MySQL 5.7 结构迁移及可重复执行的菜单种子,部署先迁移后更新代码;不自动赋权。
## Impact
涉及 backend、web-admin、mobile-h5 和 docs/sql。原有已办筛选、分页、审批流程和其他中心返回保持原语义。mobile-oa-h5 的跳转与详情兼容性需回归检查。
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: 实例级财务备注
系统 SHALL 为同一审批实例保存一份最多 512 字的纯文本财务备注,允许修改与清空,各端共享;不改变审批意见或业务表单。
#### Scenario: 已授权用户保存
- **WHEN** 具备 `oa:done:finance-remark` 权限且有该实例有效已办任务的用户保存备注
- **THEN** 记录备注、修改人和修改时间,其他有权限的相关已办用户读取同一份值,空串清空,超长拒绝。
### Requirement: 权限与数据隔离
系统 SHALL 按当前有效用户的真实租户及直接/职位继承有效角色并集校验显式权限。菜单权限不扩大本人已办数据范围。
#### Scenario: 无权限或越权
- **WHEN** 用户无该权限、无本人有效已办任务、访问其他租户或软删实例
- **THEN** 拒绝读写;无权限的列表及其他通用实例接口不返回财务备注。
### Requirement: 跨端已办入口
系统 SHALL 在 Web 已办表格和 uni-app 已办卡片提供财务备注展示及编辑入口,以后端能力判定可见性;保留原有跳转、筛选和分页。
#### Scenario: 打开和保存编辑器
- **WHEN** 有权限的用户打开编辑器
- **THEN** 读取最新备注;保存成功刷新列表,取消不写入,失败保留输入并提示;无权限用户看不到字段和编辑入口。
@@ -0,0 +1,13 @@
# 实施与验证计划
关联 Gitea #7;Web 与所有相关移动端,同一审批实例共用备注,单一权限控制查看和修改。默认 Agent 验证。
- [x] 后端:新增真实权限、本人已办、租户隔离、共享读写与拒绝输入测试;实现独立财务备注服务、模型和 API。新增 32 项测试通过,OA 全部回归 314 项通过。
- [x] 迁移:新增 MySQL 5.7 字段及对应已办菜单下按钮权限,不默认赋权;隔离 MySQL 5.7.24 连续执行两次通过,更新数据库变更入口。
- [x] 前端:Web 列表列与编辑弹窗、uni-app 卡片与编辑入口;23 项行为测试通过,包含最新值、保存/取消/失败、清空、权限隐藏、纯文本和页面切换竞态。
- [x] 静态与构建:OpenSpec 严格校验、字段链路、git diff 格式检查、Web 生产构建及 HBuilderX 微信小程序和 H5 编译通过。Web 构建使用 4 GB Node 堆;H5 验证复用现有 Sass 依赖,临时依赖不入库。字段一致性工具报告 5 项已知旧路径/映射警告、0 错误,OA 新字段另行逐层核对。
- [x] 审查:独立规格与代码质量审查通过,无阻塞项。
- [x] H5 页面验证:编译产物在 375px 浏览器中验证展示、读取最新值、取消、保存、纯文本、失败保留、清空和无权限隐藏;模拟接口,无业务数据写入,无页面异常。
- [x] 移动端验收方式:用户明确本次可用嵌套 H5 验证,不要求微信平台交互验证;采用已通过的 H5 编译产物交互结果。微信小程序编译通过,但未做模拟器/真机交互验证。
- [x] 测试库迁移:用户确认当前远程库为测试库并授权执行;迁移后字段为可空 VARCHAR(512),为 2 个已办父菜单新增 2 个权限节点,角色授权数量未变。管理员须按需授予角色权限,其他环境仍需先迁移再更新应用。
- [x] 交付准备:任务分支及 PR #8 已推送,用户确认的验收方式与测试库迁移条件已满足。按持续授权核对最新 V2.0 后合并、同步本地主分支并关闭 #7;最终合并提交和关单结果记录在 Gitea 工单中。不打 tag、不部署、不启停服务。
@@ -0,0 +1,299 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { parse, compileTemplate } from '@vue/compiler-sfc'
import ts from 'typescript'
import { ref, reactive, computed, proxyRefs } from 'vue'
const root = new URL('../../', import.meta.url)
const fields = [
'canManageFinanceRemark',
'financeRemarkVisible',
'financeRemarkLoading',
'financeRemarkSaving',
'financeRemarkText',
'openFinanceRemark',
'saveFinanceRemark',
'cancelFinanceRemark',
'clearFinanceAccess',
'load',
'rows'
]
function evaluate(source, deps, tail = '') {
const withoutImports = source.replace(/import[\s\S]*?from\s*['"][^'"]+['"]/g, '')
const js = ts.transpileModule(withoutImports + tail, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
const exports = {}
return (
new Function(...Object.keys(deps), 'exports', js)(...Object.values(deps), exports) ||
exports.default ||
exports
)
}
function harness(platform) {
const calls = []
const hooks = {}
const api = {
list: {
list: [{ id: 'task-1', instanceId: 'instance-1', financeRemark: '列表旧值' }],
total: 1,
canManageFinanceRemark: true
},
get: async () => ({ financeRemark: '最新备注' }),
put: async () => ({ financeRemark: '' })
}
const deps = {
ref,
reactive,
computed,
defineOptions() {},
watchDebounced() {},
onMounted() {},
onActivated() {},
onDeactivated(fn) {
hooks.hide = fn
},
useRouter: () => ({}),
useMenuPerm: () => ({ hasMenuPerm: () => false }),
useUserStore: () => ({}),
ElMessage: { success() {}, error() {}, warning() {} },
uni: { showToast() {}, navigateTo() {} },
VanEmpty: {},
getToken: () => 'token',
forceRelogin() {},
fetchOaTasks: async () => {
calls.push(['list'])
return api.list
},
fetchOaFinanceRemark: async (id) => {
calls.push(['get', id])
return api.get(id)
},
fetchUpdateOaFinanceRemark: async (id, text) => {
calls.push(['put', id, text])
return api.put(id, text)
}
}
const path =
platform === 'web'
? 'web-admin/src/views/oa-approval/done/index.vue'
: 'mobile-h5/pages/oa/done/index.vue'
const { descriptor } = parse(readFileSync(new URL(path, root), 'utf8'))
let vm
if (platform === 'web') {
vm = proxyRefs(
evaluate(
descriptor.scriptSetup.content,
deps,
'\nreturn {' +
fields.map((k) => `${k}: typeof ${k} === 'undefined' ? undefined : ${k}`).join(',') +
'}'
)
)
} else {
const options = evaluate(descriptor.script.content, deps)
vm = options.data()
for (const [key, fn] of Object.entries(options.methods)) vm[key] = fn.bind(vm)
hooks.hide = () => options.onHide?.call(vm)
}
const load = () => (platform === 'web' ? vm.load() : vm.loadList(true))
const rows = () => (platform === 'web' ? vm.rows : vm.list)
return { vm, calls, api, load, rows, hooks, descriptor }
}
for (const platform of ['web', 'mobile']) {
test(`${platform}: capability gates access and opening gets latest instance value`, async () => {
const h = harness(platform)
await h.load()
assert.equal(h.vm.canManageFinanceRemark, true)
await h.vm.openFinanceRemark(h.rows()[0])
assert.deepEqual(h.calls.at(-1), ['get', 'instance-1'])
assert.equal(h.vm.financeRemarkText, '最新备注')
assert.equal(h.vm.financeRemarkVisible, true)
})
test(`${platform}: cancellation makes no write and discards late GET`, async () => {
const h = harness(platform)
await h.load()
let resolve
h.api.get = () =>
new Promise((r) => {
resolve = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
h.vm.cancelFinanceRemark()
resolve({ financeRemark: '不得重新显示' })
await opening
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
})
test(`${platform}: save failure preserves input; clear succeeds and refreshes`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.vm.financeRemarkText = '我的未保存输入'
h.api.put = async () => {
throw new Error('failure')
}
await h.vm.saveFinanceRemark()
assert.equal(h.vm.financeRemarkText, '我的未保存输入')
assert.equal(h.vm.financeRemarkVisible, true)
h.api.put = async () => ({ financeRemark: '' })
h.vm.financeRemarkText = ''
await h.vm.saveFinanceRemark()
assert.deepEqual(h.calls.slice(-2), [['put', 'instance-1', ''], ['list']])
assert.equal(h.vm.financeRemarkVisible, false)
})
test(`${platform}: missing capability strips cached notes and prevents GET`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.api.list = { list: [{ instanceId: 'instance-1', financeRemark: '敏感备注' }], total: 1 }
await h.load()
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal('financeRemark' in h.rows()[0], false)
const count = h.calls.length
await h.vm.openFinanceRemark(h.rows()[0])
assert.equal(h.calls.length, count)
})
test(`${platform}: hiding page clears notes and invalidates pending reads`, async () => {
const h = harness(platform)
await h.load()
let resolve
h.api.get = () =>
new Promise((r) => {
resolve = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
h.hooks.hide()
resolve({ financeRemark: '不得恢复的旧备注' })
await opening
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal('financeRemark' in h.rows()[0], false)
})
test(`${platform}: overlong input cannot send PUT`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.vm.financeRemarkText = '字'.repeat(513)
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
assert.equal(h.vm.financeRemarkVisible, true)
})
test(`${platform}: saving while reading or already saving sends no duplicate PUT`, async () => {
const h = harness(platform)
await h.load()
let resolveGet
h.api.get = () =>
new Promise((r) => {
resolveGet = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
resolveGet({ financeRemark: '备注' })
await opening
let resolvePut
h.api.put = () =>
new Promise((r) => {
resolvePut = r
})
const saving = h.vm.saveFinanceRemark()
await h.vm.saveFinanceRemark()
assert.equal(h.calls.filter((c) => c[0] === 'put').length, 1)
resolvePut({ financeRemark: '备注' })
await saving
})
test(`${platform}: late list response cannot restore permission after hiding`, async () => {
const h = harness(platform)
await h.load()
let resolve
const previous = h.api.list
h.api.list = new Promise((r) => {
resolve = r
})
const loading = h.load()
h.hooks.hide()
resolve(previous)
await loading
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal('financeRemark' in h.rows()[0], false)
})
test(`${platform}: failed latest-value request cannot save stale list text`, async () => {
const h = harness(platform)
await h.load()
h.api.get = async () => {
throw new Error('forbidden')
}
await h.vm.openFinanceRemark(h.rows()[0])
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
})
test(`${platform}: template compiles with plain text and gated edit controls`, () => {
const { descriptor } = harness(platform)
const result = compileTemplate({
source: descriptor.template.content,
filename: 'done.vue',
id: platform
})
assert.deepEqual(result.errors, [])
assert.match(result.code, /canManageFinanceRemark/)
assert.match(result.code, /toDisplayString\([^)]*financeRemark/)
assert.doesNotMatch(result.code, /innerHTML/)
if (platform === 'mobile')
assert.match(result.code, /withModifiers[^\n]*openFinanceRemark[^\n]*[\s\S]*?\["stop"\]/)
})
}
for (const platform of ['web', 'mobile']) {
test(`${platform}: finance API uses dedicated authenticated instance endpoint`, async () => {
const calls = []
const request = (config) => {
calls.push(config)
return Promise.resolve({})
}
request.get = (config) => request({ ...config, method: 'GET' })
request.put = (config) => request({ ...config, method: 'PUT' })
const path =
platform === 'web' ? 'web-admin/src/api/oaApproval.ts' : 'mobile-h5/utils/oa-api.js'
const api = evaluate(readFileSync(new URL(path, root), 'utf8'), {
request,
get: (url, header) => request({ url, header, method: 'GET' }),
getToken: () => 'token'
})
assert.equal(typeof api.fetchOaFinanceRemark, 'function')
await api.fetchOaFinanceRemark('instance-1')
await api.fetchUpdateOaFinanceRemark('instance-1', '')
assert.equal(calls[0].method, 'GET')
assert.equal(calls[1].method, 'PUT')
assert.equal(calls[0].url, '/web/admin/oa-approval/instances/instance-1/finance-remark')
assert.equal(calls[1].url, calls[0].url)
assert.deepEqual(calls[1].data, { financeRemark: '' })
if (platform === 'mobile') assert.equal(calls[1].header.Authorization, 'Bearer token')
})
}
test('web: delayed refresh on an inactive cached page cannot restore finance data', async () => {
const h = harness('web')
await h.load()
h.hooks.hide()
const count = h.calls.length
await h.load()
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.calls.length, count)
})
+16 -1
View File
@@ -408,7 +408,7 @@ export function fetchOaTasks(
const applyTimeFrom = (params?.applyTimeFrom || '').trim()
const applyTimeTo = (params?.applyTimeTo || '').trim()
const instanceStatus = (params?.instanceStatus || '').trim()
return request.get<{ list: any[]; total: number }>({
return request.get<{ list: any[]; total: number; canManageFinanceRemark?: boolean }>({
url: `${BASE}/tasks/${kind}`,
params: {
page: params?.page ?? 1,
@@ -664,3 +664,18 @@ export function fetchReplaceOaBankReceipts(
data: { items }
})
}
export type OaFinanceRemark = { instanceId: string; financeRemark: string }
export function fetchOaFinanceRemark(instanceId: string) {
return request.get<OaFinanceRemark>({
url: `${BASE}/instances/${encodeURIComponent(instanceId)}/finance-remark`
})
}
export function fetchUpdateOaFinanceRemark(instanceId: string, financeRemark: string) {
return request.put<OaFinanceRemark>({
url: `${BASE}/instances/${encodeURIComponent(instanceId)}/finance-remark`,
data: { financeRemark }
})
}
+176 -38
View File
@@ -6,18 +6,10 @@
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="流程名称">
<el-input
v-model="searchForm.appName"
placeholder="请输入流程名称"
clearable
/>
<el-input v-model="searchForm.appName" placeholder="请输入流程名称" clearable />
</el-form-item>
<el-form-item label="申请人">
<el-input
v-model="searchForm.initiatorName"
placeholder="请输入申请人"
clearable
/>
<el-input v-model="searchForm.initiatorName" placeholder="请输入申请人" clearable />
</el-form-item>
<el-form-item label="申请时间">
<el-date-picker
@@ -76,28 +68,13 @@
>
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
</el-table-column>
<el-table-column
prop="initiatorUserName"
label="申请人"
min-width="120"
align="center"
>
<el-table-column prop="initiatorUserName" label="申请人" min-width="120" align="center">
<template #default="{ row }">{{ row.initiatorUserName || '-' }}</template>
</el-table-column>
<el-table-column
prop="applyTime"
label="申请时间"
min-width="170"
align="center"
>
<el-table-column prop="applyTime" label="申请时间" min-width="170" align="center">
<template #default="{ row }">{{ row.applyTime || '-' }}</template>
</el-table-column>
<el-table-column
prop="currentNodeName"
label="当前节点"
min-width="140"
align="center"
>
<el-table-column prop="currentNodeName" label="当前节点" min-width="140" align="center">
<template #default="{ row }">{{ row.currentNodeName || '-' }}</template>
</el-table-column>
<el-table-column
@@ -116,6 +93,15 @@
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="canManageFinanceRemark"
prop="financeRemark"
label="财务备注"
min-width="220"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="400" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)">
@@ -130,6 +116,14 @@
<el-icon><Printer /></el-icon>
打印
</el-button>
<el-button
v-if="canManageFinanceRemark"
class="btn-op-item"
@click="openFinanceRemark(row)"
>
<el-icon><EditPen /></el-icon>
财务备注
</el-button>
<el-button
v-if="canUploadBankReceipt"
class="btn-op-item"
@@ -157,6 +151,49 @@
</div>
</section>
<el-dialog
:model-value="financeRemarkVisible"
title="财务备注"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
:close-on-press-escape="!financeRemarkSaving"
:show-close="!financeRemarkSaving"
@update:model-value="cancelFinanceRemark"
>
<el-form v-loading="financeRemarkLoading" label-position="top" @submit.prevent>
<el-form-item label="财务备注">
<el-input
v-model="financeRemarkText"
type="textarea"
:rows="5"
:maxlength="512"
show-word-limit
:disabled="financeRemarkLoading || financeRemarkSaving"
placeholder="请输入财务备注,留空可清空"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" :disabled="financeRemarkSaving" @click="cancelFinanceRemark">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="financeRemarkSaving"
:disabled="financeRemarkLoading"
@click="saveFinanceRemark"
>
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<OaBankReceiptDialog
v-model="bankReceiptVisible"
:instance-id="bankReceiptInstanceId"
@@ -166,11 +203,22 @@
</template>
<script setup lang="ts">
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Printer, RefreshLeft, Search, Upload, View } from '@element-plus/icons-vue'
import { fetchOaInstanceDetail, fetchOaTasks } from '@/api/oaApproval'
import {
Check,
Close,
EditPen,
Printer,
RefreshLeft,
Search,
Upload,
View
} from '@element-plus/icons-vue'
import {
fetchOaInstanceDetail,
fetchOaTasks,
fetchOaFinanceRemark,
fetchUpdateOaFinanceRemark
} from '@/api/oaApproval'
import OaBankReceiptDialog from '@/components/oa-approval/OaBankReceiptDialog.vue'
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
import { useUserStore } from '@/store/modules/user'
@@ -187,6 +235,82 @@
const loading = ref(false)
const printingId = ref('')
const rows = ref<any[]>([])
const canManageFinanceRemark = ref(false)
const financeRemarkVisible = ref(false)
const financeRemarkLoading = ref(false)
const financeRemarkSaving = ref(false)
const financeRemarkText = ref('')
let financeRemarkInstanceId = ''
let financeRemarkVersion = 0
let listVersion = 0
let pageActive = true
function cancelFinanceRemark() {
financeRemarkVersion++
financeRemarkVisible.value = false
financeRemarkText.value = ''
financeRemarkInstanceId = ''
financeRemarkLoading.value = false
financeRemarkSaving.value = false
}
function clearFinanceAccess() {
canManageFinanceRemark.value = false
cancelFinanceRemark()
rows.value = rows.value.map((row) => {
const sanitized = { ...row }
delete sanitized.financeRemark
return sanitized
})
}
async function openFinanceRemark(row: any) {
if (!canManageFinanceRemark.value || financeRemarkSaving.value) return
const id = String(row.instanceId || '').trim()
if (!id) return
cancelFinanceRemark()
const version = financeRemarkVersion
financeRemarkInstanceId = id
financeRemarkVisible.value = true
financeRemarkLoading.value = true
try {
const result = await fetchOaFinanceRemark(id)
if (version !== financeRemarkVersion) return
financeRemarkText.value = result.financeRemark || ''
} catch {
if (version === financeRemarkVersion) cancelFinanceRemark()
} finally {
if (version === financeRemarkVersion) financeRemarkLoading.value = false
}
}
async function saveFinanceRemark() {
if (
!canManageFinanceRemark.value ||
!financeRemarkInstanceId ||
financeRemarkLoading.value ||
financeRemarkSaving.value
)
return
if (financeRemarkText.value.length > 512) {
ElMessage.warning('财务备注最多 512 个字符')
return
}
const version = financeRemarkVersion
financeRemarkSaving.value = true
try {
await fetchUpdateOaFinanceRemark(financeRemarkInstanceId, financeRemarkText.value)
if (version !== financeRemarkVersion) return
cancelFinanceRemark()
ElMessage.success('财务备注已保存')
await load()
} catch {
// 请求层统一提示错误,保留输入以便重试。
} finally {
if (version === financeRemarkVersion) financeRemarkSaving.value = false
}
}
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
@@ -210,9 +334,7 @@
function currentPrinterName() {
const info: any = userStore.info || userStore.getUserInfo || {}
return String(
info.realName || info.nickName || info.userName || info.username || ''
).trim()
return String(info.realName || info.nickName || info.userName || info.username || '').trim()
}
function statusType(s: string) {
@@ -306,16 +428,24 @@
}
async function load() {
if (!pageActive) return
const version = ++listVersion
clearFinanceAccess()
loading.value = true
try {
const res = await fetchOaTasks('done', buildFilterParams())
if (version !== listVersion) return
canManageFinanceRemark.value = res?.canManageFinanceRemark === true
rows.value = res?.list || []
if (!canManageFinanceRemark.value) clearFinanceAccess()
total.value = res?.total || 0
} catch {
if (version !== listVersion) return
clearFinanceAccess()
rows.value = []
total.value = 0
} finally {
loading.value = false
if (version === listVersion) loading.value = false
}
}
@@ -328,6 +458,14 @@
{ debounce: 300, deep: true }
)
onDeactivated(() => {
pageActive = false
listVersion++
clearFinanceAccess()
})
onMounted(load)
onActivated(load)
onActivated(() => {
pageActive = true
void load()
})
</script>