Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7115205d | ||
|
|
a8ee0fcebd | ||
|
|
5b1367c775 | ||
|
|
802985dd99 | ||
|
|
0e64b413ce | ||
|
|
86df360f35 |
No files matched your search
@@ -21,6 +21,7 @@
|
||||
- 提交使用 Conventional Commits,完整说明“改了什么、为什么”。
|
||||
- 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。
|
||||
- 修改端不明确时,先查当前任务和工单的已确认范围;仍不明确则询问修改 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 并关单。
|
||||
|
||||
@@ -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,89 @@
|
||||
<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="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>
|
||||
</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 +193,17 @@
|
||||
components: { VanEmpty },
|
||||
data() {
|
||||
return {
|
||||
canManageFinanceRemark: false,
|
||||
financeRemarkVisible: false,
|
||||
financeRemarkLoading: false,
|
||||
financeRemarkSaving: false,
|
||||
financeRemarkText: '',
|
||||
financeRemarkError: '',
|
||||
financeRemarkViewportStyle: {},
|
||||
financeRemarkWindowHeight: 0,
|
||||
financeRemarkInstanceId: '',
|
||||
financeRemarkVersion: 0,
|
||||
listVersion: 0,
|
||||
keyword: '',
|
||||
searchFocused: false,
|
||||
list: [],
|
||||
@@ -134,6 +217,7 @@
|
||||
},
|
||||
onShow() {
|
||||
if (!getToken()) {
|
||||
this.clearFinanceAccess()
|
||||
forceRelogin('请先登录')
|
||||
return
|
||||
}
|
||||
@@ -150,13 +234,137 @@
|
||||
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() {
|
||||
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) {
|
||||
return formatCornerIndex(Number(idx) + 1)
|
||||
},
|
||||
@@ -188,10 +396,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 +405,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 +470,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 +490,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 +521,251 @@
|
||||
</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-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 {
|
||||
min-height: 100vh;
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
### Requirement: 界面与弹窗先经 Figma 原型确认
|
||||
|
||||
新页面、新弹窗及用户要求的视觉改版 SHALL 先在 Figma 创建可编辑原型,覆盖主要尺寸和交互状态,关联具体文件/节点,并经用户明确确认后再修改业务界面代码。简单界面、单字段弹窗、已有组件或赶时间不构成豁免。只有用户明确本任务不用生成 Figma 原型时才记录范围并跳过;用户沉默、工具生成成功或“代码完成再看”不能代替确认。原型确认不替代功能验证。
|
||||
|
||||
#### Scenario: 复用已有组件新增弹窗
|
||||
|
||||
- **WHEN** 新弹窗可以复用工程现有组件
|
||||
- **THEN** 在原型中复用对应设计并向用户确认,确认后实现;已有明确确认的同版原型直接沿用,不重复询问。
|
||||
|
||||
#### Scenario: 原型服务不可用或尚未确认
|
||||
|
||||
- **WHEN** Figma 不可用或用户尚未确认原型,且没有明确豁免
|
||||
- **THEN** 保持界面实现待处理,说明缺口并继续独立工作;不先编码再补原型。
|
||||
|
||||
#### Scenario: 明确豁免或非视觉变更
|
||||
|
||||
- **WHEN** 用户明确本任务不用 Figma,或任务仅为无布局变化的文案纠错、后台逻辑、恢复既有已确认设计
|
||||
- **THEN** 按原任务规则实施与验证;原型豁免仅作用于用户指定范围,不推广到后续新界面。
|
||||
|
||||
### Requirement: Web 管理端以 Fresh Mint 规范为唯一视觉权威
|
||||
|
||||
`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 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