fix(核算): 对齐抵顶口径并完善界面细节

回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
This commit is contained in:
liuwenxin 2026-11-06 16:27:45 +08:00
parent 35fea3802f
commit b3de35c2ec
53 changed files with 1381 additions and 362 deletions

10
.gitignore vendored
View File

@ -1,5 +1,7 @@
# AI辅助目录(整个目录忽略) # AI辅助目录(整个目录忽略)
.ai/ .ai/
.agents/
/AGENTS.md
# 依赖 # 依赖
node_modules/ node_modules/
@ -53,6 +55,7 @@ frontend/e2e/**/*.png
*.tmp *.tmp
*.bak *.bak
*.cache *.cache
.tmp-processes*.txt
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx) # Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
~$* ~$*
@ -67,6 +70,12 @@ backend/uploads/
db/backups/ db/backups/
# 客户发来的原始账套备份压缩包。 # 客户发来的原始账套备份压缩包。
/db/*.zip /db/*.zip
# 本地恢复与连通性排查脚本/结果,不作为业务交付物。
/db/restore_*.ps1
/db/restore_*.sql
/db/restore_result.txt
/db/probe*.ps1
/backend/probe_*_status.py
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库 # 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
docs/**/*.rar docs/**/*.rar
@ -82,3 +91,4 @@ backend/.venv-workbench/
# 本地浏览器验收自动生成的快照 # 本地浏览器验收自动生成的快照
/.playwright-mcp/ /.playwright-mcp/
/phase2-evidence-*/

View File

@ -43,6 +43,8 @@ T_PLUS_USER=
T_PLUS_PASSWORD= T_PLUS_PASSWORD=
# 仅用于已确认的开发测试库,默认禁止使用 sa。 # 仅用于已确认的开发测试库,默认禁止使用 sa。
T_PLUS_ALLOW_DEV_SA=false T_PLUS_ALLOW_DEV_SA=false
# 仅在客户确认只能使用管理员账号时显式开启。
T_PLUS_ALLOW_ADMIN_ACCOUNT=false
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。 # 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
T_PLUS_CONNECTION_FILE= T_PLUS_CONNECTION_FILE=
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。 # 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
@ -70,5 +72,7 @@ T_PLUS_SCHEDULE_WEEKDAY=0
T_PLUS_SCHEDULE_TIME=02:00 T_PLUS_SCHEDULE_TIME=02:00
# 托管启动器自动设置;发现标记后停止排队和领取新任务 # 托管启动器自动设置;发现标记后停止排队和领取新任务
T_PLUS_STOP_FILE= T_PLUS_STOP_FILE=
# 仅允许本机 HTTP 健康检查地址;API 更换监听端口时同步修改。
T_PLUS_SUPERVISOR_HEALTH_URL=http://127.0.0.1:8000/api/health
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。 # 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
UPLOAD_DIR= UPLOAD_DIR=

View File

@ -43,12 +43,18 @@ class CalcTemplateOptionOut(BaseModel):
class FormulaOptionOut(BaseModel): class FormulaOptionOut(BaseModel):
"""公式骨架下拉项(新增模板时选择,受控集合)。""" """公式骨架下拉项(新增模板时选择,受控集合)。
role_labels / role_hints 把英文角色键翻译成业务可读的中文标签与提示,
模板表单据此渲染每个参数槽位的标签与 placeholder。
"""
code: str code: str
name: str name: str
description: str description: str
roles: list[str] roles: list[str]
role_labels: dict[str, str] = Field(default_factory=dict)
role_hints: dict[str, str] = Field(default_factory=dict)
class CalcTemplateCreateIn(BaseModel): class CalcTemplateCreateIn(BaseModel):

View File

@ -45,7 +45,6 @@ from app.schemas.calc import CalcReceiptOut
from app.services import audit_service, param_service, personnel_service from app.services import audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import ( from app.services.formula import (
apply_barter,
clamp_weight_r, clamp_weight_r,
converted_profit, converted_profit,
payment_days, payment_days,
@ -366,12 +365,10 @@ def _compose_amounts(
performance = ( performance = (
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate) Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
) )
barter_applied = receipt.is_barter == 1 barter_applied = False
# 以物抵顶口径(2026-11-03 决策):抵顶回款线下核算绩效,系统不做自动折算。
# 原回款的 is_barter 标记保留;barter_applied=False 表示没有执行折算。
base_amount = performance base_amount = performance
if barter_applied:
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
base_amount = apply_barter(performance, discount)
received_total = business.total_received_amount or Decimal("0") received_total = business.total_received_amount or Decimal("0")
start_ratio = parse_fraction( start_ratio = parse_fraction(
snapshot.get("payment.start.ratio", role_code=role_code_of(person)) snapshot.get("payment.start.ratio", role_code=role_code_of(person))

View File

@ -75,7 +75,14 @@ def list_templates(session) -> PageResult[CalcTemplateOut]:
def list_formula_options() -> list[dict]: def list_formula_options() -> list[dict]:
"""骨架下拉(受控集合)。""" """骨架下拉(受控集合)。"""
return [ return [
{"code": spec.code, "name": spec.name, "description": spec.description, "roles": list(spec.roles)} {
"code": spec.code,
"name": spec.name,
"description": spec.description,
"roles": list(spec.roles),
"role_labels": dict(spec.role_labels),
"role_hints": dict(spec.role_hints),
}
for spec in FORMULAS.values() for spec in FORMULAS.values()
] ]

View File

@ -12,11 +12,17 @@ from sqlalchemy.orm import Session
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class FormulaSpec: class FormulaSpec:
"""公式骨架元数据:参数角色 → 模板参数映射键。""" """公式骨架元数据:参数角色 → 模板参数映射键。
role_labels 把英文角色键翻译成业务可读的中文标签(模板表单展示),
role_hints 进一步解释该槽位承载的口径(placeholder 提示)。
"""
code: str code: str
name: str name: str
roles: tuple[str, ...] roles: tuple[str, ...]
role_labels: dict[str, str]
role_hints: dict[str, str]
description: str description: str
@ -26,30 +32,40 @@ FORMULAS: dict[str, FormulaSpec] = {
code="linear_decay", code="linear_decay",
name="天数线性递减", name="天数线性递减",
roles=("start", "slope", "floor", "boundary"), roles=("start", "slope", "floor", "boundary"),
role_labels={"start": "起始比例", "slope": "日递减斜率", "floor": "下限比例", "boundary": "递减截止天数"},
role_hints={"start": "回款第 1 天适用的计提比例", "slope": "每延后一天递减的幅度", "floor": "超过截止天数后固定使用的比例", "boundary": "按日递减的天数上限"},
description="按回款天数线性递减,超过截止天数取下限比例", description="按回款天数线性递减,超过截止天数取下限比例",
), ),
"fixed_profit": FormulaSpec( "fixed_profit": FormulaSpec(
code="fixed_profit", code="fixed_profit",
name="折算毛利固定比例", name="折算毛利固定比例",
roles=("rate",), roles=("rate",),
role_labels={"rate": "计提比例"},
role_hints={"rate": "折算毛利润额乘以的固定比例"},
description="折算毛利润额 × 固定比例", description="折算毛利润额 × 固定比例",
), ),
"fixed_sales": FormulaSpec( "fixed_sales": FormulaSpec(
code="fixed_sales", code="fixed_sales",
name="销售额固定比例", name="销售额固定比例",
roles=("rate",), roles=("rate",),
role_labels={"rate": "计提比例"},
role_hints={"rate": "本人销售额乘以的固定比例"},
description="本人销售额 × 固定比例", description="本人销售额 × 固定比例",
), ),
"manager_share": FormulaSpec( "manager_share": FormulaSpec(
code="manager_share", code="manager_share",
name="经理团队分成", name="经理团队分成",
roles=("team", "company"), roles=("team", "company"),
role_labels={"team": "团队分成比例", "company": "全公司分成比例"},
role_hints={"team": "本团队业务员绩效乘以的比例", "company": "全公司业务员绩效乘以的比例"},
description="本团队绩效 × 团队比例 + 全公司绩效 × 公司比例", description="本团队绩效 × 团队比例 + 全公司绩效 × 公司比例",
), ),
"prepay_fixed": FormulaSpec( "prepay_fixed": FormulaSpec(
code="prepay_fixed", code="prepay_fixed",
name="预付款固定比例", name="预付款固定比例",
roles=("rate",), roles=("rate",),
role_labels={"rate": "计提比例"},
role_hints={"rate": "预付回款额乘以的固定比例"},
description="预付回款额 × 固定比例", description="预付回款额 × 固定比例",
), ),
} }

View File

@ -175,7 +175,7 @@ def confirm_receipt(session, receipt_id, payload, *, operator, meta=None) -> Rec
if not receipt_confirmation.is_confirmed(receipt): if not receipt_confirmation.is_confirmed(receipt):
receipt.confirmation_status = 1 receipt.confirmation_status = 1
receipt.confirmed_by = operator.username receipt.confirmed_by = operator.username
receipt.confirmed_at = datetime.now() receipt.confirmed_at = datetime.now().replace(microsecond=0)
receipt.confirmed_revision = payload.expected_revision receipt.confirmed_revision = payload.expected_revision
out = get_detail(session, receipt_id) out = get_detail(session, receipt_id)
session.commit() session.commit()

View File

@ -393,3 +393,15 @@ uv run pytest tests/integration -q # 仅集成
- 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。 - 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。
- 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。 - 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。
- 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。 - 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。
## 更正单与备份恢复(2026-11-03)
105 项定点单元、37 项隔离 MySQL 集成通过。新用例覆盖正负成对核对、回款转原业务、来源防重/原子拒绝/同号实体隔离、来源变化拦截、普通抵顶不打折、备份篡改/断链/服务器更换/无更新不归档。独立 Docker 全量+增量重放逐值核对通过;说明及证据见 `docs/更正单与备份恢复交付_2026-11-03.md`。
## 真实样本闭环与历史岗位(2026-11-03)
新增 `tests/unit/test_historical_roles.py`,覆盖按月任职区间、重叠拒绝、跨月转岗、经理本人业务排除、正式台账与月结冻结岗位、客户负责人暂缺、带实际成交参照的产品导入。完整单元 961 项、隔离 MySQL 专项 19 项通过;辽宁兴晟达真实样本完成正式导入至月结、四张报表及导出,30 个不同检查点通过。证据与边界见 `docs/真实样本业务闭环验收_2026-11-03.md`。
定点复验:`python -m pytest tests/unit/test_historical_roles.py -q`。真实样本只证明本组经理本人排除场景,不能据 0 元结果认定人员整月工资为零。

View File

@ -390,9 +390,11 @@ def tplus_tasks(monkeypatch):
@pytest.fixture @pytest.fixture
def legacy_calculation(monkeypatch): def legacy_calculation(monkeypatch):
"""明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。""" """旧引擎回归使用关闭确认开关的口径;确认门槛由独立用例覆盖。"""
from app.core.config import get_settings from app.core.config import get_settings
from app.services import receipt_confirmation
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True) monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True)
monkeypatch.setattr(receipt_confirmation, "enabled", lambda _session: False)
@pytest.fixture @pytest.fixture

View File

@ -307,11 +307,10 @@ class TestGuards:
resp = _calc(client, admin["headers"], receipt["id"]) resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
ledger = resp.json()["data"]["ledger"] ledger = resp.json()["data"]["ledger"]
# 成本缺失 → 毛利按 0 → 折算 = 销售额×(0−30%) = **−3000**(保守少算 # 旧引擎缺成本标记保留,零毛利按现行公式特例折算为零。
# 方向:缺成本的行宁少算不多发),missing_cost=1
assert ledger["missing_cost"] == 1 assert ledger["missing_cost"] == 1
assert float(ledger["converted_profit"]) == -3000.0 assert float(ledger["converted_profit"]) == 0.0
assert float(ledger["performance_amount"]) == pytest.approx(-29.33, abs=0.01) assert float(ledger["performance_amount"]) == 0.0
def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None: def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None:
items = [ items = [

View File

@ -30,8 +30,9 @@ def post(client, path, headers, data):
@pytest.fixture @pytest.fixture
def formal_chain(client, admin): def unconfirmed_formal_chain(client, admin, request):
headers = admin["headers"] headers = admin["headers"]
unit_cost = getattr(request, "param", {}).get("unit_cost", "25")
with get_session_factory()() as s: with get_session_factory()() as s:
roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))} roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))}
persons = [] persons = []
@ -56,7 +57,7 @@ def formal_chain(client, admin):
"product_category": "municipal_water", "product_category": "municipal_water",
"quantity": "2", "quantity": "2",
"sale_price": "50", "sale_price": "50",
"guided_price": "25", "guided_price": unit_cost,
} }
business = post( business = post(
client, client,
@ -103,7 +104,7 @@ def formal_chain(client, admin):
"shipment_item_id": detail["items"][0]["id"], "shipment_item_id": detail["items"][0]["id"],
"personnel_id": owner["id"], "personnel_id": owner["id"],
"manager_id": manager["id"], "manager_id": manager["id"],
"unit_cost": "25", "unit_cost": unit_cost,
} }
], ],
} }
@ -111,6 +112,15 @@ def formal_chain(client, admin):
return business, receipt, setup, owner, manager return business, receipt, setup, owner, manager
@pytest.fixture
def formal_chain(client, admin, unconfirmed_formal_chain):
business, receipt, setup, owner, manager = unconfirmed_formal_chain
confirmed = post(client, f"/api/v1/business/receipts/{receipt['id']}/confirm", admin["headers"], {
"expected_revision": receipt["confirmation_revision"],
})
return business, confirmed, setup, owner, manager
def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain): def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain):
business, _, setup, owner, manager = formal_chain business, _, setup, owner, manager = formal_chain
headers = admin["headers"] headers = admin["headers"]
@ -321,3 +331,30 @@ def test_无正式资料时默认拒绝旧核算(client, admin, formal_chain):
f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"] f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"]
) )
assert response.status_code == 400 and "正式核算" in response.json()["message"] assert response.status_code == 400 and "正式核算" in response.json()["message"]
def test_抵顶标记不阻断正式平账且不自动打八折(client, admin, formal_chain):
from app.models import Receipt
business, cash, setup, _, _ = formal_chain
with get_session_factory()() as session:
receipt = session.get(Receipt, cash["id"])
receipt.is_barter = 1
session.commit()
post(client, f"{BASE}/businesses/{business['id']}/input", admin["headers"],
{**setup, "expected_version": 1})
rejected = client.post(f"{BASE}/businesses/{business['id']}/calculate", headers=admin["headers"],
json={"month": "2026-03"})
assert rejected.status_code == 400 and "人工确认" in rejected.json()["message"]
current = client.get(f"/api/v1/business/receipts/{cash['id']}", headers=admin["headers"])
assert current.status_code == 200, current.text
post(client, f"/api/v1/business/receipts/{cash['id']}/confirm", admin["headers"], {
"expected_revision": current.json()["data"]["confirmation_revision"],
})
result = post(client, f"{BASE}/businesses/{business['id']}/calculate", admin["headers"],
{"month": "2026-03"})
assert result["performance"] == "0.20" and result["payable"] == "0.20"
with get_session_factory()() as session:
assert session.get(Receipt, cash["id"]).is_barter == 1
assert all(session.get(PerformanceLedger, ledger_id).barter_applied == 0
for ledger_id in result["ledger_ids"])

View File

