Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7115205d | ||
|
|
a8ee0fcebd | ||
|
|
5b1367c775 | ||
|
|
802985dd99 | ||
|
|
0e64b413ce | ||
|
|
86df360f35 | ||
|
|
0e98593671 | ||
|
|
f12980a053 | ||
|
|
6356bdecc0 | ||
|
|
05de8924b0 |
No files matched your search
@@ -20,7 +20,11 @@
|
|||||||
- **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。
|
- **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。
|
||||||
- 提交使用 Conventional Commits,完整说明“改了什么、为什么”。
|
- 提交使用 Conventional Commits,完整说明“改了什么、为什么”。
|
||||||
- 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。
|
- 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。
|
||||||
- 修复后回写工单,附提交、PR、验证结果及未完成项,标注“已修复”;待验收、未合并或未发布须分别说明,不能以“已修复”代替验收和关闭条件。
|
- 修改端不明确时,先查当前任务和工单的已确认范围;仍不明确则询问修改 Web、小程序、移动 OA、后端中的哪些端,还是所有相关端。确认前仅只读定位、回写工单待确认范围,不修改业务代码;截图不能代替确认。已有明确范围直接沿用,答复“所有端”须记录具体应用清单;共享后端/API 影响范围外的端时,说明影响并确认扩展后再实施。
|
||||||
|
- 新界面、新弹窗及视觉改版,按 [UI 原型确认规则](openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md#requirement-界面与弹窗先经-figma-原型确认)先完成 Figma 原型并取得用户确认后开发;仅用户明确不用生成 Figma 原型时豁免。
|
||||||
|
- 默认由 Agent 测试:自动进入任务分支/worktree,核对实际运行版本,完成测试与审查后合入 `V2.0`、提交推送、同步本地主分支并关闭 Gitea 工单。用户已授予这条常规收尾链路持续授权,不逐次询问合并;打 tag、部署与服务启停仍分别受原有授权限制。
|
||||||
|
- 用户明确“我来测试”时,该任务由用户测试:Agent 不运行测试或构建,仍完成代码审查和 Git 核对;交付时提醒切换到具体分支,提供 worktree 路径、提交、前后端配套入口和验证要点。用户确认通过后直接合并推送、关闭工单;未确认或失败时不合并、不关闭。
|
||||||
|
- 合并前核对验收提交与最新目标;目标更新或冲突处理改变代码时,按原测试责任补验,用户模式仍交用户验证。合并后代码树与验收版本一致不重复测试;确认远端同步成功后回写验收结果、合并提交和 PR 并关单。
|
||||||
- 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。
|
- 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。
|
||||||
- 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。
|
- 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。
|
||||||
|
|
||||||
@@ -45,7 +49,7 @@
|
|||||||
|
|
||||||
## 验证与交付
|
## 验证与交付
|
||||||
|
|
||||||
- 执行与改动范围相符的检查,报告实际结果和未执行项;文档变更检查链接与引用,业务变更检查受影响测试及构建。
|
- 按任务测试责任验证,报告实际结果和未执行项:Agent 模式按改动范围检查文档链接或业务测试与构建;用户模式记录用户验收结论,不由 Agent 运行测试和构建。
|
||||||
- CRUD 字段一致性按 [开发指南](docs/dev-field-consistency.md) 检查;根目录命令是 `python backend/scripts/check_field_consistency.py`。该工具仅覆盖注册实体,已知失效路径见该指南。
|
- CRUD 字段一致性按 [开发指南](docs/dev-field-consistency.md) 检查;根目录命令是 `python backend/scripts/check_field_consistency.py`。该工具仅覆盖注册实体,已知失效路径见该指南。
|
||||||
- 使用源码、引用检索和测试核对影响范围。已移除的 graphify 不再是开发依赖或交付步骤,不安装、运行或恢复其自动 hook。
|
- 使用源码、引用检索和测试核对影响范围。已移除的 graphify 不再是开发依赖或交付步骤,不安装、运行或恢复其自动 hook。
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
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 pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy.orm import Session
|
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.crud import oa_receive_account as ora_crud
|
||||||
from app.models.models import SysUser
|
from app.models.models import SysUser
|
||||||
from app.services.oa_receive_account import normalize_account_fields
|
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审批"])
|
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))
|
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")
|
@router.get("/instances/{instance_id}/bank-receipts")
|
||||||
async def list_oa_bank_receipts(
|
async def list_oa_bank_receipts(
|
||||||
instance_id: str,
|
instance_id: str,
|
||||||
@@ -1365,7 +1404,8 @@ async def list_oa_tasks(
|
|||||||
from app.services.oa_engine import OaEngineError, list_tasks
|
from app.services.oa_engine import OaEngineError, list_tasks
|
||||||
|
|
||||||
try:
|
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(
|
data = list_tasks(
|
||||||
db,
|
db,
|
||||||
tenant_no=tenant_no,
|
tenant_no=tenant_no,
|
||||||
@@ -1381,7 +1421,13 @@ async def list_oa_tasks(
|
|||||||
page=page,
|
page=page,
|
||||||
page_size=pageSize,
|
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:
|
except OaEngineError as e:
|
||||||
return error_response(400, str(e))
|
return error_response(400, str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -920,6 +920,7 @@ class OaInstance(Base):
|
|||||||
default="running",
|
default="running",
|
||||||
comment="running/returned/completed/rejected/cancelled",
|
comment="running/returned/completed/rejected/cancelled",
|
||||||
)
|
)
|
||||||
|
finance_remark = Column(String(512), nullable=True, comment="已办财务备注(专用权限控制)")
|
||||||
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID")
|
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID")
|
||||||
current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次")
|
current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次")
|
||||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||||
|
|||||||
@@ -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"]]
|
||||||
@@ -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'])
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
| 数据库 | [SQL 初始化与迁移](sql/) |
|
| 数据库 | [SQL 初始化与迁移](sql/) |
|
||||||
| 部署 | [部署导航](../deploy/README.md) |
|
| 部署 | [部署导航](../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 是导入脚本的输入资源,继续保留。
|
`docs/sql/zkxw.sql` 仍被交付打包和 Compose 引用,但当前初始化基线不完整,不能据此宣称可从空库部署。SOP HTML 是导入脚本的输入资源,继续保留。
|
||||||
|
|
||||||
历史细胞产品导入必须显式指定 `--file`,不再自带默认 TSV。实际业务测试保留在各应用源码目录;删除历史报告不代表现有测试已经执行或通过。
|
历史细胞产品导入必须显式指定 `--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;
|
||||||
@@ -32,11 +32,7 @@
|
|||||||
<view v-else-if="!list.length" class="oa-empty-wrap">
|
<view v-else-if="!list.length" class="oa-empty-wrap">
|
||||||
<VanEmpty
|
<VanEmpty
|
||||||
:description="keyword ? '未找到相关已办' : '暂无已办'"
|
:description="keyword ? '未找到相关已办' : '暂无已办'"
|
||||||
:tip="
|
:tip="keyword ? '试试其他关键词,或清空搜索' : '你处理过的审批会出现在这里'"
|
||||||
keyword
|
|
||||||
? '试试其他关键词,或清空搜索'
|
|
||||||
: '你处理过的审批会出现在这里'
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -58,23 +54,22 @@
|
|||||||
}}</text>
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="metaLines(row).length" class="dd-card__meta">
|
<view v-if="metaLines(row).length" class="dd-card__meta">
|
||||||
<text
|
<text v-for="(line, idx) in metaLines(row)" :key="idx" class="dd-card__meta-line">
|
||||||
v-for="(line, idx) in metaLines(row)"
|
|
||||||
:key="idx"
|
|
||||||
class="dd-card__meta-line"
|
|
||||||
>
|
|
||||||
{{ line }}
|
{{ line }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</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__foot">
|
||||||
<view class="dd-card__user">
|
<view class="dd-card__user">
|
||||||
<view class="dd-card__avatar">{{ avatarText(row) }}</view>
|
<view class="dd-card__avatar">{{ avatarText(row) }}</view>
|
||||||
<text class="dd-card__user-text">{{ submitterText(row) }}</text>
|
<text class="dd-card__user-text">{{ submitterText(row) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text
|
<text class="dd-card__status" :class="'is-' + statusTone(row.instanceStatus)">
|
||||||
class="dd-card__status"
|
|
||||||
:class="'is-' + statusTone(row.instanceStatus)"
|
|
||||||
>
|
|
||||||
{{ statusText(row.instanceStatus) }}
|
{{ statusText(row.instanceStatus) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -82,12 +77,89 @@
|
|||||||
<view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
|
<view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
|
||||||
<view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
|
<view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="financeRemarkVisible"
|
||||||
|
class="finance-dialog"
|
||||||
|
:style="financeRemarkViewportStyle"
|
||||||
|
@touchmove.stop.prevent
|
||||||
|
>
|
||||||
|
<view class="finance-dialog__mask" />
|
||||||
|
<view class="finance-dialog__panel" role="dialog" aria-label="财务备注" @touchmove.stop>
|
||||||
|
<view class="finance-dialog__header">
|
||||||
|
<view class="finance-dialog__heading">
|
||||||
|
<text class="finance-dialog__title">财务备注</text>
|
||||||
|
<text class="finance-dialog__subtitle">同一审批单在各端共享</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="finance-dialog__close"
|
||||||
|
aria-label="关闭财务备注"
|
||||||
|
hover-class="finance-dialog__pressed"
|
||||||
|
:disabled="financeRemarkSaving"
|
||||||
|
@click="cancelFinanceRemark"
|
||||||
|
>
|
||||||
|
<image class="finance-dialog__close-icon" src="/static/finance-remark-close.svg" />
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view class="finance-dialog__body">
|
||||||
|
<view class="finance-dialog__field-header">
|
||||||
|
<text class="finance-dialog__label">备注内容</text>
|
||||||
|
<text class="finance-dialog__count">{{ financeRemarkText.length }} / 512</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="financeRemarkLoading" class="finance-dialog__loading" role="status">
|
||||||
|
正在加载最新备注…
|
||||||
|
</view>
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
v-model="financeRemarkText"
|
||||||
|
class="finance-dialog__input"
|
||||||
|
:class="{
|
||||||
|
'is-error': financeRemarkError,
|
||||||
|
'is-saving': financeRemarkSaving
|
||||||
|
}"
|
||||||
|
:maxlength="512"
|
||||||
|
:disabled="financeRemarkSaving"
|
||||||
|
:adjust-position="false"
|
||||||
|
:show-confirm-bar="false"
|
||||||
|
:fixed="true"
|
||||||
|
aria-label="备注内容"
|
||||||
|
placeholder="填写需要补充的财务说明…"
|
||||||
|
placeholder-class="finance-dialog__placeholder"
|
||||||
|
@keyboardheightchange="onFinanceRemarkKeyboardChange"
|
||||||
|
/>
|
||||||
|
<text class="finance-dialog__hint" :class="{ 'is-error': financeRemarkError }">
|
||||||
|
{{ financeRemarkError || '留空并保存,可清空原有备注' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="finance-dialog__actions">
|
||||||
|
<button
|
||||||
|
class="finance-dialog__button finance-dialog__button--cancel"
|
||||||
|
hover-class="finance-dialog__pressed"
|
||||||
|
:disabled="financeRemarkSaving"
|
||||||
|
@click="cancelFinanceRemark"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="finance-dialog__button finance-dialog__button--save"
|
||||||
|
hover-class="finance-dialog__pressed"
|
||||||
|
:disabled="financeRemarkLoading || financeRemarkSaving"
|
||||||
|
@click="saveFinanceRemark"
|
||||||
|
>
|
||||||
|
{{ financeRemarkSaving ? '保存中…' : '保存备注' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import VanEmpty from '../../../components/van-empty/van-empty.vue'
|
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 { forceRelogin, getToken } from '../../../utils/auth.js'
|
||||||
import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
|
import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
|
||||||
import { formatCornerIndex } from '../../../utils/oa-corner-index.js'
|
import { formatCornerIndex } from '../../../utils/oa-corner-index.js'
|
||||||
@@ -121,6 +193,17 @@
|
|||||||
components: { VanEmpty },
|
components: { VanEmpty },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
canManageFinanceRemark: false,
|
||||||
|
financeRemarkVisible: false,
|
||||||
|
financeRemarkLoading: false,
|
||||||
|
financeRemarkSaving: false,
|
||||||
|
financeRemarkText: '',
|
||||||
|
financeRemarkError: '',
|
||||||
|
financeRemarkViewportStyle: {},
|
||||||
|
financeRemarkWindowHeight: 0,
|
||||||
|
financeRemarkInstanceId: '',
|
||||||
|
financeRemarkVersion: 0,
|
||||||
|
listVersion: 0,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
searchFocused: false,
|
searchFocused: false,
|
||||||
list: [],
|
list: [],
|
||||||
@@ -134,6 +217,7 @@
|
|||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
|
this.clearFinanceAccess()
|
||||||
forceRelogin('请先登录')
|
forceRelogin('请先登录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,13 +234,137 @@
|
|||||||
if (!this.hasMore || this.loading || this.loadingMore) return
|
if (!this.hasMore || this.loading || this.loadingMore) return
|
||||||
this.loadList(false)
|
this.loadList(false)
|
||||||
},
|
},
|
||||||
|
onHide() {
|
||||||
|
this.listVersion++
|
||||||
|
this.clearFinanceAccess()
|
||||||
|
if (this._searchTimer) clearTimeout(this._searchTimer)
|
||||||
|
this._searchTimer = null
|
||||||
|
},
|
||||||
onUnload() {
|
onUnload() {
|
||||||
|
this.listVersion++
|
||||||
|
this.clearFinanceAccess()
|
||||||
if (this._searchTimer) {
|
if (this._searchTimer) {
|
||||||
clearTimeout(this._searchTimer)
|
clearTimeout(this._searchTimer)
|
||||||
this._searchTimer = null
|
this._searchTimer = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
cancelFinanceRemark() {
|
||||||
|
if (this.financeRemarkSaving) return
|
||||||
|
this.resetFinanceRemark()
|
||||||
|
},
|
||||||
|
resetFinanceRemark() {
|
||||||
|
if (this._financeRemarkViewportCleanup) this._financeRemarkViewportCleanup()
|
||||||
|
this._financeRemarkViewportCleanup = null
|
||||||
|
this.financeRemarkViewportStyle = {}
|
||||||
|
this.financeRemarkWindowHeight = 0
|
||||||
|
this.financeRemarkVersion++
|
||||||
|
this.financeRemarkVisible = false
|
||||||
|
this.financeRemarkText = ''
|
||||||
|
this.financeRemarkError = ''
|
||||||
|
this.financeRemarkInstanceId = ''
|
||||||
|
this.financeRemarkLoading = false
|
||||||
|
this.financeRemarkSaving = false
|
||||||
|
},
|
||||||
|
clearFinanceAccess() {
|
||||||
|
this.canManageFinanceRemark = false
|
||||||
|
this.resetFinanceRemark()
|
||||||
|
this.list = this.list.map((row) => {
|
||||||
|
const sanitized = { ...row }
|
||||||
|
delete sanitized.financeRemark
|
||||||
|
return sanitized
|
||||||
|
})
|
||||||
|
},
|
||||||
|
watchFinanceRemarkViewport() {
|
||||||
|
// H5 uses the visual viewport so keyboard resize and browser panning are both covered.
|
||||||
|
// #ifdef H5
|
||||||
|
if (typeof window !== 'undefined' && window.visualViewport) {
|
||||||
|
const viewport = window.visualViewport
|
||||||
|
const update = () => {
|
||||||
|
if (!this.financeRemarkVisible) return
|
||||||
|
this.financeRemarkViewportStyle = {
|
||||||
|
height: viewport.height + 'px',
|
||||||
|
top: viewport.offsetTop + 'px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
viewport.addEventListener('resize', update)
|
||||||
|
viewport.addEventListener('scroll', update)
|
||||||
|
this._financeRemarkViewportCleanup = () => {
|
||||||
|
viewport.removeEventListener('resize', update)
|
||||||
|
viewport.removeEventListener('scroll', update)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
const info = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
|
||||||
|
this.financeRemarkWindowHeight = info.windowHeight
|
||||||
|
this.financeRemarkViewportStyle = {
|
||||||
|
height: info.windowHeight + 'px',
|
||||||
|
top: '0px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinanceRemarkKeyboardChange(event) {
|
||||||
|
if (!this.financeRemarkVisible || !this.financeRemarkWindowHeight) return
|
||||||
|
const keyboardHeight = Math.max(0, Number(event?.detail?.height) || 0)
|
||||||
|
this.financeRemarkViewportStyle = {
|
||||||
|
height: Math.max(0, this.financeRemarkWindowHeight - keyboardHeight) + 'px',
|
||||||
|
top: '0px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
||||||
|
this.watchFinanceRemarkViewport()
|
||||||
|
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) {
|
||||||
|
this.financeRemarkError = '财务备注最多 512 个字符'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const version = this.financeRemarkVersion
|
||||||
|
this.financeRemarkError = ''
|
||||||
|
this.financeRemarkSaving = true
|
||||||
|
try {
|
||||||
|
await fetchUpdateOaFinanceRemark(this.financeRemarkInstanceId, this.financeRemarkText)
|
||||||
|
if (version !== this.financeRemarkVersion) return
|
||||||
|
this.resetFinanceRemark()
|
||||||
|
uni.showToast({ title: '财务备注已保存', icon: 'success' })
|
||||||
|
await this.loadList(true)
|
||||||
|
} catch (e) {
|
||||||
|
if (version !== this.financeRemarkVersion) return
|
||||||
|
this.financeRemarkError = '保存失败,请稍后重试,输入已保留。'
|
||||||
|
} finally {
|
||||||
|
if (version === this.financeRemarkVersion) this.financeRemarkSaving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
cornerIndex(idx) {
|
cornerIndex(idx) {
|
||||||
return formatCornerIndex(Number(idx) + 1)
|
return formatCornerIndex(Number(idx) + 1)
|
||||||
},
|
},
|
||||||
@@ -188,10 +396,7 @@
|
|||||||
for (let i = 0; i < fields.length && i < 3; i++) {
|
for (let i = 0; i < fields.length && i < 3; i++) {
|
||||||
const f = fields[i] || {}
|
const f = fields[i] || {}
|
||||||
const label = String(f.label || '').trim() || '字段'
|
const label = String(f.label || '').trim() || '字段'
|
||||||
const value =
|
const value = f.value != null && String(f.value).trim() !== '' ? String(f.value) : '-'
|
||||||
f.value != null && String(f.value).trim() !== ''
|
|
||||||
? String(f.value)
|
|
||||||
: '-'
|
|
||||||
lines.push(label + ':' + value)
|
lines.push(label + ':' + value)
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
@@ -200,28 +405,16 @@
|
|||||||
const d = parseDateTime(str)
|
const d = parseDateTime(str)
|
||||||
if (!d) return str || '-'
|
if (!d) return str || '-'
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const startToday = new Date(
|
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
now.getFullYear(),
|
|
||||||
now.getMonth(),
|
|
||||||
now.getDate()
|
|
||||||
)
|
|
||||||
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||||
const dayDiff = Math.round(
|
const dayDiff = Math.round((startToday.getTime() - startThat.getTime()) / 86400000)
|
||||||
(startToday.getTime() - startThat.getTime()) / 86400000
|
|
||||||
)
|
|
||||||
if (dayDiff === 0) {
|
if (dayDiff === 0) {
|
||||||
return pad2(d.getHours()) + ':' + pad2(d.getMinutes())
|
return pad2(d.getHours()) + ':' + pad2(d.getMinutes())
|
||||||
}
|
}
|
||||||
if (dayDiff === 1) return '昨天'
|
if (dayDiff === 1) return '昨天'
|
||||||
if (dayDiff === 2) return '前天'
|
if (dayDiff === 2) return '前天'
|
||||||
if (dayDiff > 2 && dayDiff < 7) return dayDiff + '天前'
|
if (dayDiff > 2 && dayDiff < 7) return dayDiff + '天前'
|
||||||
return (
|
return d.getFullYear() + '.' + pad2(d.getMonth() + 1) + '.' + pad2(d.getDate())
|
||||||
d.getFullYear() +
|
|
||||||
'.' +
|
|
||||||
pad2(d.getMonth() + 1) +
|
|
||||||
'.' +
|
|
||||||
pad2(d.getDate())
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
_inputVal(e) {
|
_inputVal(e) {
|
||||||
if (e && e.detail && e.detail.value != null) return String(e.detail.value)
|
if (e && e.detail && e.detail.value != null) return String(e.detail.value)
|
||||||
@@ -277,11 +470,14 @@
|
|||||||
},
|
},
|
||||||
async loadList(reset) {
|
async loadList(reset) {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
|
this.clearFinanceAccess()
|
||||||
forceRelogin('请先登录')
|
forceRelogin('请先登录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const version = ++this.listVersion
|
||||||
const isReset = !!reset
|
const isReset = !!reset
|
||||||
if (isReset) {
|
if (isReset) {
|
||||||
|
this.clearFinanceAccess()
|
||||||
this.page = 1
|
this.page = 1
|
||||||
this.hasMore = true
|
this.hasMore = true
|
||||||
this.loading = true
|
this.loading = true
|
||||||
@@ -294,23 +490,30 @@
|
|||||||
page: this.page,
|
page: this.page,
|
||||||
pageSize: PAGE_SIZE
|
pageSize: PAGE_SIZE
|
||||||
})
|
})
|
||||||
|
if (version !== this.listVersion) return
|
||||||
|
this.canManageFinanceRemark = res?.canManageFinanceRemark === true
|
||||||
const rows = (res && res.list) || []
|
const rows = (res && res.list) || []
|
||||||
const total = (res && res.total) != null ? Number(res.total) : 0
|
const total = (res && res.total) != null ? Number(res.total) : 0
|
||||||
this.total = total
|
this.total = total
|
||||||
if (isReset) this.list = rows
|
if (isReset) this.list = rows
|
||||||
else this.list = this.list.concat(rows)
|
else this.list = this.list.concat(rows)
|
||||||
|
if (!this.canManageFinanceRemark) this.clearFinanceAccess()
|
||||||
const loaded = this.list.length
|
const loaded = this.list.length
|
||||||
this.hasMore = loaded < total && rows.length > 0
|
this.hasMore = loaded < total && rows.length > 0
|
||||||
if (rows.length > 0) this.page += 1
|
if (rows.length > 0) this.page += 1
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (version !== this.listVersion) return
|
||||||
|
this.clearFinanceAccess()
|
||||||
if (isReset) this.list = []
|
if (isReset) this.list = []
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: (e && e.message) || '加载失败',
|
title: (e && e.message) || '加载失败',
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
if (version === this.listVersion) {
|
||||||
this.loadingMore = false
|
this.loading = false
|
||||||
|
this.loadingMore = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +521,251 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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-dialog__button::after,
|
||||||
|
.finance-dialog__close::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
padding-top: max(16px, env(safe-area-inset-top));
|
||||||
|
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background: rgba(0, 26, 15, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__panel {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 26, 15, 0.18);
|
||||||
|
color: #1f2a26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__heading {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__subtitle,
|
||||||
|
.finance-dialog__count,
|
||||||
|
.finance-dialog__hint {
|
||||||
|
color: #6b7b74;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #f5f8f7;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__close-icon {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__field-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__input,
|
||||||
|
.finance-dialog__loading {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #dcebe4;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f7faf9;
|
||||||
|
color: #1f2a26;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 21px;
|
||||||
|
text-align: left;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #6b7b74;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__placeholder {
|
||||||
|
color: #6b7b74;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__input.is-error {
|
||||||
|
border-color: #d14343;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__input.is-saving {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__hint {
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__hint.is-error {
|
||||||
|
color: #d14343;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 46px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__button--cancel,
|
||||||
|
.finance-dialog__button--cancel[disabled] {
|
||||||
|
flex: 0 0 100px;
|
||||||
|
width: 100px;
|
||||||
|
color: #6b7b74;
|
||||||
|
background: #f5f8f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__button--save,
|
||||||
|
.finance-dialog__button--save[disabled] {
|
||||||
|
flex: 1;
|
||||||
|
color: #fff;
|
||||||
|
background: #0a9f6d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__button--cancel[disabled],
|
||||||
|
.finance-dialog__close[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__button--save[disabled] {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finance-dialog__pressed {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding-bottom: 48rpx;
|
padding-bottom: 48rpx;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Frame">
|
||||||
|
<g id="Vector">
|
||||||
|
<path d="M4 4L12 12ZM12 4L4 12Z" fill="black"/>
|
||||||
|
<path d="M4 4L12 12M12 4L4 12" stroke-width="1.6" stroke-linecap="round" stroke="#6B7B74"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 354 B |
@@ -158,3 +158,19 @@ export function fetchReturnOaTask(taskId, comment) {
|
|||||||
authHeader()
|
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,18 @@
|
|||||||
|
# 设计
|
||||||
|
|
||||||
|
`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 并保留既有请求审计。
|
||||||
|
|
||||||
|
各端采用后端能力标记,不依赖角色名称或仅前端隐藏。打开弹窗时重新读取最新备注,保存后刷新列表;取消不写入,失败保留编辑内容。文字按纯文本渲染。单字段最后成功保存值生效。
|
||||||
|
|
||||||
|
权限菜单放在已办中心下,Web/移动对应菜单可用同一权限码,不自动授予角色。
|
||||||
|
|
||||||
|
## 移动弹窗视觉改版
|
||||||
|
|
||||||
|
初版通用弹窗的按钮贴边与留白不符合用户预期。本次以用户已明确确认的 [Figma 四状态原型](https://www.figma.com/design/jc7D8Fe7lKNTVj4jM3AoGW?node-id=14-56)为视觉依据:375px/414px 屏幕各留 24px 外边距,弹窗 22px 圆角、20px 内边距,独立的 46px 高圆角按钮,120px 高浅色输入区,明确加载、失败与保存中状态。Figma 的 Noto Sans SC 为工具字体回退,实现保留移动端系统字体栈。
|
||||||
|
|
||||||
|
仅更新 mobile-h5 财务备注弹窗,不修改全局确认弹窗或 Web 视觉。关闭/取消不写入,遮罩不直接关闭,保存中禁止重复提交与关闭,失败保留输入,键盘缩小可用视口后仍可操作。权限、本人已办范围和接口契约保持不变。今后新增界面和弹窗按工程 UI 原型确认规则执行,不再因简单或已有参考自动跳过 Figma。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
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,23 @@
|
|||||||
|
# 实施与验证计划
|
||||||
|
|
||||||
|
关联 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、不部署、不启停服务。
|
||||||
|
|
||||||
|
## 弹窗视觉改版(工单 #7 重新打开)
|
||||||
|
|
||||||
|
用户已确认 [Figma 原型及四种状态](https://www.figma.com/design/jc7D8Fe7lKNTVj4jM3AoGW?node-id=14-56),同意按此实现。范围仅 mobile-h5 的财务备注弹窗(小程序/H5 共用);默认 Agent 验证,沿用用户确认的 H5 验收方式,小程序做编译检查。Web 与后端业务契约不变。
|
||||||
|
|
||||||
|
- [x] 读取 Figma 设计上下文,实现紧凑居中布局、标题与关闭、浅色输入区、字数及辅助提示、独立圆角按钮;处理加载、失败保留输入、保存中禁用和键盘可见性。
|
||||||
|
- [x] 前端行为测试 27/27 通过,HBuilderX H5/微信小程序编译通过。实际 H5 产物在 375px/414px 下弹窗为 327/366×352px,与原型一致;380px 短视口下按钮可见,长文本可滚动。模拟接口验证保存、取消、清空、失败保留、保存中锁定、权限隐藏和纯文本,无页面异常;未进行微信模拟器/真机交互。
|
||||||
|
- [x] 独立规格与质量审查通过,无阻塞项;交付材料已就绪。按持续授权推送 PR,核对目标分支、合入 V2.0 并同步主目录,最终合并提交及关闭 #7 的结果记录在 Gitea 工单。
|
||||||
|
|
||||||
|
个人开发规则另行沉淀至 personal-dev 的界面原型引用:所有新页面、新弹窗及视觉改版先生成 Figma 原型并经用户明确确认;只有用户明确本任务不用 Figma 时才豁免。
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 界面与弹窗先经 Figma 原型确认
|
||||||
|
|
||||||
|
新页面、新弹窗及用户要求的视觉改版 SHALL 先在 Figma 创建可编辑原型,覆盖主要尺寸和交互状态,关联具体文件/节点,并经用户明确确认后再修改业务界面代码。简单界面、单字段弹窗、已有组件或赶时间不构成豁免。只有用户明确本任务不用生成 Figma 原型时才记录范围并跳过;用户沉默、工具生成成功或“代码完成再看”不能代替确认。原型确认不替代功能验证。
|
||||||
|
|
||||||
|
#### Scenario: 复用已有组件新增弹窗
|
||||||
|
|
||||||
|
- **WHEN** 新弹窗可以复用工程现有组件
|
||||||
|
- **THEN** 在原型中复用对应设计并向用户确认,确认后实现;已有明确确认的同版原型直接沿用,不重复询问。
|
||||||
|
|
||||||
|
#### Scenario: 原型服务不可用或尚未确认
|
||||||
|
|
||||||
|
- **WHEN** Figma 不可用或用户尚未确认原型,且没有明确豁免
|
||||||
|
- **THEN** 保持界面实现待处理,说明缺口并继续独立工作;不先编码再补原型。
|
||||||
|
|
||||||
|
#### Scenario: 明确豁免或非视觉变更
|
||||||
|
|
||||||
|
- **WHEN** 用户明确本任务不用 Figma,或任务仅为无布局变化的文案纠错、后台逻辑、恢复既有已确认设计
|
||||||
|
- **THEN** 按原任务规则实施与验证;原型豁免仅作用于用户指定范围,不推广到后续新界面。
|
||||||
|
|
||||||
### Requirement: Web 管理端以 Fresh Mint 规范为唯一视觉权威
|
### Requirement: Web 管理端以 Fresh Mint 规范为唯一视觉权威
|
||||||
|
|
||||||
`web-admin` 工程的界面实现 SHALL 以 `docs/web-admin-ui-spec-fresh-mint.md` 及其中引用的 `docs/fresh-mint-green.html` 为权威来源;主色链 SHALL 使用文档定义的 Mint 绿系(如 `--primary` `#10b981` 等),不得擅自替换为小程序品牌色。
|
`web-admin` 工程的界面实现 SHALL 以 `docs/web-admin-ui-spec-fresh-mint.md` 及其中引用的 `docs/fresh-mint-green.html` 为权威来源;主色链 SHALL 使用文档定义的 Mint 绿系(如 `--primary` `#10b981` 等),不得擅自替换为小程序品牌色。
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
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, overrides = {}) {
|
||||||
|
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() {}, getSystemInfoSync: () => ({ windowHeight: 800 }) },
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mobile: save errors remain inline until retry or cancellation', async () => {
|
||||||
|
const h = harness('mobile')
|
||||||
|
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.financeRemarkError, '保存失败,请稍后重试,输入已保留。')
|
||||||
|
assert.equal(h.vm.financeRemarkText, '保留输入')
|
||||||
|
let resolve
|
||||||
|
h.api.put = () =>
|
||||||
|
new Promise((r) => {
|
||||||
|
resolve = r
|
||||||
|
})
|
||||||
|
const retry = h.vm.saveFinanceRemark()
|
||||||
|
assert.equal(h.vm.financeRemarkError, '')
|
||||||
|
resolve({})
|
||||||
|
await retry
|
||||||
|
assert.equal(h.vm.financeRemarkVisible, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mobile: saving blocks user cancellation but page cleanup invalidates the response', async () => {
|
||||||
|
const h = harness('mobile')
|
||||||
|
await h.load()
|
||||||
|
await h.vm.openFinanceRemark(h.rows()[0])
|
||||||
|
let resolve
|
||||||
|
h.api.put = () =>
|
||||||
|
new Promise((r) => {
|
||||||
|
resolve = r
|
||||||
|
})
|
||||||
|
const saving = h.vm.saveFinanceRemark()
|
||||||
|
h.vm.cancelFinanceRemark()
|
||||||
|
assert.equal(h.vm.financeRemarkVisible, true)
|
||||||
|
assert.equal(h.vm.financeRemarkSaving, true)
|
||||||
|
h.hooks.hide()
|
||||||
|
assert.equal(h.vm.financeRemarkVisible, false)
|
||||||
|
assert.equal(h.vm.financeRemarkText, '')
|
||||||
|
const callCount = h.calls.length
|
||||||
|
resolve({})
|
||||||
|
await saving
|
||||||
|
assert.equal(h.calls.length, callCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mobile: keyboard height keeps the dialog inside available viewport and resets on close', async () => {
|
||||||
|
const h = harness('mobile')
|
||||||
|
await h.load()
|
||||||
|
await h.vm.openFinanceRemark(h.rows()[0])
|
||||||
|
assert.equal(h.vm.financeRemarkViewportStyle?.height, '800px')
|
||||||
|
h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 320 } })
|
||||||
|
assert.equal(h.vm.financeRemarkViewportStyle.height, '480px')
|
||||||
|
h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 0 } })
|
||||||
|
assert.equal(h.vm.financeRemarkViewportStyle.height, '800px')
|
||||||
|
h.vm.cancelFinanceRemark()
|
||||||
|
assert.deepEqual(h.vm.financeRemarkViewportStyle, {})
|
||||||
|
h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 320 } })
|
||||||
|
assert.deepEqual(h.vm.financeRemarkViewportStyle, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mobile: H5 viewport resize and scroll track keyboard geometry and listeners are released', async () => {
|
||||||
|
const listeners = new Map()
|
||||||
|
const viewport = {
|
||||||
|
height: 800,
|
||||||
|
offsetTop: 0,
|
||||||
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||||
|
removeEventListener: (name, fn) => {
|
||||||
|
assert.equal(listeners.get(name), fn)
|
||||||
|
listeners.delete(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const h = harness('mobile', { window: { visualViewport: viewport } })
|
||||||
|
await h.load()
|
||||||
|
await h.vm.openFinanceRemark(h.rows()[0])
|
||||||
|
assert.equal(listeners.size, 2)
|
||||||
|
viewport.height = 420
|
||||||
|
viewport.offsetTop = 20
|
||||||
|
listeners.get('resize')()
|
||||||
|
assert.deepEqual(h.vm.financeRemarkViewportStyle, { height: '420px', top: '20px' })
|
||||||
|
// A native keyboard notification must not subtract the same keyboard twice on H5.
|
||||||
|
h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 380 } })
|
||||||
|
assert.equal(h.vm.financeRemarkViewportStyle.height, '420px')
|
||||||
|
h.hooks.hide()
|
||||||
|
assert.equal(listeners.size, 0)
|
||||||
|
assert.deepEqual(h.vm.financeRemarkViewportStyle, {})
|
||||||
|
})
|
||||||
@@ -408,7 +408,7 @@ export function fetchOaTasks(
|
|||||||
const applyTimeFrom = (params?.applyTimeFrom || '').trim()
|
const applyTimeFrom = (params?.applyTimeFrom || '').trim()
|
||||||
const applyTimeTo = (params?.applyTimeTo || '').trim()
|
const applyTimeTo = (params?.applyTimeTo || '').trim()
|
||||||
const instanceStatus = (params?.instanceStatus || '').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}`,
|
url: `${BASE}/tasks/${kind}`,
|
||||||
params: {
|
params: {
|
||||||
page: params?.page ?? 1,
|
page: params?.page ?? 1,
|
||||||
@@ -664,3 +664,18 @@ export function fetchReplaceOaBankReceipts(
|
|||||||
data: { items }
|
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,18 +6,10 @@
|
|||||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||||
<div class="filter-grid">
|
<div class="filter-grid">
|
||||||
<el-form-item label="流程名称">
|
<el-form-item label="流程名称">
|
||||||
<el-input
|
<el-input v-model="searchForm.appName" placeholder="请输入流程名称" clearable />
|
||||||
v-model="searchForm.appName"
|
|
||||||
placeholder="请输入流程名称"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="申请人">
|
<el-form-item label="申请人">
|
||||||
<el-input
|
<el-input v-model="searchForm.initiatorName" placeholder="请输入申请人" clearable />
|
||||||
v-model="searchForm.initiatorName"
|
|
||||||
placeholder="请输入申请人"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="申请时间">
|
<el-form-item label="申请时间">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
@@ -76,28 +68,13 @@
|
|||||||
>
|
>
|
||||||
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
|
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column prop="initiatorUserName" label="申请人" min-width="120" align="center">
|
||||||
prop="initiatorUserName"
|
|
||||||
label="申请人"
|
|
||||||
min-width="120"
|
|
||||||
align="center"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">{{ row.initiatorUserName || '-' }}</template>
|
<template #default="{ row }">{{ row.initiatorUserName || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column prop="applyTime" label="申请时间" min-width="170" align="center">
|
||||||
prop="applyTime"
|
|
||||||
label="申请时间"
|
|
||||||
min-width="170"
|
|
||||||
align="center"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">{{ row.applyTime || '-' }}</template>
|
<template #default="{ row }">{{ row.applyTime || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column prop="currentNodeName" label="当前节点" min-width="140" align="center">
|
||||||
prop="currentNodeName"
|
|
||||||
label="当前节点"
|
|
||||||
min-width="140"
|
|
||||||
align="center"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">{{ row.currentNodeName || '-' }}</template>
|
<template #default="{ row }">{{ row.currentNodeName || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
@@ -116,6 +93,15 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="操作" min-width="400" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button class="btn-op-item primary" @click="openDetail(row)">
|
<el-button class="btn-op-item primary" @click="openDetail(row)">
|
||||||
@@ -130,6 +116,14 @@
|
|||||||
<el-icon><Printer /></el-icon>
|
<el-icon><Printer /></el-icon>
|
||||||
打印
|
打印
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canManageFinanceRemark"
|
||||||
|
class="btn-op-item"
|
||||||
|
@click="openFinanceRemark(row)"
|
||||||
|
>
|
||||||
|
<el-icon><EditPen /></el-icon>
|
||||||
|
财务备注
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canUploadBankReceipt"
|
v-if="canUploadBankReceipt"
|
||||||
class="btn-op-item"
|
class="btn-op-item"
|
||||||
@@ -157,6 +151,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<OaBankReceiptDialog
|
||||||
v-model="bankReceiptVisible"
|
v-model="bankReceiptVisible"
|
||||||
:instance-id="bankReceiptInstanceId"
|
:instance-id="bankReceiptInstanceId"
|
||||||
@@ -166,11 +203,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
|
import {
|
||||||
import { useRouter } from 'vue-router'
|
Check,
|
||||||
import { ElMessage } from 'element-plus'
|
Close,
|
||||||
import { Printer, RefreshLeft, Search, Upload, View } from '@element-plus/icons-vue'
|
EditPen,
|
||||||
import { fetchOaInstanceDetail, fetchOaTasks } from '@/api/oaApproval'
|
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 OaBankReceiptDialog from '@/components/oa-approval/OaBankReceiptDialog.vue'
|
||||||
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
|
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
@@ -187,6 +235,82 @@
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const printingId = ref('')
|
const printingId = ref('')
|
||||||
const rows = ref<any[]>([])
|
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 page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -210,9 +334,7 @@
|
|||||||
|
|
||||||
function currentPrinterName() {
|
function currentPrinterName() {
|
||||||
const info: any = userStore.info || userStore.getUserInfo || {}
|
const info: any = userStore.info || userStore.getUserInfo || {}
|
||||||
return String(
|
return String(info.realName || info.nickName || info.userName || info.username || '').trim()
|
||||||
info.realName || info.nickName || info.userName || info.username || ''
|
|
||||||
).trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusType(s: string) {
|
function statusType(s: string) {
|
||||||
@@ -306,16 +428,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
if (!pageActive) return
|
||||||
|
const version = ++listVersion
|
||||||
|
clearFinanceAccess()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetchOaTasks('done', buildFilterParams())
|
const res = await fetchOaTasks('done', buildFilterParams())
|
||||||
|
if (version !== listVersion) return
|
||||||
|
canManageFinanceRemark.value = res?.canManageFinanceRemark === true
|
||||||
rows.value = res?.list || []
|
rows.value = res?.list || []
|
||||||
|
if (!canManageFinanceRemark.value) clearFinanceAccess()
|
||||||
total.value = res?.total || 0
|
total.value = res?.total || 0
|
||||||
} catch {
|
} catch {
|
||||||
|
if (version !== listVersion) return
|
||||||
|
clearFinanceAccess()
|
||||||
rows.value = []
|
rows.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (version === listVersion) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +458,14 @@
|
|||||||
{ debounce: 300, deep: true }
|
{ debounce: 300, deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
pageActive = false
|
||||||
|
listVersion++
|
||||||
|
clearFinanceAccess()
|
||||||
|
})
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
onActivated(load)
|
onActivated(() => {
|
||||||
|
pageActive = true
|
||||||
|
void load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
Reference in new issue
Block a user