Author SHA1 Message Date
cykj 0f7115205d Merge pull request 'fix(mobile): 按确认原型美化财务备注弹窗' (#9) from codex/oa-finance-remark-ui into V2.0 2026-10-30 14:18:30 +00:00
zlwu a8ee0fcebd fix(mobile): polish finance remark dialog from approved Figma design
Use compact layout, independent buttons and keyboard-aware viewport; retain drafts on failure and block closing during save. Require Figma approval for future screens and dialogs unless explicitly waived.
2026-10-30 22:17:49 +08:00
cykj 5b1367c775 feat(oa): add permission-controlled shared finance remarks (#8)
Refs #7. H5 acceptance confirmed; approved test database migration verified. No release tag or deployment.
2026-10-30 13:44:33 +00:00
zlwu 802985dd99 docs(oa): record H5 acceptance and approved test database migration 2026-10-30 21:43:41 +08:00
zlwu 0e64b413ce feat(oa): add permission-controlled shared finance remarks to done center
Share one finance remark per approval instance across Web and mobile. Require the explicit permission and the current tenant user's own done task for every read and write; keep general instance responses free of financial remarks.

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

No files matched your search

+6 -2
View File
@@ -20,7 +20,11 @@
- **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。 - **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。
- 提交使用 Conventional Commits,完整说明“改了什么、为什么”。 - 提交使用 Conventional Commits,完整说明“改了什么、为什么”。
- 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。 - 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。
- 修复后回写工单,附提交、PR、验证结果及未完成项,标注“已修复”;待验收、未合并或未发布须分别说明,不能以“已修复”代替验收和关闭条件。 - 修改端不明确时,先查当前任务和工单的已确认范围;仍不明确则询问修改 Web、小程序、移动 OA、后端中的哪些端,还是所有相关端。确认前仅只读定位、回写工单待确认范围,不修改业务代码;截图不能代替确认。已有明确范围直接沿用,答复“所有端”须记录具体应用清单;共享后端/API 影响范围外的端时,说明影响并确认扩展后再实施。
- 新界面、新弹窗及视觉改版,按 [UI 原型确认规则](openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md#requirement-界面与弹窗先经-figma-原型确认)先完成 Figma 原型并取得用户确认后开发;仅用户明确不用生成 Figma 原型时豁免。
- 默认由 Agent 测试:自动进入任务分支/worktree,核对实际运行版本,完成测试与审查后合入 `V2.0`、提交推送、同步本地主分支并关闭 Gitea 工单。用户已授予这条常规收尾链路持续授权,不逐次询问合并;打 tag、部署与服务启停仍分别受原有授权限制。
- 用户明确“我来测试”时,该任务由用户测试:Agent 不运行测试或构建,仍完成代码审查和 Git 核对;交付时提醒切换到具体分支,提供 worktree 路径、提交、前后端配套入口和验证要点。用户确认通过后直接合并推送、关闭工单;未确认或失败时不合并、不关闭。
- 合并前核对验收提交与最新目标;目标更新或冲突处理改变代码时,按原测试责任补验,用户模式仍交用户验证。合并后代码树与验收版本一致不重复测试;确认远端同步成功后回写验收结果、合并提交和 PR 并关单。
- 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。 - 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。
- 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。 - 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。
@@ -45,7 +49,7 @@
## 验证与交付 ## 验证与交付
- 执行与改动范围相符的检查,报告实际结果和未执行项;文档变更检查链接与引用,业务变更检查受影响测试及构建。 - 按任务测试责任验证,报告实际结果和未执行项:Agent 模式按改动范围检查文档链接或业务测试与构建;用户模式记录用户验收结论,不由 Agent 运行测试和构建。
- CRUD 字段一致性按 [开发指南](docs/dev-field-consistency.md) 检查;根目录命令是 `python backend/scripts/check_field_consistency.py`。该工具仅覆盖注册实体,已知失效路径见该指南。 - CRUD 字段一致性按 [开发指南](docs/dev-field-consistency.md) 检查;根目录命令是 `python backend/scripts/check_field_consistency.py`。该工具仅覆盖注册实体,已知失效路径见该指南。
- 使用源码、引用检索和测试核对影响范围。已移除的 graphify 不再是开发依赖或交付步骤,不安装、运行或恢复其自动 hook。 - 使用源码、引用检索和测试核对影响范围。已移除的 graphify 不再是开发依赖或交付步骤,不安装、运行或恢复其自动 hook。
+49 -3
View File
@@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,6 +15,7 @@ from app.crud import oa_instance_draft as oid_crud
from app.crud import oa_receive_account as ora_crud from app.crud import oa_receive_account as ora_crud
from app.models.models import SysUser from app.models.models import SysUser
from app.services.oa_receive_account import normalize_account_fields from app.services.oa_receive_account import normalize_account_fields
from app.services import oa_finance_remark as finance_remark_svc
router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"]) router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"])
@@ -1054,6 +1055,44 @@ async def list_oa_instance_form_attachments(
return error_response(400, str(e)) return error_response(400, str(e))
class OaFinanceRemarkBody(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="forbid")
finance_remark: str = Field(..., alias="financeRemark", max_length=512, strict=True)
@router.get("/instances/{instance_id}/finance-remark")
async def get_oa_finance_remark(
instance_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
access = finance_remark_svc.resolve_access(db, user)
return success_response(finance_remark_svc.get_finance_remark(db, access, instance_id))
except finance_remark_svc.OaFinanceRemarkError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
@router.put("/instances/{instance_id}/finance-remark")
async def update_oa_finance_remark(
instance_id: str,
body: OaFinanceRemarkBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
access = finance_remark_svc.resolve_access(db, user)
result = finance_remark_svc.update_finance_remark(db, access, instance_id, body.finance_remark)
db.commit()
return success_response(result, message="财务备注已保存")
except finance_remark_svc.OaFinanceRemarkError as exc:
db.rollback()
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
except Exception:
db.rollback()
raise
@router.get("/instances/{instance_id}/bank-receipts") @router.get("/instances/{instance_id}/bank-receipts")
async def list_oa_bank_receipts( async def list_oa_bank_receipts(
instance_id: str, instance_id: str,
@@ -1365,7 +1404,8 @@ async def list_oa_tasks(
from app.services.oa_engine import OaEngineError, list_tasks from app.services.oa_engine import OaEngineError, list_tasks
try: try:
tenant_no = _get_tenant_no(db, user) access = finance_remark_svc.resolve_access(db, user) if kind == "done" else None
tenant_no = access.tenant_no if access else _get_tenant_no(db, user)
data = list_tasks( data = list_tasks(
db, db,
tenant_no=tenant_no, tenant_no=tenant_no,
@@ -1381,7 +1421,13 @@ async def list_oa_tasks(
page=page, page=page,
page_size=pageSize, page_size=pageSize,
) )
return success_response({"list": data.get("list") or [], "total": int(data.get("total") or 0)}) result = {"list": data.get("list") or [], "total": int(data.get("total") or 0)}
if access is not None:
finance_remark_svc.add_list_remarks(db, access, result["list"])
result["canManageFinanceRemark"] = access.can_manage
return success_response(result)
except finance_remark_svc.OaFinanceRemarkError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
except OaEngineError as e: except OaEngineError as e:
return error_response(400, str(e)) return error_response(400, str(e))
+1
View File
@@ -920,6 +920,7 @@ class OaInstance(Base):
default="running", default="running",
comment="running/returned/completed/rejected/cancelled", comment="running/returned/completed/rejected/cancelled",
) )
finance_remark = Column(String(512), nullable=True, comment="已办财务备注(专用权限控制)")
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID") source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID")
current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次") current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
+100
View File
@@ -0,0 +1,100 @@
"""已办中心实例级财务备注;权限与本人已办范围同时满足才可访问。"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List
from sqlalchemy.orm import Session
from app.models.models import OaInstance, OaTask, SysUser
from app.services.web_auth_roles import iter_user_menu_perm_codes
PERMISSION = "oa:done:finance-remark"
DONE_STATUSES = ("approved", "rejected", "returned", "transferred")
class OaFinanceRemarkError(ValueError):
def __init__(self, status_code: int, message: str):
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class FinanceRemarkAccess:
user_id: str
tenant_no: str
can_manage: bool
def resolve_access(db: Session, user: dict) -> FinanceRemarkAccess:
# 不使用缺省租户、客户端租户或角色名称作为敏感字段的授权依据。
uid = str(user.get("user_id") or "").strip()
identity = db.query(SysUser.id, SysUser.tenant_no).filter(
SysUser.id == uid, SysUser.del_flag == 0, SysUser.status == "1"
).first()
if not identity or not str(identity.tenant_no or "").strip():
raise OaFinanceRemarkError(403, "用户不可用")
tenant = str(identity.tenant_no).strip()
granted = iter_user_menu_perm_codes(db, uid, tenant_no=tenant)
return FinanceRemarkAccess(uid, tenant, PERMISSION in granted)
def _accessible_instances(db: Session, access: FinanceRemarkAccess):
own_done = db.query(OaTask.id).filter(
OaTask.instance_id == OaInstance.id,
OaTask.tenant_no == access.tenant_no,
OaTask.assignee_user_id == access.user_id,
OaTask.task_type == "approval",
OaTask.status.in_(DONE_STATUSES),
OaTask.del_flag == 0,
).exists()
return db.query(OaInstance).filter(
OaInstance.tenant_no == access.tenant_no,
OaInstance.del_flag == 0,
own_done,
)
def _require_instance(db: Session, access: FinanceRemarkAccess, instance_id: str) -> OaInstance:
if not access.can_manage:
raise OaFinanceRemarkError(403, "无权查看或修改财务备注")
instance = _accessible_instances(db, access).filter(OaInstance.id == instance_id).first()
if instance is None:
raise OaFinanceRemarkError(404, "审批单不在本人已办范围内")
return instance
def _remark_data(instance: OaInstance) -> Dict[str, str]:
return {"instanceId": instance.id, "financeRemark": instance.finance_remark or ""}
def get_finance_remark(db: Session, access: FinanceRemarkAccess, instance_id: str) -> Dict[str, str]:
return _remark_data(_require_instance(db, access, instance_id))
def update_finance_remark(
db: Session, access: FinanceRemarkAccess, instance_id: str, finance_remark: str
) -> Dict[str, str]:
instance = _require_instance(db, access, instance_id)
if not isinstance(finance_remark, str) or len(finance_remark) > 512:
raise OaFinanceRemarkError(422, "财务备注最多 512 字")
instance.finance_remark = finance_remark
instance.update_by = access.user_id
instance.update_time = datetime.now()
db.flush()
return _remark_data(instance)
def add_list_remarks(db: Session, access: FinanceRemarkAccess, rows: List[Dict[str, Any]]) -> None:
# 即使调用方未来调整序列化,也先移除敏感字段,再按权限补充。
for row in rows:
row.pop("financeRemark", None)
if not access.can_manage:
return
ids = {row.get("instanceId") for row in rows if row.get("instanceId")}
if not ids:
return
instances = _accessible_instances(db, access).filter(OaInstance.id.in_(ids)).all()
remarks = {inst.id: inst.finance_remark or "" for inst in instances}
for row in rows:
if row.get("instanceId") in remarks:
row["financeRemark"] = remarks[row["instanceId"]]
+195
View File
@@ -0,0 +1,195 @@
"""OA 财务备注:真实隔离数据库和 HTTP 路由的读写/权限契约。"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.oa_approval import router
from app.core.auth import get_current_user
from app.core.database import Base, get_db
from app.models.models import (
OaApp, OaAppVersion, OaInstance, OaTask,
SysUser, SysRole, SysUserRole, SysMenu, SysRoleMenu,
SysPost, SysPostRole, SysUserPost,
)
PERM = 'oa:done:finance-remark'
PREFIX = '/web/admin/oa-approval'
URL = PREFIX + '/instances/i1/finance-remark'
@pytest.fixture
def case():
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
tables = (OaApp, OaAppVersion, OaInstance, OaTask, SysUser, SysRole,
SysUserRole, SysMenu, SysRoleMenu, SysPost, SysPostRole, SysUserPost)
Base.metadata.create_all(engine, tables=[m.__table__ for m in tables])
db = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
for uid, tenant in [('u1', 'a'), ('u2', 'a'), ('outsider', 'a'), ('other', 'b')]:
db.add(SysUser(id=uid, user_no=uid, nick_name=uid, password='unused', tenant_no=tenant, status='1'))
db.add_all([
OaApp(id='app', app_name='报销', tenant_no='a'),
OaAppVersion(id='v1', app_id='app', version_no=1, tenant_no='a'),
SysRole(id='finance', role_name='财务', tenant_no='a', status='1'),
SysMenu(id='menu', menu_name='财务备注', parent_id='done', menu_type='F', perms=PERM, tenant_no='a', status='1'),
SysRoleMenu(id='rm', role_id='finance', menu_id='menu', tenant_no='a'),
])
for uid in ['u1', 'u2', 'outsider']:
db.add(SysUserRole(id='ur-'+uid, user_id=uid, role_id='finance', tenant_no='a'))
for iid, tenant in [('i1', 'a'), ('i2', 'a'), ('foreign', 'b')]:
inst = OaInstance(id=iid, app_id='app', app_version_id='v1', title=iid,
initiator_user_id='u1', tenant_no=tenant, status='completed', form_data_json='{}')
inst.finance_remark = '原备注'
db.add(inst)
for tid, uid, iid, tenant, status in [
('t1', 'u1', 'i1', 'a', 'approved'), ('t2', 'u2', 'i1', 'a', 'rejected'),
('pending', 'u1', 'i2', 'a', 'pending'), ('foreign-task', 'u1', 'foreign', 'b', 'approved'),
]:
db.add(OaTask(id=tid, instance_id=iid, assignee_user_id=uid, tenant_no=tenant,
task_type='approval', status=status))
db.commit()
user = {'user_id': 'u1', 'tenant_no': 'a', 'role': 'admin'}
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[get_db] = lambda: db
with TestClient(app) as client:
yield db, client, user
db.close()
engine.dispose()
def test_shared_save_reload_and_clear(case):
db, client, user = case
result = client.get(URL)
assert result.status_code == 200
assert result.json()['data'] == {'instanceId': 'i1', 'financeRemark': '原备注'}
for text in ['财务核对完成\n<标签按原文显示>', '汉' * 512, '']:
user['user_id'] = 'u1'
response = client.put(URL, json={'financeRemark': text})
assert response.status_code == 200
assert response.json()['code'] == 200
db.expire_all()
assert db.get(OaInstance, 'i1').finance_remark == text
assert db.get(OaInstance, 'i1').update_by == 'u1'
user['user_id'] = 'u2'
assert client.get(URL).json()['data']['financeRemark'] == text
rows = client.get(PREFIX + '/tasks/done').json()['data']
assert rows['canManageFinanceRemark'] is True
assert rows['list'][0]['financeRemark'] == text
@pytest.mark.parametrize('role', ['admin', 'super_admin'])
def test_no_permission_hides_list_and_denies_both_routes(case, role):
db, client, user = case
db.get(SysRoleMenu, 'rm').del_flag = 1
db.commit()
user['role'] = role
data = client.get(PREFIX + '/tasks/done').json()['data']
assert data['canManageFinanceRemark'] is False
assert len(data['list']) == 1
assert 'financeRemark' not in data['list'][0]
assert client.get(URL).status_code == 403
assert client.put(URL, json={'financeRemark': '泄漏'}).status_code == 403
@pytest.mark.parametrize('target', ['i2', 'foreign', 'missing'])
def test_only_own_done_instances_are_accessible(case, target):
_, client, _ = case
url = PREFIX + '/instances/' + target + '/finance-remark'
assert client.get(url).status_code == 404
assert client.put(url, json={'financeRemark': '越权'}).status_code == 404
def test_authorized_outsider_cannot_read_or_write(case):
_, client, user = case
user['user_id'] = 'outsider'
assert client.get(URL).status_code == 404
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404
@pytest.mark.parametrize('model,key', [(OaInstance, 'i1'), (OaTask, 't1')])
def test_deleted_instance_or_done_task_denies_access(case, model, key):
db, client, _ = case
db.get(model, key).del_flag = 1
db.commit()
assert client.get(URL).status_code == 404
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404
data = client.get(PREFIX + '/tasks/done').json()['data']
assert all('financeRemark' not in row for row in data['list'])
@pytest.mark.parametrize('status', ['approved', 'rejected', 'returned', 'transferred'])
def test_all_existing_done_statuses_can_manage(case, status):
db, client, _ = case
db.get(OaTask, 't1').status = status
db.commit()
assert client.get(URL).status_code == 200
@pytest.mark.parametrize('status', ['pending', 'done', 'cancelled'])
def test_non_done_statuses_cannot_manage(case, status):
db, client, _ = case
db.get(OaTask, 't1').status = status
db.commit()
assert client.get(URL).status_code == 404
def test_forged_tenant_uses_real_user_tenant(case):
_, client, user = case
user['tenant_no'] = 'b'
assert client.get(URL).json()['data']['financeRemark'] == '原备注'
data = client.get(PREFIX + '/tasks/done').json()['data']
assert [r['instanceId'] for r in data['list']] == ['i1']
assert client.get(PREFIX + '/instances/foreign/finance-remark').status_code == 404
@pytest.mark.parametrize('model,key,field,value', [
(SysUser, 'u1', 'status', '0'), (SysUser, 'u1', 'del_flag', 1),
(SysRole, 'finance', 'status', '0'), (SysRole, 'finance', 'tenant_no', 'b'),
(SysMenu, 'menu', 'status', '0'), (SysMenu, 'menu', 'tenant_no', 'b'),
(SysUserRole, 'ur-u1', 'tenant_no', 'b'),
])
def test_inactive_or_cross_tenant_grants_deny_access(case, model, key, field, value):
db, client, _ = case
setattr(db.get(model, key), field, value)
db.commit()
assert client.get(URL).status_code == 403
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 403
def test_post_role_permission_union(case):
db, client, _ = case
db.get(SysUserRole, 'ur-u1').del_flag = 1
db.add_all([
SysPost(id='post', post_name='财务岗', post_code='finance', tenant_no='a', status='1'),
SysUserPost(id='up', user_id='u1', post_id='post', tenant_no='a'),
SysPostRole(id='pr', post_id='post', role_id='finance', tenant_no='a'),
])
db.commit()
assert client.get(URL).status_code == 200
db.get(SysPost, 'post').status = '0'
db.commit()
assert client.get(URL).status_code == 403
@pytest.mark.parametrize('body', [
{}, {'financeRemark': None}, {'financeRemark': 123}, {'financeRemark': 'a' * 513},
{'financeRemark': 'note', 'tenantNo': 'b'}, {'financeRemark': 'note', 'status': 'running'},
])
def test_invalid_body_rejected_without_writes(case, body):
db, client, _ = case
assert client.put(URL, json=body).status_code == 422
db.expire_all()
assert db.get(OaInstance, 'i1').finance_remark == '原备注'
def test_generic_instance_serialization_does_not_expose_finance_remark(case):
db, client, _ = case
from app.services.oa_engine import _instance_to_dict
assert 'financeRemark' not in _instance_to_dict(db.get(OaInstance, 'i1'))
for kind in ['mine', 'pending']:
data = client.get(PREFIX + '/tasks/' + kind).json()['data']
assert all('financeRemark' not in row for row in data['list'])
+2
View File
@@ -13,6 +13,8 @@
| 数据库 | [SQL 初始化与迁移](sql/) | | 数据库 | [SQL 初始化与迁移](sql/) |
| 部署 | [部署导航](../deploy/README.md) | | 部署 | [部署导航](../deploy/README.md) |
OA 已办财务备注上线前先执行 [2026-10-30 迁移](sql/migrations/2026-10-30-oa-done-finance-remark.sql),再更新应用;迁移不自动赋权。通过角色菜单配置授予已办中心下“财务备注(查看和修改)”权限(`oa:done:finance-remark`),同一权限作用于 Web 和移动端,仍限本人已办单据。
`docs/sql/zkxw.sql` 仍被交付打包和 Compose 引用,但当前初始化基线不完整,不能据此宣称可从空库部署。SOP HTML 是导入脚本的输入资源,继续保留。 `docs/sql/zkxw.sql` 仍被交付打包和 Compose 引用,但当前初始化基线不完整,不能据此宣称可从空库部署。SOP HTML 是导入脚本的输入资源,继续保留。
历史细胞产品导入必须显式指定 `--file`,不再自带默认 TSV。实际业务测试保留在各应用源码目录;删除历史报告不代表现有测试已经执行或通过。 历史细胞产品导入必须显式指定 `--file`,不再自带默认 TSV。实际业务测试保留在各应用源码目录;删除历史报告不代表现有测试已经执行或通过。
@@ -0,0 +1,50 @@
-- OA 已办中心财务备注(Gitea #7),MySQL 5.7,先于应用代码部署。
-- 在目标业务库执行;结构与菜单插入均可重复执行,不自动授予任何角色权限。
-- 权限配置:在已办中心下勾选「财务备注(查看和修改)」;同一权限码作用于两端。
-- 菜单按当前租户中既有 OA 已办页面定位,不挂载到旧工作流已办中心。
SET @oa_finance_column_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'oa_instance'
AND column_name = 'finance_remark'
);
SET @oa_finance_ddl = IF(
@oa_finance_column_exists = 0,
'ALTER TABLE oa_instance ADD COLUMN finance_remark VARCHAR(512) NULL COMMENT ''已办财务备注(专用权限控制)'' AFTER status',
'SELECT ''oa_instance.finance_remark already exists'' AS migration_status'
);
PREPARE oa_finance_stmt FROM @oa_finance_ddl;
EXECUTE oa_finance_stmt;
DEALLOCATE PREPARE oa_finance_stmt;
-- 由租户及父菜单生成稳定的 UUID 形状 ID,避免多行 SELECT 中 UUID 表达式被复用。
-- 已有软删/停用权限节点不自动复活,由管理员决定是否恢复。
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('oa-finance-remark:', parent.tenant_no, ':', parent.id)),
13, 1, '3'), 17, 1, '8'),
'财务备注(查看和修改)', parent.id, 'F', '', NULL,
'oa:done:finance-remark', 1, '1', '1', parent.tenant_no, 90,
'控制本人已办审批单的共享财务备注;Web 与移动端共用权限',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu AS parent
WHERE parent.del_flag = 0 AND parent.status = '1' AND parent.menu_type = 'C'
AND (
parent.perms IN ('oa:done', 'mini:oa:done')
OR TRIM(BOTH '/' FROM parent.component) = 'oa-approval/done/index'
OR TRIM(BOTH '/' FROM parent.path) IN ('pages/oa/done/index', 'oa/done/index')
)
AND NOT EXISTS (
SELECT 1 FROM sys_menu AS existing
WHERE existing.parent_id = parent.id
AND existing.tenant_no = parent.tenant_no
AND existing.perms = 'oa:done:finance-remark'
);
-- 验证必须至少存在当前租户的权限节点;若无结果,先核对该租户 OA 已办菜单配置。
SELECT tenant_no, parent_id, id, menu_name, perms, status
FROM sys_menu WHERE perms = 'oa:done:finance-remark' AND del_flag = 0;
+484 -36
View File
@@ -32,11 +32,7 @@
<view v-else-if="!list.length" class="oa-empty-wrap"> <view v-else-if="!list.length" class="oa-empty-wrap">
<VanEmpty <VanEmpty
:description="keyword ? '未找到相关已办' : '暂无已办'" :description="keyword ? '未找到相关已办' : '暂无已办'"
:tip=" :tip="keyword ? '试试其他关键词,或清空搜索' : '你处理过的审批会出现在这里'"
keyword
? '试试其他关键词,或清空搜索'
: '你处理过的审批会出现在这里'
"
/> />
</view> </view>
@@ -58,23 +54,22 @@
}}</text> }}</text>
</view> </view>
<view v-if="metaLines(row).length" class="dd-card__meta"> <view v-if="metaLines(row).length" class="dd-card__meta">
<text <text v-for="(line, idx) in metaLines(row)" :key="idx" class="dd-card__meta-line">
v-for="(line, idx) in metaLines(row)"
:key="idx"
class="dd-card__meta-line"
>
{{ line }} {{ line }}
</text> </text>
</view> </view>
<view v-if="canManageFinanceRemark" class="finance-remark">
<text class="finance-remark__text">财务备注:{{ row.financeRemark || '—' }}</text>
<button class="finance-remark__edit" @click.stop="openFinanceRemark(row)" @longpress.stop>
编辑备注
</button>
</view>
<view class="dd-card__foot"> <view class="dd-card__foot">
<view class="dd-card__user"> <view class="dd-card__user">
<view class="dd-card__avatar">{{ avatarText(row) }}</view> <view class="dd-card__avatar">{{ avatarText(row) }}</view>
<text class="dd-card__user-text">{{ submitterText(row) }}</text> <text class="dd-card__user-text">{{ submitterText(row) }}</text>
</view> </view>
<text <text class="dd-card__status" :class="'is-' + statusTone(row.instanceStatus)">
class="dd-card__status"
:class="'is-' + statusTone(row.instanceStatus)"
>
{{ statusText(row.instanceStatus) }} {{ statusText(row.instanceStatus) }}
</text> </text>
</view> </view>
@@ -82,12 +77,89 @@
<view v-if="loadingMore" class="oa-task-list__end">加载更多…</view> <view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
<view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view> <view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
</view> </view>
<view
v-if="financeRemarkVisible"
class="finance-dialog"
:style="financeRemarkViewportStyle"
@touchmove.stop.prevent
>
<view class="finance-dialog__mask" />
<view class="finance-dialog__panel" role="dialog" aria-label="财务备注" @touchmove.stop>
<view class="finance-dialog__header">
<view class="finance-dialog__heading">
<text class="finance-dialog__title">财务备注</text>
<text class="finance-dialog__subtitle">同一审批单在各端共享</text>
</view>
<button
class="finance-dialog__close"
aria-label="关闭财务备注"
hover-class="finance-dialog__pressed"
:disabled="financeRemarkSaving"
@click="cancelFinanceRemark"
>
<image class="finance-dialog__close-icon" src="/static/finance-remark-close.svg" />
</button>
</view>
<view class="finance-dialog__body">
<view class="finance-dialog__field-header">
<text class="finance-dialog__label">备注内容</text>
<text class="finance-dialog__count">{{ financeRemarkText.length }} / 512</text>
</view>
<view v-if="financeRemarkLoading" class="finance-dialog__loading" role="status">
正在加载最新备注…
</view>
<textarea
v-else
v-model="financeRemarkText"
class="finance-dialog__input"
:class="{
'is-error': financeRemarkError,
'is-saving': financeRemarkSaving
}"
:maxlength="512"
:disabled="financeRemarkSaving"
:adjust-position="false"
:show-confirm-bar="false"
:fixed="true"
aria-label="备注内容"
placeholder="填写需要补充的财务说明…"
placeholder-class="finance-dialog__placeholder"
@keyboardheightchange="onFinanceRemarkKeyboardChange"
/>
<text class="finance-dialog__hint" :class="{ 'is-error': financeRemarkError }">
{{ financeRemarkError || '留空并保存,可清空原有备注' }}
</text>
</view>
<view class="finance-dialog__actions">
<button
class="finance-dialog__button finance-dialog__button--cancel"
hover-class="finance-dialog__pressed"
:disabled="financeRemarkSaving"
@click="cancelFinanceRemark"
>
取消
</button>
<button
class="finance-dialog__button finance-dialog__button--save"
hover-class="finance-dialog__pressed"
:disabled="financeRemarkLoading || financeRemarkSaving"
@click="saveFinanceRemark"
>
{{ financeRemarkSaving ? '保存中…' : '保存备注' }}
</button>
</view>
</view>
</view>
</view> </view>
</template> </template>
<script> <script>
import VanEmpty from '../../../components/van-empty/van-empty.vue' import VanEmpty from '../../../components/van-empty/van-empty.vue'
import { fetchOaTasks } from '../../../utils/oa-api.js' import {
fetchOaTasks,
fetchOaFinanceRemark,
fetchUpdateOaFinanceRemark
} from '../../../utils/oa-api.js'
import { forceRelogin, getToken } from '../../../utils/auth.js' import { forceRelogin, getToken } from '../../../utils/auth.js'
import { stashOaDetailTitle } from '../../../utils/oa-h5.js' import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
import { formatCornerIndex } from '../../../utils/oa-corner-index.js' import { formatCornerIndex } from '../../../utils/oa-corner-index.js'
@@ -121,6 +193,17 @@
components: { VanEmpty }, components: { VanEmpty },
data() { data() {
return { return {
canManageFinanceRemark: false,
financeRemarkVisible: false,
financeRemarkLoading: false,
financeRemarkSaving: false,
financeRemarkText: '',
financeRemarkError: '',
financeRemarkViewportStyle: {},
financeRemarkWindowHeight: 0,
financeRemarkInstanceId: '',
financeRemarkVersion: 0,
listVersion: 0,
keyword: '', keyword: '',
searchFocused: false, searchFocused: false,
list: [], list: [],
@@ -134,6 +217,7 @@
}, },
onShow() { onShow() {
if (!getToken()) { if (!getToken()) {
this.clearFinanceAccess()
forceRelogin('请先登录') forceRelogin('请先登录')
return return
} }
@@ -150,13 +234,137 @@
if (!this.hasMore || this.loading || this.loadingMore) return if (!this.hasMore || this.loading || this.loadingMore) return
this.loadList(false) this.loadList(false)
}, },
onHide() {
this.listVersion++
this.clearFinanceAccess()
if (this._searchTimer) clearTimeout(this._searchTimer)
this._searchTimer = null
},
onUnload() { onUnload() {
this.listVersion++
this.clearFinanceAccess()
if (this._searchTimer) { if (this._searchTimer) {
clearTimeout(this._searchTimer) clearTimeout(this._searchTimer)
this._searchTimer = null this._searchTimer = null
} }
}, },
methods: { methods: {
cancelFinanceRemark() {
if (this.financeRemarkSaving) return
this.resetFinanceRemark()
},
resetFinanceRemark() {
if (this._financeRemarkViewportCleanup) this._financeRemarkViewportCleanup()
this._financeRemarkViewportCleanup = null
this.financeRemarkViewportStyle = {}
this.financeRemarkWindowHeight = 0
this.financeRemarkVersion++
this.financeRemarkVisible = false
this.financeRemarkText = ''
this.financeRemarkError = ''
this.financeRemarkInstanceId = ''
this.financeRemarkLoading = false
this.financeRemarkSaving = false
},
clearFinanceAccess() {
this.canManageFinanceRemark = false
this.resetFinanceRemark()
this.list = this.list.map((row) => {
const sanitized = { ...row }
delete sanitized.financeRemark
return sanitized
})
},
watchFinanceRemarkViewport() {
// H5 uses the visual viewport so keyboard resize and browser panning are both covered.
// #ifdef H5
if (typeof window !== 'undefined' && window.visualViewport) {
const viewport = window.visualViewport
const update = () => {
if (!this.financeRemarkVisible) return
this.financeRemarkViewportStyle = {
height: viewport.height + 'px',
top: viewport.offsetTop + 'px'
}
}
update()
viewport.addEventListener('resize', update)
viewport.addEventListener('scroll', update)
this._financeRemarkViewportCleanup = () => {
viewport.removeEventListener('resize', update)
viewport.removeEventListener('scroll', update)
}
return
}
// #endif
const info = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
this.financeRemarkWindowHeight = info.windowHeight
this.financeRemarkViewportStyle = {
height: info.windowHeight + 'px',
top: '0px'
}
},
onFinanceRemarkKeyboardChange(event) {
if (!this.financeRemarkVisible || !this.financeRemarkWindowHeight) return
const keyboardHeight = Math.max(0, Number(event?.detail?.height) || 0)
this.financeRemarkViewportStyle = {
height: Math.max(0, this.financeRemarkWindowHeight - keyboardHeight) + 'px',
top: '0px'
}
},
async openFinanceRemark(row) {
if (!this.canManageFinanceRemark || this.financeRemarkSaving) return
const id = String(row.instanceId || '').trim()
if (!id) return
this.cancelFinanceRemark()
const version = this.financeRemarkVersion
this.financeRemarkInstanceId = id
this.financeRemarkVisible = true
this.financeRemarkLoading = true
this.watchFinanceRemarkViewport()
try {
const result = await fetchOaFinanceRemark(id)
if (version !== this.financeRemarkVersion) return
this.financeRemarkText = result.financeRemark || ''
} catch (e) {
if (version !== this.financeRemarkVersion) return
this.cancelFinanceRemark()
uni.showToast({
title: (e && e.message) || '加载财务备注失败',
icon: 'none'
})
} finally {
if (version === this.financeRemarkVersion) this.financeRemarkLoading = false
}
},
async saveFinanceRemark() {
if (
!this.canManageFinanceRemark ||
!this.financeRemarkInstanceId ||
this.financeRemarkLoading ||
this.financeRemarkSaving
)
return
if (this.financeRemarkText.length > 512) {
this.financeRemarkError = '财务备注最多 512 个字符'
return
}
const version = this.financeRemarkVersion
this.financeRemarkError = ''
this.financeRemarkSaving = true
try {
await fetchUpdateOaFinanceRemark(this.financeRemarkInstanceId, this.financeRemarkText)
if (version !== this.financeRemarkVersion) return
this.resetFinanceRemark()
uni.showToast({ title: '财务备注已保存', icon: 'success' })
await this.loadList(true)
} catch (e) {
if (version !== this.financeRemarkVersion) return
this.financeRemarkError = '保存失败,请稍后重试,输入已保留。'
} finally {
if (version === this.financeRemarkVersion) this.financeRemarkSaving = false
}
},
cornerIndex(idx) { cornerIndex(idx) {
return formatCornerIndex(Number(idx) + 1) return formatCornerIndex(Number(idx) + 1)
}, },
@@ -188,10 +396,7 @@
for (let i = 0; i < fields.length && i < 3; i++) { for (let i = 0; i < fields.length && i < 3; i++) {
const f = fields[i] || {} const f = fields[i] || {}
const label = String(f.label || '').trim() || '字段' const label = String(f.label || '').trim() || '字段'
const value = const value = f.value != null && String(f.value).trim() !== '' ? String(f.value) : '-'
f.value != null && String(f.value).trim() !== ''
? String(f.value)
: '-'
lines.push(label + ':' + value) lines.push(label + ':' + value)
} }
return lines return lines
@@ -200,28 +405,16 @@
const d = parseDateTime(str) const d = parseDateTime(str)
if (!d) return str || '-' if (!d) return str || '-'
const now = new Date() const now = new Date()
const startToday = new Date( const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
now.getFullYear(),
now.getMonth(),
now.getDate()
)
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate()) const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const dayDiff = Math.round( const dayDiff = Math.round((startToday.getTime() - startThat.getTime()) / 86400000)
(startToday.getTime() - startThat.getTime()) / 86400000
)
if (dayDiff === 0) { if (dayDiff === 0) {
return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) return pad2(d.getHours()) + ':' + pad2(d.getMinutes())
} }
if (dayDiff === 1) return '昨天' if (dayDiff === 1) return '昨天'
if (dayDiff === 2) return '前天' if (dayDiff === 2) return '前天'
if (dayDiff > 2 && dayDiff < 7) return dayDiff + '天前' if (dayDiff > 2 && dayDiff < 7) return dayDiff + '天前'
return ( return d.getFullYear() + '.' + pad2(d.getMonth() + 1) + '.' + pad2(d.getDate())
d.getFullYear() +
'.' +
pad2(d.getMonth() + 1) +
'.' +
pad2(d.getDate())
)
}, },
_inputVal(e) { _inputVal(e) {
if (e && e.detail && e.detail.value != null) return String(e.detail.value) if (e && e.detail && e.detail.value != null) return String(e.detail.value)
@@ -277,11 +470,14 @@
}, },
async loadList(reset) { async loadList(reset) {
if (!getToken()) { if (!getToken()) {
this.clearFinanceAccess()
forceRelogin('请先登录') forceRelogin('请先登录')
return return
} }
const version = ++this.listVersion
const isReset = !!reset const isReset = !!reset
if (isReset) { if (isReset) {
this.clearFinanceAccess()
this.page = 1 this.page = 1
this.hasMore = true this.hasMore = true
this.loading = true this.loading = true
@@ -294,23 +490,30 @@
page: this.page, page: this.page,
pageSize: PAGE_SIZE pageSize: PAGE_SIZE
}) })
if (version !== this.listVersion) return
this.canManageFinanceRemark = res?.canManageFinanceRemark === true
const rows = (res && res.list) || [] const rows = (res && res.list) || []
const total = (res && res.total) != null ? Number(res.total) : 0 const total = (res && res.total) != null ? Number(res.total) : 0
this.total = total this.total = total
if (isReset) this.list = rows if (isReset) this.list = rows
else this.list = this.list.concat(rows) else this.list = this.list.concat(rows)
if (!this.canManageFinanceRemark) this.clearFinanceAccess()
const loaded = this.list.length const loaded = this.list.length
this.hasMore = loaded < total && rows.length > 0 this.hasMore = loaded < total && rows.length > 0
if (rows.length > 0) this.page += 1 if (rows.length > 0) this.page += 1
} catch (e) { } catch (e) {
if (version !== this.listVersion) return
this.clearFinanceAccess()
if (isReset) this.list = [] if (isReset) this.list = []
uni.showToast({ uni.showToast({
title: (e && e.message) || '加载失败', title: (e && e.message) || '加载失败',
icon: 'none' icon: 'none'
}) })
} finally { } finally {
this.loading = false if (version === this.listVersion) {
this.loadingMore = false this.loading = false
this.loadingMore = false
}
} }
} }
} }
@@ -318,6 +521,251 @@
</script> </script>
<style scoped> <style scoped>
.finance-remark {
margin-top: 20rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f1f5f9;
}
.finance-remark__text {
display: block;
font-size: 26rpx;
color: #6b7b74;
white-space: pre-wrap;
word-break: break-all;
}
.finance-remark__edit {
margin: 8rpx 0 0;
padding: 0 12rpx;
min-height: 88rpx;
line-height: 88rpx;
font-size: 26rpx;
color: #0a9f6d;
background: transparent;
text-align: right;
}
.finance-remark__edit::after,
.finance-dialog__button::after,
.finance-dialog__close::after {
border: 0;
}
.finance-dialog {
position: fixed;
z-index: 2000;
top: 0;
left: 0;
width: 100%;
height: 100vh;
height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px 24px;
padding-top: max(16px, env(safe-area-inset-top));
padding-bottom: max(16px, env(safe-area-inset-bottom));
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
}
.finance-dialog__mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 26, 15, 0.38);
}
.finance-dialog__panel {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
max-width: 480px;
max-height: 100%;
padding: 20px;
box-sizing: border-box;
overflow: hidden;
border-radius: 22px;
background: #fff;
box-shadow: 0 12px 40px rgba(0, 26, 15, 0.18);
color: #1f2a26;
}
.finance-dialog__header {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.finance-dialog__heading {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.finance-dialog__title {
font-size: 20px;
font-weight: 700;
line-height: 30px;
}
.finance-dialog__subtitle,
.finance-dialog__count,
.finance-dialog__hint {
color: #6b7b74;
font-size: 12px;
line-height: 18px;
}
.finance-dialog__close {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 44px;
width: 44px;
height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 22px;
background: #f5f8f7;
line-height: 1;
}
.finance-dialog__close-icon {
display: block;
width: 16px;
height: 16px;
}
.finance-dialog__body {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.finance-dialog__field-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-shrink: 0;
gap: 8px;
}
.finance-dialog__label {
font-size: 13px;
font-weight: 500;
line-height: 20px;
}
.finance-dialog__input,
.finance-dialog__loading {
flex-shrink: 0;
width: 100%;
height: 120px;
margin: 0;
padding: 14px;
box-sizing: border-box;
border: 1px solid #dcebe4;
border-radius: 12px;
background: #f7faf9;
color: #1f2a26;
font-family: inherit;
font-size: 14px;
line-height: 21px;
text-align: left;
overflow-y: auto;
}
.finance-dialog__loading {
display: flex;
align-items: center;
justify-content: center;
color: #6b7b74;
}
.finance-dialog__placeholder {
color: #6b7b74;
}
.finance-dialog__input.is-error {
border-color: #d14343;
}
.finance-dialog__input.is-saving {
opacity: 0.6;
}
.finance-dialog__hint {
display: block;
flex-shrink: 0;
}
.finance-dialog__hint.is-error {
color: #d14343;
}
.finance-dialog__actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.finance-dialog__button {
display: flex;
align-items: center;
justify-content: center;
height: 46px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
font-family: inherit;
font-size: 15px;
font-weight: 500;
line-height: 23px;
}
.finance-dialog__button--cancel,
.finance-dialog__button--cancel[disabled] {
flex: 0 0 100px;
width: 100px;
color: #6b7b74;
background: #f5f8f7;
}
.finance-dialog__button--save,
.finance-dialog__button--save[disabled] {
flex: 1;
color: #fff;
background: #0a9f6d;
}
.finance-dialog__button--cancel[disabled],
.finance-dialog__close[disabled] {
opacity: 0.55;
}
.finance-dialog__button--save[disabled] {
opacity: 0.65;
}
.finance-dialog__pressed {
opacity: 0.8;
}
.page { .page {
min-height: 100vh; min-height: 100vh;
padding-bottom: 48rpx; padding-bottom: 48rpx;
@@ -0,0 +1,8 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<g id="Vector">
<path d="M4 4L12 12ZM12 4L4 12Z" fill="black"/>
<path d="M4 4L12 12M12 4L4 12" stroke-width="1.6" stroke-linecap="round" stroke="#6B7B74"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 354 B

+16
View File
@@ -158,3 +158,19 @@ export function fetchReturnOaTask(taskId, comment) {
authHeader() authHeader()
) )
} }
export function fetchOaFinanceRemark(instanceId) {
return get(
'/web/admin/oa-approval/instances/' + encodeURIComponent(instanceId) + '/finance-remark',
authHeader()
)
}
export function fetchUpdateOaFinanceRemark(instanceId, financeRemark) {
return request({
url: '/web/admin/oa-approval/instances/' + encodeURIComponent(instanceId) + '/finance-remark',
method: 'PUT',
data: { financeRemark },
header: authHeader()
})
}
@@ -0,0 +1,18 @@
# 设计
`oa_instance.finance_remark VARCHAR(512)` 保存实例级备注,通用实例序列化不输出该字段。专用服务按真实用户租户、显式菜单权限及本人有效已办任务查询实例;审批任务状态沿用 approved/rejected/returned/transferred,软删任务/实例和其他租户不可访问。
GET `/web/admin/oa-approval/tasks/done` 在 data 增加 `canManageFinanceRemark`。有权限时只给可访问实例行增加 `financeRemark`;无权限不返回字段。GET/PUT `/web/admin/oa-approval/instances/{id}/finance-remark` 返回 `{instanceId, financeRemark}`,PUT 接收 `{financeRemark: string}`,拒绝额外字段、超长和非字符串输入,允许空串清空。权限不足 403、实例不在本人已办范围 404。更新记录 update_by/update_time 并保留既有请求审计。
各端采用后端能力标记,不依赖角色名称或仅前端隐藏。打开弹窗时重新读取最新备注,保存后刷新列表;取消不写入,失败保留编辑内容。文字按纯文本渲染。单字段最后成功保存值生效。
权限菜单放在已办中心下,Web/移动对应菜单可用同一权限码,不自动授予角色。
## 移动弹窗视觉改版
初版通用弹窗的按钮贴边与留白不符合用户预期。本次以用户已明确确认的 [Figma 四状态原型](https://www.figma.com/design/jc7D8Fe7lKNTVj4jM3AoGW?node-id=14-56)为视觉依据:375px/414px 屏幕各留 24px 外边距,弹窗 22px 圆角、20px 内边距,独立的 46px 高圆角按钮,120px 高浅色输入区,明确加载、失败与保存中状态。Figma 的 Noto Sans SC 为工具字体回退,实现保留移动端系统字体栈。
仅更新 mobile-h5 财务备注弹窗,不修改全局确认弹窗或 Web 视觉。关闭/取消不写入,遮罩不直接关闭,保存中禁止重复提交与关闭,失败保留输入,键盘缩小可用视口后仍可操作。权限、本人已办范围和接口契约保持不变。今后新增界面和弹窗按工程 UI 原型确认规则执行,不再因简单或已有参考自动跳过 Figma。
## 验证
SQLite 隔离集成测试真实角色/菜单/任务:允许、拒绝、跨租户、伪造租户、无本人已办、软删/停用、角色/职位并集、保存读取清空、超长、非字符串、其他中心不泄露。各端 UI 行为检查和构建;uni-app HBuilderX 编译及可用平台检查。缺失环境如实记录,必要验证未完成前不关单。
@@ -0,0 +1,15 @@
# OA 已办中心财务备注
关联 Gitea #7。用户已确认 Web 和所有相关移动端、单一权限控制读写、同一审批实例共用备注、仍限本人已办单据。
## Why
已办列表缺少受控的财务补充说明入口。
## What Changes
- 审批实例新增独立财务备注(最多 512 字,可清空),不复用表单内容、审批意见或其他业务财务字段。
- 权限 `oa:done:finance-remark` 控制读写,使用有效角色并集;无权限不返回内容。
- Web 列表列和编辑弹窗、小程序/uni-app 已办卡片和编辑入口共用后端契约。mobile-oa-h5 当前只承载审批详情,不新增需求外的详情字段。
- 新增 MySQL 5.7 结构迁移及可重复执行的菜单种子,部署先迁移后更新代码;不自动赋权。
## Impact
涉及 backend、web-admin、mobile-h5 和 docs/sql。原有已办筛选、分页、审批流程和其他中心返回保持原语义。mobile-oa-h5 的跳转与详情兼容性需回归检查。
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: 实例级财务备注
系统 SHALL 为同一审批实例保存一份最多 512 字的纯文本财务备注,允许修改与清空,各端共享;不改变审批意见或业务表单。
#### Scenario: 已授权用户保存
- **WHEN** 具备 `oa:done:finance-remark` 权限且有该实例有效已办任务的用户保存备注
- **THEN** 记录备注、修改人和修改时间,其他有权限的相关已办用户读取同一份值,空串清空,超长拒绝。
### Requirement: 权限与数据隔离
系统 SHALL 按当前有效用户的真实租户及直接/职位继承有效角色并集校验显式权限。菜单权限不扩大本人已办数据范围。
#### Scenario: 无权限或越权
- **WHEN** 用户无该权限、无本人有效已办任务、访问其他租户或软删实例
- **THEN** 拒绝读写;无权限的列表及其他通用实例接口不返回财务备注。
### Requirement: 跨端已办入口
系统 SHALL 在 Web 已办表格和 uni-app 已办卡片提供财务备注展示及编辑入口,以后端能力判定可见性;保留原有跳转、筛选和分页。
#### Scenario: 打开和保存编辑器
- **WHEN** 有权限的用户打开编辑器
- **THEN** 读取最新备注;保存成功刷新列表,取消不写入,失败保留输入并提示;无权限用户看不到字段和编辑入口。
@@ -0,0 +1,23 @@
# 实施与验证计划
关联 Gitea #7;Web 与所有相关移动端,同一审批实例共用备注,单一权限控制查看和修改。默认 Agent 验证。
- [x] 后端:新增真实权限、本人已办、租户隔离、共享读写与拒绝输入测试;实现独立财务备注服务、模型和 API。新增 32 项测试通过,OA 全部回归 314 项通过。
- [x] 迁移:新增 MySQL 5.7 字段及对应已办菜单下按钮权限,不默认赋权;隔离 MySQL 5.7.24 连续执行两次通过,更新数据库变更入口。
- [x] 前端:Web 列表列与编辑弹窗、uni-app 卡片与编辑入口;23 项行为测试通过,包含最新值、保存/取消/失败、清空、权限隐藏、纯文本和页面切换竞态。
- [x] 静态与构建:OpenSpec 严格校验、字段链路、git diff 格式检查、Web 生产构建及 HBuilderX 微信小程序和 H5 编译通过。Web 构建使用 4 GB Node 堆;H5 验证复用现有 Sass 依赖,临时依赖不入库。字段一致性工具报告 5 项已知旧路径/映射警告、0 错误,OA 新字段另行逐层核对。
- [x] 审查:独立规格与代码质量审查通过,无阻塞项。
- [x] H5 页面验证:编译产物在 375px 浏览器中验证展示、读取最新值、取消、保存、纯文本、失败保留、清空和无权限隐藏;模拟接口,无业务数据写入,无页面异常。
- [x] 移动端验收方式:用户明确本次可用嵌套 H5 验证,不要求微信平台交互验证;采用已通过的 H5 编译产物交互结果。微信小程序编译通过,但未做模拟器/真机交互验证。
- [x] 测试库迁移:用户确认当前远程库为测试库并授权执行;迁移后字段为可空 VARCHAR(512),为 2 个已办父菜单新增 2 个权限节点,角色授权数量未变。管理员须按需授予角色权限,其他环境仍需先迁移再更新应用。
- [x] 交付准备:任务分支及 PR #8 已推送,用户确认的验收方式与测试库迁移条件已满足。按持续授权核对最新 V2.0 后合并、同步本地主分支并关闭 #7;最终合并提交和关单结果记录在 Gitea 工单中。不打 tag、不部署、不启停服务。
## 弹窗视觉改版(工单 #7 重新打开)
用户已确认 [Figma 原型及四种状态](https://www.figma.com/design/jc7D8Fe7lKNTVj4jM3AoGW?node-id=14-56),同意按此实现。范围仅 mobile-h5 的财务备注弹窗(小程序/H5 共用);默认 Agent 验证,沿用用户确认的 H5 验收方式,小程序做编译检查。Web 与后端业务契约不变。
- [x] 读取 Figma 设计上下文,实现紧凑居中布局、标题与关闭、浅色输入区、字数及辅助提示、独立圆角按钮;处理加载、失败保留输入、保存中禁用和键盘可见性。
- [x] 前端行为测试 27/27 通过,HBuilderX H5/微信小程序编译通过。实际 H5 产物在 375px/414px 下弹窗为 327/366×352px,与原型一致;380px 短视口下按钮可见,长文本可滚动。模拟接口验证保存、取消、清空、失败保留、保存中锁定、权限隐藏和纯文本,无页面异常;未进行微信模拟器/真机交互。
- [x] 独立规格与质量审查通过,无阻塞项;交付材料已就绪。按持续授权推送 PR,核对目标分支、合入 V2.0 并同步主目录,最终合并提交及关闭 #7 的结果记录在 Gitea 工单。
个人开发规则另行沉淀至 personal-dev 的界面原型引用:所有新页面、新弹窗及视觉改版先生成 Figma 原型并经用户明确确认;只有用户明确本任务不用 Figma 时才豁免。
@@ -1,5 +1,24 @@
## ADDED Requirements ## ADDED Requirements
### Requirement: 界面与弹窗先经 Figma 原型确认
新页面、新弹窗及用户要求的视觉改版 SHALL 先在 Figma 创建可编辑原型,覆盖主要尺寸和交互状态,关联具体文件/节点,并经用户明确确认后再修改业务界面代码。简单界面、单字段弹窗、已有组件或赶时间不构成豁免。只有用户明确本任务不用生成 Figma 原型时才记录范围并跳过;用户沉默、工具生成成功或“代码完成再看”不能代替确认。原型确认不替代功能验证。
#### Scenario: 复用已有组件新增弹窗
- **WHEN** 新弹窗可以复用工程现有组件
- **THEN** 在原型中复用对应设计并向用户确认,确认后实现;已有明确确认的同版原型直接沿用,不重复询问。
#### Scenario: 原型服务不可用或尚未确认
- **WHEN** Figma 不可用或用户尚未确认原型,且没有明确豁免
- **THEN** 保持界面实现待处理,说明缺口并继续独立工作;不先编码再补原型。
#### Scenario: 明确豁免或非视觉变更
- **WHEN** 用户明确本任务不用 Figma,或任务仅为无布局变化的文案纠错、后台逻辑、恢复既有已确认设计
- **THEN** 按原任务规则实施与验证;原型豁免仅作用于用户指定范围,不推广到后续新界面。
### Requirement: Web 管理端以 Fresh Mint 规范为唯一视觉权威 ### Requirement: Web 管理端以 Fresh Mint 规范为唯一视觉权威
`web-admin` 工程的界面实现 SHALL 以 `docs/web-admin-ui-spec-fresh-mint.md` 及其中引用的 `docs/fresh-mint-green.html` 为权威来源;主色链 SHALL 使用文档定义的 Mint 绿系(如 `--primary` `#10b981` 等),不得擅自替换为小程序品牌色。 `web-admin` 工程的界面实现 SHALL 以 `docs/web-admin-ui-spec-fresh-mint.md` 及其中引用的 `docs/fresh-mint-green.html` 为权威来源;主色链 SHALL 使用文档定义的 Mint 绿系(如 `--primary` `#10b981` 等),不得擅自替换为小程序品牌色。
@@ -0,0 +1,387 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { parse, compileTemplate } from '@vue/compiler-sfc'
import ts from 'typescript'
import { ref, reactive, computed, proxyRefs } from 'vue'
const root = new URL('../../', import.meta.url)
const fields = [
'canManageFinanceRemark',
'financeRemarkVisible',
'financeRemarkLoading',
'financeRemarkSaving',
'financeRemarkText',
'openFinanceRemark',
'saveFinanceRemark',
'cancelFinanceRemark',
'clearFinanceAccess',
'load',
'rows'
]
function evaluate(source, deps, tail = '') {
const withoutImports = source.replace(/import[\s\S]*?from\s*['"][^'"]+['"]/g, '')
const js = ts.transpileModule(withoutImports + tail, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
const exports = {}
return (
new Function(...Object.keys(deps), 'exports', js)(...Object.values(deps), exports) ||
exports.default ||
exports
)
}
function harness(platform, overrides = {}) {
const calls = []
const hooks = {}
const api = {
list: {
list: [{ id: 'task-1', instanceId: 'instance-1', financeRemark: '列表旧值' }],
total: 1,
canManageFinanceRemark: true
},
get: async () => ({ financeRemark: '最新备注' }),
put: async () => ({ financeRemark: '' })
}
const deps = {
ref,
reactive,
computed,
defineOptions() {},
watchDebounced() {},
onMounted() {},
onActivated() {},
onDeactivated(fn) {
hooks.hide = fn
},
useRouter: () => ({}),
useMenuPerm: () => ({ hasMenuPerm: () => false }),
useUserStore: () => ({}),
ElMessage: { success() {}, error() {}, warning() {} },
uni: { showToast() {}, navigateTo() {}, getSystemInfoSync: () => ({ windowHeight: 800 }) },
VanEmpty: {},
getToken: () => 'token',
forceRelogin() {},
fetchOaTasks: async () => {
calls.push(['list'])
return api.list
},
fetchOaFinanceRemark: async (id) => {
calls.push(['get', id])
return api.get(id)
},
fetchUpdateOaFinanceRemark: async (id, text) => {
calls.push(['put', id, text])
return api.put(id, text)
},
...overrides
}
const path =
platform === 'web'
? 'web-admin/src/views/oa-approval/done/index.vue'
: 'mobile-h5/pages/oa/done/index.vue'
const { descriptor } = parse(readFileSync(new URL(path, root), 'utf8'))
let vm
if (platform === 'web') {
vm = proxyRefs(
evaluate(
descriptor.scriptSetup.content,
deps,
'\nreturn {' +
fields.map((k) => `${k}: typeof ${k} === 'undefined' ? undefined : ${k}`).join(',') +
'}'
)
)
} else {
const options = evaluate(descriptor.script.content, deps)
vm = options.data()
for (const [key, fn] of Object.entries(options.methods)) vm[key] = fn.bind(vm)
hooks.hide = () => options.onHide?.call(vm)
}
const load = () => (platform === 'web' ? vm.load() : vm.loadList(true))
const rows = () => (platform === 'web' ? vm.rows : vm.list)
return { vm, calls, api, load, rows, hooks, descriptor }
}
for (const platform of ['web', 'mobile']) {
test(`${platform}: capability gates access and opening gets latest instance value`, async () => {
const h = harness(platform)
await h.load()
assert.equal(h.vm.canManageFinanceRemark, true)
await h.vm.openFinanceRemark(h.rows()[0])
assert.deepEqual(h.calls.at(-1), ['get', 'instance-1'])
assert.equal(h.vm.financeRemarkText, '最新备注')
assert.equal(h.vm.financeRemarkVisible, true)
})
test(`${platform}: cancellation makes no write and discards late GET`, async () => {
const h = harness(platform)
await h.load()
let resolve
h.api.get = () =>
new Promise((r) => {
resolve = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
h.vm.cancelFinanceRemark()
resolve({ financeRemark: '不得重新显示' })
await opening
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
})
test(`${platform}: save failure preserves input; clear succeeds and refreshes`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.vm.financeRemarkText = '我的未保存输入'
h.api.put = async () => {
throw new Error('failure')
}
await h.vm.saveFinanceRemark()
assert.equal(h.vm.financeRemarkText, '我的未保存输入')
assert.equal(h.vm.financeRemarkVisible, true)
h.api.put = async () => ({ financeRemark: '' })
h.vm.financeRemarkText = ''
await h.vm.saveFinanceRemark()
assert.deepEqual(h.calls.slice(-2), [['put', 'instance-1', ''], ['list']])
assert.equal(h.vm.financeRemarkVisible, false)
})
test(`${platform}: missing capability strips cached notes and prevents GET`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.api.list = { list: [{ instanceId: 'instance-1', financeRemark: '敏感备注' }], total: 1 }
await h.load()
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal('financeRemark' in h.rows()[0], false)
const count = h.calls.length
await h.vm.openFinanceRemark(h.rows()[0])
assert.equal(h.calls.length, count)
})
test(`${platform}: hiding page clears notes and invalidates pending reads`, async () => {
const h = harness(platform)
await h.load()
let resolve
h.api.get = () =>
new Promise((r) => {
resolve = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
h.hooks.hide()
resolve({ financeRemark: '不得恢复的旧备注' })
await opening
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.vm.financeRemarkText, '')
assert.equal('financeRemark' in h.rows()[0], false)
})
test(`${platform}: overlong input cannot send PUT`, async () => {
const h = harness(platform)
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.vm.financeRemarkText = '字'.repeat(513)
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
assert.equal(h.vm.financeRemarkVisible, true)
})
test(`${platform}: saving while reading or already saving sends no duplicate PUT`, async () => {
const h = harness(platform)
await h.load()
let resolveGet
h.api.get = () =>
new Promise((r) => {
resolveGet = r
})
const opening = h.vm.openFinanceRemark(h.rows()[0])
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
resolveGet({ financeRemark: '备注' })
await opening
let resolvePut
h.api.put = () =>
new Promise((r) => {
resolvePut = r
})
const saving = h.vm.saveFinanceRemark()
await h.vm.saveFinanceRemark()
assert.equal(h.calls.filter((c) => c[0] === 'put').length, 1)
resolvePut({ financeRemark: '备注' })
await saving
})
test(`${platform}: late list response cannot restore permission after hiding`, async () => {
const h = harness(platform)
await h.load()
let resolve
const previous = h.api.list
h.api.list = new Promise((r) => {
resolve = r
})
const loading = h.load()
h.hooks.hide()
resolve(previous)
await loading
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal('financeRemark' in h.rows()[0], false)
})
test(`${platform}: failed latest-value request cannot save stale list text`, async () => {
const h = harness(platform)
await h.load()
h.api.get = async () => {
throw new Error('forbidden')
}
await h.vm.openFinanceRemark(h.rows()[0])
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
await h.vm.saveFinanceRemark()
assert.equal(
h.calls.some((c) => c[0] === 'put'),
false
)
})
test(`${platform}: template compiles with plain text and gated edit controls`, () => {
const { descriptor } = harness(platform)
const result = compileTemplate({
source: descriptor.template.content,
filename: 'done.vue',
id: platform
})
assert.deepEqual(result.errors, [])
assert.match(result.code, /canManageFinanceRemark/)
assert.match(result.code, /toDisplayString\([^)]*financeRemark/)
assert.doesNotMatch(result.code, /innerHTML/)
if (platform === 'mobile')
assert.match(result.code, /withModifiers[^\n]*openFinanceRemark[^\n]*[\s\S]*?\["stop"\]/)
})
}
for (const platform of ['web', 'mobile']) {
test(`${platform}: finance API uses dedicated authenticated instance endpoint`, async () => {
const calls = []
const request = (config) => {
calls.push(config)
return Promise.resolve({})
}
request.get = (config) => request({ ...config, method: 'GET' })
request.put = (config) => request({ ...config, method: 'PUT' })
const path =
platform === 'web' ? 'web-admin/src/api/oaApproval.ts' : 'mobile-h5/utils/oa-api.js'
const api = evaluate(readFileSync(new URL(path, root), 'utf8'), {
request,
get: (url, header) => request({ url, header, method: 'GET' }),
getToken: () => 'token'
})
assert.equal(typeof api.fetchOaFinanceRemark, 'function')
await api.fetchOaFinanceRemark('instance-1')
await api.fetchUpdateOaFinanceRemark('instance-1', '')
assert.equal(calls[0].method, 'GET')
assert.equal(calls[1].method, 'PUT')
assert.equal(calls[0].url, '/web/admin/oa-approval/instances/instance-1/finance-remark')
assert.equal(calls[1].url, calls[0].url)
assert.deepEqual(calls[1].data, { financeRemark: '' })
if (platform === 'mobile') assert.equal(calls[1].header.Authorization, 'Bearer token')
})
}
test('web: delayed refresh on an inactive cached page cannot restore finance data', async () => {
const h = harness('web')
await h.load()
h.hooks.hide()
const count = h.calls.length
await h.load()
assert.equal(h.vm.canManageFinanceRemark, false)
assert.equal(h.calls.length, count)
})
test('mobile: save errors remain inline until retry or cancellation', async () => {
const h = harness('mobile')
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
h.vm.financeRemarkText = '保留输入'
h.api.put = async () => {
throw new Error('failure')
}
await h.vm.saveFinanceRemark()
assert.equal(h.vm.financeRemarkError, '保存失败,请稍后重试,输入已保留。')
assert.equal(h.vm.financeRemarkText, '保留输入')
let resolve
h.api.put = () =>
new Promise((r) => {
resolve = r
})
const retry = h.vm.saveFinanceRemark()
assert.equal(h.vm.financeRemarkError, '')
resolve({})
await retry
assert.equal(h.vm.financeRemarkVisible, false)
})
test('mobile: saving blocks user cancellation but page cleanup invalidates the response', async () => {
const h = harness('mobile')
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
let resolve
h.api.put = () =>
new Promise((r) => {
resolve = r
})
const saving = h.vm.saveFinanceRemark()
h.vm.cancelFinanceRemark()
assert.equal(h.vm.financeRemarkVisible, true)
assert.equal(h.vm.financeRemarkSaving, true)
h.hooks.hide()
assert.equal(h.vm.financeRemarkVisible, false)
assert.equal(h.vm.financeRemarkText, '')
const callCount = h.calls.length
resolve({})
await saving
assert.equal(h.calls.length, callCount)
})
test('mobile: keyboard height keeps the dialog inside available viewport and resets on close', async () => {
const h = harness('mobile')
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
assert.equal(h.vm.financeRemarkViewportStyle?.height, '800px')
h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 320 } })
assert.equal(h.vm.financeRemarkViewportStyle.height, '480px')
h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 0 } })
assert.equal(h.vm.financeRemarkViewportStyle.height, '800px')
h.vm.cancelFinanceRemark()
assert.deepEqual(h.vm.financeRemarkViewportStyle, {})
h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 320 } })
assert.deepEqual(h.vm.financeRemarkViewportStyle, {})
})
test('mobile: H5 viewport resize and scroll track keyboard geometry and listeners are released', async () => {
const listeners = new Map()
const viewport = {
height: 800,
offsetTop: 0,
addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: (name, fn) => {
assert.equal(listeners.get(name), fn)
listeners.delete(name)
}
}
const h = harness('mobile', { window: { visualViewport: viewport } })
await h.load()
await h.vm.openFinanceRemark(h.rows()[0])
assert.equal(listeners.size, 2)
viewport.height = 420
viewport.offsetTop = 20
listeners.get('resize')()
assert.deepEqual(h.vm.financeRemarkViewportStyle, { height: '420px', top: '20px' })
// A native keyboard notification must not subtract the same keyboard twice on H5.
h.vm.onFinanceRemarkKeyboardChange({ detail: { height: 380 } })
assert.equal(h.vm.financeRemarkViewportStyle.height, '420px')
h.hooks.hide()
assert.equal(listeners.size, 0)
assert.deepEqual(h.vm.financeRemarkViewportStyle, {})
})
+16 -1
View File
@@ -408,7 +408,7 @@ export function fetchOaTasks(
const applyTimeFrom = (params?.applyTimeFrom || '').trim() const applyTimeFrom = (params?.applyTimeFrom || '').trim()
const applyTimeTo = (params?.applyTimeTo || '').trim() const applyTimeTo = (params?.applyTimeTo || '').trim()
const instanceStatus = (params?.instanceStatus || '').trim() const instanceStatus = (params?.instanceStatus || '').trim()
return request.get<{ list: any[]; total: number }>({ return request.get<{ list: any[]; total: number; canManageFinanceRemark?: boolean }>({
url: `${BASE}/tasks/${kind}`, url: `${BASE}/tasks/${kind}`,
params: { params: {
page: params?.page ?? 1, page: params?.page ?? 1,
@@ -664,3 +664,18 @@ export function fetchReplaceOaBankReceipts(
data: { items } data: { items }
}) })
} }
export type OaFinanceRemark = { instanceId: string; financeRemark: string }
export function fetchOaFinanceRemark(instanceId: string) {
return request.get<OaFinanceRemark>({
url: `${BASE}/instances/${encodeURIComponent(instanceId)}/finance-remark`
})
}
export function fetchUpdateOaFinanceRemark(instanceId: string, financeRemark: string) {
return request.put<OaFinanceRemark>({
url: `${BASE}/instances/${encodeURIComponent(instanceId)}/finance-remark`,
data: { financeRemark }
})
}
+176 -38
View File
@@ -6,18 +6,10 @@
<el-form :model="searchForm" class="filter-form" @submit.prevent> <el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid"> <div class="filter-grid">
<el-form-item label="流程名称"> <el-form-item label="流程名称">
<el-input <el-input v-model="searchForm.appName" placeholder="请输入流程名称" clearable />
v-model="searchForm.appName"
placeholder="请输入流程名称"
clearable
/>
</el-form-item> </el-form-item>
<el-form-item label="申请人"> <el-form-item label="申请人">
<el-input <el-input v-model="searchForm.initiatorName" placeholder="请输入申请人" clearable />
v-model="searchForm.initiatorName"
placeholder="请输入申请人"
clearable
/>
</el-form-item> </el-form-item>
<el-form-item label="申请时间"> <el-form-item label="申请时间">
<el-date-picker <el-date-picker
@@ -76,28 +68,13 @@
> >
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template> <template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column prop="initiatorUserName" label="申请人" min-width="120" align="center">
prop="initiatorUserName"
label="申请人"
min-width="120"
align="center"
>
<template #default="{ row }">{{ row.initiatorUserName || '-' }}</template> <template #default="{ row }">{{ row.initiatorUserName || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column prop="applyTime" label="申请时间" min-width="170" align="center">
prop="applyTime"
label="申请时间"
min-width="170"
align="center"
>
<template #default="{ row }">{{ row.applyTime || '-' }}</template> <template #default="{ row }">{{ row.applyTime || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column prop="currentNodeName" label="当前节点" min-width="140" align="center">
prop="currentNodeName"
label="当前节点"
min-width="140"
align="center"
>
<template #default="{ row }">{{ row.currentNodeName || '-' }}</template> <template #default="{ row }">{{ row.currentNodeName || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column
@@ -116,6 +93,15 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column
v-if="canManageFinanceRemark"
prop="financeRemark"
label="财务备注"
min-width="220"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.financeRemark || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="400" fixed="right" align="center"> <el-table-column label="操作" min-width="400" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)"> <el-button class="btn-op-item primary" @click="openDetail(row)">
@@ -130,6 +116,14 @@
<el-icon><Printer /></el-icon> <el-icon><Printer /></el-icon>
打印 打印
</el-button> </el-button>
<el-button
v-if="canManageFinanceRemark"
class="btn-op-item"
@click="openFinanceRemark(row)"
>
<el-icon><EditPen /></el-icon>
财务备注
</el-button>
<el-button <el-button
v-if="canUploadBankReceipt" v-if="canUploadBankReceipt"
class="btn-op-item" class="btn-op-item"
@@ -157,6 +151,49 @@
</div> </div>
</section> </section>
<el-dialog
:model-value="financeRemarkVisible"
title="财务备注"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
:close-on-press-escape="!financeRemarkSaving"
:show-close="!financeRemarkSaving"
@update:model-value="cancelFinanceRemark"
>
<el-form v-loading="financeRemarkLoading" label-position="top" @submit.prevent>
<el-form-item label="财务备注">
<el-input
v-model="financeRemarkText"
type="textarea"
:rows="5"
:maxlength="512"
show-word-limit
:disabled="financeRemarkLoading || financeRemarkSaving"
placeholder="请输入财务备注,留空可清空"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button class="btn-ghost" :disabled="financeRemarkSaving" @click="cancelFinanceRemark">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
class="btn-main"
type="primary"
:loading="financeRemarkSaving"
:disabled="financeRemarkLoading"
@click="saveFinanceRemark"
>
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<OaBankReceiptDialog <OaBankReceiptDialog
v-model="bankReceiptVisible" v-model="bankReceiptVisible"
:instance-id="bankReceiptInstanceId" :instance-id="bankReceiptInstanceId"
@@ -166,11 +203,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, reactive, ref } from 'vue' import {
import { useRouter } from 'vue-router' Check,
import { ElMessage } from 'element-plus' Close,
import { Printer, RefreshLeft, Search, Upload, View } from '@element-plus/icons-vue' EditPen,
import { fetchOaInstanceDetail, fetchOaTasks } from '@/api/oaApproval' Printer,
RefreshLeft,
Search,
Upload,
View
} from '@element-plus/icons-vue'
import {
fetchOaInstanceDetail,
fetchOaTasks,
fetchOaFinanceRemark,
fetchUpdateOaFinanceRemark
} from '@/api/oaApproval'
import OaBankReceiptDialog from '@/components/oa-approval/OaBankReceiptDialog.vue' import OaBankReceiptDialog from '@/components/oa-approval/OaBankReceiptDialog.vue'
import { useMenuPerm } from '@/hooks/core/useMenuPerm' import { useMenuPerm } from '@/hooks/core/useMenuPerm'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
@@ -187,6 +235,82 @@
const loading = ref(false) const loading = ref(false)
const printingId = ref('') const printingId = ref('')
const rows = ref<any[]>([]) const rows = ref<any[]>([])
const canManageFinanceRemark = ref(false)
const financeRemarkVisible = ref(false)
const financeRemarkLoading = ref(false)
const financeRemarkSaving = ref(false)
const financeRemarkText = ref('')
let financeRemarkInstanceId = ''
let financeRemarkVersion = 0
let listVersion = 0
let pageActive = true
function cancelFinanceRemark() {
financeRemarkVersion++
financeRemarkVisible.value = false
financeRemarkText.value = ''
financeRemarkInstanceId = ''
financeRemarkLoading.value = false
financeRemarkSaving.value = false
}
function clearFinanceAccess() {
canManageFinanceRemark.value = false
cancelFinanceRemark()
rows.value = rows.value.map((row) => {
const sanitized = { ...row }
delete sanitized.financeRemark
return sanitized
})
}
async function openFinanceRemark(row: any) {
if (!canManageFinanceRemark.value || financeRemarkSaving.value) return
const id = String(row.instanceId || '').trim()
if (!id) return
cancelFinanceRemark()
const version = financeRemarkVersion
financeRemarkInstanceId = id
financeRemarkVisible.value = true
financeRemarkLoading.value = true
try {
const result = await fetchOaFinanceRemark(id)
if (version !== financeRemarkVersion) return
financeRemarkText.value = result.financeRemark || ''
} catch {
if (version === financeRemarkVersion) cancelFinanceRemark()
} finally {
if (version === financeRemarkVersion) financeRemarkLoading.value = false
}
}
async function saveFinanceRemark() {
if (
!canManageFinanceRemark.value ||
!financeRemarkInstanceId ||
financeRemarkLoading.value ||
financeRemarkSaving.value
)
return
if (financeRemarkText.value.length > 512) {
ElMessage.warning('财务备注最多 512 个字符')
return
}
const version = financeRemarkVersion
financeRemarkSaving.value = true
try {
await fetchUpdateOaFinanceRemark(financeRemarkInstanceId, financeRemarkText.value)
if (version !== financeRemarkVersion) return
cancelFinanceRemark()
ElMessage.success('财务备注已保存')
await load()
} catch {
// 请求层统一提示错误,保留输入以便重试。
} finally {
if (version === financeRemarkVersion) financeRemarkSaving.value = false
}
}
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const total = ref(0) const total = ref(0)
@@ -210,9 +334,7 @@
function currentPrinterName() { function currentPrinterName() {
const info: any = userStore.info || userStore.getUserInfo || {} const info: any = userStore.info || userStore.getUserInfo || {}
return String( return String(info.realName || info.nickName || info.userName || info.username || '').trim()
info.realName || info.nickName || info.userName || info.username || ''
).trim()
} }
function statusType(s: string) { function statusType(s: string) {
@@ -306,16 +428,24 @@
} }
async function load() { async function load() {
if (!pageActive) return
const version = ++listVersion
clearFinanceAccess()
loading.value = true loading.value = true
try { try {
const res = await fetchOaTasks('done', buildFilterParams()) const res = await fetchOaTasks('done', buildFilterParams())
if (version !== listVersion) return
canManageFinanceRemark.value = res?.canManageFinanceRemark === true
rows.value = res?.list || [] rows.value = res?.list || []
if (!canManageFinanceRemark.value) clearFinanceAccess()
total.value = res?.total || 0 total.value = res?.total || 0
} catch { } catch {
if (version !== listVersion) return
clearFinanceAccess()
rows.value = [] rows.value = []
total.value = 0 total.value = 0
} finally { } finally {
loading.value = false if (version === listVersion) loading.value = false
} }
} }
@@ -328,6 +458,14 @@
{ debounce: 300, deep: true } { debounce: 300, deep: true }
) )
onDeactivated(() => {
pageActive = false
listVersion++
clearFinanceAccess()
})
onMounted(load) onMounted(load)
onActivated(load) onActivated(() => {
pageActive = true
void load()
})
</script> </script>