@ -87,10 +87,11 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
class TestCatalog: class TestCatalog:
"""参数目录:种子完整性与时点化回溯。""" """参数目录:种子完整性与时点化回溯。"""
def test_目录含27项及产品系数组(self, client: TestClient, admin: dict) -> None: def test_目录含28项及产品系数组(self, client: TestClient, admin: dict) -> None:
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。""" """目录包含回款人工确认开关和既有参数。"""
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"] items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 27 assert len(items) == 28
assert any(item["param_code"] == "receipt.confirmation.enabled" for item in items)
groups = {item["param_group"] for item in items} groups = {item["param_group"] for item in items}
assert groups == { assert groups == {
"业务员个人户", "业务员个人户",
@ -99,6 +100,7 @@ class TestCatalog:
"企业户", "企业户",
"信息员", "信息员",
"支付环节", "支付环节",
"回款确认",
"产品阈值", "产品阈值",
"月工资口径", "月工资口径",
"审批开关", "审批开关",
@ -355,7 +357,7 @@ class TestPreview:
"salesperson.rate.floor", "salesperson.rate.floor",
"salesperson.days.boundary", "salesperson.days.boundary",
"product.threshold", "product.threshold",
"barter.discount", # 2026-11-03 口径:抵顶线下核算,预览不再读取 barter.discount(参数保留作回退位)。
"payment.start.ratio", "payment.start.ratio",
"probation.annual.threshold", "probation.annual.threshold",
] ]
@ -495,7 +497,7 @@ class TestPermission:
session.commit() session.commit()
resp = client.get(_PARAMS, headers=headers) resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.json()["data"]) == 27 assert len(resp.json()["data"]) == 28
def test_未登录访问目录_403(self, client: TestClient) -> None: def test_未登录访问目录_403(self, client: TestClient) -> None:
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401 # LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401

View File

@ -10,6 +10,8 @@ from app.core.db import get_session_factory
from app.models import OperationLog, PayrollInput, PerformanceLedger, Receipt from app.models import OperationLog, PayrollInput, PerformanceLedger, Receipt
from tests.integration.test_formal_payroll import ( from tests.integration.test_formal_payroll import (
post, post,
)
from tests.integration.test_formal_payroll import (
unconfirmed_formal_chain as unconfirmed_formal_chain, unconfirmed_formal_chain as unconfirmed_formal_chain,
) )
@ -128,7 +130,7 @@ def test_正式核算零毛利特例但低阈值仍为负(client, admin, unconfi
@pytest.mark.parametrize("role,profit,converted,performance", [ @pytest.mark.parametrize("role,profit,converted,performance", [
("salesperson", "0", "0", "0"), ("salesperson", "0", "0", "0"),
("salesperson", "10", "-20", "-0.20"), ("salesperson", "10", "-20", "-0.20"),
("info_officer", "0", "0", "0.10"), ("info_officer", "0", "100", "0.10"),
]) ])
def test_试算零毛利与信息员基数保持独立(client, admin, role, profit, converted, performance): def test_试算零毛利与信息员基数保持独立(client, admin, role, profit, converted, performance):
data = post(client, "/api/v1/system/params/preview", admin["headers"], { data = post(client, "/api/v1/system/params/preview", admin["headers"], {

View File

@ -12,6 +12,9 @@ from app.core.config import get_settings
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models import ParamChangeBatch, ProbationPayment from app.models import ParamChangeBatch, ProbationPayment
from tests.integration.test_formal_payroll import formal_chain as formal_chain from tests.integration.test_formal_payroll import formal_chain as formal_chain
from tests.integration.test_formal_payroll import (
unconfirmed_formal_chain as unconfirmed_formal_chain,
)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration

View File

@ -14,6 +14,7 @@
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。 注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
""" """
import ast
import re import re
from pathlib import Path from pathlib import Path
@ -24,7 +25,7 @@ MIGRATION_DIR = PROJECT_ROOT / "db" / "migration"
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql" INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions" ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL) _CREATE_TABLE = re.compile(r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL | re.IGNORECASE)
_COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE) _COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE)
# 这些开头的是约束定义,不是列定义 # 这些开头的是约束定义,不是列定义
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"} _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
@ -32,6 +33,7 @@ _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "IN
# MODIFY 只改类型不改列名,对列集合无影响,无需处理 # MODIFY 只改类型不改列名,对列集合无影响,无需处理
_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE) _ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE) _ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
_RENAME_TABLE = re.compile(r"RENAME TABLE\s+`?(\w+)`?\s+TO\s+`?(\w+)`?", re.IGNORECASE)
def _parse_tables(sql: str) -> dict[str, set[str]]: def _parse_tables(sql: str) -> dict[str, set[str]]:
@ -67,6 +69,8 @@ def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
merged.setdefault(table, set()).add(column) merged.setdefault(table, set()).add(column)
for table, column in _ALTER_DROP.findall(sql): for table, column in _ALTER_DROP.findall(sql):
merged.setdefault(table, set()).discard(column) merged.setdefault(table, set()).discard(column)
for old, new in _RENAME_TABLE.findall(sql):
merged[new] = merged.pop(old)
return merged return merged
@ -83,8 +87,15 @@ def _alembic_head() -> str:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE): if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
revisions.add(found.group(1)) revisions.add(found.group(1))
if found := re.search(r"^down_revision(?:\s*:.*?)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE): for node in ast.parse(text).body:
referenced.add(found.group(1)) if isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
if any(isinstance(target, ast.Name) and target.id == "down_revision" for target in targets):
value = ast.literal_eval(node.value)
if isinstance(value, str):
referenced.add(value)
elif isinstance(value, tuple):
referenced.update(value)
heads = revisions - referenced heads = revisions - referenced
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}" assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
@ -94,14 +105,14 @@ def _alembic_head() -> str:
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def migration_tables() -> dict[str, set[str]]: def migration_tables() -> dict[str, set[str]]:
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。""" """按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
files = sorted(MIGRATION_DIR.glob("V*.sql")) files = sorted(MIGRATION_DIR.glob("V*.sql"), key=lambda path: int(re.match(r"V(\d+)", path.name).group(1)))
assert files, f"未找到迁移脚本:{MIGRATION_DIR}" assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
return _migration_final_state(files) return _migration_final_state(files)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def init_dev_tables() -> dict[str, set[str]]: def init_dev_tables() -> dict[str, set[str]]:
return _parse_tables(INIT_DEV_FILE.read_text(encoding="utf-8")) return _migration_final_state([INIT_DEV_FILE])
class TestSchemaSync: class TestSchemaSync:
@ -111,6 +122,16 @@ class TestSchemaSync:
assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}" assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}" assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
def test_迁移解析支持建表后增加列与重命名(self, tmp_path: Path) -> None:
migration = tmp_path / "V1__example.sql"
migration.write_text(
"CREATE TABLE old_name (\n id BIGINT\n);\n"
"ALTER TABLE old_name ADD extra VARCHAR(8);\n"
"RENAME TABLE old_name TO new_name;\n",
encoding="utf-8",
)
assert _migration_final_state([migration]) == {"new_name": {"id", "extra"}}
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None: def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
assert set(init_dev_tables) == set(migration_tables), ( assert set(init_dev_tables) == set(migration_tables), (
"两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 " "两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 "

View File

@ -32,6 +32,7 @@ from app.services.payroll_access import can_write_payroll
from app.services.tplus.business_import import execute_business from app.services.tplus.business_import import execute_business
from app.services.tplus.workbench import digest from app.services.tplus.workbench import digest
from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview
from tests.unit.test_tplus_workbench import source as publish_source
from tests.unit.test_tplus_workbench import work_session as work_session from tests.unit.test_tplus_workbench import work_session as work_session
@ -192,6 +193,96 @@ def test_正式导入防重与来源可追溯(payroll_case):
assert record.data_json["source_facts"] assert record.data_json["source_facts"]
@pytest.fixture
def correction_case(payroll_case, monkeypatch):
from contextlib import nullcontext
from app.models import TPlusSyncRecord
from app.schemas.tplus_workbench import CorrectionPairIn
from app.services.tplus import corrections
from app.services.tplus.snapshots import source_records
s, user, _ = payroll_case
original = source_records(s, "test_source", "shipments")[0].payload_json
original_line = source_records(s, "test_source", "shipment_items")[0].payload_json
# 收款ID与负更正发货ID相同,必须由实体前缀区分。
publish_source(s, "shipments", [original,
{**original, "id": 30, "code": "A-NEG"},
{**original, "id": 40, "code": "B-POS", "idcustomer": 88}])
publish_source(s, "shipment_items", [original_line,
{**original_line, "id": 31, "source_parent_id": 30, "taxAmount": "-100", "quantity": "-2"},
{**original_line, "id": 41, "source_parent_id": 40}])
publish_source(s, "receipts", [{"id": 30, "code": "B-CASH", "idpartner": 88,
"voucherDate": "2026-03-10", "isReceiveFlag": 1, "voucherstate": 189, "amount": "100",
"accounting_voucher_count": 1, "valid_accounting_voucher_count": 1,
"accounting_voucher_id": 123, "accounting_voucher_code": "TEST-GL"}])
monkeypatch.setattr(corrections, "sync_lock", lambda db: nullcontext())
payload = CorrectionPairIn(negative_shipment_id="30", positive_shipment_id="40",
receipt_ids=["30"], target_group_key="g", evidence="隔离转客户例单核对")
before = s.scalar(select(func.count()).select_from(TPlusSyncRecord))
return s, user, corrections, payload, before
def test_更正对贯通正式导入与FIFO_保留日期金额且重复不增加(payroll_case, correction_case):
from app.models import TPlusFact, TPlusSyncRecord
s, user, service, payload, before = correction_case
first = service.save_pair(s, payload, user.id)
again = service.save_pair(s, payload, user.id)
assert first["pair_key"] == again["pair_key"]
assert s.scalar(select(func.count()).select_from(TPlusFact).where(TPlusFact.kind == "CORRECTION")) == 3
business = imported(payroll_case)
assert business.total_sale_amount == Decimal("100")
cash = s.scalar(select(Receipt))
assert cash.source_no == "B-CASH" and cash.receipt_date == date(2026, 3, 10)
assert cash.amount == Decimal("100") and cash.business_id == business.id
assert s.scalar(select(func.count()).select_from(Shipment)) == 1
assert s.scalar(select(func.count()).select_from(TPlusSyncRecord)) == before
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
assert result["performance"] == "0.20" and result["payable"] == "0.20"
repeated = business_work(s, user, "BUSINESS_PREVIEW")
assert repeated.summary_json["counts"] == {"SKIP": 1}
with pytest.raises(BadRequestError):
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
@pytest.mark.parametrize("change", ["wrong_sign", "wrong_group", "wrong_receipt", "missing_source"])
def test_无效更正对原子拒绝不留下半边事实(correction_case, change):
from app.models import TPlusFact
s, user, service, payload, _ = correction_case
updates = {"wrong_sign": {"negative_shipment_id": "40", "positive_shipment_id": "30"},
"wrong_group": {"target_group_key": "unknown"},
"wrong_receipt": {"receipt_ids": ["999"]},
"missing_source": {"positive_shipment_id": "999"}}
with pytest.raises(BadRequestError):
service.save_pair(s, payload.model_copy(update=updates[change]), user.id)
assert s.scalar(select(func.count()).select_from(TPlusFact).where(TPlusFact.kind == "CORRECTION")) == 0
def test_更正对后来源变化阻止静默排除(correction_case):
from app.services.tplus.snapshots import source_records
from app.services.tplus.workbench import latest_facts
s, user, service, payload, _ = correction_case
service.save_pair(s, payload, user.id)
rows = [r.payload_json for r in source_records(s, "test_source", "shipments")]
publish_source(s, "shipments", [{**r, "code": "CHANGED"} if r["id"] == 30 else r for r in rows])
record = source_records(s, "test_source", "shipments", ids=["30"])[0]
with pytest.raises(ValueError, match="CORRECTION_RECONFIRM_REQUIRED"):
service.verified_correction(s, "test_source", latest_facts(s, "test_source"), "SHIPMENT", record)
def test_抵顶预览显示未自动折算且不读取折扣参数():
from app.services.param_service import _preview_barter
def forbidden(_):
raise AssertionError("线下抵顶不得读取自动折扣参数")
result = _preview_barter(SimpleNamespace(payload=SimpleNamespace(barter=True), take=forbidden), Decimal("100"))
assert result.base_amount == Decimal("100")
assert result.applied is False and result.after_barter is None
def test_正式FIFO台账金额与逐段过程守恒(payroll_case): def test_正式FIFO台账金额与逐段过程守恒(payroll_case):
s, user, manager = payroll_case s, user, manager = payroll_case
business = imported(payroll_case) business = imported(payroll_case)

View File

@ -113,6 +113,12 @@ class TestConvertedProfit:
def test_毛利润额为零_折算为零_不除零(self) -> None: def test_毛利润额为零_折算为零_不除零(self) -> None:
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0") assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
def test_有销售但零毛利润_折算及普通绩效均为零(self) -> None:
rate = profit_rate(Decimal("100") - Decimal("100"), Decimal("100"))
base = converted_profit(Decimal("100"), rate, Fraction(30))
assert base == Decimal("0")
assert performance_amount(base, Fraction(1, 100)) == Decimal("0")
def test_毛利润率低于阈值_折算为负_不归零(self) -> None: def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算) # 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20") assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")

View File

@ -17,6 +17,7 @@ EXPECTED_TABLES = {
"authorization_settings", "user_permission_overrides", "authorization_settings", "user_permission_overrides",
"tplus_automation_states", "tplus_automation_states",
"tplus_automation_configs", "tplus_automation_configs",
"tplus_runtime_controls",
"payroll_months", "payroll_inputs", "payroll_allocations", "payroll_months", "payroll_inputs", "payroll_allocations",
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines", "tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
"users", "users",

View File

@ -207,6 +207,12 @@ class TestExcel模板:
text = _sheet_text(_build("business-stats", rows)) text = _sheet_text(_build("business-stats", rows))
assert "已完成" in text assert "已完成" in text
def test_业务销售额未知时导出明细和合计不显示零(self):
rows = [{"business_no": "YW001", "status": 1, "total_sale_amount": None}]
workbook = load_workbook(io.BytesIO(_build("business-stats", rows)))
values = [cell.value for row in workbook.active.iter_rows() for cell in row]
assert values.count("待核对") >= 2
def _member( def _member(
personnel_id: int, personnel_id: int,

View File

@ -4,9 +4,9 @@ from unittest.mock import Mock
import pytest import pytest
from app.api.v1.business.businesses import confirm_source_price from app.api.v1.business.businesses import confirm_guidance, confirm_source_price
from app.core.exceptions import ForbiddenError from app.core.exceptions import ForbiddenError
from app.schemas.business_source import SourcePriceConfirmIn from app.schemas.business_source import GuidanceConfirmIn, SourcePriceConfirmIn
from app.services import authorization_service, business_source from app.services import authorization_service, business_source
from app.services.authorization_policy import permission_for from app.services.authorization_policy import permission_for
@ -29,3 +29,46 @@ def test_无核算准备权限不能确认来源单价(monkeypatch):
with pytest.raises(ForbiddenError): with pytest.raises(ForbiddenError):
confirm_source_price(session, actor, 1, 1, payload, meta) confirm_source_price(session, actor, 1, 1, payload, meta)
confirm.assert_not_called() confirm.assert_not_called()
def test_指导成本登记必须同时具备核算准备和事实维护权限(monkeypatch):
session, actor, meta = Mock(), Mock(), Mock()
save = Mock(return_value={"status": "CREATED"})
permissions = []
def allow(current_session, current_actor, permission):
assert (current_session, current_actor) == (session, actor)
permissions.append(permission)
monkeypatch.setattr(authorization_service, "require_permission", allow)
monkeypatch.setattr(business_source, "confirm_guidance", save)
payload = GuidanceConfirmIn(shipment_item_id=1, amount="2.1234", evidence="核对月度指导成本")
result = confirm_guidance(session, actor, 1, payload, meta)
assert result.data == {"status": "CREATED"}
assert permissions == ["payroll:prepare", "tplus:facts"]
save.assert_called_once_with(session, 1, payload, actor, meta)
def test_缺事实维护权限不能登记指导成本(monkeypatch):
session, actor, meta = Mock(), Mock(), Mock()
save = Mock()
def deny(current_session, current_actor, permission):
if permission == "tplus:facts":
raise ForbiddenError("无权维护事实")
monkeypatch.setattr(authorization_service, "require_permission", deny)
monkeypatch.setattr(business_source, "confirm_guidance", save)
payload = GuidanceConfirmIn(shipment_item_id=1, amount="2", evidence="核对")
with pytest.raises(ForbiddenError):
confirm_guidance(session, actor, 1, payload, meta)
save.assert_not_called()
@pytest.mark.parametrize("amount", ["abc", "-1", "1.12345", "NaN"])
def test_指导成本入参拒绝非法精度和值(amount):
with pytest.raises(ValueError):
GuidanceConfirmIn(shipment_item_id=1, amount=amount, evidence="核对")

View File

@ -1,10 +1,7 @@
"""业务接入以源单据为边界;核算资料缺失不得阻断建档。""" """业务接入以源单据为边界;核算资料缺失不得阻断建档。"""
from contextlib import nullcontext
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from fractions import Fraction
from types import SimpleNamespace
from uuid import NAMESPACE_URL, uuid4, uuid5 from uuid import NAMESPACE_URL, uuid4, uuid5
import pytest import pytest
@ -17,7 +14,6 @@ from app.models import (
BusinessItem, BusinessItem,
BusinessRole, BusinessRole,
Customer, Customer,
OwnershipChangeLog,
PayrollInput, PayrollInput,
Personnel, Personnel,
Product, Product,
@ -26,41 +22,32 @@ from app.models import (
ShipmentItem, ShipmentItem,
TPlusBusinessDocument, TPlusBusinessDocument,
TPlusBusinessSyncConfig, TPlusBusinessSyncConfig,
TPlusSourceHead,
TPlusSyncBatch, TPlusSyncBatch,
TPlusWorkRun, TPlusWorkRun,
User, User,
) )
from app.repositories import customer_repository, product_repository from app.repositories import customer_repository, product_repository
from app.schemas.business_source import ( from app.schemas.business_source import (
SalesRelationConfirmIn,
SalesRelationReleaseIn,
SourceMaterialsIn, SourceMaterialsIn,
SourcePriceConfirmIn,
) )
from app.schemas.category_batch import ( from app.schemas.category_batch import (
CustomerCategoryBatch, CustomerCategoryBatch,
CustomerOwnershipBatch,
ProductCategoryBatch, ProductCategoryBatch,
) )
from app.schemas.customer import CustomerQuery from app.schemas.customer import CustomerQuery
from app.schemas.payroll import PayrollSetupIn from app.schemas.payroll import PayrollSetupIn
from app.schemas.product import ProductQuery from app.schemas.product import ProductQuery
from app.schemas.report import BusinessStatsQuery
from app.services import ( from app.services import (
business_service, business_service,
business_source, business_source,
category_batch, category_batch,
customer_service,
payroll_months, payroll_months,
receipt_confirmation, receipt_confirmation,
report_service,
) )
from app.services.business_source import ensure_source_editable from app.services.tplus import business_sync, business_sync_control
from app.services.tplus import business_sync, business_sync_control, sales_relations
from app.services.tplus.snapshots import source_manifest from app.services.tplus.snapshots import source_manifest
from app.services.tplus.workbench import WorkbenchError, digest, execute_next from app.services.tplus.workbench import WorkbenchError, digest, execute_next
from tests.unit.test_tplus_workbench import fact, source from tests.unit.test_tplus_workbench import source
from tests.unit.test_tplus_workbench import work_session as work_session from tests.unit.test_tplus_workbench import work_session as work_session

Binary file not shown.

View File

@ -54,7 +54,7 @@ Windows 本地开发任务目录为 `\PayrollManagementSystem-Dev\`,新增三
| `T_PLUS_CONNECT_TIMEOUT/QUERY_TIMEOUT` | 默认 10/120 秒;查询超时范围 1–600 秒 | | `T_PLUS_CONNECT_TIMEOUT/QUERY_TIMEOUT` | 默认 10/120 秒;查询超时范围 1–600 秒 |
| `T_PLUS_QUERY_DIR` | 留空使用仓库 `db/queries/tplus/`;部署时须交付查询文件 | | `T_PLUS_QUERY_DIR` | 留空使用仓库 `db/queries/tplus/`;部署时须交付查询文件 |
连接文件只接受 `host`、`port`(整数)、`database`、`user`、`password`、`driver` 六个键,最大 16 KB;拒绝 `sa` 和额外配置项。文件覆盖源库连接字段,TLS 和超时仍取应用配置。连接文件、真实密码与完整连接串不入 Git、不贴到确认单或报告。 连接文件只接受 `host`、`port`(整数)、`database`、`user`、`password`、`driver` 六个键,最大 16 KB,拒绝额外配置项。默认拒绝 `sa`;客户确认只能使用管理员账号时,须在应用环境中显式设置 `T_PLUS_ALLOW_ADMIN_ACCOUNT=true`,不能在连接文件内设置。文件覆盖源库连接字段,TLS 和超时仍取应用配置。连接文件、真实密码与完整连接串不入 Git、不贴到确认单或报告。
默认加密可能使旧驱动或未配置 TLS 的实例连接失败。先核对驱动与服务端能力;确需临时回退时,仅在隔离开发环境显式设置 `T_PLUS_ENCRYPT=false`,记录原因、范围和复测结果。此次变更只更新代码默认值与模板,既有私有配置不会自动被覆盖。真实 10.50.1600 的 TLS 兼容仍待实测。 默认加密可能使旧驱动或未配置 TLS 的实例连接失败。先核对驱动与服务端能力;确需临时回退时,仅在隔离开发环境显式设置 `T_PLUS_ENCRYPT=false`,记录原因、范围和复测结果。此次变更只更新代码默认值与模板,既有私有配置不会自动被覆盖。真实 10.50.1600 的 TLS 兼容仍待实测。

View File

@ -1,5 +1,7 @@
# 辽宁兴晟达物资有限公司 2026 年 7 月实际样本核对 # 辽宁兴晟达物资有限公司 2026 年 7 月实际样本核对
> 2026-11-03 后续:已修复历史岗位衔接及真实产品导入阻塞,并使用本组真实数据在隔离 MySQL 完成正式导入、核算、月结、报表及导出。结果见[真实样本业务闭环验收](真实样本业务闭环验收_2026-11-03.md)。下文保留 11 月 2 日只读核对时的阶段记录;本次未向原开发库生成工资。
记录日期:2026-11-02。来源:本次会话中用户转述的客户回复及本地已发布 T+ 源快照。当前阶段:单据、FIFO、参数及经理本人排除分支的只读核对完成;未执行正式导入或工资生成,也未将本组结果代作整月验收。 记录日期:2026-11-02。来源:本次会话中用户转述的客户回复及本地已发布 T+ 源快照。当前阶段:单据、FIFO、参数及经理本人排除分支的只读核对完成;未执行正式导入或工资生成,也未将本组结果代作整月验收。
## 客户已提供的资料 ## 客户已提供的资料

View File

@ -1,5 +1,43 @@
# 前端 # 前端
## 工作台、同步页与进度动效(2026-11-03)
工作台与 T+ 数据同步页沿用应用内容区全宽,桌面保持 22px 常规留白,与用户管理等页面对齐。工作台改为连续指标区,收紧待办与风险空态,采集状态集中展示,图表缺失月份保留空缺。同步页整理计划、记录、资料导入与关联核验,核验明细与单据分布分开呈现。
进度区分排队、读取或核验、处理结束三个阶段。已知总量按实际百分比以 `scaleX` 过渡;未知总量的轨道保持静止,仅执行指示点淡入淡出。核验没有数量时显示“核验中”。完成、异常或状态更新中断后停止执行动画,减少动态效果模式下停用动画和过渡;任务详情继续只显示所选任务的依据,不重复大进度条。
从项目根执行 `node frontend/e2e/operations_pages.mjs`。本轮 8 组合成 API 回归通过,覆盖两页 320 至 2560px 十二档宽度、68 组页面与弹层布局、七种任务状态、动态独立分页、关联核验、四类工作区、创建范围、计划开关、只读限制及失联恢复。实测比例由 40% 更新至 70%,执行点透明度变化且在减少动态效果时停用;页面错误和布局溢出均为 0,类型检查及生产构建通过。
证据在 `.ai/operations-pages-20261103/`,设计约定见 `docs/design/operations/DESIGN.md`。接口全部拦截为合成响应,未触发真实采集、自动计划设置、资料导入或工资生成。可用 `OPS_UI_URL`、`OPS_UI_OUTPUT` 和 `PLAYWRIGHT_MODULE` 调整验收环境。
## 计提参数整页打磨(2026-11-03)
页面按产品系数、计提规则、待提交变更组织,版本管理、导出记录和计算预览入口放到页头。系数表按连续的产品类别与户型合并重复单元格,精确分数仍直接展示;工具栏与表格保持 16px 间距。
规则支持按名称、编码、说明或分组搜索,以及全部展开/收起。窄屏纵向展示规则详情,待提交变更直接呈现原值和新值;版本、历史与试算抽屉提供独立关闭区,试算操作固定在底部。规则与系数加载失败时分别提示并支持重试,读取和修改权限沿用现有约定。
从项目根执行 `node frontend/e2e/params_page.mjs`。合成 API 回归通过,覆盖 320 至 2560px 九档宽度、21 组页面与弹层布局检查,以及搜索、暂存不写入、版本对比、试算、中文导入预览、故障恢复和只读限制。页面错误 0,类型检查及生产构建通过。截图与结果位于 `.ai/params-page-20261103/`;未写真实参数或工资。
## 全站界面打磨(2026-11-03)
沿用灰白底与靛蓝重点色,统一页面标题、说明、筛选区、表单分组、状态对比度和键盘焦点。产品、人员、客户、业务和用户档案按内容分组;弹窗明确区分标题、可滚动正文和固定操作区,常用编辑弹窗提交中禁止关闭。
表格补齐长字段提示、金额与数量右对齐,修正操作列宽度。窄屏筛选控件铺满行,分页的前后按钮与页码保持同行,表格操作列随表格横向滚动;日期范围双月改为纵向排列并在视口内滚动。14 个列表页接入持续错误提示和重新加载。工作台手机指标采用两列,月结主流程位于年度结清之前,参数目录保留可读列宽,四种打印预览支持独立横向查看。
登录页支持验证码加载失败后重试;无权限页面重新检查成功后跳转可访问页面。产品与客户的唯一组合条件保留为字段下方说明,金额仍按接口字符串展示。
回归命令从项目根执行:
```bash
node frontend/e2e/ui_polish.mjs
node frontend/e2e/ledger-dialog-regression.mjs
node frontend/e2e/authorization_roles.mjs
```
`ui_polish.mjs` 使用合成 API,覆盖 22 个主页面的 1440/768/390/320px 布局、档案与专项弹层、产品提取、四类打印预览、列表失败重试、授权刷新和验证码故障恢复。可通过 `UI_POLISH_URL`、`UI_POLISH_OUTPUT`、`PLAYWRIGHT_MODULE` 指定前端地址、证据目录与浏览器依赖。本轮三组浏览器回归通过,页面脚本错误和检测到的横向溢出均为 0;类型检查、生产构建与 Impeccable 静态扫描通过。证据在 `.ai/ui-polish-20261103/`。
本轮浏览器验收拦截全部业务 API,不修改真实账号、业务记录或工资,不替代真实金额联调。当前本机 Rolldown 可选依赖缺少包入口文件,验证时临时设置 `NAPI_RS_NATIVE_LIBRARY_PATH` 指向已有原生二进制,未改依赖版本或锁文件;构建仍有部分分包超过 500kB 的体积提示。
## 产品、人员及首页数据体验(2026-11-02) ## 产品、人员及首页数据体验(2026-11-02)
产品提取入口移到操作栏,来源空值改为明确说明;产品和人员长标签、日期控件及手机布局已修复。人员经理参照只列在职且绑定启用经理计提模板的人员,旧参考关系保留;实际绩效仍按业务分管。岗位编辑说明内置模板与参数维护的区别,提供参数入口。首页以趋势金额是否为 `null` 区分尚无台账/回款与真实零值,并说明正式导入和核算步骤。 产品提取入口移到操作栏,来源空值改为明确说明;产品和人员长标签、日期控件及手机布局已修复。人员经理参照只列在职且绑定启用经理计提模板的人员,旧参考关系保留;实际绩效仍按业务分管。岗位编辑说明内置模板与参数维护的区别,提供参数入口。首页以趋势金额是否为 `null` 区分尚无台账/回款与真实零值,并说明正式导入和核算步骤。
@ -332,3 +370,26 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。 业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。
本轮vue-tsc与Vite构建通过;真实浏览器使用合成接口验证资料表单、封存确认请求及768宽弹窗、默认无审核入口,控制台错误0。界面检查不替代后端真实金额与客户样本验收;详情见 `docs/正式工资闭环开发记录_2026-10-30.md`。 本轮vue-tsc与Vite构建通过;真实浏览器使用合成接口验证资料表单、封存确认请求及768宽弹窗、默认无审核入口,控制台错误0。界面检查不替代后端真实金额与客户样本验收;详情见 `docs/正式工资闭环开发记录_2026-10-30.md`。
## 更正单成对处理(2026-11-03)
T+ 数据应用工作区的补充资料增加“更正单成对处理”。运行 `node frontend/e2e/correction-pairs.mjs`(项目根)隔离验证真实组件,合成接口不访问业务库;4 组检查覆盖必填、完整成对提交、只读和 1440/768/390 布局,脚本错误 0,类型及构建通过。后台真实链路验证另见交付记录。
## 产品提取按钮禁用原因(2026-11-03)
“确认执行提取”须在勾选明细并成功预览后启用,管理员同样遵循该流程。弹窗已增加操作步骤及按钮旁的具体原因,区分未选、未预览、处理中、已完成与真实权限不足。前端构建通过;隔离 Edge 的 5 组检查通过,覆盖管理员预览后可执行、执行后防重、权限说明及 1440/768/390 三宽度,脚本错误 0。证据 `.ai/product-extract-disabled-20261103/result.json`;仅合成接口,未修改真实产品。
## 基础导入明细展示(2026-11-03)
人员、产品、客户基础任务显示源编号、名称、处理结果及原因,产品额外显示规格和单位;金额、发货单、回款天数仅在核算类明细显示。跳过明确区分名单外记录与已导入无变化,旧任务缺少名称时由后端从原始快照补齐展示。过期导入提供“重新生成预览”,沿用原范围,不自动执行导入。
运行 `node frontend/e2e/workbench-details.mjs` 验证真实组件;8 组隔离接口检查覆盖人员/产品/客户、核算列、筛选、原范围重建、过期恢复、明细失败恢复及 1440/768/390 三宽度,结果位于 `.ai/workbench-details-browser/`。已另在实际 T+ 页面只读确认 2026-10-29 历史人员任务的姓名、工号和跳过原因。
## 历史岗位资料(2026-11-03)
T+ 数据同步 → 资料与导入 → 补充资料 → 人员名单,支持保留当前岗位并按开始、结束月份追加已确认历史岗位;起止月份均包含在内,区间重叠由后端拒绝。月份未填完整时前端阻止提交,保存沿用资料版本和权限。客户公司负责人姓名在源导入资料中可暂缺,人工新建客户的必填规则不变。
浏览器隔离组件验证通过:月份回显、完整性校验、实际提交内容、只读保护和 1440/390 宽度,页面错误 0;类型与生产构建通过。真实样本 MySQL 全链路与界面合成接口验证分别记录于 `docs/真实样本业务闭环验收_2026-11-03.md`,证据 `.ai/liaoning-full-chain-20261103/`。

View File

@ -123,6 +123,10 @@ export interface FormulaOption {
name: string name: string
description: string description: string
roles: string[] roles: string[]
/** 角色 → 中文标签(模板表单展示) */
role_labels: Record<string, string>
/** 角色 → 业务口径提示(placeholder) */
role_hints: Record<string, string>
} }
export interface CalcTemplateCreateParams { export interface CalcTemplateCreateParams {

View File

@ -143,6 +143,10 @@ export function confirmMissingSourcePrice(id: number, documentId: number, data:
return request({ url: `/v1/business/businesses/${id}/source-documents/${documentId}/missing-price`, method: 'put', data }) return request({ url: `/v1/business/businesses/${id}/source-documents/${documentId}/missing-price`, method: 'put', data })
} }
export function confirmGuidance(id: number, data: { shipment_item_id: number; amount: string; evidence: string }): Promise<{ status: 'CREATED' | 'REUSED'; fact_id: number; product_id: string; month: string; unit: string; amount: string }> {
return request({ url: `/v1/business/businesses/${id}/guidance`, method: 'put', data })
}
export interface SourceQuantity { export interface SourceQuantity {
product_id: number | null product_id: number | null
product_name: string product_name: string

View File

@ -211,15 +211,16 @@ body {
.page-title { .page-title {
margin: 0; margin: 0;
font-size: 18px; font-size: 20px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--ys-ink); color: var(--ys-ink);
} }
.page-desc { .page-desc {
margin: 6px 0 0; margin: 8px 0 0;
font-size: 12px; font-size: 13px;
line-height: 1.7;
letter-spacing: 0.01em; letter-spacing: 0.01em;
color: var(--ys-ink-3); color: var(--ys-ink-3);
} }
@ -249,6 +250,7 @@ body {
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */ /* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
.card { .card {
min-width: 0;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe); background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
border: 1px solid var(--ys-line); border: 1px solid var(--ys-line);
border-radius: var(--ys-radius); border-radius: var(--ys-radius);
@ -257,6 +259,7 @@ body {
.card-head { .card-head {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@ -276,6 +279,8 @@ body {
.card-foot { .card-foot {
display: flex; display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding: 10px 16px; padding: 10px 16px;
@ -299,6 +304,7 @@ body {
} }
.filter-bar .el-form-item { .filter-bar .el-form-item {
min-width: 0;
margin-right: 0; margin-right: 0;
margin-bottom: 0; margin-bottom: 0;
} }
@ -319,10 +325,20 @@ body {
} }
.toolbar-note { .toolbar-note {
line-height: 1.7;
font-size: 12px; font-size: 12px;
color: var(--ys-ink-3); color: var(--ys-ink-3);
} }
.summary-bar {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 20px;
line-height: 1.7;
font-variant-numeric: tabular-nums;
}
/* 编号/代码类单元格:等宽字体,弱一档颜色 */ /* 编号/代码类单元格:等宽字体,弱一档颜色 */
.cell-code { .cell-code {
font-family: var(--ys-font-mono); font-family: var(--ys-font-mono);
@ -388,13 +404,13 @@ span.cell-num {
} }
.ys-pill--ok { .ys-pill--ok {
color: var(--ys-ok); color: #18764f;
background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2)); background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2));
border-color: rgba(30, 158, 106, 0.2); border-color: rgba(30, 158, 106, 0.2);
} }
.ys-pill--wait { .ys-pill--wait {
color: var(--ys-warn); color: #895d12;
background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2)); background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2));
border-color: rgba(201, 138, 27, 0.22); border-color: rgba(201, 138, 27, 0.22);
} }
@ -406,7 +422,7 @@ span.cell-num {
} }
.ys-pill--reject { .ys-pill--reject {
color: var(--ys-danger); color: #ac3d3d;
background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2)); background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2));
border-color: rgba(201, 79, 79, 0.2); border-color: rgba(201, 79, 79, 0.2);
} }
@ -569,7 +585,7 @@ span.cell-num {
padding: 10px 0; padding: 10px 0;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.04em; letter-spacing: 0.01em;
background-color: transparent; background-color: transparent;
background-image: linear-gradient(180deg, #fafbfc, #f5f6f9); background-image: linear-gradient(180deg, #fafbfc, #f5f6f9);
} }
@ -589,6 +605,39 @@ span.cell-num {
line-height: 1.5; line-height: 1.5;
} }
.el-table th.el-table__cell .cell {
word-break: normal;
overflow-wrap: break-word;
}
.el-table td.is-right .cell,
.el-descriptions__content,
.el-statistic__content {
font-variant-numeric: tabular-nums;
}
.el-table .el-input__inner {
min-width: 0;
}
.el-table .el-table__empty-block {
min-height: 144px;
}
.el-table .el-table__empty-text {
width: min(100%, 440px);
padding: 24px;
line-height: 1.8;
}
.el-table .el-scrollbar__bar.is-horizontal { opacity: 1; height: 8px; }
.el-alert--info.is-light { color: var(--ys-ink-2); }
.el-alert--warning.is-light { color: #895d12; }
.el-alert--success.is-light { color: #18764f; }
.el-alert--error.is-light { color: #ac3d3d; }
.el-alert .el-alert__description { color: inherit; line-height: 1.7; }
.el-table__body tr:hover > td.el-table__cell { .el-table__body tr:hover > td.el-table__cell {
transition: background-color 120ms var(--ys-ease); transition: background-color 120ms var(--ys-ease);
} }
@ -634,7 +683,19 @@ span.cell-num {
} }
.el-drawer__body { .el-drawer__body {
min-height: 0;
padding: 18px 20px; padding: 18px 20px;
overscroll-behavior: contain;
}
.el-drawer__footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--ys-line-soft);
background: var(--ys-mist);
} }
/* 弹窗共享同一组留白;长表单在正文内滚动,操作按钮始终可见。 */ /* 弹窗共享同一组留白;长表单在正文内滚动,操作按钮始终可见。 */
@ -654,7 +715,8 @@ span.cell-num {
.el-dialog__header { .el-dialog__header {
flex-shrink: 0; flex-shrink: 0;
margin-right: 0; margin-right: 0;
padding: 20px 52px 16px 24px; padding: 20px 56px 18px 24px;
border-bottom: 1px solid var(--ys-line-soft);
} }
.el-dialog__title { .el-dialog__title {
@ -662,6 +724,7 @@ span.cell-num {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
line-height: 1.5; line-height: 1.5;
overflow-wrap: anywhere;
} }
.el-dialog__headerbtn { .el-dialog__headerbtn {
@ -673,10 +736,11 @@ span.cell-num {
.el-dialog__body { .el-dialog__body {
min-height: 0; min-height: 0;
padding: 4px 24px 20px; padding: 20px 24px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
color: var(--ys-ink-2); color: var(--ys-ink-2);
line-height: 1.7;
} }
.el-dialog__footer { .el-dialog__footer {
@ -703,6 +767,13 @@ span.cell-num {
.el-message-box { .el-message-box {
max-width: min(var(--el-messagebox-width, 420px), calc(100vw - 32px)); max-width: min(var(--el-messagebox-width, 420px), calc(100vw - 32px));
padding: 22px;
border-radius: var(--ys-radius);
}
.el-message-box__message {
overflow-wrap: anywhere;
line-height: 1.8;
} }
.el-message-box__btns { .el-message-box__btns {
@ -726,6 +797,72 @@ span.cell-num {
line-height: 1.7; line-height: 1.7;
} }
/* 表单分组用于长档案;说明保持在控件外,输入后仍可查阅。 */
.form-section-title {
margin: 24px 0 16px;
padding-top: 20px;
border-top: 1px solid var(--ys-line-soft);
color: var(--ys-ink);
font-size: 14px;
font-weight: 600;
}
.form-section-title:first-child {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.el-dialog__body > .el-form .el-form-item__label {
height: auto;
min-height: 32px;
align-items: center;
line-height: 1.5;
}
.el-dialog__body .el-form-item__content {
min-width: 0;
}
.el-dialog__body .form-hint { flex-basis: 100%; }
.el-dialog__body .el-form-item .el-select,
.el-dialog__body .el-form-item .el-date-editor,
.el-drawer__body .el-form-item .el-select {
max-width: 100%;
}
.el-dialog__body .el-checkbox,
.el-drawer__body .el-checkbox {
max-width: 100%;
}
.el-dialog__body .el-checkbox__label,
.el-drawer__body .el-checkbox__label {
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.6;
}
::selection {
color: var(--ys-ink);
background: var(--el-color-primary-light-8);
}
input, textarea { caret-color: var(--ys-indigo); }
:where(button, a, [tabindex]):focus-visible {
outline: 2px solid var(--ys-indigo);
outline-offset: 3px;
}
.el-button.is-link.el-button--warning { --el-button-text-color: #895d12; }
.el-button.is-link.el-button--success { --el-button-text-color: #18764f; }
.el-button.is-link.el-button--danger { --el-button-text-color: #ac3d3d; }
.el-tag--success { --el-tag-text-color: #18764f; }
.el-tag--warning { --el-tag-text-color: #895d12; }
.el-tag--danger { --el-tag-text-color: #ac3d3d; }
/* 长字段标签由本行撑开高度,窄屏切到控件上方,避免换行侵入下一行。 */ /* 长字段标签由本行撑开高度,窄屏切到控件上方,避免换行侵入下一行。 */
.record-form .el-form-item__label { .record-form .el-form-item__label {
height: auto; height: auto;
@ -754,7 +891,7 @@ span.cell-num {
} }
.el-dialog__body { .el-dialog__body {
padding: 4px 16px 16px; padding: 16px;
} }
.el-dialog__footer { .el-dialog__footer {
@ -764,6 +901,74 @@ span.cell-num {
.el-dialog__footer > .el-button { .el-dialog__footer > .el-button {
min-height: 40px; min-height: 40px;
} }
.filter-bar {
align-items: stretch;
gap: 12px;
padding: 16px;
}
.filter-bar .el-form-item {
display: block;
flex: 1 1 100%;
max-width: 100%;
}
.filter-bar .el-form-item__label,
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item__label {
display: block;
width: auto !important;
min-height: 0;
height: auto;
padding: 0 0 6px;
line-height: 1.5;
text-align: left;
}
.filter-bar .el-form-item__content,
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item__content {
margin-left: 0 !important;
min-width: 0;
}
.filter-bar .el-form-item__content > .el-input,
.filter-bar .el-form-item__content > .el-select,
.filter-bar .el-form-item__content > .el-date-editor {
width: 100% !important;
min-width: 0;
}
.filter-bar__actions { flex-wrap: wrap; width: 100%; margin-left: 0; }
.filter-bar__actions > .el-button { flex: 1 1 auto; min-height: 38px; }
.toolbar { gap: 8px; }
.toolbar-note { flex-basis: 100%; }
.card-foot { padding: 12px; }
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item { display: block; }
.el-dialog__body .el-col-12 { max-width: 100%; flex: 0 0 100%; }
.el-dialog__body .el-checkbox { height: auto; min-height: 34px; }
.el-drawer__header { padding: 16px; }
.el-drawer__body { padding: 16px; }
.el-drawer__footer { padding: 12px 16px; }
.el-picker__popper.el-popper { max-width: calc(100vw - 24px); }
.el-picker__popper.el-popper:has(.el-date-range-picker) {
position: fixed !important;
inset: auto 12px 12px !important;
transform: none !important;
}
.el-picker__popper:has(.el-date-range-picker) .el-popper__arrow { display: none; }
.el-date-range-picker { width: min(646px, calc(100vw - 24px)); max-height: 70dvh; overflow-y: auto; }
.el-date-range-picker .el-picker-panel__body { min-width: 0; display: block; }
.el-date-range-picker__content { display: block; width: 100%; padding: 12px; }
.el-date-range-picker__content.is-left { border-right: 0; border-bottom: 1px solid var(--ys-line-soft); }
.el-date-range-picker__time-header { display: flex; flex-wrap: wrap; gap: 8px; }
.el-date-range-picker__time-header > .el-date-range-picker__editors-wrap { width: 100%; }
/* 手机依旧横向查看完整数据,操作列随表滚动,避免遮住整张表。 */
.el-table .el-table__cell.el-table-fixed-column--right,
.el-table .el-table__cell.el-table-fixed-column--left { position: static !important; }
.el-table .el-table-fixed-column--right::before,
.el-table .el-table-fixed-column--left::before { display: none; }
} }
/* 空状态:弱墨、留白,不喊叫 */ /* 空状态:弱墨、留白,不喊叫 */

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, EditPen, Upload } from '@element-plus/icons-vue'
import { request } from '@/api/request' import { request } from '@/api/request'
import service from '@/api/request' import service from '@/api/request'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
@ -11,6 +12,7 @@ interface Catalog { as_of?:string; rows: Row[]; fingerprint: string; average_rul
const user = useUserStore() const user = useUserStore()
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` } const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` }
const asOf = ref(today()), effective = ref(today()), loading = ref(false), saving = ref(false) const asOf = ref(today()), effective = ref(today()), loading = ref(false), saving = ref(false)
const failed = ref(false)
const data = ref<Catalog>({ rows: [], fingerprint: '', average_rule: '' }) const data = ref<Catalog>({ rows: [], fingerprint: '', average_rule: '' })
const editing = ref(false), edited = ref<Row[]>([]), pending = ref<Row[]>([]), errors = ref<{row:number;message:string}[]>([]) const editing = ref(false), edited = ref<Row[]>([]), pending = ref<Row[]>([]), errors = ref<{row:number;message:string}[]>([])
const baseDate=ref(today()) const baseDate=ref(today())
@ -19,7 +21,16 @@ const importFileName = ref('')
const categories: Record<string,string> = { municipal_water:'市政水利', rail_transit:'轨道交通' } const categories: Record<string,string> = { municipal_water:'市政水利', rail_transit:'轨道交通' }
const customers: Record<string,string> = { personal:'个人户', enterprise:'企业户', manager_personal:'经理个人户' } const customers: Record<string,string> = { personal:'个人户', enterprise:'企业户', manager_personal:'经理个人户' }
const stages: Record<string,string> = { first:'首日', linear:'线性递减', after:'超期', fixed:'固定', excluded:'不单笔计提' } const stages: Record<string,string> = { first:'首日', linear:'线性递减', after:'超期', fixed:'固定', excluded:'不单笔计提' }
async function load() { loading.value = true; try { data.value = await request<Catalog>({url:'/v1/system/coefficients',params:{as_of:asOf.value}}) } catch(e) { handledError(e) } finally { loading.value=false } } async function load() { loading.value = true; failed.value = false; try { data.value = await request<Catalog>({url:'/v1/system/coefficients',params:{as_of:asOf.value}}) } catch(e) { failed.value = true; handledError(e) } finally { loading.value=false } }
function spanMethod({ rowIndex, columnIndex }: { rowIndex: number; columnIndex: number }): [number, number] {
if (columnIndex > 1) return [1, 1]
const rows = data.value.rows
const same = (a: Row, b: Row) => a.product_category === b.product_category && (columnIndex === 0 || a.customer_type === b.customer_type)
if (rowIndex > 0 && same(rows[rowIndex - 1]!, rows[rowIndex]!)) return [0, 0]
let count = 1
while (rowIndex + count < rows.length && same(rows[rowIndex]!, rows[rowIndex + count]!)) count++
return [count, 1]
}
function edit() { edited.value = data.value.rows.filter(r=>!r.readonly).map(r=>({...r})); fingerprint.value=data.value.fingerprint; baseDate.value=asOf.value; editing.value=true } function edit() { edited.value = data.value.rows.filter(r=>!r.readonly).map(r=>({...r})); fingerprint.value=data.value.fingerprint; baseDate.value=asOf.value; editing.value=true }
async function apply(rows: Row[]) { async function apply(rows: Row[]) {
try { await ElMessageBox.confirm(`共 ${rows.length} 项阶段规则,${effective.value} 起生效。历史工资保持原快照。`, '确认系数变更') } catch { return } try { await ElMessageBox.confirm(`共 ${rows.length} 项阶段规则,${effective.value} 起生效。历史工资保持原快照。`, '确认系数变更') } catch { return }
@ -37,42 +48,115 @@ onMounted(load)
</script> </script>
<template> <template>
<section class="coefficient-panel" aria-label="完整绩效系数表"> <section class="coefficient-panel" aria-label="完整绩效系数表">
<div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段配置计提系数,页面展示与正式核算使用同一版本。</p></div></div> <div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段核对计提系数,正式核算使用对应生效版本。</p></div><span v-if="!loading && !failed" class="panel-count">{{ data.rows.length }} 项阶段规则</span></div>
<el-form inline label-position="top"> <div class="coefficient-toolbar" role="group" aria-label="产品系数表工具栏">
<el-form-item label="查询生效日期"><el-date-picker v-model="asOf" value-format="YYYY-MM-DD" @change="load" /></el-form-item> <div class="coefficient-toolbar__query">
<el-form-item label="表格操作"><el-button :disabled="!user.can('system:params:update') || asOf!==today()" @click="edit">维护系数表</el-button><el-button :disabled="!user.can('system:params:export')" @click="download">下载 Excel</el-button></el-form-item> <label for="coefficient-as-of">查询日期</label>
<el-form-item v-if="user.can('system:params:update')" label="Excel 导入"> <el-date-picker
<label class="file-button"> id="coefficient-as-of"
v-model="asOf"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 160px"
aria-label="查询生效日期"
@change="load"
/>
</div>
<div class="coefficient-toolbar__actions" role="group" aria-label="系数表操作">
<el-button type="primary" :icon="EditPen" :disabled="loading || failed || !user.can('system:params:update') || asOf!==today()" :title="asOf!==today() ? '仅可维护当天生效的系数' : ''" @click="edit">维护系数表</el-button>
<el-button :icon="Download" :disabled="!user.can('system:params:export')" @click="download">下载 Excel</el-button>
<label v-if="user.can('system:params:update')" class="file-button">
<input aria-label="选择系数表 Excel" type="file" accept=".xlsx" @change="upload" /> <input aria-label="选择系数表 Excel" type="file" accept=".xlsx" @change="upload" />
<span>选择文件</span> <el-icon aria-hidden="true"><Upload /></el-icon>
<span>导入 Excel</span>
</label> </label>
<span v-if="importFileName" class="file-name">{{ importFileName }}</span> </div>
</el-form-item> <span v-if="importFileName" class="file-name">{{ importFileName }}</span>
</el-form> </div>
<el-table v-loading="loading" :data="data.rows" border> <el-alert v-if="failed" class="coefficient-error" title="系数表加载失败,下方记录可能不是当前查询结果" type="error" :closable="false" show-icon><el-button :loading="loading" @click="load">重新加载系数</el-button></el-alert>
<el-table-column label="产品类别" width="110"><template #default="{row}">{{categories[row.product_category]}}</template></el-table-column> <el-table v-loading="loading" :data="data.rows" :span-method="spanMethod" class="coefficient-table" border empty-text="当前日期没有可显示的产品系数">
<el-table-column label="户型" width="110"><template #default="{row}">{{customers[row.customer_type]}}</template></el-table-column> <el-table-column label="产品类别" width="120" class-name="coefficient-category"><template #default="{row}">{{categories[row.product_category]}}</template></el-table-column>
<el-table-column label="户型" width="120"><template #default="{row}">{{customers[row.customer_type]}}</template></el-table-column>
<el-table-column label="阶段" width="120"><template #default="{row}">{{stages[row.stage]}}</template></el-table-column> <el-table-column label="阶段" width="120"><template #default="{row}">{{stages[row.stage]}}</template></el-table-column>
<el-table-column prop="value" label="给付标准/起点(‰)" min-width="165" /> <el-table-column prop="value" label="给付标准/起点(‰)" min-width="165" align="right" />
<el-table-column prop="slope" label="日斜率(‰)" min-width="125" /> <el-table-column prop="slope" label="日斜率(‰)" min-width="125" align="right"><template #default="{row}">{{ row.slope ?? '—' }}</template></el-table-column>
<el-table-column prop="boundary" label="截止天数" width="95" /> <el-table-column prop="boundary" label="截止天数" width="100" align="right"><template #default="{row}">{{ row.boundary ?? '—' }}</template></el-table-column>
<el-table-column label="系统系数" min-width="150"><template #default="{row}">{{row.system_coefficient ?? '不适用'}}</template></el-table-column> <el-table-column label="系统系数" min-width="150" align="right"><template #default="{row}">{{row.system_coefficient ?? '不适用'}}</template></el-table-column>
<el-table-column label="360 天平均系数" min-width="180"><template #default="{row}">{{row.average_coefficient_360 ?? '不适用'}}</template></el-table-column> <el-table-column label="360 天平均系数" min-width="180" align="right"><template #default="{row}">{{row.average_coefficient_360 ?? '不适用'}}</template></el-table-column>
</el-table> </el-table>
<p class="coefficient-note">{{ data.average_rule }}。线性阶段的系统系数按第 2 天口径展示;比例和斜率支持精确分数,导入前请先下载 Excel 核对格式。</p> <p class="coefficient-note"><template v-if="data.average_rule">{{ data.average_rule }}。</template>线性阶段的系统系数按第 2 天口径展示;比例和斜率支持精确分数,导入前请先下载 Excel 核对格式。</p>
<el-dialog v-model="editing" title="维护绩效系数表" width="min(1050px, 96vw)"> <el-dialog v-model="editing" title="维护绩效系数表" width="min(1050px, 96vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving">
<p class="dialog-intro">逐项修改给付标准、日斜率和截止天数,再确认版本生效日期。</p>
<el-table :data="edited" border><el-table-column label="产品/户型/阶段" min-width="210"><template #default="{row}">{{categories[row.product_category]}} · {{customers[row.customer_type]}} · {{stages[row.stage]}}</template></el-table-column><el-table-column label="给付标准/起点(‰)" min-width="150"><template #default="{row}"><el-input v-model="row.value" aria-label="给付标准" /></template></el-table-column><el-table-column label="日斜率(‰)" min-width="140"><template #default="{row}"><el-input v-if="row.stage==='linear'" v-model="row.slope" aria-label="日斜率" /></template></el-table-column><el-table-column label="截止天数" min-width="130"><template #default="{row}"><el-input-number v-if="row.stage==='linear'" v-model="row.boundary" :min="1" :max="3660" :controls="false" aria-label="截止天数" /></template></el-table-column></el-table> <el-table :data="edited" border><el-table-column label="产品/户型/阶段" min-width="210"><template #default="{row}">{{categories[row.product_category]}} · {{customers[row.customer_type]}} · {{stages[row.stage]}}</template></el-table-column><el-table-column label="给付标准/起点(‰)" min-width="150"><template #default="{row}"><el-input v-model="row.value" aria-label="给付标准" /></template></el-table-column><el-table-column label="日斜率(‰)" min-width="140"><template #default="{row}"><el-input v-if="row.stage==='linear'" v-model="row.slope" aria-label="日斜率" /></template></el-table-column><el-table-column label="截止天数" min-width="130"><template #default="{row}"><el-input-number v-if="row.stage==='linear'" v-model="row.boundary" :min="1" :max="3660" :controls="false" aria-label="截止天数" /></template></el-table-column></el-table>
<el-form label-position="top"><el-form-item label="生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" maxlength="255" /></el-form-item></el-form> <el-form label-position="top" class="coefficient-version-form" :disabled="saving"><el-form-item label="生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" maxlength="255" /></el-form-item></el-form>
<template #footer><el-button @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template> <template #footer><el-button :disabled="saving" @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template>
</el-dialog> </el-dialog>
<el-dialog v-model="importVisible" title="系数表导入预览" width="min(1000px, 96vw)"><p>工作簿基准日期:{{baseDate}}。确认后另存为所选生效日期的新版本。</p> <el-dialog v-model="importVisible" title="系数表导入预览" width="min(1000px, 96vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving"><p class="dialog-intro">工作簿基准日期:{{baseDate}}。确认后另存为所选生效日期的新版本。</p>
<el-alert v-if="errors.length" type="error" :closable="false" title="校验未通过,整批不能写入" /><el-table v-if="errors.length" :data="errors"><el-table-column prop="row" label="Excel 行" width="100" /><el-table-column prop="message" label="问题" /></el-table> <el-alert v-if="errors.length" type="error" :closable="false" title="校验未通过,整批不能写入" /><el-table v-if="errors.length" :data="errors"><el-table-column prop="row" label="Excel 行" width="100" /><el-table-column prop="message" label="问题" /></el-table>
<el-table :data="pending"><el-table-column v-for="key in ['product_category','customer_type','stage','value','slope','boundary']" :key="key" :prop="key" :label="({product_category:'产品类别',customer_type:'户型',stage:'阶段',value:'给付标准(‰)',slope:'日斜率',boundary:'截止天数'} as Record<string,string>)[key]" min-width="120" /></el-table> <el-table :data="pending"><el-table-column label="产品类别" min-width="120"><template #default="{row}">{{ categories[row.product_category] ?? row.product_category }}</template></el-table-column><el-table-column label="户型" min-width="120"><template #default="{row}">{{ customers[row.customer_type] ?? row.customer_type }}</template></el-table-column><el-table-column label="阶段" min-width="120"><template #default="{row}">{{ stages[row.stage] ?? row.stage }}</template></el-table-column><el-table-column prop="value" label="给付标准(‰)" min-width="140" align="right" /><el-table-column prop="slope" label="日斜率" min-width="120" align="right" /><el-table-column prop="boundary" label="截止天数" min-width="120" align="right" /></el-table>
<el-form label-position="top"><el-form-item label="导入生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" /></el-form-item></el-form> <el-form label-position="top" class="coefficient-version-form" :disabled="saving"><el-form-item label="导入生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" /></el-form-item></el-form>
<template #footer><el-button @click="importVisible=false">取消</el-button><el-button type="primary" :disabled="errors.length>0 || !pending.length" :loading="saving" @click="apply(pending)">确认导入版本</el-button></template> <template #footer><el-button :disabled="saving" @click="importVisible=false">取消</el-button><el-button type="primary" :disabled="errors.length>0 || !pending.length" :loading="saving" @click="apply(pending)">确认导入版本</el-button></template>
</el-dialog> </el-dialog>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.coefficient-panel{margin-block:24px;padding:20px;border:1px solid var(--el-border-color);background:var(--el-bg-color)}.panel-head{display:flex;justify-content:space-between;align-items:start;gap:16px}.panel-head h2{margin:0;font-size:18px}.panel-head p,.coefficient-note{color:var(--el-text-color-secondary);font-size:13px;line-height:1.7}.coefficient-panel .el-form{margin-top:16px}.file-button{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border:1px solid var(--ys-indigo);border-radius:6px;color:var(--ys-indigo);background:var(--ys-paper);font-size:13px;line-height:1.5;cursor:pointer;transition:background-color 120ms var(--ys-ease), color 120ms var(--ys-ease)}.file-button:hover{background:var(--ys-indigo-wash)}.file-button:focus-within{outline:2px solid var(--ys-indigo);outline-offset:2px}.file-button input{position:absolute;width:1px;height:1px;margin:-1px;clip:rect(0 0 0 0);overflow:hidden}.file-name{color:var(--ys-ink-2);font-size:12px;line-height:1.6}@media(max-width:768px){.coefficient-panel{padding:12px}.panel-head{flex-direction:column}} .coefficient-panel { margin-block: 0; padding: 20px; border: 1px solid var(--ys-line); border-radius: var(--ys-radius); background: var(--ys-paper); box-shadow: var(--ys-shadow-card); }
.panel-head { display: flex; justify-content: space-between; align-items: start; gap: 16px; }
.panel-head h2 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.6; }
.panel-head p { margin: 6px 0 0; }
.panel-count { flex: none; color: var(--ys-ink-3); font-size: 12px; line-height: 1.7; font-variant-numeric: tabular-nums; }
.coefficient-table :deep(.coefficient-category) { background: #fafbfd; font-weight: 600; }
.coefficient-table :deep(.cell) { font-variant-numeric: tabular-nums; }
.coefficient-note { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid var(--ys-line-soft); }
.coefficient-error { margin-bottom: 16px; }
.dialog-intro { margin: 0 0 18px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
.coefficient-version-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 16px; margin-top: 20px; }
.coefficient-version-form :deep(.el-date-editor) { width: 100%; }
.panel-head p, .coefficient-note { color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.7; }
.coefficient-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 24px;
margin: 16px 0;
}
.coefficient-toolbar__query { display: flex; align-items: center; gap: 10px; min-width: 0; }
.coefficient-toolbar__query > label { color: var(--ys-ink-2); font-size: 13px; white-space: nowrap; }
.coefficient-toolbar__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: auto; }
.file-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: var(--el-component-size);
padding: 0 14px;
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius-sm);
color: var(--ys-ink-2);
background: var(--ys-paper);
font-size: 13px;
line-height: 1.5;
white-space: nowrap;
cursor: pointer;
transition: background-color 120ms var(--ys-ease), color 120ms var(--ys-ease), border-color 120ms var(--ys-ease);
}
.file-button:hover { color: var(--ys-indigo); border-color: var(--ys-indigo); background: var(--ys-indigo-wash); }
.file-button:focus-within { outline: 2px solid var(--ys-indigo); outline-offset: 2px; }
.file-button input { position: absolute; width: 1px; height: 1px; margin: -1px; clip: rect(0 0 0 0); overflow: hidden; }
.file-name { flex-basis: 100%; min-width: 0; color: var(--ys-ink-2); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
@media (max-width: 768px) {
.coefficient-panel { padding: 12px; }
.panel-head { flex-direction: column; }
}
@media (max-width: 600px) {
.coefficient-version-form { grid-template-columns: minmax(0, 1fr); gap: 0; }
.coefficient-toolbar__query { width: 100%; }
.coefficient-toolbar__query :deep(.el-date-editor) { flex: 1; min-width: 0; }
.coefficient-toolbar__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
.coefficient-toolbar__actions > .el-button:first-child { grid-column: 1 / -1; }
.coefficient-toolbar__actions > .el-button, .file-button { min-height: 38px; }
}
</style> </style>

View File

@ -9,7 +9,7 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
</script> </script>
<template> <template>
<el-menu class="app-menu" :default-active="route.path" router> <el-menu class="app-menu" :default-active="route.path" aria-label="主导航" router>
<el-menu-item <el-menu-item
v-for="item in topLevelMenus" v-for="item in topLevelMenus"
:key="item.path" :key="item.path"

View File

@ -69,6 +69,7 @@ async function handleLogout(): Promise<void> {
<template> <template>
<el-container class="app-layout"> <el-container class="app-layout">
<a class="skip-link" href="#main-content">跳到页面内容</a>
<el-aside class="app-aside" width="220px"> <el-aside class="app-aside" width="220px">
<div class="app-brand"> <div class="app-brand">
<span class="app-brand__name">衍水集团</span> <span class="app-brand__name">衍水集团</span>
@ -115,7 +116,7 @@ async function handleLogout(): Promise<void> {
</div> </div>
</el-header> </el-header>
<el-main class="app-main"> <el-main id="main-content" class="app-main" tabindex="-1">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<Transition name="ys-page" mode="out-in"> <Transition name="ys-page" mode="out-in">
<div :key="route.path" class="app-page-view"> <div :key="route.path" class="app-page-view">
@ -129,6 +130,8 @@ async function handleLogout(): Promise<void> {
<el-drawer <el-drawer
v-model="drawerVisible" v-model="drawerVisible"
class="app-drawer" class="app-drawer"
title="功能导航"
aria-label="功能导航"
direction="ltr" direction="ltr"
size="240px" size="240px"
:with-header="false" :with-header="false"
@ -148,6 +151,11 @@ async function handleLogout(): Promise<void> {
height: 100%; height: 100%;
} }
.app-body { min-width: 0; }
.skip-link { position: fixed; top: -80px; left: 16px; z-index: 4000; padding: 12px 18px; color: var(--ys-indigo); background: var(--ys-paper); border: 1px solid var(--ys-indigo); border-radius: var(--ys-radius-sm); }
.skip-link:focus { top: 12px; }
.app-aside { .app-aside {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -283,6 +291,7 @@ async function handleLogout(): Promise<void> {
*/ */
padding: 22px; padding: 22px;
background-color: var(--ys-mist); background-color: var(--ys-mist);
scroll-behavior: smooth;
} }
.app-page-view { .app-page-view {
@ -321,6 +330,7 @@ async function handleLogout(): Promise<void> {
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.app-main { padding: 16px 12px; }
.app-header { .app-header {
gap: 12px; gap: 12px;
padding: 0 16px; padding: 0 16px;
@ -374,6 +384,7 @@ async function handleLogout(): Promise<void> {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.app-main { scroll-behavior: auto; }
.app-header__menu-toggle, .app-header__menu-toggle,
.ys-page-enter-active, .ys-page-enter-active,
.ys-page-leave-active { .ys-page-leave-active {

View File

@ -17,14 +17,21 @@ onMounted(load)
<template> <template>
<section class="probation-panel" aria-label="试用期缓发与年度结清"> <section class="probation-panel" aria-label="试用期缓发与年度结清">
<div class="panel-head"><div><h2>试用期缓发与年度结清</h2><p>{{rule}}</p></div><el-button :disabled="!user.can('reports:personal:export')" @click="download">导出年度余额及明细</el-button></div> <div class="panel-head"><div><h2>试用期缓发与年度结清</h2><p>{{rule}}</p></div><el-button :disabled="!user.can('reports:personal:export')" @click="download">导出年度余额及明细</el-button></div>
<el-form inline label-position="top"><el-form-item label="工资月份/结清预览"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" @change="load" /></el-form-item><el-form-item label="人员筛选"><el-input v-model="keyword" placeholder="姓名" clearable /></el-form-item><el-form-item label="原累计年度"><el-input v-model="year" placeholder="如 2026" clearable /></el-form-item><el-form-item label="查询"><el-button :loading="loading" @click="load">刷新结清预览</el-button></el-form-item></el-form> <el-form class="filter-bar" label-position="top" @submit.prevent="load"><el-form-item label="工资月份/结清预览"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" :clearable="false" @change="load" /></el-form-item><el-form-item label="人员筛选"><el-input v-model="keyword" placeholder="姓名" clearable /></el-form-item><el-form-item label="原累计年度"><el-input v-model="year" placeholder="如 2026" clearable /></el-form-item><div class="filter-bar__actions"><el-button :loading="loading" native-type="submit">刷新结清预览</el-button></div></el-form>
<el-table v-loading="loading" :data="rows.filter(r=>(!keyword||r.personnel_name.includes(keyword))&&(!year||r.year===year))" border empty-text="该月没有试用期缓发记录"> <el-table v-loading="loading" :data="rows.filter(r=>(!keyword||r.personnel_name.includes(keyword))&&(!year||r.year===year))" border empty-text="该月没有试用期缓发记录">
<el-table-column prop="personnel_name" label="人员" width="110" /><el-table-column prop="year" label="原年度" width="90" /> <el-table-column prop="personnel_name" label="人员" width="110" /><el-table-column prop="year" label="原年度" width="90" />
<el-table-column v-for="[key,label] in [['accrued','试用期累计计提'],['eligible','符合支付条件'],['threshold','年度门槛'],['entitlement','超额权益'],['paid','已补发'],['reserved','待审核占用'],['delta','本月补发/扣回'],['remaining','剩余权益']]" :key="key" :prop="key" :label="label" min-width="140" align="right" /> <el-table-column v-for="[key,label] in [['accrued','试用期累计计提'],['eligible','符合支付条件'],['threshold','年度门槛'],['entitlement','超额权益'],['paid','已补发'],['reserved','待审核占用'],['delta','本月补发/扣回'],['remaining','剩余权益']]" :key="key" :prop="key" :label="label" min-width="140" align="right" />
<el-table-column label="依据/结清" min-width="210"><template #default="{row}"><el-button link type="primary" @click="detail=row;visible=true">台账明细</el-button><el-button link type="primary" :disabled="!user.can('adjustments:create') || row.delta==='0.00' || row.delta==='0'" @click="adjust(row)">生成当月调整</el-button></template></el-table-column> <el-table-column label="依据/结清" min-width="230"><template #default="{row}"><div class="table-actions"><el-button link type="primary" @click="detail=row;visible=true">台账明细</el-button><el-button link type="primary" :disabled="!user.can('adjustments:create') || row.delta==='0.00' || row.delta==='0'" @click="adjust(row)">生成当月调整</el-button></div></template></el-table-column>
</el-table> </el-table>
<p class="panel-note">这里只查询及预览。实际补发随月结在同一事务中固化;生成失败不占余额,已生成工资保留历史结果。</p> <p class="panel-note">这里只查询及预览。实际补发随月结在同一事务中固化;生成失败不占余额,已生成工资保留历史结果。</p>
<el-dialog v-model="visible" :title="`${detail?.personnel_name ?? ''} · ${detail?.year ?? ''} 年缓发依据`" width="min(950px, 96vw)"><el-table :data="detail?.details ?? []"><el-table-column prop="ledger_id" label="台账 ID" width="100" /><el-table-column prop="business_no" label="业务编号" min-width="160" /><el-table-column prop="month" label="归属月" width="110" /><el-table-column prop="receipt_date" label="到账日" width="120" /><el-table-column prop="accrued" label="计提金额" /><el-table-column prop="eligible" label="应付变化" /></el-table></el-dialog> <el-dialog v-model="visible" :title="`${detail?.personnel_name ?? ''} · ${detail?.year ?? ''} 年缓发依据`" width="min(950px, 96vw)"><el-table :data="detail?.details ?? []"><el-table-column prop="ledger_id" label="台账 ID" width="100" /><el-table-column prop="business_no" label="业务编号" min-width="160" show-overflow-tooltip /><el-table-column prop="month" label="归属月" width="110" /><el-table-column prop="receipt_date" label="到账日" width="120" /><el-table-column prop="accrued" label="计提金额(元)" min-width="140" align="right" /><el-table-column prop="eligible" label="应付变化(元)" min-width="140" align="right" /></el-table><template #footer><el-button @click="visible=false">关闭</el-button></template></el-dialog>
</section> </section>
</template> </template>
<style scoped>.probation-panel{margin:24px 0;padding:20px;border:1px solid var(--el-border-color);background:var(--el-bg-color)}.panel-head{display:flex;justify-content:space-between;gap:16px}.panel-head h2{font-size:18px;margin:0}.panel-head p,.panel-note{font-size:13px;color:var(--el-text-color-secondary);line-height:1.7}.el-form{margin-top:16px}@media(max-width:768px){.probation-panel{padding:12px}.panel-head{flex-direction:column}}</style> <style scoped>
.probation-panel { margin: 24px 0; padding: 20px; min-width: 0; border: 1px solid var(--ys-line); border-radius: var(--ys-radius); background: var(--ys-paper); }
.panel-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; }
.panel-head h2 { font-size: 16px; margin: 0; }
.panel-head p, .panel-note { font-size: 13px; color: var(--ys-ink-2); line-height: 1.7; }
.filter-bar { margin-top: 16px; padding: 0 0 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--ys-line-soft); box-shadow: none; background: none; align-items: flex-end; }
@media (max-width: 768px) { .probation-panel { padding: 16px; } .panel-head { flex-direction: column; } .panel-head > .el-button { align-self: flex-start; } }
</style>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '@/api/request' import { request } from '@/api/request'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
@ -7,6 +7,14 @@ import { useUserStore } from '@/store/user'
interface Candidate {id:number;business_no:string;product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string} interface Candidate {id:number;business_no:string;product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string}
interface PlanRow {product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string;action:string;reason:string|null;target_id:number|null} interface PlanRow {product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string;action:string;reason:string|null;target_id:number|null}
const emit=defineEmits<{done:[]}>(),user=useUserStore(),visible=ref(false),loading=ref(false),keyword=ref(''),page=ref(1),more=ref(false),rows=ref<Candidate[]>([]),selected=ref<Candidate[]>([]),result=ref<{rows:PlanRow[];fingerprint:string}|null>(null),executed=ref(false) const emit=defineEmits<{done:[]}>(),user=useUserStore(),visible=ref(false),loading=ref(false),keyword=ref(''),page=ref(1),more=ref(false),rows=ref<Candidate[]>([]),selected=ref<Candidate[]>([]),result=ref<{rows:PlanRow[];fingerprint:string}|null>(null),executed=ref(false)
const confirmDisabledReason = computed(() => {
if (!user.can('base:products:create')) return user.permissionReason('base:products:create')
if (loading.value) return '正在处理,请稍候。'
if (!selected.value.length) return '请先勾选业务明细,再点击“预览所选”。'
if (!result.value) return `已选 ${selected.value.length} 条,请先点击“预览所选 ${selected.value.length} 条”,核对结果后再执行提取。`
if (executed.value) return '本次提取已完成;如需继续,请重新勾选明细并预览。'
return ''
})
async function load(){loading.value=true;result.value=null;try{const data=await request<{rows:Candidate[];has_more:boolean}>({url:'/v1/base/products/extract/candidates',params:{keyword:keyword.value,page_num:page.value}});rows.value=data.rows;more.value=data.has_more}catch(e){handledError(e)}finally{loading.value=false}} async function load(){loading.value=true;result.value=null;try{const data=await request<{rows:Candidate[];has_more:boolean}>({url:'/v1/base/products/extract/candidates',params:{keyword:keyword.value,page_num:page.value}});rows.value=data.rows;more.value=data.has_more}catch(e){handledError(e)}finally{loading.value=false}}
async function open(){visible.value=true;page.value=1;selected.value=[];await load()} async function open(){visible.value=true;page.value=1;selected.value=[];await load()}
async function preview(){loading.value=true;try{result.value=await request({url:'/v1/base/products/extract/preview',method:'post',data:{item_ids:selected.value.map(r=>r.id)}});executed.value=false}catch(e){handledError(e)}finally{loading.value=false}} async function preview(){loading.value=true;try{result.value=await request({url:'/v1/base/products/extract/preview',method:'post',data:{item_ids:selected.value.map(r=>r.id)}});executed.value=false}catch(e){handledError(e)}finally{loading.value=false}}
@ -14,14 +22,60 @@ async function apply(){if(!result.value)return;try{await ElMessageBox.confirm('
defineExpose({open}) defineExpose({open})
</script> </script>
<template> <template>
<el-dialog v-model="visible" title="从业务明细提取产品" width="min(1180px, 96vw)"> <el-dialog v-model="visible" title="从业务明细提取产品" width="min(1180px, 96vw)"
<el-form inline><el-form-item label="名称/业务编号"><el-input v-model="keyword" clearable @keyup.enter="page=1;load()" /></el-form-item><el-form-item><el-button @click="page=1;load()">查询</el-button></el-form-item></el-form> :close-on-click-modal="!loading" :close-on-press-escape="!loading" :show-close="!loading">
<p>先在业务管理录入产品明细,或将 T+ 数据正式导入业务,再勾选未关联产品档案的明细,点击“预览所选”并确认提取。T+ 原始采集成功不会自动建产品档案。</p> <p class="extract-intro">操作步骤:勾选业务明细 → 点击“预览所选” → 核对预览结果 → 确认执行提取。</p>
<p>同名同规格合并,数量与单价取所选最新明细;已有档案只建立关联,不覆盖人工信息。</p> <el-form class="filter-bar" @submit.prevent="page=1;load()">
<el-table v-loading="loading" :data="rows" row-key="id" empty-text="暂无可提取明细。请先创建含未关联产品的业务,或完成 T+ 正式业务导入。" @selection-change="(values:Candidate[])=>{selected=values;result=null}"><el-table-column type="selection" width="50" /><el-table-column prop="business_no" label="业务编号" min-width="155" /><el-table-column prop="product_name" label="产品名称" min-width="150" /><el-table-column prop="spec_model" label="规格" min-width="110" /><el-table-column prop="unit" label="单位" width="80" /><el-table-column label="类别" width="110"><template #default="{row}">{{row.product_category==='municipal_water'?'市政水利':'轨道交通'}}</template></el-table-column><el-table-column prop="quantity" label="数量" width="100" /><el-table-column prop="sale_price" label="成交单价" width="120" /></el-table> <el-form-item label="名称/业务编号">
<div class="extract-actions"><el-button :disabled="page<=1" @click="page--;load()">上一页</el-button><span>第 {{page}} 页</span><el-button :disabled="!more" @click="page++;load()">下一页</el-button><el-button :disabled="!selected.length" :loading="loading" @click="preview">预览所选 {{selected.length}} 条</el-button></div> <el-input v-model="keyword" placeholder="搜索产品名称或业务编号" clearable />
<el-table v-if="result" :data="result.rows" border><el-table-column prop="product_name" label="产品" /><el-table-column prop="spec_model" label="规格" /><el-table-column label="处理结果" width="130"><template #default="{row}">{{({CREATE:executed?'已创建':'将创建',LINK:executed?'已关联':'关联已有',CONFLICT:'冲突待处理'} as Record<string,string>)[row.action]}}</template></el-table-column><el-table-column prop="reason" label="说明" min-width="210" /><el-table-column prop="target_id" label="产品 ID" width="100" /></el-table> </el-form-item>
<template #footer><el-button @click="visible=false">关闭</el-button><el-button type="primary" :disabled="!result||executed||!user.can('base:products:create')" :loading="loading" @click="apply">确认执行提取</el-button></template> <div class="filter-bar__actions"><el-button :loading="loading" native-type="submit">查询</el-button></div>
</el-form>
<el-table v-loading="loading" :data="rows" row-key="id"
empty-text="暂无可提取明细。请先创建含未关联产品的业务,或完成 T+ 正式业务导入。"
@selection-change="(values:Candidate[])=>{selected=values;result=null}">
<el-table-column type="selection" width="50" />
<el-table-column prop="business_no" label="业务编号" min-width="155" show-overflow-tooltip />
<el-table-column prop="product_name" label="产品名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="110" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="80" />
<el-table-column label="类别" width="110"><template #default="{row}">{{row.product_category==='municipal_water'?'市政水利':'轨道交通'}}</template></el-table-column>
<el-table-column prop="quantity" label="数量" width="110" align="right" />
<el-table-column prop="sale_price" label="成交单价(元)" width="140" align="right" />
</el-table>
<div class="extract-actions">
<span class="selection-note" role="status">已选 {{selected.length}} 条</span>
<el-button :disabled="page<=1 || loading" @click="page--;load()">上一页</el-button>
<span>第 {{page}} 页</span>
<el-button :disabled="!more || loading" @click="page++;load()">下一页</el-button>
<el-button :disabled="!selected.length" :loading="loading" @click="preview">预览所选 {{selected.length}} 条</el-button>
</div>
<template v-if="result">
<h3 class="form-section-title">{{ executed ? '提取结果' : '提取预览' }}</h3>
<el-table :data="result.rows" border>
<el-table-column prop="product_name" label="产品" min-width="150" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="110" show-overflow-tooltip />
<el-table-column label="处理结果" width="130"><template #default="{row}"><el-tag :type="row.action==='CONFLICT'?'warning':'info'">{{({CREATE:executed?'已创建':'将创建',LINK:executed?'已关联':'关联已有',CONFLICT:'冲突待处理'} as Record<string,string>)[row.action]}}</el-tag></template></el-table-column>
<el-table-column prop="reason" label="说明" min-width="210" />
<el-table-column prop="target_id" label="产品 ID" width="100" />
</el-table>
</template>
<p class="form-hint">同名同规格的产品合并,数量和单价取所选最新明细;已有档案只建立关联,保留人工信息。T+ 原始数据需正式导入业务后才能提取。</p>
<template #footer>
<div class="extract-footer">
<span class="extract-footer__reason" role="status">{{ confirmDisabledReason || '预览已就绪,请核对处理结果后确认执行。' }}</span>
<div class="extract-footer__buttons"><el-button :disabled="loading" @click="visible=false">关闭</el-button><el-button type="primary" :disabled="!!confirmDisabledReason" :title="confirmDisabledReason" :loading="loading" @click="apply">确认执行提取</el-button></div>
</div>
</template>
</el-dialog> </el-dialog>
</template> </template>
<style scoped>.extract-actions{display:flex;gap:12px;align-items:center;justify-content:flex-end;margin:16px 0}p{font-size:13px;line-height:1.7;color:var(--el-text-color-secondary)}@media(max-width:600px){.extract-actions{flex-wrap:wrap}}</style> <style scoped>
.extract-intro { margin: 0 0 16px; color: var(--ys-ink-2); font-size: 13px; }
.extract-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: flex-end; margin: 16px 0; font-size: 13px; }
.selection-note { margin-right: auto; color: var(--ys-ink-2); }
.extract-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; text-align: left; }
.extract-footer__reason { flex: 1 1 260px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.6; }
.extract-footer__buttons { display: flex; flex-wrap: wrap; gap: 12px; margin-left: auto; }
.extract-footer__buttons .el-button + .el-button { margin-left: 0; }
@media (max-width: 600px) { .selection-note { flex-basis: 100%; } }
</style>

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
/** /**
* 分页条:统一各列表页的布局与页码尺寸选项。 * 分页条:统一各列表页的布局与页码尺寸选项。
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。 * 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
@ -16,13 +18,26 @@ defineEmits<{
(e: 'page-change', page: number): void (e: 'page-change', page: number): void
(e: 'size-change', size: number): void (e: 'size-change', size: number): void
}>() }>()
const compact = ref(false)
let query: MediaQueryList | undefined
const layout = computed(() => compact.value ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper')
function updateLayout(): void { compact.value = query?.matches ?? false }
onMounted(() => {
query = window.matchMedia('(max-width: 600px)')
updateLayout()
query.addEventListener('change', updateLayout)
})
onBeforeUnmount(() => query?.removeEventListener('change', updateLayout))
</script> </script>
<template> <template>
<el-pagination <el-pagination
class="app-pager" class="app-pager"
background background
layout="total, sizes, prev, pager, next, jumper" :layout="layout"
:pager-count="5"
:size="compact ? 'small' : 'default'"
:total="total" :total="total"
:current-page="pageNum" :current-page="pageNum"
:page-size="pageSize" :page-size="pageSize"
@ -34,7 +49,18 @@ defineEmits<{
<style scoped> <style scoped>
.app-pager { .app-pager {
flex-wrap: wrap;
gap: 8px;
max-width: 100%;
justify-content: flex-end; justify-content: flex-end;
margin-top: 14px; margin-top: 14px;
} }
@media (max-width: 600px) {
.app-pager { display: grid; grid-template-columns: auto auto auto; width: 100%; justify-content: center; gap: 8px 4px; }
.app-pager :deep(.el-pagination__total) { grid-column: 1 / -1; justify-content: center; margin: 0; }
.app-pager.el-pagination.is-background :deep(button),
.app-pager.el-pagination.is-background :deep(.el-pager li) { min-width: 28px; width: 28px; height: 36px; margin: 0; }
.app-pager :deep(.el-pager) { display: flex; gap: 2px; }
}
</style> </style>

View File

@ -547,6 +547,9 @@ function removeGrade(row: RatingGradeItem): void {
/* 移动端:标签左对齐、结果块自然换行到表单下方 */ /* 移动端:标签左对齐、结果块自然换行到表单下方 */
@media (max-width: 768px) { @media (max-width: 768px) {
.calc-form { min-width: 0; }
.score-row { gap: 8px; justify-content: space-between; }
.score-row :deep(.el-input-number) { width: 120px; flex: none; }
.score-label { .score-label {
width: auto; width: auto;
text-align: left; text-align: left;

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { optionPage } from '@/api/metadata' import { optionPage } from '@/api/metadata'
import { useUserStore as usePermissionUser } from '@/store/user' import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser() const permissionUser = usePermissionUser()
@ -47,7 +48,7 @@ const filters = reactive({
business_role_id: undefined as number | undefined, business_role_id: undefined as number | undefined,
}) })
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } = const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>( usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>(
personnelApi.listPersonnel, personnelApi.listPersonnel,
() => ({ () => ({
@ -403,7 +404,7 @@ onMounted(() => {
<div class="page-head"> <div class="page-head">
<h1 class="page-title">人员管理</h1> <h1 class="page-title">人员管理</h1>
<p class="page-desc"> <p class="page-desc">
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用 维护人员档案、任职日期与工资标准。岗位变更影响后续回款的绩效核算,离职人员不能再分配到业务或客户。
</p> </p>
<span class="waterline"></span> <span class="waterline"></span>
</div> </div>
@ -412,6 +413,7 @@ onMounted(() => {
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器, .filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。 外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
--> -->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent> <el-form class="filter-bar" @submit.prevent>
<el-form-item label="姓名"> <el-form-item label="姓名">
<el-input <el-input
@ -456,7 +458,7 @@ onMounted(() => {
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:personnel:create')" :title="permissionUser.permissionReason('base:personnel:create')">新增人员</el-button> <el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:personnel:create')" :title="permissionUser.permissionReason('base:personnel:create')">新增人员</el-button>
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span> <span class="toolbar-note">在“区域与计划”维护辐射区域和拓展计划,保存后替换原有内容。</span>
</div> </div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 --> <!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
@ -465,10 +467,10 @@ onMounted(() => {
v-loading="loading" v-loading="loading"
:data="records" :data="records"
stripe stripe
empty-text="还没有人员 —— 点「新增人员」建第一个,或换个关键词再查" empty-text="暂无符合条件的人员,可调整筛选条件。"
> >
<el-table-column prop="personnel_name" label="姓名" min-width="100" /> <el-table-column prop="personnel_name" label="姓名" min-width="100" show-overflow-tooltip />
<el-table-column prop="role_name" label="岗位" min-width="100"> <el-table-column prop="role_name" label="岗位" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.role_name ?? '—' }}</template> <template #default="{ row }">{{ row.role_name ?? '—' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="manager_name" label="直属经理" min-width="100"> <el-table-column prop="manager_name" label="直属经理" min-width="100">
@ -499,7 +501,7 @@ onMounted(() => {
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" /> <StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="270" fixed="right" align="center"> <el-table-column label="操作" width="350" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<div class="table-actions"> <div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button> <el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
@ -531,11 +533,15 @@ onMounted(() => {
<!-- 新增 / 编辑(基本信息;不含状态与子表) --> <!-- 新增 / 编辑(基本信息;不含状态与子表) -->
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
:close-on-click-modal="!submitting"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:title="isEdit ? '编辑人员' : '新增人员'" :title="isEdit ? '编辑人员' : '新增人员'"
width="640px" width="640px"
destroy-on-close destroy-on-close
> >
<el-form ref="formRef" class="record-form" :disabled="!permissionUser.can(isEdit ? 'base:personnel:update' : 'base:personnel:create')" :model="form" :rules="rules" label-width="132px"> <el-form ref="formRef" class="record-form" :disabled="!permissionUser.can(isEdit ? 'base:personnel:update' : 'base:personnel:create')" :model="form" :rules="rules" label-width="132px">
<h2 class="form-section-title">人员与岗位</h2>
<el-form-item label="姓名" prop="personnel_name"> <el-form-item label="姓名" prop="personnel_name">
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" /> <el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
</el-form-item> </el-form-item>
@ -575,6 +581,7 @@ onMounted(() => {
<el-form-item label="联系电话" prop="phone"> <el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" maxlength="20" placeholder="选填" /> <el-input v-model="form.phone" maxlength="20" placeholder="选填" />
</el-form-item> </el-form-item>
<h2 class="form-section-title">任职与试用期</h2>
<el-form-item label="入职日期" prop="hire_date"> <el-form-item label="入职日期" prop="hire_date">
<el-date-picker <el-date-picker
v-model="form.hire_date" v-model="form.hire_date"
@ -588,20 +595,21 @@ onMounted(() => {
<el-form-item label="转正生效日"><el-date-picker v-model="form.regularization_date" value-format="YYYY-MM-DD" placeholder="当日按正式人员计算" /></el-form-item> <el-form-item label="转正生效日"><el-date-picker v-model="form.regularization_date" value-format="YYYY-MM-DD" placeholder="当日按正式人员计算" /></el-form-item>
<el-form-item label="试用期" prop="is_probation"> <el-form-item label="试用期" prop="is_probation">
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" /> <el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
<span class="switch-hint">试用期绩效照算但不发放,年累计超 18000 元部分年底一次性结算</span> <p class="form-hint">试用期绩效照算但不发放,年累计超过 18000 元的部分在年底一次性结算。</p>
</el-form-item> </el-form-item>
<el-form-item label="离职生效日"><el-date-picker v-model="form.resignation_date" value-format="YYYY-MM-DD" placeholder="当日起不计基本工资" /></el-form-item> <el-form-item label="离职生效日"><el-date-picker v-model="form.resignation_date" value-format="YYYY-MM-DD" placeholder="当日起不计基本工资" /></el-form-item>
<h2 class="form-section-title">工资资料</h2>
<el-form-item label="工资资料生效日"><el-date-picker v-model="form.salary_effective_date" value-format="YYYY-MM-DD" placeholder="选择生效日期" /><div class="form-hint">新增默认入职日期;编辑默认今天,历史资料保留。</div></el-form-item> <el-form-item label="工资资料生效日"><el-date-picker v-model="form.salary_effective_date" value-format="YYYY-MM-DD" placeholder="选择生效日期" /><div class="form-hint">新增默认入职日期;编辑默认今天,历史资料保留。</div></el-form-item>
<el-form-item label="工资标准" prop="salary_standard"> <el-form-item label="月工资标准(元)" prop="salary_standard">
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" /> <el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark"> <el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" /> <el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-collapse v-if="salaryHistory.length"><el-collapse-item title="工资标准与任职资料生效记录" name="salary-history"><el-table :data="salaryHistory"><el-table-column prop="effective_date" label="生效日" width="115" /><el-table-column prop="salary_standard" label="工资标准" /><el-table-column prop="business_role_id" label="岗位 ID" /><el-table-column prop="regularization_date" label="转正日" width="115" /><el-table-column prop="resignation_date" label="离职日" width="115" /></el-table></el-collapse-item></el-collapse> <el-collapse v-if="salaryHistory.length"><el-collapse-item title="工资标准与任职资料生效记录" name="salary-history"><el-table :data="salaryHistory"><el-table-column prop="effective_date" label="生效日" width="115" /><el-table-column prop="salary_standard" label="工资标准" align="right" /><el-table-column prop="business_role_id" label="岗位 ID" /><el-table-column prop="regularization_date" label="转正日" width="115" /><el-table-column prop="resignation_date" label="离职日" width="115" /></el-table></el-collapse-item></el-collapse>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:personnel:update' : 'base:personnel:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:personnel:update' : 'base:personnel:create'))">保存</el-button> <el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:personnel:update' : 'base:personnel:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:personnel:update' : 'base:personnel:create'))">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>

View File

@ -6,6 +6,7 @@ import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus } from '@element-plus/icons-vue' import { Delete, Plus } from '@element-plus/icons-vue'
import * as payroll from '@/api/payroll' import * as payroll from '@/api/payroll'
import * as businesses from '@/api/businesses'
import { request } from '@/api/request' import { request } from '@/api/request'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
@ -18,7 +19,7 @@ const monthChecked = ref(false), monthLoading = ref(false)
let monthRequest = 0 let monthRequest = 0
const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null) const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null)
const people = ref<{id: number; personnel_name: string}[]>([]) const people = ref<{id: number; personnel_name: string}[]>([])
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean})[]>([]) const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string; unit?: string | null; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean})[]>([])
const rowPage = ref(1) const rowPage = ref(1)
const businessKey = ref('') const businessKey = ref('')
const handovers = ref<payroll.Handover[]>([]) const handovers = ref<payroll.Handover[]>([])
@ -27,12 +28,7 @@ const handoversChanged = computed(() => JSON.stringify(handovers.value) !== save
const visibleRows = computed(() => rows.value.slice((rowPage.value - 1) * 50, rowPage.value * 50)) const visibleRows = computed(() => rows.value.slice((rowPage.value - 1) * 50, rowPage.value * 50))
type PayrollMetadata = { business_key?: string | null; personnel: typeof people.value; personnel_total?: number; shipments_total?: number; type PayrollMetadata = { business_key?: string | null; personnel: typeof people.value; personnel_total?: number; shipments_total?: number;
shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_unit_cost?: string | null; guidance_line_total?: string | null}[] } shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string; unit?: string | null; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_unit_cost?: string | null; guidance_line_total?: string | null}[] }
function guidanceHint(row: {guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean}): string {
const labels: Record<string, string> = {FINALIZED_UNIT: row.frozen ? '指导价已封存,历史成本以原版本为准' : '使用已封存的月度指导单价', FINALIZED_LINE_TOTAL: `已封存明细总成本:${row.guidance_line_total ?? '待核对'} 元`, AMBIGUOUS: '存在多条指导成本,请先核对', UNIT_MISMATCH: '指导成本单位不一致,请先核对', UNFINALIZED: '指导成本尚未封存'}
return labels[row.guidance_status ?? ''] ?? ''
}
async function loadMetadata(): Promise<PayrollMetadata> { async function loadMetadata(): Promise<PayrollMetadata> {
const pageSize = 200 const pageSize = 200
@ -62,7 +58,7 @@ async function load(): Promise<void> {
const data = await loadMetadata() const data = await loadMetadata()
people.value = data.personnel people.value = data.personnel
businessKey.value = data.business_key ?? '' businessKey.value = data.business_key ?? ''
rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, source_personnel_name: s.source_personnel_name, matched_personnel_id: s.matched_personnel_id, personnel_id: s.matched_personnel_id ?? undefined, guidance_status: s.guidance_status, guidance_line_total: s.guidance_line_total, unit_cost: s.guidance_unit_cost ?? '', freight_amount: ''})) rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, unit: s.unit, source_personnel_name: s.source_personnel_name, matched_personnel_id: s.matched_personnel_id, guidance_status: s.guidance_status, guidance_line_total: s.guidance_line_total, personnel_id: s.matched_personnel_id ?? undefined, unit_cost: s.guidance_unit_cost ?? '', freight_amount: ''}))
rowPage.value = 1 rowPage.value = 1
if (input.value) { if (input.value) {
const data = input.value.data as {group?: {opening_balance?: string}; facts?: {handovers?: payroll.Handover[]}; shipments?: {line_id: string; personnel_id: string; manager_id: string | null; cost_total: string; quantity: string; unit_cost?: string; freight_amount?: string}[]; evidence?: string} const data = input.value.data as {group?: {opening_balance?: string}; facts?: {handovers?: payroll.Handover[]}; shipments?: {line_id: string; personnel_id: string; manager_id: string | null; cost_total: string; quantity: string; unit_cost?: string; freight_amount?: string}[]; evidence?: string}
@ -112,6 +108,22 @@ async function save(): Promise<void> {
ElMessage.success('核算资料已保存为新版本') ElMessage.success('核算资料已保存为新版本')
} catch (e) { handledError(e) } finally { busy.value = false } } catch (e) { handledError(e) } finally { busy.value = false }
} }
async function registerGuidance(row: typeof rows.value[number]): Promise<void> {
if (!row.unit_cost || !evidence.value.trim()) {
ElMessage.warning('请先填写单位指导成本和核对依据')
return
}
busy.value = true
try {
await businesses.confirmGuidance(props.businessId, {
shipment_item_id: row.shipment_item_id,
amount: row.unit_cost,
evidence: evidence.value.trim(),
})
await load()
ElMessage.success('指导成本已登记并封存')
} catch (e) { handledError(e) } finally { busy.value = false }
}
function addHandover(): void { function addHandover(): void {
handovers.value.push({business_key: businessKey.value, mode: 'RESIGNATION', from_personnel_id: '', to_personnel_id: '', effective_date: '', shipping_ownership_confirmed: false}) handovers.value.push({business_key: businessKey.value, mode: 'RESIGNATION', from_personnel_id: '', to_personnel_id: '', effective_date: '', shipping_ownership_confirmed: false})
} }
@ -146,8 +158,9 @@ watch(open, value => { monthRequest++; if (value) { month.value = ''; opening.va
<el-table-column v-if="sourceManaged" label="来源经办人" min-width="140"><template #default="{row}">{{ row.source_personnel_name || '待确认' }}{{ row.source_personnel_name && !row.matched_personnel_id ? '(待匹配)' : '' }}</template></el-table-column> <el-table-column v-if="sourceManaged" label="来源经办人" min-width="140"><template #default="{row}">{{ row.source_personnel_name || '待确认' }}{{ row.source_personnel_name && !row.matched_personnel_id ? '(待匹配)' : '' }}</template></el-table-column>
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column> <el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :clearable="sourceManaged" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column> <el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :clearable="sourceManaged" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
<el-table-column label="含税单位指导成本" min-width="170"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen || row.guidance_status === 'FINALIZED_UNIT'" aria-label="含税单位指导成本" /><small v-if="guidanceHint(row)">{{ guidanceHint(row) }}</small></template></el-table-column> <el-table-column label="含税单位指导成本" min-width="170"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen || row.guidance_status === 'FINALIZED_UNIT'" aria-label="含税单位指导成本" /></template></el-table-column>
<el-table-column v-if="sourceManaged" label="运费(已核实无运费填 0)" min-width="170"><template #default="{row}"><el-input v-model="row.freight_amount" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货明细运费" /></template></el-table-column> <el-table-column v-if="sourceManaged" label="运费(已核实无运费填 0)" min-width="170"><template #default="{row}"><el-input v-model="row.freight_amount" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货明细运费" /></template></el-table-column>
<el-table-column v-if="sourceManaged" label="指导成本登记" width="130"><template #default="{row}"><el-button v-if="row.guidance_status !== 'FINALIZED_UNIT'" link :disabled="!permissionUser.can('payroll:prepare') || locked || busy" @click="registerGuidance(row)">登记并封存</el-button><span v-else class="form-hint">已封存</span></template></el-table-column>
</el-table> </el-table>
<el-pagination v-if="rows.length > 50" v-model:current-page="rowPage" :page-size="50" :total="rows.length" layout="prev, pager, next, total" /> <el-pagination v-if="rows.length > 50" v-model:current-page="rowPage" :page-size="50" :total="rows.length" layout="prev, pager, next, total" />
<section v-if="sourceManaged" class="handover-section" aria-label="业务交接"> <section v-if="sourceManaged" class="handover-section" aria-label="业务交接">

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { optionPage } from '@/api/metadata' import { optionPage } from '@/api/metadata'
import { useUserStore as usePermissionUser } from '@/store/user' import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser() const permissionUser = usePermissionUser()
@ -32,7 +33,7 @@ const filters = reactive({
is_active: 1 as number | undefined, is_active: 1 as number | undefined,
}) })
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } = const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
usePagedList<LedgerRow, { settlement_month?: string; is_active?: number }>(calcApi.listLedger, () => ({ usePagedList<LedgerRow, { settlement_month?: string; is_active?: number }>(calcApi.listLedger, () => ({
settlement_month: filters.settlement_month.trim() || undefined, settlement_month: filters.settlement_month.trim() || undefined,
is_active: filters.is_active, is_active: filters.is_active,
@ -132,6 +133,7 @@ async function submitRecalc(): Promise<void> {
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器, .filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。 外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
--> -->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent> <el-form class="filter-bar" @submit.prevent>
<el-form-item label="归属月"> <el-form-item label="归属月">
<el-input <el-input
@ -179,7 +181,7 @@ async function submitRecalc(): Promise<void> {
<span class="cell-code">{{ row.business_no }}</span> <span class="cell-code">{{ row.business_no }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="personnel_name" label="归属人员" width="100" /> <el-table-column prop="personnel_name" label="归属人员" width="100" show-overflow-tooltip />
<el-table-column label="岗位" width="110"><template #default="{ row }">{{ ({salesperson: '业务员', sales_manager: '营销经理', info_officer: '信息员'} as Record<string, string>)[row.role_code] ?? '自定义岗位' }}</template></el-table-column> <el-table-column label="岗位" width="110"><template #default="{ row }">{{ ({salesperson: '业务员', sales_manager: '营销经理', info_officer: '信息员'} as Record<string, string>)[row.role_code] ?? '自定义岗位' }}</template></el-table-column>
<el-table-column prop="receipt_date" label="回款日" width="100" /> <el-table-column prop="receipt_date" label="回款日" width="100" />
<el-table-column label="天数" width="70" align="right"> <el-table-column label="天数" width="70" align="right">
@ -265,8 +267,8 @@ async function submitRecalc(): Promise<void> {
<el-table v-loading="allocationLoading" :data="allocationRows" empty-text="暂无正式分配明细"> <el-table v-loading="allocationLoading" :data="allocationRows" empty-text="暂无正式分配明细">
<el-table-column prop="receipt_no" label="回款单" min-width="130" /><el-table-column prop="shipment_no" label="发货单" min-width="130" /> <el-table-column prop="receipt_no" label="回款单" min-width="130" /><el-table-column prop="shipment_no" label="发货单" min-width="130" />
<el-table-column prop="shipment_date" label="发货日期" width="110" /><el-table-column prop="receipt_date" label="到账日期" width="110" /> <el-table-column prop="shipment_date" label="发货日期" width="110" /><el-table-column prop="receipt_date" label="到账日期" width="110" />
<el-table-column prop="amount" label="本次分配金额" width="120" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" /> <el-table-column prop="amount" label="本次分配金额" width="120" align="right" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" align="right" />
<el-table-column prop="performance_delta" label="本次计提" width="100" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" /> <el-table-column prop="performance_delta" label="本次计提" width="100" align="right" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" align="right" />
<el-table-column prop="input_id" label="资料版本编号" width="120" /> <el-table-column prop="input_id" label="资料版本编号" width="120" />
</el-table> </el-table>
</el-dialog> </el-dialog>

View File

@ -6,10 +6,12 @@ import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components' import { GridComponent, TooltipComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers' import { SVGRenderer } from 'echarts/renderers'
import type { ECharts, EChartsCoreOption } from 'echarts/core' import type { ECharts, EChartsCoreOption } from 'echarts/core'
import { Refresh, ArrowRight } from '@element-plus/icons-vue'
import * as dashboardApi from '@/api/dashboard' import * as dashboardApi from '@/api/dashboard'
import type { ActivityItem, ActivityPage, DashboardData, TrendPoint } from '@/api/dashboard' import type { ActivityItem, ActivityPage, DashboardData, TrendPoint } from '@/api/dashboard'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
@ -66,6 +68,7 @@ const data = ref<DashboardData | null>(null)
const hasPerformanceTrend = computed(() => data.value?.overview.trend.some(point => point.performance_total != null) ?? false) const hasPerformanceTrend = computed(() => data.value?.overview.trend.some(point => point.performance_total != null) ?? false)
const hasReceiptTrend = computed(() => data.value?.overview.trend.some(point => point.receipt_total != null) ?? false) const hasReceiptTrend = computed(() => data.value?.overview.trend.some(point => point.receipt_total != null) ?? false)
const loading = ref(false) const loading = ref(false)
const failed = ref(false)
/** 快速切换月份时,只允许最新请求写入数据与 loading */ /** 快速切换月份时,只允许最新请求写入数据与 loading */
let loadSeq = 0 let loadSeq = 0
@ -78,6 +81,7 @@ let receiptChart: ECharts | null = null
* 用 ResizeObserver 监听图表容器本身,布局一变就同步图表尺寸。 * 用 ResizeObserver 监听图表容器本身,布局一变就同步图表尺寸。
*/ */
let chartObserver: ResizeObserver | null = null let chartObserver: ResizeObserver | null = null
let resizeFrame: number | undefined
function currentMonth(): string { function currentMonth(): string {
const now = new Date() const now = new Date()
@ -87,6 +91,7 @@ function currentMonth(): string {
async function load(): Promise<void> { async function load(): Promise<void> {
const seq = ++loadSeq const seq = ++loadSeq
loading.value = true loading.value = true
failed.value = false
try { try {
const result = await dashboardApi.getDashboard({ month: month.value }) const result = await dashboardApi.getDashboard({ month: month.value })
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应 if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
@ -94,7 +99,7 @@ async function load(): Promise<void> {
await nextTick() await nextTick()
renderCharts() renderCharts()
} catch (error) { } catch (error) {
if (seq === loadSeq) handledError(error) if (seq === loadSeq) { failed.value = true; handledError(error) }
} finally { } finally {
if (seq === loadSeq) loading.value = false if (seq === loadSeq) loading.value = false
} }
@ -124,12 +129,13 @@ function axisCommon(trend: TrendPoint[]) {
data: trend.map((item) => item.month.slice(5)), data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } }, axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false }, axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 }, axisLabel: { fontFamily: CHART_FONT, color: '#657086', fontSize: 11, margin: 8 },
}, },
yAxis: { yAxis: {
type: 'value' as const, type: 'value' as const,
splitLine: { show: false }, splitNumber: 3,
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 }, splitLine: { show: true, lineStyle: { color: '#edf0f5', type: 'dashed' as const } },
axisLabel: { fontFamily: CHART_FONT, color: '#657086', fontSize: 11, margin: 8 },
}, },
} }
} }
@ -153,11 +159,18 @@ function verticalGradient(top: string, bottom: string) {
} }
} }
function chartValue(value: unknown): string {
if (value == null || value === '-') return '暂无记录'
const number = Number(value)
return Number.isFinite(number) ? number.toFixed(2) : '暂无记录'
}
function performanceOption(trend: TrendPoint[]): EChartsCoreOption { function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
return { return {
animation: false,
textStyle: { fontFamily: CHART_FONT }, textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true }, grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) }, tooltip: { trigger: 'axis', valueFormatter: chartValue },
...axisCommon(trend), ...axisCommon(trend),
series: [ series: [
{ {
@ -165,7 +178,7 @@ function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
type: 'line', type: 'line',
smooth: 0.4, smooth: 0.4,
symbol: 'none', symbol: 'none',
data: trend.map((item) => Number(item.performance_total ?? 0)), data: trend.map((item) => item.performance_total == null ? null : Number(item.performance_total)),
lineStyle: { color: '#2e4ba8', width: 2 }, lineStyle: { color: '#2e4ba8', width: 2 },
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') }, areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
}, },
@ -177,9 +190,10 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪 // 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
const lastIndex = trend.length - 1 const lastIndex = trend.length - 1
return { return {
animation: false,
textStyle: { fontFamily: CHART_FONT }, textStyle: { fontFamily: CHART_FONT },
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true }, grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) }, tooltip: { trigger: 'axis', valueFormatter: chartValue },
...axisCommon(trend), ...axisCommon(trend),
series: [ series: [
{ {
@ -187,7 +201,7 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
type: 'bar', type: 'bar',
barMaxWidth: 22, barMaxWidth: 22,
data: trend.map((item, index) => ({ data: trend.map((item, index) => ({
value: Number(item.receipt_total ?? 0), value: item.receipt_total == null ? null : Number(item.receipt_total),
itemStyle: { itemStyle: {
color: color:
index === lastIndex index === lastIndex
@ -202,27 +216,28 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
} }
function handleResize(): void { function handleResize(): void {
perfChart?.resize() if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
receiptChart?.resize() resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
perfChart?.resize()
receiptChart?.resize()
})
} }
onMounted(() => { onMounted(() => {
void load() void load()
void loadActivities() void loadActivities()
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
if (perfChartRef.value) { chartObserver = new ResizeObserver(handleResize)
chartObserver = new ResizeObserver(() => { if (perfChartRef.value) chartObserver.observe(perfChartRef.value)
perfChart?.resize() if (receiptChartRef.value) chartObserver.observe(receiptChartRef.value)
receiptChart?.resize()
})
chartObserver.observe(perfChartRef.value)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
chartObserver?.disconnect() chartObserver?.disconnect()
chartObserver = null chartObserver = null
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
perfChart?.dispose() perfChart?.dispose()
receiptChart?.dispose() receiptChart?.dispose()
}) })
@ -239,6 +254,7 @@ const {
pageNum: activityPageNum, pageNum: activityPageNum,
pageSize: activityPageSize, pageSize: activityPageSize,
loading: activityLoading, loading: activityLoading,
loadFailed: activityFailed,
load: loadActivities, load: loadActivities,
search: searchActivities, search: searchActivities,
onPageChange: onActivityPageChange, onPageChange: onActivityPageChange,
@ -269,24 +285,28 @@ function formatTime(value: string): string {
<template> <template>
<div v-loading="loading" class="workbench"> <div v-loading="loading" class="workbench">
<div class="page-head"> <header class="page-head dashboard-head">
<div>
<h1 class="page-title">工作台</h1> <h1 class="page-title">工作台</h1>
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p> <p class="page-desc">查看本月绩效与回款,处理待办并核对业务风险。</p>
<span class="waterline"></span> <span class="waterline"></span>
</div> </div>
<div class="dashboard-filter">
<div class="toolbar"> <span>统计月份</span>
<el-date-picker <el-date-picker
v-model="month" v-model="month"
type="month" type="month"
value-format="YYYY-MM" value-format="YYYY-MM"
:clearable="false" :clearable="false"
style="width: 140px" style="width: 140px"
aria-label="统计月份"
@change="load" @change="load"
/> />
<el-button type="primary" :loading="loading" @click="load">刷新</el-button> <el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
<span class="toolbar-note">{{ data?.overview.note ?? '' }}</span>
</div> </div>
</header>
<el-alert v-if="failed" class="dashboard-notice" title="工作台更新失败,显示内容可能不是最新结果" type="warning" :closable="false" show-icon><el-button link @click="load">重新加载工作台</el-button></el-alert>
<p v-if="data?.overview.note" class="overview-note">{{ data.overview.note }}</p>
<!-- ① 经营总览:KPI 四格 --> <!-- ① 经营总览:KPI 四格 -->
<section class="kpi-row" aria-label="经营总览指标"> <section class="kpi-row" aria-label="经营总览指标">
@ -333,7 +353,7 @@ function formatTime(value: string): string {
<span class="panel-title">近 6 个月绩效</span> <span class="panel-title">近 6 个月绩效</span>
<span class="panel-hint">按台账归属月</span> <span class="panel-hint">按台账归属月</span>
</div> </div>
<div v-show="hasPerformanceTrend" ref="perfChartRef" class="chart"></div> <div v-show="hasPerformanceTrend" ref="perfChartRef" class="chart" role="img" aria-label="近六个月绩效趋势,按台账归属月统计"></div>
<div v-if="data && !hasPerformanceTrend" class="chart-empty" role="status"><strong>该时段还没有绩效台账</strong><p>采集成功后,仍需导入正式业务、准备核算资料并执行核算,才会形成绩效统计。</p><el-button v-if="user.hasPermission('business:businesses:view')" link type="primary" @click="router.push('/business/businesses')">查看业务与核算</el-button></div> <div v-if="data && !hasPerformanceTrend" class="chart-empty" role="status"><strong>该时段还没有绩效台账</strong><p>采集成功后,仍需导入正式业务、准备核算资料并执行核算,才会形成绩效统计。</p><el-button v-if="user.hasPermission('business:businesses:view')" link type="primary" @click="router.push('/business/businesses')">查看业务与核算</el-button></div>
</div> </div>
<div class="panel"> <div class="panel">
@ -341,7 +361,7 @@ function formatTime(value: string): string {
<span class="panel-title">近 6 个月回款</span> <span class="panel-title">近 6 个月回款</span>
<span class="panel-hint">按到账月</span> <span class="panel-hint">按到账月</span>
</div> </div>
<div v-show="hasReceiptTrend" ref="receiptChartRef" class="chart"></div> <div v-show="hasReceiptTrend" ref="receiptChartRef" class="chart" role="img" aria-label="近六个月回款趋势,按到账月统计"></div>
<div v-if="data && !hasReceiptTrend" class="chart-empty" role="status"><strong>该时段还没有正式回款记录</strong><p>T+ 原始回款需正式导入,或在回款管理登记后,才会进入这里的统计。</p><el-button v-if="user.hasPermission('business:receipts:view')" link type="primary" @click="router.push('/business/receipts')">查看回款管理</el-button></div> <div v-if="data && !hasReceiptTrend" class="chart-empty" role="status"><strong>该时段还没有正式回款记录</strong><p>T+ 原始回款需正式导入,或在回款管理登记后,才会进入这里的统计。</p><el-button v-if="user.hasPermission('business:receipts:view')" link type="primary" @click="router.push('/business/receipts')">查看回款管理</el-button></div>
</div> </div>
</section> </section>
@ -358,7 +378,7 @@ function formatTime(value: string): string {
<div class="list-block__head"> <div class="list-block__head">
<span class="list-block__title">待审批</span> <span class="list-block__title">待审批</span>
<span class="list-block__count">{{ data?.todos.pending_approval_total ?? 0 }}</span> <span class="list-block__count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
<button class="list-block__more" type="button" @click="go('/system/approvals')"> <button v-if="data?.todos.pending_approval_total" class="list-block__more" type="button" @click="go('/system/approvals')">
全部 → 全部 →
</button> </button>
</div> </div>
@ -389,7 +409,7 @@ function formatTime(value: string): string {
<div class="list-block__head"> <div class="list-block__head">
<span class="list-block__title">未指定批次的回款(供核对)</span> <span class="list-block__title">未指定批次的回款(供核对)</span>
<span class="list-block__count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span> <span class="list-block__count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
<button class="list-block__more" type="button" @click="go('/business/receipts')"> <button v-if="data?.todos.unassigned_receipt_total" class="list-block__more" type="button" @click="go('/business/receipts')">
全部 → 全部 →
</button> </button>
</div> </div>
@ -410,17 +430,6 @@ function formatTime(value: string): string {
<div v-else class="list-empty">无未指定批次的回款</div> <div v-else class="list-empty">无未指定批次的回款</div>
</div> </div>
<div class="status-strip">
<span class="status-strip__label">T+ 数据采集</span>
<span class="status-strip__value">
{{
!data ? '正在加载采集状态…' : !data.sync.available ? '采集记录暂不可查询'
: data.sync.last_success_at
? `最近成功:${formatDateTime(data.sync.last_success_at)}`
: '尚无成功采集记录'
}}
</span>
</div>
</div> </div>
<div class="panel panel--list"> <div class="panel panel--list">
@ -465,21 +474,22 @@ function formatTime(value: string): string {
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条 仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
</div> </div>
<div class="status-strip">
<span class="status-strip__label">采集运行状态</span>
<span class="status-strip__value">
<template v-if="data?.sync.available">
{{ SYNC_STATUS_LABELS[data.sync.latest_status ?? ''] ?? '尚无采集任务' }};
{{ data.sync.enabled ? '源读取已启用' : '源读取未启用' }};
{{ data.sync.automation_enabled ? '自动采集已开启' : '自动采集未开启' }}
<span v-if="data.sync.historical_failure_total">;历史异常 {{ data.sync.historical_failure_total }} 批次</span>
</template>
<template v-else>{{ data ? '采集记录暂不可查询' : '正在加载采集状态…' }}</template>
</span>
</div>
</div> </div>
</section> </section>
<section class="sync-context" aria-label="T+ 数据采集状态">
<strong>T+ 数据采集</strong>
<template v-if="data?.sync.available">
<span class="sync-context__state">{{ SYNC_STATUS_LABELS[data.sync.latest_status ?? ''] ?? '尚无采集任务' }}</span>
<span>{{ data.sync.enabled ? '源读取已启用' : '源读取未启用' }}</span>
<span>{{ data.sync.automation_enabled ? '自动采集已开启' : '自动采集未开启' }}</span>
<span>最近成功:{{ formatDateTime(data.sync.last_success_at, '尚无成功记录') }}</span>
<span v-if="data.sync.historical_failure_total">历史异常 {{ data.sync.historical_failure_total }} 批次</span>
</template>
<span v-else>{{ data ? '采集记录暂不可查询' : '正在加载采集状态…' }}</span>
<el-button v-if="user.hasPermission('tplus:view')" link type="primary" :icon="ArrowRight" @click="go('/system/tplus')">查看同步</el-button>
</section>
<!-- ④ 最近动态(服务端分页:列表有边界,翻页只重取这张表) --> <!-- ④ 最近动态(服务端分页:列表有边界,翻页只重取这张表) -->
<section class="panel panel--activity" aria-label="最近动态"> <section class="panel panel--activity" aria-label="最近动态">
<div class="panel-head"> <div class="panel-head">
@ -490,6 +500,7 @@ function formatTime(value: string): string {
clearable clearable
placeholder="全部模块" placeholder="全部模块"
class="activity-filter" class="activity-filter"
aria-label="动态模块"
@change="searchActivities" @change="searchActivities"
> >
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" /> <el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
@ -503,6 +514,7 @@ function formatTime(value: string): string {
观感就是「数据反复变化」。 观感就是「数据反复变化」。
--> -->
<div v-loading="activityLoading" class="activity-list-wrap"> <div v-loading="activityLoading" class="activity-list-wrap">
<ListLoadError v-if="activityFailed" :loading="activityLoading" @retry="loadActivities" />
<div v-if="activities.length" class="activity-list"> <div v-if="activities.length" class="activity-list">
<div class="activity-row activity-row--head"> <div class="activity-row activity-row--head">
<span>时间</span> <span>时间</span>
@ -529,7 +541,7 @@ function formatTime(value: string): string {
<span class="activity-cell activity-cell--user">{{ item.username ?? '—' }}</span> <span class="activity-cell activity-cell--user">{{ item.username ?? '—' }}</span>
</div> </div>
</div> </div>
<div v-else class="list-empty list-empty--block">暂无动态</div> <div v-else-if="!activityLoading && !activityFailed" class="list-empty list-empty--block">暂无动态</div>
</div> </div>
<AppPagination <AppPagination
@ -550,10 +562,10 @@ function formatTime(value: string): string {
<span class="panel-title">常用操作</span> <span class="panel-title">常用操作</span>
</div> </div>
<div class="quick-actions"> <div class="quick-actions">
<button class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button> <button v-if="user.hasPermission('business:receipts:view')" class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button>
<button class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button> <button v-if="user.hasPermission('payroll:view')" class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button>
<button class="quick-btn" type="button" @click="go('/settlements')">月度结算</button> <button v-if="user.hasPermission('settlements:view')" class="quick-btn" type="button" @click="go('/settlements')">月度结算</button>
<button class="quick-btn" type="button" @click="go('/reports/personal-detail')"> <button v-if="user.hasPermission('reports:personal:view')" class="quick-btn" type="button" @click="go('/reports/personal-detail')">
报表统计 报表统计
</button> </button>
</div> </div>
@ -562,6 +574,16 @@ function formatTime(value: string): string {
</template> </template>
<style scoped> <style scoped>
.workbench { min-width: 0; }
.dashboard-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 16px 24px; }
.dashboard-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.dashboard-filter > span { font-size: 13px; color: var(--ys-ink-2); }
.overview-note { margin: 0 0 14px; color: var(--ys-ink-3); font-size: 12px; line-height: 1.7; }
.dashboard-notice { margin-bottom: 16px; }
.sync-context { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 20px; padding: 16px 0; border-top: 1px solid var(--ys-line); border-bottom: 1px solid var(--ys-line); color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
.sync-context strong { color: var(--ys-ink); font-size: 13px; font-weight: 600; }
.sync-context__state { color: var(--ys-indigo); }
.sync-context .el-button { margin-left: auto; }
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */ /* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
/* /*
@ -581,7 +603,10 @@ function formatTime(value: string): string {
.kpi-row { .kpi-row {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px; gap: 0;
background: var(--ys-paper);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
} }
.kpi-card { .kpi-card {
@ -589,22 +614,11 @@ function formatTime(value: string): string {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
min-width: 0; min-width: 0;
min-height: 116px; min-height: 108px;
padding: 16px 18px; padding: 16px 18px;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe); background: none;
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card);
transition:
transform 180ms var(--ys-ease),
box-shadow 180ms var(--ys-ease);
animation: ys-rise 320ms var(--ys-ease) backwards;
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--ys-shadow-raise);
} }
.kpi-card + .kpi-card { border-left: 1px solid var(--ys-line-soft); }
.kpi-label { .kpi-label {
font-size: 12px; font-size: 12px;
@ -647,36 +661,10 @@ function formatTime(value: string): string {
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
padding: 16px 18px; padding: 16px 18px;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe); background: var(--ys-paper);
border: 1px solid var(--ys-line); border: 1px solid var(--ys-line);
border-radius: var(--ys-radius); border-radius: var(--ys-radius);
box-shadow: var(--ys-shadow-card); box-shadow: var(--ys-shadow-card);
transition:
transform 180ms var(--ys-ease),
box-shadow 180ms var(--ys-ease);
animation: ys-rise 320ms var(--ys-ease) backwards;
}
.panel:hover {
transform: translateY(-2px);
box-shadow: var(--ys-shadow-raise);
}
/* 交错入场:区块之间延后 60ms,避免整屏同时闪一下 */
.workbench > section:nth-of-type(2) .panel {
animation-delay: 60ms;
}
.workbench > section:nth-of-type(3) .panel {
animation-delay: 120ms;
}
.workbench > section:nth-of-type(4) .panel {
animation-delay: 180ms;
}
.workbench > section:nth-of-type(5) .panel {
animation-delay: 240ms;
} }
.panel-head { .panel-head {
@ -690,14 +678,14 @@ function formatTime(value: string): string {
} }
.panel-title { .panel-title {
font-size: 13px; font-size: 14px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: var(--ys-ink-2); color: var(--ys-ink);
} }
.panel-hint { .panel-hint {
font-size: 11px; font-size: 12px;
color: var(--ys-ink-3); color: var(--ys-ink-3);
} }
@ -724,12 +712,12 @@ function formatTime(value: string): string {
/* ── ③ 待办 / 预警 ────────────────────────────────────────────────────── */ /* ── ③ 待办 / 预警 ────────────────────────────────────────────────────── */
/* 高度下限保证列表区有 4~5 行可视;内容更长时由内部滚动消化 */ /* 高度下限保证列表区有 4~5 行可视;内容更长时由内部滚动消化 */
.panel--list { .panel--list {
min-height: 420px; min-height: 0;
} }
.list-block { .list-block {
display: flex; display: flex;
flex: 1 1 0; flex: none;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
} }
@ -740,6 +728,7 @@ function formatTime(value: string): string {
.list-block__head { .list-block__head {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex: none; flex: none;
@ -784,6 +773,7 @@ function formatTime(value: string): string {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
max-height: 260px;
} }
/* /*
@ -824,15 +814,17 @@ function formatTime(value: string): string {
.row__main { .row__main {
font-size: 13px; font-size: 13px;
color: var(--ys-ink); color: var(--ys-ink);
overflow: hidden; overflow-wrap: anywhere;
text-overflow: ellipsis; line-height: 1.6;
white-space: nowrap;
} }
.row__meta { .row__meta {
font-size: 11px; font-size: 11px;
color: var(--ys-ink-3); color: var(--ys-ink-3);
white-space: nowrap; white-space: normal;
overflow-wrap: anywhere;
max-width: 28ch;
line-height: 1.6;
} }
/* 预警行:标签 | 标题 | 详情 三列并排 */ /* 预警行:标签 | 标题 | 详情 三列并排 */
@ -840,6 +832,7 @@ function formatTime(value: string): string {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 0.85fr); grid-template-columns: auto minmax(0, 1fr) minmax(0, 0.85fr);
align-items: center; align-items: center;
} }
.row--warn .row__meta { white-space: normal; line-height: 1.7; }
.row__tag { .row__tag {
display: inline-flex; display: inline-flex;
@ -848,13 +841,15 @@ function formatTime(value: string): string {
/* 空态在卡片剩余高度里居中,避免内容全挤在顶部、下方留一大片空白 */ /* 空态在卡片剩余高度里居中,避免内容全挤在顶部、下方留一大片空白 */
.list-empty { .list-empty {
display: flex; display: flex;
flex: 1; flex: none;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
padding: 12px 0; min-height: 56px;
padding: 16px 8px;
font-size: 12px; font-size: 12px;
color: var(--ys-ink-3); color: var(--ys-ink-3);
text-align: center; text-align: left;
line-height: 1.8;
} }
/* 面板级空态(预警/动态无数据时独占一屏高度) */ /* 面板级空态(预警/动态无数据时独占一屏高度) */
@ -873,6 +868,7 @@ function formatTime(value: string): string {
.status-strip { .status-strip {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 10px; gap: 10px;
flex: none; flex: none;
margin-top: 12px; margin-top: 12px;
@ -895,7 +891,7 @@ function formatTime(value: string): string {
/* ── ④ 最近动态 ───────────────────────────────────────────────────────── */ /* ── ④ 最近动态 ───────────────────────────────────────────────────────── */
.panel--activity { .panel--activity {
min-height: 320px; min-height: 0;
} }
.activity-filter { .activity-filter {
@ -969,6 +965,8 @@ function formatTime(value: string): string {
.activity-cell--summary { .activity-cell--summary {
color: var(--ys-ink-2); color: var(--ys-ink-2);
white-space: normal;
line-height: 1.7;
} }
.activity-cell--user { .activity-cell--user {
@ -1015,6 +1013,8 @@ function formatTime(value: string): string {
.kpi-row { .kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.kpi-card:nth-child(3) { border-left: 0; }
.kpi-card:nth-child(n+3) { border-top: 1px solid var(--ys-line-soft); }
} }
@media (max-width: 1080px) { @media (max-width: 1080px) {
@ -1024,13 +1024,13 @@ function formatTime(value: string): string {
} }
.panel--list { .panel--list {
min-height: 360px; min-height: 0;
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.kpi-row { .kpi-row {
grid-template-columns: minmax(0, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.activity-row { .activity-row {
@ -1043,6 +1043,14 @@ function formatTime(value: string): string {
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.row:not(.row--warn) { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.row__meta { max-width: none; }
.dashboard-filter { width: 100%; }
.sync-context { gap: 8px 16px; }
.sync-context strong { flex-basis: 100%; }
.kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kpi-card { padding: 16px; }
.kpi-value { font-size: clamp(18px, 5vw, 26px); overflow-wrap: anywhere; }
.panel { .panel {
padding: 14px; padding: 14px;
} }

View File

@ -14,6 +14,7 @@ const userStore = useUserStore()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const submitting = ref(false) const submitting = ref(false)
const captchaLoading = ref(false) const captchaLoading = ref(false)
const captchaFailed = ref(false)
const form = reactive({ const form = reactive({
username: '', username: '',
@ -61,7 +62,9 @@ const rules: FormRules = {
/** 加载或刷新验证码。点击图片亦可触发。 */ /** 加载或刷新验证码。点击图片亦可触发。 */
async function refreshCaptcha(): Promise<void> { async function refreshCaptcha(): Promise<void> {
if (captchaLoading.value) return
captchaLoading.value = true captchaLoading.value = true
captchaFailed.value = false
try { try {
const data = await getCaptcha() const data = await getCaptcha()
captchaId.value = data.captcha_id captchaId.value = data.captcha_id
@ -69,6 +72,9 @@ async function refreshCaptcha(): Promise<void> {
captchaImage.value = `data:image/png;base64,${data.image_base64}` captchaImage.value = `data:image/png;base64,${data.image_base64}`
form.captcha_code = '' form.captcha_code = ''
} catch { } catch {
captchaFailed.value = true
captchaId.value = ''
captchaImage.value = ''
// 失败提示已由 axios 拦截器统一处理,这里只需保证界面回到可用状态 // 失败提示已由 axios 拦截器统一处理,这里只需保证界面回到可用状态
} finally { } finally {
captchaLoading.value = false captchaLoading.value = false
@ -76,7 +82,11 @@ async function refreshCaptcha(): Promise<void> {
} }
async function handleSubmit(): Promise<void> { async function handleSubmit(): Promise<void> {
if (!formRef.value) return if (!formRef.value || submitting.value || captchaLoading.value) return
if (!captchaId.value) {
await refreshCaptcha()
return
}
const valid = await formRef.value.validate().catch(() => false) const valid = await formRef.value.validate().catch(() => false)
if (!valid) return if (!valid) return
@ -172,17 +182,19 @@ onMounted(() => {
type="button" type="button"
class="captcha__image" class="captcha__image"
:aria-busy="captchaLoading" :aria-busy="captchaLoading"
:disabled="captchaLoading || submitting"
:aria-label="captchaFailed ? '验证码加载失败,点击重试' : '换一张验证码'"
title="点击换一张" title="点击换一张"
@click="refreshCaptcha" @click="refreshCaptcha"
> >
<img v-if="captchaImage" :src="captchaImage" alt="验证码" draggable="false" /> <img v-if="captchaImage" :src="captchaImage" alt="验证码" draggable="false" />
<span v-else class="captcha__placeholder">加载中</span> <span v-else class="captcha__placeholder">{{ captchaFailed ? '点击重试' : '加载中' }}</span>
</button> </button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item class="form-actions"> <el-form-item class="form-actions">
<el-button type="primary" class="submit" :loading="submitting" @click="handleSubmit"> <el-button type="primary" class="submit" :loading="submitting" :disabled="captchaLoading" @click="handleSubmit">
登 录 登 录
</el-button> </el-button>
</el-form-item> </el-form-item>

View File

@ -1,5 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { firstAccessiblePath } from '@/router/access'
import { handledError } from '@/utils/error'
const user = useUserStore() const user = useUserStore()
const router = useRouter()
const checking = ref(false)
async function checkAccess(): Promise<void> {
if (checking.value) return
checking.value = true
try {
await user.fetchUserInfo()
const target = firstAccessiblePath(router, user.roles)
if (target !== '/no-access') await router.replace(target)
} catch (error) { handledError(error) }
finally { checking.value = false }
}
</script> </script>
<template><section class="card card-body"><h1 class="page-title">暂无可访问功能</h1><p>当前账号尚未获得功能权限,请联系系统管理员在用户管理中授权。</p><el-button @click="user.fetchUserInfo">重新检查权限</el-button></section></template> <template>
<section class="card access-empty" aria-labelledby="access-title">
<h1 id="access-title" class="page-title">暂无可访问功能</h1>
<p>当前账号尚未分配功能权限。请联系管理员分配角色,授权后可在这里重新检查。</p>
<el-button type="primary" :loading="checking" @click="checkAccess">重新检查权限</el-button>
</section>
</template>
<style scoped>
.access-empty { max-width: 640px; margin: 48px auto; padding: 40px 24px; text-align: center; }
.access-empty p { max-width: 38em; margin: 16px auto 24px; color: var(--ys-ink-2); line-height: 1.8; }
</style>

View File

@ -215,7 +215,7 @@ function pct(value: string): string {
</div> </div>
<div class="card"> <div class="card">
<section v-if="data?.salary_rows?.length" class="salary-snapshot"><h2 class="section-title">已生成工资分项(冻结快照)</h2><el-table :data="data.salary_rows" border><el-table-column prop="personnel_name" label="人员" width="120" /><el-table-column v-for="[key,label] in [['basic_amount','基本工资'],['normal_payable_sum','正常绩效'],['deferred_sum','试用期缓发'],['probation_payment_sum','年终/续付差额'],['payable_sum','本月实付绩效'],['net_amount','净工资']]" :key="key" :prop="key" :label="label" min-width="140" align="right" /></el-table></section> <section v-if="data?.salary_rows?.length" class="salary-snapshot"><h2 class="section-title">已生成工资分项(冻结快照)</h2><el-table :data="data.salary_rows" border><el-table-column prop="personnel_name" label="人员" width="120" show-overflow-tooltip /><el-table-column v-for="[key,label] in [['basic_amount','基本工资'],['normal_payable_sum','正常绩效'],['deferred_sum','试用期缓发'],['probation_payment_sum','年终/续付差额'],['payable_sum','本月实付绩效'],['net_amount','净工资']]" :key="key" :prop="key" :label="label" min-width="140" align="right" /></el-table></section>
<el-table <el-table
v-loading="loading" v-loading="loading"
class="dense" class="dense"
@ -228,7 +228,7 @@ function pct(value: string): string {
<template #default="{ row }"> <template #default="{ row }">
<div class="member-wrap"> <div class="member-wrap">
<el-table :data="row.members" size="small" stripe> <el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" label="成员" width="120" /> <el-table-column prop="personnel_name" label="成员" width="120" show-overflow-tooltip />
<el-table-column prop="role_code" label="岗位" width="120" /> <el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right"> <el-table-column prop="performance_sum" label="绩效金额" width="120" align="right">
<template #default="{ row: member }"> <template #default="{ row: member }">
@ -325,7 +325,7 @@ function pct(value: string): string {
<div v-if="data && data.unassigned.length" class="unassigned"> <div v-if="data && data.unassigned.length" class="unassigned">
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p> <p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
<el-table :data="data.unassigned" size="small" stripe> <el-table :data="data.unassigned" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" width="130" /> <el-table-column prop="personnel_name" label="人员" width="130" show-overflow-tooltip />
<el-table-column prop="role_code" label="岗位" width="120" /> <el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right"> <el-table-column prop="performance_sum" label="绩效金额" width="130" align="right">
<template #default="{ row }"> <template #default="{ row }">
@ -363,6 +363,7 @@ function pct(value: string): string {
.company-card { .company-card {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 24px; gap: 24px;
margin-bottom: 12px; margin-bottom: 12px;
@ -399,4 +400,9 @@ function pct(value: string): string {
.unassigned { .unassigned {
margin-top: 20px; margin-top: 20px;
} }
@media (max-width: 600px) {
.company-card { gap: 10px 18px; }
.company-note { width: 100%; line-height: 1.7; }
}
</style> </style>

View File

@ -249,11 +249,11 @@ function today(): string {
size="small" size="small"
stripe stripe
> >
<el-table-column prop="business_no" label="合同编号" min-width="150" /> <el-table-column prop="business_no" label="合同编号" min-width="150" show-overflow-tooltip />
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" /> <el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" /> <el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" /> <el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="personnel_name" label="归属人员" width="110" /> <el-table-column prop="personnel_name" label="归属人员" width="110" show-overflow-tooltip />
<el-table-column prop="payment_days" label="天数" width="80" align="right" /> <el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column label="客户类型" width="100"> <el-table-column label="客户类型" width="100">
<template #default="{ row: detail }"> <template #default="{ row: detail }">
@ -402,7 +402,7 @@ function today(): string {
.dimension-grid { .dimension-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
gap: 20px; gap: 20px;
margin-top: 20px; margin-top: 20px;
} }

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { optionPage } from '@/api/metadata' import { optionPage } from '@/api/metadata'
import { useUserStore as usePermissionUser } from '@/store/user' import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser() const permissionUser = usePermissionUser()
@ -85,7 +86,7 @@ function fetchPage(params: ReportFilterParams) {
} }
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护 // summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } = const { records, total, pageNum, pageSize, loadFailed, loading, search, onPageChange, onSizeChange } =
usePagedList(fetchPage, getFilters, (data) => { usePagedList(fetchPage, getFilters, (data) => {
summary.value = data.summary summary.value = data.summary
}) })
@ -193,6 +194,7 @@ function today(): string {
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器, .filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。 外面再包一层 div 会让 el-form-item 的 gap 排版失效。
--> -->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="search" />
<el-form class="filter-bar" @submit.prevent> <el-form class="filter-bar" @submit.prevent>
<el-form-item label="月份"> <el-form-item label="月份">
<el-date-picker <el-date-picker
@ -278,7 +280,7 @@ function today(): string {
<LedgerCalculationDetails v-else :ledger="snapshots[row.ledger_id]" :loading="snapshotLoading[row.ledger_id]" /> <LedgerCalculationDetails v-else :ledger="snapshots[row.ledger_id]" :loading="snapshotLoading[row.ledger_id]" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="business_no" label="合同编号" min-width="160"> <el-table-column prop="business_no" label="合同编号" min-width="160" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span class="cell-code">{{ row.business_no }}</span> <span class="cell-code">{{ row.business_no }}</span>
</template> </template>
@ -298,7 +300,7 @@ function today(): string {
<el-table-column label="客户类型" width="100"> <el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template> <template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="role_name" label="岗位" width="100" /> <el-table-column prop="role_name" label="岗位" width="100" show-overflow-tooltip />
<el-table-column prop="rate_text" label="计提比例" width="110"> <el-table-column prop="rate_text" label="计提比例" width="110">
<template #default="{ row }"> <template #default="{ row }">
<span class="cell-num">{{ row.rate_text }}</span> <span class="cell-num">{{ row.rate_text }}</span>

View File

@ -187,6 +187,7 @@ onMounted(load)
</div> </div>
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 --> <!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
<div class="sheet-viewport" role="region" aria-label="报表预览,可横向滚动" tabindex="0">
<div class="sheet"> <div class="sheet">
<header class="sheet-header"> <header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" /> <img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
@ -489,6 +490,7 @@ onMounted(load)
<div>审批:____________</div> <div>审批:____________</div>
</footer> </footer>
</div> </div>
</div>
</div> </div>
</template> </template>
@ -563,6 +565,14 @@ onMounted(load)
background: #fff; background: #fff;
} }
.sheet-viewport { overflow-x: auto; overscroll-behavior-x: contain; }
@media screen and (max-width: 800px) {
.print-bar { height: auto; min-height: 52px; padding: 14px 16px; gap: 12px; flex-wrap: wrap; }
.print-bar__actions { flex-wrap: wrap; gap: 8px; }
.sheet { min-width: 1000px; margin: 0 16px; }
}
.sheet-header { .sheet-header {
text-align: center; text-align: center;
} }
@ -661,6 +671,7 @@ onMounted(load)
} }
@media print { @media print {
.sheet-viewport { overflow: visible; }
.no-print { .no-print {
display: none !important; display: none !important;
} }

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import httpService from '@/api/request' import httpService from '@/api/request'
import ProbationPanel from '@/components/payroll/ProbationPanel.vue' import ProbationPanel from '@/components/payroll/ProbationPanel.vue'
import { useUserStore as usePermissionUser } from '@/store/user' import { useUserStore as usePermissionUser } from '@/store/user'
@ -35,7 +36,7 @@ const filters = reactive({
status: undefined as number | undefined, status: undefined as number | undefined,
}) })
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } = const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
usePagedList<Settlement, { status?: number }>(listSettlements, () => ({ status: filters.status })) usePagedList<Settlement, { status?: number }>(listSettlements, () => ({ status: filters.status }))
onMounted(() => { onMounted(() => {
@ -129,7 +130,6 @@ async function handleSubmit(row: Settlement): Promise<void> {
<template> <template>
<div class="requirements-surface"> <div class="requirements-surface">
<ProbationPanel />
<div class="page"> <div class="page">
<div class="page-head"> <div class="page-head">
<h1 class="page-title">月度结算</h1> <h1 class="page-title">月度结算</h1>
@ -178,6 +178,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器, .filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。 外面再包一层 div 会让 el-form-item 的 gap 排版失效。
--> -->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent> <el-form class="filter-bar" @submit.prevent>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px"> <el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
@ -250,6 +251,8 @@ async function handleSubmit(row: Settlement): Promise<void> {
</div> </div>
</div> </div>
<ProbationPanel />
<el-drawer v-model="detailVisible" title="月结详情" size="min(1180px, 96vw)"> <el-drawer v-model="detailVisible" title="月结详情" size="min(1180px, 96vw)">
<div v-loading="detailLoading"> <div v-loading="detailLoading">
<template v-if="detail"> <template v-if="detail">
@ -275,7 +278,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
<p class="section-title section-title--gap">按人明细</p><el-button :disabled="!permissionUser.can('reports:personal:export')" @click="exportSalary(detail.settlement_month)">导出工资分项 Excel</el-button> <p class="section-title section-title--gap">按人明细</p><el-button :disabled="!permissionUser.can('reports:personal:export')" @click="exportSalary(detail.settlement_month)">导出工资分项 Excel</el-button>
<el-table :data="detail.items" size="small" stripe> <el-table :data="detail.items" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" min-width="90" /> <el-table-column prop="personnel_name" label="人员" min-width="90" show-overflow-tooltip />
<el-table-column label="类型" width="90"> <el-table-column label="类型" width="90">
<template #default="{ row }"> <template #default="{ row }">
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }} {{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
@ -296,7 +299,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
<span class="cell-num">{{ row.net_amount ?? '—' }}</span> <span class="cell-num">{{ row.net_amount ?? '—' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="basic_amount" label="基本工资" min-width="110" /><el-table-column prop="normal_payable_sum" label="正常绩效" min-width="110" /><el-table-column prop="deferred_sum" label="试用期缓发" min-width="120" /><el-table-column prop="probation_payment_sum" label="年终/续付差额" min-width="140" /> <el-table-column prop="basic_amount" label="基本工资" min-width="110" align="right" /><el-table-column prop="normal_payable_sum" label="正常绩效" min-width="110" /><el-table-column prop="deferred_sum" label="试用期缓发" min-width="120" /><el-table-column prop="probation_payment_sum" label="年终/续付差额" min-width="140" />
<el-table-column label="试用期" width="70"> <el-table-column label="试用期" width="70">
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template> <template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
</el-table-column> </el-table-column>

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { useUserStore as usePermissionUser } from '@/store/user' import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser() const permissionUser = usePermissionUser()
@ -43,7 +44,7 @@ const filters = reactive({
order_type: undefined as number | undefined, order_type: undefined as number | undefined,
}) })
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } = const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
usePagedList<ApprovalOrder, { keyword?: string; status?: number; order_type?: number }>( usePagedList<ApprovalOrder, { keyword?: string; status?: number; order_type?: number }>(
listApprovals, listApprovals,
() => ({ () => ({
@ -185,6 +186,7 @@ async function submitAction(): Promise<void> {
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item .filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
失去 gap 排版(el-form 不是 flex 透明容器)。 失去 gap 排版(el-form 不是 flex 透明容器)。
--> -->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent> <el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input <el-input
@ -337,7 +339,7 @@ async function submitAction(): Promise<void> {
<dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd> <dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd>
</dl> </dl>
<el-table :data="allocationData.allocations" stripe size="small" class="content-table"> <el-table :data="allocationData.allocations" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" /> <el-table-column prop="personnel_name" label="参与业务员" min-width="160" show-overflow-tooltip />
<el-table-column label="分配比例" width="120"> <el-table-column label="分配比例" width="120">
<template #default="{ row }"> <template #default="{ row }">
<span class="cell-num">{{ row.ratio }}%</span> <span class="cell-num">{{ row.ratio }}%</span>
@ -371,7 +373,7 @@ async function submitAction(): Promise<void> {
</dd> </dd>
</dl> </dl>
<el-table :data="settlementData.items" stripe size="small" class="content-table"> <el-table :data="settlementData.items" stripe size="small" class="content-table">
<el-table-column prop="personnel_name" label="人员" min-width="110" /> <el-table-column prop="personnel_name" label="人员" min-width="110" show-overflow-tooltip />
<el-table-column prop="role_code" label="岗位" width="110" /> <el-table-column prop="role_code" label="岗位" width="110" />
<el-table-column label="绩效(元)" width="120"> <el-table-column label="绩效(元)" width="120">
<template #default="{ row }"> <template #default="{ row }">

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { useUserStore as usePermissionUser } from '@/store/user' import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser() const permissionUser = usePermissionUser()
@ -33,7 +34,7 @@ const filters = reactive({
status: undefined as number | undefined, status: undefined as number | undefined,
}) })
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } = const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>( usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>(
businessRoleApi.listBusinessRoles, businessRoleApi.listBusinessRoles,
() => ({ () => ({
@ -67,7 +68,7 @@ const templateLoading = ref(false)
const templateSubmitting = ref(false) const templateSubmitting = ref(false)
const templateDialogVisible = ref(false) const templateDialogVisible = ref(false)
const templateEditingId = ref<number | null>(null) const templateEditingId = ref<number | null>(null)
const paramOptions = ref<{ code: string; name: string; group: string }[]>([]) const paramOptions = ref<{ code: string; name: string; group: string; current: string }[]>([])
const templateFormRef = ref<FormInstance>() const templateFormRef = ref<FormInstance>()
const templateForm = reactive({ const templateForm = reactive({
@ -99,10 +100,18 @@ function formulaName(code: string): string {
return formulas.value.find((f) => f.code === code)?.name ?? code return formulas.value.find((f) => f.code === code)?.name ?? code
} }
function formulaRoleLabel(formulaType: string, role: string): string {
return formulas.value.find((f) => f.code === formulaType)?.role_labels[role] ?? role
}
function paramName(code: string): string { function paramName(code: string): string {
return paramOptions.value.find((item) => item.code === code)?.name ?? code return paramOptions.value.find((item) => item.code === code)?.name ?? code
} }
function paramCurrentValue(code: string): string {
return paramOptions.value.find((item) => item.code === code)?.current ?? '—'
}
async function loadTemplateManager(): Promise<void> { async function loadTemplateManager(): Promise<void> {
templateLoading.value = true templateLoading.value = true
try { try {
@ -111,7 +120,12 @@ async function loadTemplateManager(): Promise<void> {
formulas.value = formulaList formulas.value = formulaList
if (paramOptions.value.length === 0) { if (paramOptions.value.length === 0) {
const catalog = await paramsApi.getCatalog() const catalog = await paramsApi.getCatalog()
paramOptions.value = catalog.map((item) => ({ code: item.param_code, name: item.param_name, group: item.param_group })) paramOptions.value = catalog.map((item) => ({
code: item.param_code,
name: item.param_name,
group: item.param_group,
current: item.values[0]?.value_text ?? item.default_value ?? '未设置',
}))
} }
} catch (error) { } catch (error) {
handledError(error) handledError(error)
@ -360,6 +374,7 @@ onMounted(() => {
筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块 筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块
排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。 排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。
--> -->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent> <el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input <el-input
@ -405,11 +420,11 @@ onMounted(() => {
<span class="cell-code">{{ row.role_code }}</span> <span class="cell-code">{{ row.role_code }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="role_name" label="岗位名称" min-width="110" /> <el-table-column prop="role_name" label="岗位名称" min-width="110" show-overflow-tooltip />
<el-table-column label="公式模板" min-width="170"> <el-table-column label="公式模板" min-width="170">
<template #default="{ row }">{{ templateName(row.calc_template) }}</template> <template #default="{ row }">{{ templateName(row.calc_template) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="说明" min-width="200"> <el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '—' }}</template> <template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column> </el-table-column>
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 --> <!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
@ -467,6 +482,9 @@ onMounted(() => {
<!-- 新增 / 编辑 --> <!-- 新增 / 编辑 -->
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
:close-on-click-modal="!submitting"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:title="isEdit ? '编辑岗位' : '新增岗位'" :title="isEdit ? '编辑岗位' : '新增岗位'"
width="540px" width="540px"
destroy-on-close destroy-on-close
@ -498,11 +516,11 @@ onMounted(() => {
</el-form-item> </el-form-item>
<el-form-item v-if="selectedTemplate" label-width="90px"> <el-form-item v-if="selectedTemplate" label-width="90px">
<div class="tpl-info"> <div class="tpl-info">
<p>公式模板由系统内置,这里选择适用公式。比例、递减斜率和天数等数值在“计提参数”维护。</p> <p>选择岗位适用的计提公式。比例、斜率和天数等数值在“计提参数”中维护;公式结构可在“公式模板管理”中调整。</p>
<router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params" target="_blank" rel="noopener">打开计提参数</router-link> <router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params">打开计提参数</router-link>
<div>{{ selectedTemplate.description }}</div> <div>{{ selectedTemplate.description }}</div>
<div v-if="selectedTemplate.params.length" class="tpl-params"> <div v-if="selectedTemplate.params.length" class="tpl-params">
涉及参数:{{ selectedTemplate.params.map((p) => p.name).join('、') }}(在「计提参数」中维护) 涉及参数:{{ selectedTemplate.params.map((p) => `${p.name} = ${paramCurrentValue(p.code)}`).join(';') }}(在「计提参数」中维护)
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
@ -514,7 +532,7 @@ onMounted(() => {
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">保存</el-button> <el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
@ -527,9 +545,9 @@ onMounted(() => {
</div> </div>
<el-table v-loading="templateLoading" :data="templates" stripe empty-text="暂无模板定义"> <el-table v-loading="templateLoading" :data="templates" stripe empty-text="暂无模板定义">
<el-table-column prop="template_code" label="编码" min-width="180"><template #default="{ row }"><span class="cell-code">{{ row.template_code }}</span></template></el-table-column> <el-table-column prop="template_code" label="编码" min-width="180"><template #default="{ row }"><span class="cell-code">{{ row.template_code }}</span></template></el-table-column>
<el-table-column prop="template_name" label="名称" min-width="160" /> <el-table-column prop="template_name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="骨架" min-width="130"><template #default="{ row }">{{ formulaName(row.formula_type) }}</template></el-table-column> <el-table-column label="骨架" min-width="130"><template #default="{ row }">{{ formulaName(row.formula_type) }}</template></el-table-column>
<el-table-column label="参数绑定" min-width="240"><template #default="{ row }">{{ (Object.entries(row.params) as [string, string][]).map(([role, code]) => `${role}:${paramName(code)}`).join(';') || '—' }}</template></el-table-column> <el-table-column label="参数绑定" min-width="240"><template #default="{ row }">{{ (Object.entries(row.params) as [string, string][]).map(([role, code]) => `${formulaRoleLabel(row.formula_type, role)}:${paramName(code)}`).join(';') || '—' }}</template></el-table-column>
<el-table-column label="岗位引用" width="90" align="right"><template #default="{ row }"><span class="cell-num">{{ row.role_count }}</span></template></el-table-column> <el-table-column label="岗位引用" width="90" align="right"><template #default="{ row }"><span class="cell-num">{{ row.role_count }}</span></template></el-table-column>
<el-table-column label="状态" width="90" align="center"><template #default="{ row }">{{ row.is_system === 1 ? '系统' : '自定义' }}</template></el-table-column> <el-table-column label="状态" width="90" align="center"><template #default="{ row }">{{ row.is_system === 1 ? '系统' : '自定义' }}</template></el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center"> <el-table-column label="操作" width="130" fixed="right" align="center">
@ -564,8 +582,18 @@ onMounted(() => {
<p v-if="selectedFormula" class="form-hint">{{ selectedFormula.description }}</p> <p v-if="selectedFormula" class="form-hint">{{ selectedFormula.description }}</p>
</el-form-item> </el-form-item>
<template v-if="selectedFormula"> <template v-if="selectedFormula">
<el-form-item v-for="role in selectedFormula.roles" :key="role" :label="`参数·${role}`"> <el-form-item
<el-select v-model="templateForm.params[role]" :disabled="editingTemplate?.is_system === 1" placeholder="选择计提参数" style="width: 100%" filterable> v-for="role in selectedFormula.roles"
:key="role"
:label="selectedFormula.role_labels[role] ?? role"
>
<el-select
v-model="templateForm.params[role]"
:disabled="editingTemplate?.is_system === 1"
:placeholder="selectedFormula.role_hints[role] ?? '选择计提参数'"
style="width: 100%"
filterable
>
<el-option v-for="item in paramOptions" :key="item.code" :label="`${item.name}(${item.group})`" :value="item.code" /> <el-option v-for="item in paramOptions" :key="item.code" :label="`${item.name}(${item.group})`" :value="item.code" />
</el-select> </el-select>
</el-form-item> </el-form-item>

View File

@ -415,6 +415,7 @@ watch(activeTab, (tab) => {
.log-detail { .log-detail {
padding: 4px 16px; padding: 4px 16px;
color: var(--ys-ink-2); color: var(--ys-ink-2);
overflow-wrap: anywhere;
} }
.log-detail__label { .log-detail__label {
@ -426,6 +427,8 @@ watch(activeTab, (tab) => {
padding: 8px 12px; padding: 8px 12px;
margin: 6px 0 12px; margin: 6px 0 12px;
overflow-x: auto; overflow-x: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: Consolas, 'Courier New', monospace; font-family: Consolas, 'Courier New', monospace;
font-size: 12px; font-size: 12px;
background-color: var(--ys-paper); background-color: var(--ys-paper);

View File

@ -6,6 +6,7 @@ const permissionUser = usePermissionUser()
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type InputInstance, type SelectInstance } from 'element-plus' import { ElMessage, ElMessageBox, type InputInstance, type SelectInstance } from 'element-plus'
import { Clock, DataAnalysis, Download, Search } from '@element-plus/icons-vue'
import * as businessRoleApi from '@/api/businessRoles' import * as businessRoleApi from '@/api/businessRoles'
import * as paramsApi from '@/api/params' import * as paramsApi from '@/api/params'
@ -18,6 +19,7 @@ import type {
ParamPreviewResult, ParamPreviewResult,
} from '@/api/params' } from '@/api/params'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
@ -43,10 +45,13 @@ const isAdmin = computed(() => userStore.can('system:params:update'))
const catalog = ref<ParamCatalogItem[]>([]) const catalog = ref<ParamCatalogItem[]>([])
const catalogLoading = ref(false) const catalogLoading = ref(false)
const catalogFailed = ref(false)
const catalogKeyword = ref('')
const expandedGroups = ref(['业务员个人户']) const expandedGroups = ref(['业务员个人户'])
async function loadCatalog(): Promise<void> { async function loadCatalog(): Promise<void> {
catalogLoading.value = true catalogLoading.value = true
catalogFailed.value = false
try { try {
catalog.value = await paramsApi.getCatalog() catalog.value = await paramsApi.getCatalog()
// 预览「所用参数」按编码取中文名;目录加载后填充映射 // 预览「所用参数」按编码取中文名;目录加载后填充映射
@ -54,6 +59,7 @@ async function loadCatalog(): Promise<void> {
PARAM_NAME_BY_CODE[item.param_code] = item.param_name PARAM_NAME_BY_CODE[item.param_code] = item.param_name
} }
} catch (error) { } catch (error) {
catalogFailed.value = true
handledError(error) handledError(error)
} finally { } finally {
catalogLoading.value = false catalogLoading.value = false
@ -72,6 +78,24 @@ const groupedCatalog = computed(() => {
return [...groups.entries()].map(([name, items]) => ({ name, items })) return [...groups.entries()].map(([name, items]) => ({ name, items }))
}) })
const ruleCount = computed(() => groupedCatalog.value.reduce((total, group) => total + group.items.length, 0))
const visibleGroups = computed(() => {
const keyword = catalogKeyword.value.trim().toLowerCase()
if (!keyword) return groupedCatalog.value
return groupedCatalog.value.map(group => ({
...group,
items: group.name.toLowerCase().includes(keyword) ? group.items : group.items.filter(item =>
`${item.param_name} ${item.param_code} ${item.description ?? ''}`.toLowerCase().includes(keyword)),
})).filter(group => group.items.length)
})
const allGroupsExpanded = computed(() => visibleGroups.value.length > 0 && visibleGroups.value.every(group => expandedGroups.value.includes(group.name)))
function toggleGroups(): void {
expandedGroups.value = allGroupsExpanded.value ? [] : visibleGroups.value.map(group => group.name)
}
watch(catalogKeyword, keyword => {
if (keyword.trim()) expandedGroups.value = visibleGroups.value.map(group => group.name)
})
/** 维度值 → 展示名(NONE 维度为空串;角色维度给出中文岗位名) */ /** 维度值 → 展示名(NONE 维度为空串;角色维度给出中文岗位名) */
const DIMENSION_LABELS: Record<string, string> = { const DIMENSION_LABELS: Record<string, string> = {
'': '默认', '': '默认',
@ -283,6 +307,7 @@ const {
pageNum: batchesPage, pageNum: batchesPage,
pageSize: batchesPageSize, pageSize: batchesPageSize,
loading: batchesLoading, loading: batchesLoading,
loadFailed: batchesFailed,
load: loadBatches, load: loadBatches,
onPageChange: onBatchesPageChange, onPageChange: onBatchesPageChange,
onSizeChange: onBatchesSizeChange, onSizeChange: onBatchesSizeChange,
@ -386,6 +411,7 @@ const {
pageNum: historyPage, pageNum: historyPage,
pageSize: historyPageSize, pageSize: historyPageSize,
loading: historyLoading, loading: historyLoading,
loadFailed: historyFailed,
load: loadHistory, load: loadHistory,
onPageChange: onHistoryPageChange, onPageChange: onHistoryPageChange,
onSizeChange: onHistorySizeChange, onSizeChange: onHistorySizeChange,
@ -480,34 +506,47 @@ onMounted(() => {
<template> <template>
<div class="requirements-surface"> <div class="requirements-surface">
<CoefficientPanel /> <div class="page params-page">
<div class="page"> <header class="page-head params-head">
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 --> <div>
<div class="page-head"> <h1 class="page-title">计提参数</h1>
<h1 class="page-title">计提参数</h1> <p class="page-desc">核对产品系数、维护计提规则,查看变更历史与计算预览。</p>
<p class="page-desc"> <div class="waterline"></div>
维护绩效计算规则与产品系数,查看当前生效值和变更历史。修改先暂存,提交时可选择生效日期。 </div>
</p> <div class="params-actions">
<div class="waterline"></div> <el-button :icon="Clock" @click="openVersionDrawer">版本管理</el-button>
</div> <el-button :icon="Download" @click="doExport" :loading="exporting" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
<el-button type="primary" :icon="DataAnalysis" @click="openPreviewDrawer" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">计算预览</el-button>
</div>
</header>
<!-- <nav class="params-outline" aria-label="计提参数页内导航">
主操作行:这页没有筛选条件(参数按分组浏览而非检索),所以只有工具按钮。 <a href="#coefficient-section">产品系数</a>
「计算预览」是管理员最常走的一步,用主按钮把它与另两个次要动作区分开。 <a href="#rules-section">计提规则</a>
--> <button v-if="pendingList.length" type="button" class="pending-anchor" @click="showPending">待提交变更 <b>{{ pendingList.length }}</b></button>
<div class="toolbar"> <span>规则参数修改先暂存,提交时确认生效日期。</span>
<el-button type="primary" @click="openPreviewDrawer" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">计算预览</el-button> </nav>
<el-button @click="openVersionDrawer">版本管理</el-button>
<el-button @click="doExport" :loading="exporting" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
<el-button v-if="pendingList.length" @click="showPending">待提交变更({{ pendingList.length }})</el-button>
</div>
<div class="card"> <CoefficientPanel id="coefficient-section" />
<div class="card-body">
<el-collapse v-model="expandedGroups" v-loading="catalogLoading"> <section id="rules-section" class="card rules-section" aria-labelledby="rules-title">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name"> <div class="card-head rules-head">
<div>
<h2 id="rules-title">计提规则 <span v-if="!catalogLoading && !catalogFailed">{{ ruleCount }} 项 · {{ groupedCatalog.length }} 组</span></h2>
<p>按业务分组查看当前生效值,修改记录可逐项追溯。</p>
</div>
<div class="rule-tools">
<el-input v-model="catalogKeyword" :prefix-icon="Search" placeholder="搜索参数名称、说明或分组" aria-label="搜索计提参数" clearable />
<el-button :disabled="!visibleGroups.length" @click="toggleGroups">{{ allGroupsExpanded ? '收起全部' : '展开全部' }}</el-button>
</div>
</div>
<div class="card-body rule-catalog" v-loading="catalogLoading">
<el-alert v-if="catalogFailed" type="error" title="计提规则加载失败,请重试" :closable="false" show-icon><el-button :loading="catalogLoading" @click="loadCatalog">重新加载</el-button></el-alert>
<p v-else-if="!catalogLoading && !visibleGroups.length" class="catalog-empty" role="status">{{ catalogKeyword ? '没有匹配的参数,请调整关键词。' : '暂无计提规则。' }}</p>
<el-collapse v-else v-model="expandedGroups">
<el-collapse-item v-for="group in visibleGroups" :key="group.name" :name="group.name">
<template #title> <template #title>
<span class="group-title">{{ group.name }}</span> <span class="group-title">{{ group.name }} <span>{{ group.items.length }} 项参数</span></span>
</template> </template>
<p v-if="group.name === '审批开关'" class="approval-notice"> <p v-if="group.name === '审批开关'" class="approval-notice">
{{ approvalEnabled ? '系统审核已开启,以下设置决定各类单据是否进入审核流程。' : '系统审核当前关闭。以下为保留的审核设置;生成工资后直接完成,不产生审核流程记录。' }} {{ approvalEnabled ? '系统审核已开启,以下设置决定各类单据是否进入审核流程。' : '系统审核当前关闭。以下为保留的审核设置;生成工资后直接完成,不产生审核流程记录。' }}
@ -523,16 +562,17 @@ onMounted(() => {
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in group.items" :key="item.param_code"> <tr v-for="item in group.items" :key="item.param_code">
<td> <td class="param-definition-cell">
<div class="param-name">{{ item.param_name }}</div> <div class="param-name">{{ item.param_name }}</div>
<div class="param-desc">{{ item.description || item.param_code }}</div> <div class="param-desc">{{ item.description || item.param_code }}</div>
</td> </td>
<td> <td class="param-value-cell">
<span class="mobile-value-label">当前生效值</span>
<template v-if="item.values.length"> <template v-if="item.values.length">
<div v-for="v in item.values" :key="v.dimension_value" class="value-row"> <div v-for="v in item.values" :key="v.dimension_value" class="value-row">
<div class="value-info">
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span> <span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
<b class="num">{{ formatValue(item, v.value_text) }}</b> <b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag <el-tag
v-if="pendingOf(item, v.dimension_value)" v-if="pendingOf(item, v.dimension_value)"
size="small" size="small"
@ -541,6 +581,9 @@ onMounted(() => {
> >
待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }} 待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
</el-tag> </el-tag>
</div>
<div class="value-meta">
<span v-if="v.effective_date" class="eff">{{ v.effective_date }} 起</span>
<el-button <el-button
v-if="isAdmin" v-if="isAdmin"
link link
@ -550,13 +593,14 @@ onMounted(() => {
:disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')"> :disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">
修改 修改
</el-button> </el-button>
</div>
</div> </div>
</template> </template>
<span v-else class="no-value"> <span v-else class="no-value">
未设置(默认 {{ formatByCode(item.param_code, item.default_value) }}) 未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
</span> </span>
</td> </td>
<td> <td class="param-actions-cell">
<el-button <el-button
v-if="isAdmin && !item.values.length" v-if="isAdmin && !item.values.length"
link link
@ -575,7 +619,7 @@ onMounted(() => {
</el-collapse-item> </el-collapse-item>
</el-collapse> </el-collapse>
</div> </div>
</div> </section>
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 --> <!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
<div v-if="isAdmin && pendingList.length" ref="pendingCard" class="card pending-card"> <div v-if="isAdmin && pendingList.length" ref="pendingCard" class="card pending-card">
@ -585,7 +629,7 @@ onMounted(() => {
<span class="pending-hint">暂存内容尚未生效。请确认参数值与生效日期后提交。</span> <span class="pending-hint">暂存内容尚未生效。请确认参数值与生效日期后提交。</span>
</div> </div>
</div> </div>
<el-table :data="pendingList" size="small"> <el-table :data="pendingList" size="small" class="pending-table">
<el-table-column label="参数" min-width="160"> <el-table-column label="参数" min-width="160">
<template #default="{ row }">{{ row.item.param_name }}</template> <template #default="{ row }">{{ row.item.param_name }}</template>
</el-table-column> </el-table-column>
@ -608,26 +652,46 @@ onMounted(() => {
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="card-foot"> <ul class="pending-list" aria-label="待提交参数核对">
<div class="submit-row"> <li v-for="row in pendingList" :key="row.key">
<div class="pending-line-head">
<div><strong>{{ row.item.param_name }}</strong><span>{{ dimensionLabel(row.dimension_value) }}</span></div>
<el-button link type="danger" @click="removePending(row.key)">移除</el-button>
</div>
<dl class="pending-values">
<div><dt>原值</dt><dd>{{ row.old_value ? formatByCode(row.item.param_code, row.old_value) : '—' }}</dd></div>
<div><dt>调整为</dt><dd class="pending-new-value">{{ formatByCode(row.item.param_code, row.new_value) }}</dd></div>
</dl>
</li>
</ul>
<div class="card-foot pending-footer">
<el-form label-position="top" class="submit-options" :disabled="submitting" @submit.prevent>
<el-form-item label="生效方式">
<el-radio-group v-model="changeType"> <el-radio-group v-model="changeType">
<el-radio-button :value="1">立即生效</el-radio-button> <el-radio-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button> <el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group> </el-radio-group>
</el-form-item>
<el-form-item v-if="changeType === 2" label="生效日期">
<el-date-picker <el-date-picker
v-if="changeType === 2"
v-model="effectiveDate" v-model="effectiveDate"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
placeholder="生效日期" placeholder="生效日期"
style="width: 150px" style="width: 100%"
/> />
</el-form-item>
<el-form-item label="变更说明(选填)" class="submit-remark">
<el-input <el-input
v-model="remark" v-model="remark"
placeholder="变更说明(选填,如:调整第四季度计提比例)" placeholder="例如:调整第四季度计提比例"
style="width: 240px" style="width: 100%"
maxlength="255" maxlength="255"
/> />
</el-form-item>
</el-form>
<div class="submit-commit">
<span>请核对以上新值与生效方式,提交后生成变更版本。</span>
<el-button type="primary" :loading="submitting" @click="submitAll" :disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">提交变更</el-button> <el-button type="primary" :loading="submitting" @click="submitAll" :disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">提交变更</el-button>
</div> </div>
</div> </div>
@ -708,24 +772,32 @@ onMounted(() => {
<!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 --> <!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
<el-drawer v-model="versionVisible" title="版本管理" size="760px"> <el-drawer v-model="versionVisible" title="版本管理" size="760px">
<div class="drawer-block"> <div class="drawer-block">
<div class="drawer-row"> <h3 class="drawer-section-title">按日期对比</h3>
<p class="drawer-section-note">对比两个日期各自生效的参数值,核对调整范围。</p>
<el-form class="compare-form" label-position="top" @submit.prevent="doCompare">
<el-form-item label="起始日期">
<el-date-picker <el-date-picker
v-model="compareFrom" v-model="compareFrom"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
placeholder="对比起始日期" placeholder="对比起始日期"
style="width: 150px" style="width: 100%"
/> />
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker <el-date-picker
v-model="compareTo" v-model="compareTo"
type="date" type="date"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
placeholder="对比截止日期" placeholder="对比截止日期"
style="width: 150px" style="width: 100%"
/> />
<el-button :loading="comparing" @click="doCompare">对比</el-button> </el-form-item>
<div class="compare-actions">
<el-button type="primary" :loading="comparing" native-type="submit">对比</el-button>
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button> <el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
</div> </div>
</el-form>
<template v-if="diffSearched"> <template v-if="diffSearched">
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div> <div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
<el-table :data="diffResult" size="small" max-height="260"> <el-table :data="diffResult" size="small" max-height="260">
@ -751,8 +823,9 @@ onMounted(() => {
</div> </div>
<div class="drawer-block"> <div class="drawer-block">
<div class="drawer-title">变更记录</div> <h3 class="drawer-section-title">变更记录</h3>
<el-table v-loading="batchesLoading" :data="batches" size="small"> <ListLoadError v-if="batchesFailed" :loading="batchesLoading" @retry="loadBatches" />
<el-table v-loading="batchesLoading" :data="batches" size="small" empty-text="暂无参数变更记录">
<el-table-column type="expand"> <el-table-column type="expand">
<template #default="{ row }"> <template #default="{ row }">
<div class="batch-items"> <div class="batch-items">
@ -798,11 +871,14 @@ onMounted(() => {
@size-change="onBatchesSizeChange" @size-change="onBatchesSizeChange"
/> />
</div> </div>
<template #footer><el-button @click="versionVisible=false">关闭</el-button></template>
</el-drawer> </el-drawer>
<!-- 历史抽屉 --> <!-- 历史抽屉 -->
<el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px"> <el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px">
<el-table v-loading="historyLoading" :data="historyRows" size="small"> <p class="drawer-section-note">按生效日期查看历次调整,保留修改前后的参数值。</p>
<ListLoadError v-if="historyFailed" :loading="historyLoading" @retry="loadHistory" />
<el-table v-loading="historyLoading" :data="historyRows" size="small" empty-text="该参数暂无变更记录">
<el-table-column prop="effective_date" label="生效日期" width="105" /> <el-table-column prop="effective_date" label="生效日期" width="105" />
<el-table-column label="适用范围" width="90"> <el-table-column label="适用范围" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template> <template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
@ -826,12 +902,14 @@ onMounted(() => {
@page-change="onHistoryPageChange" @page-change="onHistoryPageChange"
@size-change="onHistorySizeChange" @size-change="onHistorySizeChange"
/> />
<template #footer><el-button @click="historyVisible=false">关闭</el-button></template>
</el-drawer> </el-drawer>
<!-- 计算预览抽屉 --> <!-- 计算预览抽屉 -->
<el-drawer v-model="previewVisible" title="计算预览" size="480px"> <el-drawer v-model="previewVisible" title="计算预览" size="480px">
<p class="preview-scope-note">按所选日期的参数试算单笔公式,不生成工资或修改业务数据。正式工资请在业务管理中按月核算。</p> <p class="preview-scope-note">按所选日期的参数试算单笔公式,不生成工资或修改业务数据。正式工资请在业务管理中按月核算。</p>
<el-form label-width="90px" @submit.prevent> <el-form label-position="top" class="preview-form" @submit.prevent>
<h3 class="form-section-title">业务条件</h3>
<el-form-item label="客户类型"> <el-form-item label="客户类型">
<el-select v-model="previewForm.customer_type" style="width: 100%"> <el-select v-model="previewForm.customer_type" style="width: 100%">
<el-option v-for="o in CUSTOMER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" /> <el-option v-for="o in CUSTOMER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
@ -868,24 +946,24 @@ onMounted(() => {
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item label="销售额"> <h3 class="form-section-title">金额信息</h3>
<el-form-item label="销售额(元)">
<el-input v-model="previewForm.revenue" placeholder="不含运费" /> <el-input v-model="previewForm.revenue" placeholder="不含运费" />
</el-form-item> </el-form-item>
<el-form-item label="毛利润额"> <el-form-item label="毛利润额(元)">
<el-input v-model="previewForm.profit" placeholder="可为负" /> <el-input v-model="previewForm.profit" placeholder="可为负" />
</el-form-item> </el-form-item>
<el-form-item label="本笔回款"> <el-form-item label="本笔回款(元)">
<el-input v-model="previewForm.received_amount" /> <el-input v-model="previewForm.received_amount" />
</el-form-item> </el-form-item>
<div class="preview-flags">
<el-form-item label="以物抵顶"> <el-form-item label="以物抵顶">
<el-switch v-model="previewForm.barter" /> <el-switch v-model="previewForm.barter" />
</el-form-item> </el-form-item>
<el-form-item label="试用期"> <el-form-item label="试用期">
<el-switch v-model="previewForm.probation" /> <el-switch v-model="previewForm.probation" />
</el-form-item> </el-form-item>
<el-form-item> </div>
<el-button type="primary" :loading="previewLoading" @click="runPreview" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">开始试算</el-button>
</el-form-item>
</el-form> </el-form>
<template v-if="previewResult"> <template v-if="previewResult">
@ -912,21 +990,55 @@ onMounted(() => {
</div> </div>
</div> </div>
</template> </template>
<template #footer>
<el-button @click="previewVisible=false">关闭</el-button>
<el-button type="primary" :loading="previewLoading" @click="runPreview" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">开始试算</el-button>
</template>
</el-drawer> </el-drawer>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* 页面里已经没有 .page-toolbar/.page-desc —— 描述与工具栏改用全局 .page-head/.toolbar */ .params-page { --params-section-gap: 24px; }
.params-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 16px 24px; }
.params-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.params-outline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; padding: 0 0 16px; margin-bottom: var(--params-section-gap); border-bottom: 1px solid var(--ys-line); font-size: 13px; line-height: 1.7; }
.params-outline a, .params-outline button { color: var(--ys-ink-2); text-decoration: none; }
.params-outline a:hover, .params-outline button:hover { color: var(--ys-indigo); }
.params-outline button { border: 0; padding: 0; background: none; font: inherit; cursor: pointer; }
.params-outline > span { margin-left: auto; color: var(--ys-ink-3); font-size: 12px; }
.params-outline .pending-anchor { color: #895d12; }
.pending-anchor b { margin-left: 4px; font-variant-numeric: tabular-nums; }
#coefficient-section, #rules-section, .pending-card { scroll-margin-top: 20px; }
.rules-section { container-type: inline-size; margin-top: var(--params-section-gap); }
.rules-head { align-items: center; padding: 20px; }
.rules-head h2 { margin: 0; color: var(--ys-ink); font-size: 16px; font-weight: 600; line-height: 1.6; }
.rules-head h2 > span { margin-left: 10px; color: var(--ys-ink-3); font-size: 12px; font-weight: 400; }
.rules-head p { margin: 6px 0 0; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
.rule-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.rule-tools .el-input { width: 260px; }
.rule-catalog { padding: 0 20px; min-height: 120px; }
.catalog-empty { margin: 0; padding: 40px 16px; color: var(--ys-ink-3); text-align: center; line-height: 1.8; }
.rule-catalog :deep(.el-collapse) { border-top: 0; border-bottom: 0; }
.rule-catalog :deep(.el-collapse-item:last-child .el-collapse-item__header) { border-bottom: 0; }
.rule-catalog :deep(.el-collapse-item__header) { min-height: 56px; height: auto; padding: 12px 0; line-height: 1.6; }
.rule-catalog :deep(.el-collapse-item__content) { padding-bottom: 16px; }
.rule-catalog :deep(.el-collapse-item__wrap) { border-bottom: 0; }
.group-title { .group-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
font-weight: 600; font-weight: 600;
color: var(--ys-ink); color: var(--ys-ink);
} }
.group-title > span { color: var(--ys-ink-3); font-size: 12px; font-weight: 400; }
.param-table { .param-table {
width: 100%; width: 100%;
min-width: 640px;
border-collapse: collapse; border-collapse: collapse;
} }
@ -950,22 +1062,27 @@ onMounted(() => {
text-align: left; text-align: left;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.04em; letter-spacing: 0.01em;
color: var(--ys-ink-2); color: var(--ys-ink-2);
padding: 6px 8px; padding: 10px 12px;
background: var(--ys-mist);
border-bottom: 1px solid var(--ys-line); border-bottom: 1px solid var(--ys-line);
} }
.param-table td { .param-table td {
padding: 10px 8px; padding: 16px 12px;
border-bottom: 1px solid var(--ys-line); border-bottom: 1px solid var(--ys-line);
font-size: 13px; font-size: 13px;
vertical-align: top; vertical-align: top;
} }
.param-name { .param-name {
font-weight: 500; font-weight: 600;
} }
.param-actions-cell { white-space: nowrap; }
.param-actions-cell .el-button { min-height: 32px; }
.param-actions-cell .el-button + .el-button { margin-left: 8px; }
.mobile-value-label { display: none; }
.param-desc { .param-desc {
font-size: 12px; font-size: 12px;
@ -976,12 +1093,17 @@ onMounted(() => {
} }
.value-row { .value-row {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline; align-items: center;
gap: 8px; gap: 8px 16px;
line-height: 1.9; line-height: 1.9;
} }
.value-row + .value-row { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--ys-line-soft); }
.value-info, .value-meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; min-width: 0; }
.value-info .num { color: var(--ys-ink); font-size: 14px; font-weight: 600; }
.value-meta { justify-content: flex-end; }
.value-meta .el-button { min-height: 32px; }
.dim-label { .dim-label {
font-size: 12px; font-size: 12px;
@ -1005,11 +1127,22 @@ onMounted(() => {
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */ /* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
.pending-card { .pending-card {
margin-top: 14px; container-type: inline-size;
margin-top: var(--params-section-gap);
} }
.pending-list { display: none; margin: 0; padding: 0 16px; list-style: none; }
.pending-list li { padding: 16px 0; border-bottom: 1px solid var(--ys-line-soft); }
.pending-line-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.pending-line-head strong { color: var(--ys-ink); font-size: 13px; font-weight: 600; }
.pending-line-head span { display: block; margin-top: 6px; color: var(--ys-ink-3); font-size: 12px; }
.pending-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 16px 0 0; }
.pending-values dt { color: var(--ys-ink-3); font-size: 12px; }
.pending-values dd { margin: 6px 0 0; color: var(--ys-ink-2); font-size: 14px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.pending-values .pending-new-value { color: var(--ys-indigo); font-weight: 600; }
.pending-head { .pending-head {
display: flex; display: flex;
flex-wrap: wrap;
align-items: baseline; align-items: baseline;
gap: 12px; gap: 12px;
} }
@ -1019,16 +1152,12 @@ onMounted(() => {
color: var(--ys-ink-3); color: var(--ys-ink-3);
} }
/* .pending-footer { display: block; padding: 20px; }
* 提交区已放进 .card-foot:底栏自带 10px 16px 内边距与右对齐, .submit-options { display: flex; flex-wrap: wrap; gap: 0 20px; }
* 所以这里去掉原先的 margin-top,否则会与底栏留白叠成双份。 .submit-options .el-form-item { flex: 0 1 auto; min-width: 0; margin-bottom: 16px; }
*/ .submit-options .submit-remark { flex: 1 1 280px; }
.submit-row { .submit-commit { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding-top: 16px; border-top: 1px solid var(--ys-line); }
display: flex; .submit-commit > span { color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.edit-name { .edit-name {
font-size: 15px; font-size: 15px;
@ -1073,14 +1202,11 @@ onMounted(() => {
.drawer-block { .drawer-block {
margin-bottom: 24px; margin-bottom: 24px;
} }
.drawer-section-title { margin: 0 0 12px; color: var(--ys-ink); font-size: 15px; font-weight: 600; }
.drawer-row { .drawer-section-note { margin: 0 0 20px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
display: flex; .compare-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
flex-wrap: wrap; .compare-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; margin-bottom: 8px; }
gap: 10px; .preview-flags { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
align-items: center;
margin-bottom: 12px;
}
.drawer-title { .drawer-title {
font-size: 13px; font-size: 13px;
@ -1097,6 +1223,7 @@ onMounted(() => {
.batch-item { .batch-item {
display: flex; display: flex;
flex-wrap: wrap;
gap: 10px; gap: 10px;
align-items: baseline; align-items: baseline;
font-size: 12.5px; font-size: 12.5px;
@ -1125,6 +1252,7 @@ onMounted(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
gap: 12px;
padding: 6px 0; padding: 6px 0;
font-size: 13px; font-size: 13px;
color: var(--ys-ink-2); color: var(--ys-ink-2);
@ -1144,12 +1272,14 @@ onMounted(() => {
.pr-row b { .pr-row b {
color: var(--ys-ink); color: var(--ys-ink);
font-weight: 600; font-weight: 600;
text-align: right;
overflow-wrap: anywhere;
} }
.pr-note { .pr-note {
margin-top: 10px; margin-top: 10px;
font-size: 12px; font-size: 12px;
color: var(--ys-warn); color: #895d12;
background: #fbf4e4; background: #fbf4e4;
border-radius: 6px; border-radius: 6px;
padding: 8px 12px; padding: 8px 12px;
@ -1165,4 +1295,36 @@ onMounted(() => {
line-height: 2; line-height: 2;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@container (max-width: 650px) {
.pending-table { display: none; }
.pending-list { display: block; }
.rules-head { gap: 16px; }
.rule-tools { width: 100%; }
.rule-tools .el-input { flex: 1; width: auto; min-width: 140px; }
.param-table { min-width: 0; }
.param-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.param-table, .param-table tbody, .param-table tr, .param-table td { display: block; width: 100%; }
.param-table tr { padding: 16px 0; border-bottom: 1px solid var(--ys-line); }
.param-table tr:last-child { border-bottom: 0; }
.param-table td { padding: 0; border-bottom: 0; }
.param-value-cell { margin-top: 14px; }
.mobile-value-label { display: block; margin-bottom: 8px; color: var(--ys-ink-3); font-size: 12px; }
.param-actions-cell { margin-top: 8px; text-align: right; }
.value-row { grid-template-columns: minmax(0, 1fr); gap: 2px; }
.value-meta { justify-content: space-between; }
}
@media (max-width: 600px) {
.params-page { --params-section-gap: 20px; }
.params-actions { width: 100%; }
.params-outline { gap: 10px 20px; }
.params-outline > span { width: 100%; margin-left: 0; }
.rules-head { padding: 16px; }
.rule-catalog { padding: 0 16px; }
.pending-head { gap: 8px; }
.pending-footer { padding: 16px; }
.submit-options .el-form-item { flex-basis: 100%; }
.compare-form { grid-template-columns: minmax(0, 1fr); }
}
</style> </style>

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
@ -35,7 +36,7 @@ const filters = reactive({
status: undefined as number | undefined, status: undefined as number | undefined,
}) })
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } = const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({ usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({
keyword: filters.keyword, keyword: filters.keyword,
status: filters.status, status: filters.status,
@ -177,6 +178,7 @@ onMounted(load)
筛选条:.filter-bar 自带 flex + gap + 内边距,故 el-form 不再写 inline; 筛选条:.filter-bar 自带 flex + gap + 内边距,故 el-form 不再写 inline;
查询/重置收进 .filter-bar__actions,靠 margin-left:auto 贴到行尾。 查询/重置收进 .filter-bar__actions,靠 margin-left:auto 贴到行尾。
--> -->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent> <el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input <el-input
@ -230,8 +232,8 @@ onMounted(load)
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="role_name" label="名称" min-width="120" /> <el-table-column prop="role_name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="description" label="说明" min-width="180"> <el-table-column prop="description" label="说明" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '—' }}</template> <template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column> </el-table-column>
<!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 --> <!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
@ -295,6 +297,9 @@ onMounted(load)
<CreateRoleDrawer v-model="createOpen" @saved="load" /> <CreateRoleDrawer v-model="createOpen" @saved="load" />
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
:close-on-click-modal="!submitting"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:title="isEdit ? '编辑角色' : '新增角色'" :title="isEdit ? '编辑角色' : '新增角色'"
width="480px" width="480px"
destroy-on-close destroy-on-close
@ -318,7 +323,7 @@ onMounted(load)
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button> <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>

View File

@ -540,4 +540,12 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 600px) {
.field-grid { grid-template-columns: minmax(0, 1fr); gap: 8px; }
.field-control__main, .field-control__note { min-width: 0; flex-basis: 100%; }
.mock-card { padding: 24px 18px; }
.mock-captcha { flex-basis: 80px; }
.preview-note { line-height: 1.6; }
}
</style> </style>

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
@ -42,7 +43,7 @@ const filters = reactive({
role_code: undefined as string | undefined, role_code: undefined as string | undefined,
}) })
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } = const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({ usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({
username: filters.username, username: filters.username,
real_name: filters.real_name, real_name: filters.real_name,
@ -266,6 +267,7 @@ onMounted(() => {
inline 模式靠行内块排布,会带基线下沉与固定 right-margin,与 gap 叠成不等距; inline 模式靠行内块排布,会带基线下沉与固定 right-margin,与 gap 叠成不等距;
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。 查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
--> -->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent> <el-form class="filter-bar" @submit.prevent>
<el-form-item label="用户名"> <el-form-item label="用户名">
<el-input <el-input
@ -322,7 +324,7 @@ onMounted(() => {
v-loading="loading" v-loading="loading"
:data="records" :data="records"
stripe stripe
empty-text="还没有用户,点「新增用户」创建第一个账号" empty-text="暂无符合条件的用户,可调整筛选条件。"
> >
<el-table-column prop="username" label="用户名" min-width="120" /> <el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="real_name" label="姓名" min-width="100"> <el-table-column prop="real_name" label="姓名" min-width="100">
@ -405,11 +407,15 @@ onMounted(() => {
<!-- 新增 / 编辑 --> <!-- 新增 / 编辑 -->
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
:close-on-click-modal="!submitting"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:title="isEdit ? '编辑用户' : '新增用户'" :title="isEdit ? '编辑用户' : '新增用户'"
width="520px" width="520px"
destroy-on-close destroy-on-close
> >
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<h2 class="form-section-title">登录账号</h2>
<el-form-item label="用户名" prop="username"> <el-form-item label="用户名" prop="username">
<el-input <el-input
v-model="form.username" v-model="form.username"
@ -425,6 +431,7 @@ onMounted(() => {
placeholder="8–72 字节(中文一个字符约 3 字节)" placeholder="8–72 字节(中文一个字符约 3 字节)"
/> />
</el-form-item> </el-form-item>
<h2 class="form-section-title">人员信息</h2>
<el-form-item label="姓名" prop="real_name"> <el-form-item label="姓名" prop="real_name">
<el-input v-model="form.real_name" placeholder="真实姓名" /> <el-input v-model="form.real_name" placeholder="真实姓名" />
</el-form-item> </el-form-item>
@ -449,7 +456,7 @@ onMounted(() => {
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button> <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>