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.
This commit is contained in:
1 parent
86df360f35
commit
0e64b413ce
15 files changed
+1188
-78
No files matched your search
@@ -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))
|
||||
|
||||
|
||||
@@ -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="租户编号")
|
||||
|
||||
@@ -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/) |
|
||||
| 部署 | [部署导航](../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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 浏览器中验证展示、读取最新值、取消、保存、纯文本、失败保留、清空和无权限隐藏;模拟接口,无业务数据写入,无页面异常。
|
||||
- [ ] 微信平台验证:开发者工具要求重新登录,等待用户完成;浏览器验证不代替微信平台验证。
|
||||
- [ ] 迁移安排:业务配置指向远程数据库,尚未执行迁移;等待用户确认测试库授权或由用户执行。更新应用前必须完成迁移,再由管理员授予角色权限。
|
||||
- [ ] 交付:推送任务分支和 PR;必要验证及迁移条件满足后核对最新 V2.0,合并推送并同步本地主分支,回写验收结果和合并提交后关闭 #7。当前保持工单开放,不打 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)
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user