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/
.agents/
/AGENTS.md
# 依赖
node_modules/
@ -53,6 +55,7 @@ frontend/e2e/**/*.png
*.tmp
*.bak
*.cache
.tmp-processes*.txt
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
~$*
@ -67,6 +70,12 @@ backend/uploads/
db/backups/
# 客户发来的原始账套备份压缩包。
/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 数据字典压缩包与解压目录不入版本库
docs/**/*.rar
@ -82,3 +91,4 @@ backend/.venv-workbench/
# 本地浏览器验收自动生成的快照
/.playwright-mcp/
/phase2-evidence-*/

View File

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

View File

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

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.audit_service import AuditRecord, RequestMeta
from app.services.formula import (
apply_barter,
clamp_weight_r,
converted_profit,
payment_days,
@ -366,12 +365,10 @@ def _compose_amounts(
performance = (
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
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")
start_ratio = parse_fraction(
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]:
"""骨架下拉(受控集合)。"""
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()
]

View File

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

View File

@ -393,3 +393,15 @@ uv run pytest tests/integration -q # 仅集成
- 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。
- 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。
- 岗位相关 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
def legacy_calculation(monkeypatch):
"""明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。"""
"""旧引擎回归使用关闭确认开关的口径;确认门槛由独立用例覆盖。"""
from app.core.config import get_settings
from app.services import receipt_confirmation
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True)
monkeypatch.setattr(receipt_confirmation, "enabled", lambda _session: False)
@pytest.fixture

View File

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

View File

@ -30,8 +30,9 @@ def post(client, path, headers, data):
@pytest.fixture
def formal_chain(client, admin):
def unconfirmed_formal_chain(client, admin, request):
headers = admin["headers"]
unit_cost = getattr(request, "param", {}).get("unit_cost", "25")
with get_session_factory()() as s:
roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))}
persons = []
@ -56,7 +57,7 @@ def formal_chain(client, admin):
"product_category": "municipal_water",
"quantity": "2",
"sale_price": "50",
"guided_price": "25",
"guided_price": unit_cost,
}
business = post(
client,
@ -103,7 +104,7 @@ def formal_chain(client, admin):
"shipment_item_id": detail["items"][0]["id"],
"personnel_id": owner["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
@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):
business, _, setup, owner, manager = formal_chain
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"]
)
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:
"""参数目录:种子完整性与时点化回溯。"""
def test_目录含27项及产品系数组(self, client: TestClient, admin: dict) -> None:
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
def test_目录含28项及产品系数组(self, client: TestClient, admin: dict) -> None:
"""目录包含回款人工确认开关和既有参数。"""
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}
assert groups == {
"业务员个人户",
@ -99,6 +100,7 @@ class TestCatalog:
"企业户",
"信息员",
"支付环节",
"回款确认",
"产品阈值",
"月工资口径",
"审批开关",
@ -355,7 +357,7 @@ class TestPreview:
"salesperson.rate.floor",
"salesperson.days.boundary",
"product.threshold",
"barter.discount",
# 2026-11-03 口径:抵顶线下核算,预览不再读取 barter.discount(参数保留作回退位)。
"payment.start.ratio",
"probation.annual.threshold",
]
@ -495,7 +497,7 @@ class TestPermission:
session.commit()
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 27
assert len(resp.json()["data"]) == 28
def test_未登录访问目录_403(self, client: TestClient) -> None:
# 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 tests.integration.test_formal_payroll import (
post,
)
from tests.integration.test_formal_payroll import (
unconfirmed_formal_chain as unconfirmed_formal_chain,
)
@ -128,7 +130,7 @@ def test_正式核算零毛利特例但低阈值仍为负(client, admin, unconfi
@pytest.mark.parametrize("role,profit,converted,performance", [
("salesperson", "0", "0", "0"),
("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):
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.models import ParamChangeBatch, ProbationPayment
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

View File

@ -14,6 +14,7 @@
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
"""
import ast
import re
from pathlib import Path
@ -24,7 +25,7 @@ MIGRATION_DIR = PROJECT_ROOT / "db" / "migration"
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
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)
# 这些开头的是约束定义,不是列定义
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
@ -32,6 +33,7 @@ _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "IN
# MODIFY 只改类型不改列名,对列集合无影响,无需处理
_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)
_RENAME_TABLE = re.compile(r"RENAME TABLE\s+`?(\w+)`?\s+TO\s+`?(\w+)`?", re.IGNORECASE)
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)
for table, column in _ALTER_DROP.findall(sql):
merged.setdefault(table, set()).discard(column)
for old, new in _RENAME_TABLE.findall(sql):
merged[new] = merged.pop(old)
return merged
@ -83,8 +87,15 @@ def _alembic_head() -> str:
text = path.read_text(encoding="utf-8")
if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
revisions.add(found.group(1))
if found := re.search(r"^down_revision(?:\s*:.*?)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
referenced.add(found.group(1))
for node in ast.parse(text).body:
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
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
@ -94,14 +105,14 @@ def _alembic_head() -> str:
@pytest.fixture(scope="module")
def migration_tables() -> dict[str, set[str]]:
"""按文件序重放 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}"
return _migration_final_state(files)
@pytest.fixture(scope="module")
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:
@ -111,6 +122,16 @@ class TestSchemaSync:
assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
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:
assert set(init_dev_tables) == set(migration_tables), (
"两份 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.workbench import digest
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
@ -192,6 +193,96 @@ def test_正式导入防重与来源可追溯(payroll_case):
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):
s, user, manager = payroll_case
business = imported(payroll_case)

View File

@ -113,6 +113,12 @@ class TestConvertedProfit:
def test_毛利润额为零_折算为零_不除零(self) -> None:
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:
# 100 × (1/10 − 3/10) = -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",
"tplus_automation_states",
"tplus_automation_configs",
"tplus_runtime_controls",
"payroll_months", "payroll_inputs", "payroll_allocations",
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
"users",

View File

@ -207,6 +207,12 @@ class TestExcel模板:
text = _sheet_text(_build("business-stats", rows))
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(
personnel_id: int,

View File

@ -4,9 +4,9 @@ from unittest.mock import Mock
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.schemas.business_source import SourcePriceConfirmIn
from app.schemas.business_source import GuidanceConfirmIn, SourcePriceConfirmIn
from app.services import authorization_service, business_source
from app.services.authorization_policy import permission_for
@ -29,3 +29,46 @@ def test_无核算准备权限不能确认来源单价(monkeypatch):
with pytest.raises(ForbiddenError):
confirm_source_price(session, actor, 1, 1, payload, meta)
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 decimal import Decimal
from fractions import Fraction
from types import SimpleNamespace
from uuid import NAMESPACE_URL, uuid4, uuid5
import pytest
@ -17,7 +14,6 @@ from app.models import (
BusinessItem,
BusinessRole,
Customer,
OwnershipChangeLog,
PayrollInput,
Personnel,
Product,
@ -26,41 +22,32 @@ from app.models import (
ShipmentItem,
TPlusBusinessDocument,
TPlusBusinessSyncConfig,
TPlusSourceHead,
TPlusSyncBatch,
TPlusWorkRun,
User,
)
from app.repositories import customer_repository, product_repository
from app.schemas.business_source import (
SalesRelationConfirmIn,
SalesRelationReleaseIn,
SourceMaterialsIn,
SourcePriceConfirmIn,
)
from app.schemas.category_batch import (
CustomerCategoryBatch,
CustomerOwnershipBatch,
ProductCategoryBatch,
)
from app.schemas.customer import CustomerQuery
from app.schemas.payroll import PayrollSetupIn
from app.schemas.product import ProductQuery
from app.schemas.report import BusinessStatsQuery
from app.services import (
business_service,
business_source,
category_batch,
customer_service,
payroll_months,
receipt_confirmation,
report_service,
)
from app.services.business_source import ensure_source_editable
from app.services.tplus import business_sync, business_sync_control, sales_relations
from app.services.tplus import business_sync, business_sync_control
from app.services.tplus.snapshots import source_manifest
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

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_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 兼容仍待实测。

View File

@ -1,5 +1,7 @@
# 辽宁兴晟达物资有限公司 2026 年 7 月实际样本核对
> 2026-11-03 后续:已修复历史岗位衔接及真实产品导入阻塞,并使用本组真实数据在隔离 MySQL 完成正式导入、核算、月结、报表及导出。结果见[真实样本业务闭环验收](真实样本业务闭环验收_2026-11-03.md)。下文保留 11 月 2 日只读核对时的阶段记录;本次未向原开发库生成工资。
记录日期: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)
产品提取入口移到操作栏,来源空值改为明确说明;产品和人员长标签、日期控件及手机布局已修复。人员经理参照只列在职且绑定启用经理计提模板的人员,旧参考关系保留;实际绩效仍按业务分管。岗位编辑说明内置模板与参数维护的区别,提供参数入口。首页以趋势金额是否为 `null` 区分尚无台账/回款与真实零值,并说明正式导入和核算步骤。
@ -332,3 +370,26 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。
本轮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
description: string
roles: string[]
/** 角色 → 中文标签(模板表单展示) */
role_labels: Record<string, string>
/** 角色 → 业务口径提示(placeholder) */
role_hints: Record<string, string>
}
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 })
}
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 {
product_id: number | null
product_name: string

View File

@ -211,15 +211,16 @@ body {
.page-title {
margin: 0;
font-size: 18px;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ys-ink);
}
.page-desc {
margin: 6px 0 0;
font-size: 12px;
margin: 8px 0 0;
font-size: 13px;
line-height: 1.7;
letter-spacing: 0.01em;
color: var(--ys-ink-3);
}
@ -249,6 +250,7 @@ body {
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
.card {
min-width: 0;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
border: 1px solid var(--ys-line);
border-radius: var(--ys-radius);
@ -257,6 +259,7 @@ body {
.card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
@ -276,6 +279,8 @@ body {
.card-foot {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: flex-end;
padding: 10px 16px;
@ -299,6 +304,7 @@ body {
}
.filter-bar .el-form-item {
min-width: 0;
margin-right: 0;
margin-bottom: 0;
}
@ -319,10 +325,20 @@ body {
}
.toolbar-note {
line-height: 1.7;
font-size: 12px;
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 {
font-family: var(--ys-font-mono);
@ -388,13 +404,13 @@ span.cell-num {
}
.ys-pill--ok {
color: var(--ys-ok);
color: #18764f;
background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2));
border-color: rgba(30, 158, 106, 0.2);
}
.ys-pill--wait {
color: var(--ys-warn);
color: #895d12;
background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2));
border-color: rgba(201, 138, 27, 0.22);
}
@ -406,7 +422,7 @@ span.cell-num {
}
.ys-pill--reject {
color: var(--ys-danger);
color: #ac3d3d;
background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2));
border-color: rgba(201, 79, 79, 0.2);
}
@ -569,7 +585,7 @@ span.cell-num {
padding: 10px 0;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
letter-spacing: 0.01em;
background-color: transparent;
background-image: linear-gradient(180deg, #fafbfc, #f5f6f9);
}
@ -589,6 +605,39 @@ span.cell-num {
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 {
transition: background-color 120ms var(--ys-ease);
}
@ -634,7 +683,19 @@ span.cell-num {
}
.el-drawer__body {
min-height: 0;
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 {
flex-shrink: 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 {
@ -662,6 +724,7 @@ span.cell-num {
font-size: 16px;
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.el-dialog__headerbtn {
@ -673,10 +736,11 @@ span.cell-num {
.el-dialog__body {
min-height: 0;
padding: 4px 24px 20px;
padding: 20px 24px;
overflow-y: auto;
overscroll-behavior: contain;
color: var(--ys-ink-2);
line-height: 1.7;
}
.el-dialog__footer {
@ -703,6 +767,13 @@ span.cell-num {
.el-message-box {
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 {
@ -726,6 +797,72 @@ span.cell-num {
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 {
height: auto;
@ -754,7 +891,7 @@ span.cell-num {
}
.el-dialog__body {
padding: 4px 16px 16px;
padding: 16px;
}
.el-dialog__footer {
@ -764,6 +901,74 @@ span.cell-num {
.el-dialog__footer > .el-button {
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">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, EditPen, Upload } from '@element-plus/icons-vue'
import { request } from '@/api/request'
import service from '@/api/request'
import { useUserStore } from '@/store/user'
@ -11,6 +12,7 @@ interface Catalog { as_of?:string; rows: Row[]; fingerprint: string; average_rul
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 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 editing = ref(false), edited = ref<Row[]>([]), pending = ref<Row[]>([]), errors = ref<{row:number;message:string}[]>([])
const baseDate=ref(today())
@ -19,7 +21,16 @@ const importFileName = ref('')
const categories: Record<string,string> = { municipal_water:'市政水利', rail_transit:'轨道交通' }
const customers: Record<string,string> = { personal:'个人户', enterprise:'企业户', manager_personal:'经理个人户' }
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 }
async function apply(rows: Row[]) {
try { await ElMessageBox.confirm(`共 ${rows.length} 项阶段规则,${effective.value} 起生效。历史工资保持原快照。`, '确认系数变更') } catch { return }
@ -37,42 +48,115 @@ onMounted(load)
</script>
<template>
<section class="coefficient-panel" aria-label="完整绩效系数表">
<div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段配置计提系数,页面展示与正式核算使用同一版本。</p></div></div>
<el-form inline label-position="top">
<el-form-item label="查询生效日期"><el-date-picker v-model="asOf" value-format="YYYY-MM-DD" @change="load" /></el-form-item>
<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>
<el-form-item v-if="user.can('system:params:update')" label="Excel 导入">
<label class="file-button">
<div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段核对计提系数,正式核算使用对应生效版本。</p></div><span v-if="!loading && !failed" class="panel-count">{{ data.rows.length }} 项阶段规则</span></div>
<div class="coefficient-toolbar" role="group" aria-label="产品系数表工具栏">
<div class="coefficient-toolbar__query">
<label for="coefficient-as-of">查询日期</label>
<el-date-picker
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" />
<span>选择文件</span>
<el-icon aria-hidden="true"><Upload /></el-icon>
<span>导入 Excel</span>
</label>
<span v-if="importFileName" class="file-name">{{ importFileName }}</span>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="data.rows" border>
<el-table-column label="产品类别" width="110"><template #default="{row}">{{categories[row.product_category]}}</template></el-table-column>
<el-table-column label="户型" width="110"><template #default="{row}">{{customers[row.customer_type]}}</template></el-table-column>
</div>
<span v-if="importFileName" class="file-name">{{ importFileName }}</span>
</div>
<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 v-loading="loading" :data="data.rows" :span-method="spanMethod" class="coefficient-table" border empty-text="当前日期没有可显示的产品系数">
<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 prop="value" label="给付标准/起点(‰)" min-width="165" />
<el-table-column prop="slope" label="日斜率(‰)" min-width="125" />
<el-table-column prop="boundary" label="截止天数" width="95" />
<el-table-column label="系统系数" min-width="150"><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 prop="value" label="给付标准/起点(‰)" min-width="165" align="right" />
<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="100" align="right"><template #default="{row}">{{ row.boundary ?? '—' }}</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" align="right"><template #default="{row}">{{row.average_coefficient_360 ?? '不适用'}}</template></el-table-column>
</el-table>
<p class="coefficient-note">{{ data.average_rule }}。线性阶段的系统系数按第 2 天口径展示;比例和斜率支持精确分数,导入前请先下载 Excel 核对格式。</p>
<el-dialog v-model="editing" title="维护绩效系数表" width="min(1050px, 96vw)">
<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)" :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-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>
<template #footer><el-button @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template>
<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 :disabled="saving" @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template>
</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-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-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>
<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>
<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" 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 :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>
</section>
</template>
<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>

View File

@ -9,7 +9,7 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
</script>
<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
v-for="item in topLevelMenus"
:key="item.path"

View File

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

View File

@ -17,14 +17,21 @@ onMounted(load)
<template>
<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>
<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-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 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>
<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>
</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">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '@/api/request'
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 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 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 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}}
@ -14,14 +22,60 @@ async function apply(){if(!result.value)return;try{await ElMessageBox.confirm('
defineExpose({open})
</script>
<template>
<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>
<p>先在业务管理录入产品明细,或将 T+ 数据正式导入业务,再勾选未关联产品档案的明细,点击“预览所选”并确认提取。T+ 原始采集成功不会自动建产品档案。</p>
<p>同名同规格合并,数量与单价取所选最新明细;已有档案只建立关联,不覆盖人工信息。</p>
<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>
<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-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>
<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>
<el-dialog v-model="visible" title="从业务明细提取产品" width="min(1180px, 96vw)"
:close-on-click-modal="!loading" :close-on-press-escape="!loading" :show-close="!loading">
<p class="extract-intro">操作步骤:勾选业务明细 → 点击“预览所选” → 核对预览结果 → 确认执行提取。</p>
<el-form class="filter-bar" @submit.prevent="page=1;load()">
<el-form-item label="名称/业务编号">
<el-input v-model="keyword" placeholder="搜索产品名称或业务编号" 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" 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>
</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">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
/**
* 分页条:统一各列表页的布局与页码尺寸选项。
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
@ -16,13 +18,26 @@ defineEmits<{
(e: 'page-change', page: 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>
<template>
<el-pagination
class="app-pager"
background
layout="total, sizes, prev, pager, next, jumper"
:layout="layout"
:pager-count="5"
:size="compact ? 'small' : 'default'"
:total="total"
:current-page="pageNum"
:page-size="pageSize"
@ -34,7 +49,18 @@ defineEmits<{
<style scoped>
.app-pager {
flex-wrap: wrap;
gap: 8px;
max-width: 100%;
justify-content: flex-end;
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>

View File

@ -547,6 +547,9 @@ function removeGrade(row: RatingGradeItem): void {
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
@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 {
width: auto;
text-align: left;

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { optionPage } from '@/api/metadata'
import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser()
@ -47,7 +48,7 @@ const filters = reactive({
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>(
personnelApi.listPersonnel,
() => ({
@ -403,7 +404,7 @@ onMounted(() => {
<div class="page-head">
<h1 class="page-title">人员管理</h1>
<p class="page-desc">
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用
维护人员档案、任职日期与工资标准。岗位变更影响后续回款的绩效核算,离职人员不能再分配到业务或客户。
</p>
<span class="waterline"></span>
</div>
@ -412,6 +413,7 @@ onMounted(() => {
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="姓名">
<el-input
@ -456,7 +458,7 @@ onMounted(() => {
<div class="toolbar">
<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>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
@ -465,10 +467,10 @@ onMounted(() => {
v-loading="loading"
:data="records"
stripe
empty-text="还没有人员 —— 点「新增人员」建第一个,或换个关键词再查"
empty-text="暂无符合条件的人员,可调整筛选条件。"
>
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
<el-table-column prop="role_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" show-overflow-tooltip>
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="manager_name" label="直属经理" min-width="100">
@ -499,7 +501,7 @@ onMounted(() => {
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</template>
</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 }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
@ -531,11 +533,15 @@ onMounted(() => {
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
<el-dialog
v-model="dialogVisible"
:close-on-click-modal="!submitting"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:title="isEdit ? '编辑人员' : '新增人员'"
width="640px"
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">
<h2 class="form-section-title">人员与岗位</h2>
<el-form-item label="姓名" prop="personnel_name">
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
</el-form-item>
@ -575,6 +581,7 @@ onMounted(() => {
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
</el-form-item>
<h2 class="form-section-title">任职与试用期</h2>
<el-form-item label="入职日期" prop="hire_date">
<el-date-picker
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="试用期" prop="is_probation">
<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 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="工资标准" prop="salary_standard">
<el-form-item label="月工资标准(元)" prop="salary_standard">
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
</el-form-item>
</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>
<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>
</template>
</el-dialog>

View File

@ -6,6 +6,7 @@ import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus } from '@element-plus/icons-vue'
import * as payroll from '@/api/payroll'
import * as businesses from '@/api/businesses'
import { request } from '@/api/request'
import { handledError } from '@/utils/error'
@ -18,7 +19,7 @@ const monthChecked = ref(false), monthLoading = ref(false)
let monthRequest = 0
const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null)
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 businessKey = ref('')
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))
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}[] }
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 ?? ''] ?? ''
}
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}[] }
async function loadMetadata(): Promise<PayrollMetadata> {
const pageSize = 200
@ -62,7 +58,7 @@ async function load(): Promise<void> {
const data = await loadMetadata()
people.value = data.personnel
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
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}
@ -112,6 +108,22 @@ async function save(): Promise<void> {
ElMessage.success('核算资料已保存为新版本')
} 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 {
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 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="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 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 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="指导成本登记" 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-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="业务交接">

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { optionPage } from '@/api/metadata'
import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser()
@ -32,7 +33,7 @@ const filters = reactive({
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, () => ({
settlement_month: filters.settlement_month.trim() || undefined,
is_active: filters.is_active,
@ -132,6 +133,7 @@ async function submitRecalc(): Promise<void> {
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="归属月">
<el-input
@ -179,7 +181,7 @@ async function submitRecalc(): Promise<void> {
<span class="cell-code">{{ row.business_no }}</span>
</template>
</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 prop="receipt_date" label="回款日" width="100" />
<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-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="amount" label="本次分配金额" width="120" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" />
<el-table-column prop="performance_delta" label="本次计提" width="100" /><el-table-column prop="payable_delta" 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" align="right" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" align="right" />
<el-table-column prop="input_id" label="资料版本编号" width="120" />
</el-table>
</el-dialog>

View File

@ -6,10 +6,12 @@ import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { SVGRenderer } from 'echarts/renderers'
import type { ECharts, EChartsCoreOption } from 'echarts/core'
import { Refresh, ArrowRight } from '@element-plus/icons-vue'
import * as dashboardApi from '@/api/dashboard'
import type { ActivityItem, ActivityPage, DashboardData, TrendPoint } from '@/api/dashboard'
import AppPagination from '@/components/table/AppPagination.vue'
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error'
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 hasReceiptTrend = computed(() => data.value?.overview.trend.some(point => point.receipt_total != null) ?? false)
const loading = ref(false)
const failed = ref(false)
/** 快速切换月份时,只允许最新请求写入数据与 loading */
let loadSeq = 0
@ -78,6 +81,7 @@ let receiptChart: ECharts | null = null
* 用 ResizeObserver 监听图表容器本身,布局一变就同步图表尺寸。
*/
let chartObserver: ResizeObserver | null = null
let resizeFrame: number | undefined
function currentMonth(): string {
const now = new Date()
@ -87,6 +91,7 @@ function currentMonth(): string {
async function load(): Promise<void> {
const seq = ++loadSeq
loading.value = true
failed.value = false
try {
const result = await dashboardApi.getDashboard({ month: month.value })
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
@ -94,7 +99,7 @@ async function load(): Promise<void> {
await nextTick()
renderCharts()
} catch (error) {
if (seq === loadSeq) handledError(error)
if (seq === loadSeq) { failed.value = true; handledError(error) }
} finally {
if (seq === loadSeq) loading.value = false
}
@ -124,12 +129,13 @@ function axisCommon(trend: TrendPoint[]) {
data: trend.map((item) => item.month.slice(5)),
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
axisTick: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
axisLabel: { fontFamily: CHART_FONT, color: '#657086', fontSize: 11, margin: 8 },
},
yAxis: {
type: 'value' as const,
splitLine: { show: false },
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
splitNumber: 3,
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 {
return {
animation: false,
textStyle: { fontFamily: CHART_FONT },
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),
series: [
{
@ -165,7 +178,7 @@ function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
type: 'line',
smooth: 0.4,
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 },
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
return {
animation: false,
textStyle: { fontFamily: CHART_FONT },
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),
series: [
{
@ -187,7 +201,7 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
type: 'bar',
barMaxWidth: 22,
data: trend.map((item, index) => ({
value: Number(item.receipt_total ?? 0),
value: item.receipt_total == null ? null : Number(item.receipt_total),
itemStyle: {
color:
index === lastIndex
@ -202,27 +216,28 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
}
function handleResize(): void {
perfChart?.resize()
receiptChart?.resize()
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
perfChart?.resize()
receiptChart?.resize()
})
}
onMounted(() => {
void load()
void loadActivities()
window.addEventListener('resize', handleResize)
if (perfChartRef.value) {
chartObserver = new ResizeObserver(() => {
perfChart?.resize()
receiptChart?.resize()
})
chartObserver.observe(perfChartRef.value)
}
chartObserver = new ResizeObserver(handleResize)
if (perfChartRef.value) chartObserver.observe(perfChartRef.value)
if (receiptChartRef.value) chartObserver.observe(receiptChartRef.value)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
chartObserver?.disconnect()
chartObserver = null
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
perfChart?.dispose()
receiptChart?.dispose()
})
@ -239,6 +254,7 @@ const {
pageNum: activityPageNum,
pageSize: activityPageSize,
loading: activityLoading,
loadFailed: activityFailed,
load: loadActivities,
search: searchActivities,
onPageChange: onActivityPageChange,
@ -269,24 +285,28 @@ function formatTime(value: string): string {
<template>
<div v-loading="loading" class="workbench">
<div class="page-head">
<header class="page-head dashboard-head">
<div>
<h1 class="page-title">工作台</h1>
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p>
<p class="page-desc">查看本月绩效与回款,处理待办并核对业务风险。</p>
<span class="waterline"></span>
</div>
<div class="toolbar">
</div>
<div class="dashboard-filter">
<span>统计月份</span>
<el-date-picker
v-model="month"
type="month"
value-format="YYYY-MM"
:clearable="false"
style="width: 140px"
aria-label="统计月份"
@change="load"
/>
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
<span class="toolbar-note">{{ data?.overview.note ?? '' }}</span>
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
</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 四格 -->
<section class="kpi-row" aria-label="经营总览指标">
@ -333,7 +353,7 @@ function formatTime(value: string): string {
<span class="panel-title">近 6 个月绩效</span>
<span class="panel-hint">按台账归属月</span>
</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>
<div class="panel">
@ -341,7 +361,7 @@ function formatTime(value: string): string {
<span class="panel-title">近 6 个月回款</span>
<span class="panel-hint">按到账月</span>
</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>
</section>
@ -358,7 +378,7 @@ function formatTime(value: string): string {
<div class="list-block__head">
<span class="list-block__title">待审批</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>
</div>
@ -389,7 +409,7 @@ function formatTime(value: string): string {
<div class="list-block__head">
<span class="list-block__title">未指定批次的回款(供核对)</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>
</div>
@ -410,17 +430,6 @@ function formatTime(value: string): string {
<div v-else class="list-empty">无未指定批次的回款</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 class="panel panel--list">
@ -465,21 +474,22 @@ function formatTime(value: string): string {
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
</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>
</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="最近动态">
<div class="panel-head">
@ -490,6 +500,7 @@ function formatTime(value: string): string {
clearable
placeholder="全部模块"
class="activity-filter"
aria-label="动态模块"
@change="searchActivities"
>
<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">
<ListLoadError v-if="activityFailed" :loading="activityLoading" @retry="loadActivities" />
<div v-if="activities.length" class="activity-list">
<div class="activity-row activity-row--head">
<span>时间</span>
@ -529,7 +541,7 @@ function formatTime(value: string): string {
<span class="activity-cell activity-cell--user">{{ item.username ?? '—' }}</span>
</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>
<AppPagination
@ -550,10 +562,10 @@ function formatTime(value: string): string {
<span class="panel-title">常用操作</span>
</div>
<div class="quick-actions">
<button class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button>
<button class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button>
<button 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('business:receipts:view')" class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button>
<button v-if="user.hasPermission('payroll:view')" class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button>
<button v-if="user.hasPermission('settlements:view')" class="quick-btn" type="button" @click="go('/settlements')">月度结算</button>
<button v-if="user.hasPermission('reports:personal:view')" class="quick-btn" type="button" @click="go('/reports/personal-detail')">
报表统计
</button>
</div>
@ -562,6 +574,16 @@ function formatTime(value: string): string {
</template>
<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 */
/*
@ -581,7 +603,10 @@ function formatTime(value: string): string {
.kpi-row {
display: grid;
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 {
@ -589,22 +614,11 @@ function formatTime(value: string): string {
flex-direction: column;
justify-content: center;
min-width: 0;
min-height: 116px;
min-height: 108px;
padding: 16px 18px;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
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);
background: none;
}
.kpi-card + .kpi-card { border-left: 1px solid var(--ys-line-soft); }
.kpi-label {
font-size: 12px;
@ -647,36 +661,10 @@ function formatTime(value: string): string {
flex-direction: column;
min-width: 0;
padding: 16px 18px;
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
background: var(--ys-paper);
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;
}
.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 {
@ -690,14 +678,14 @@ function formatTime(value: string): string {
}
.panel-title {
font-size: 13px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ys-ink-2);
color: var(--ys-ink);
}
.panel-hint {
font-size: 11px;
font-size: 12px;
color: var(--ys-ink-3);
}
@ -724,12 +712,12 @@ function formatTime(value: string): string {
/* ── ③ 待办 / 预警 ────────────────────────────────────────────────────── */
/* 高度下限保证列表区有 4~5 行可视;内容更长时由内部滚动消化 */
.panel--list {
min-height: 420px;
min-height: 0;
}
.list-block {
display: flex;
flex: 1 1 0;
flex: none;
flex-direction: column;
min-height: 0;
}
@ -740,6 +728,7 @@ function formatTime(value: string): string {
.list-block__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex: none;
@ -784,6 +773,7 @@ function formatTime(value: string): string {
flex: 1;
min-height: 0;
overflow-y: auto;
max-height: 260px;
}
/*
@ -824,15 +814,17 @@ function formatTime(value: string): string {
.row__main {
font-size: 13px;
color: var(--ys-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
line-height: 1.6;
}
.row__meta {
font-size: 11px;
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);
align-items: center;
}
.row--warn .row__meta { white-space: normal; line-height: 1.7; }
.row__tag {
display: inline-flex;
@ -848,13 +841,15 @@ function formatTime(value: string): string {
/* 空态在卡片剩余高度里居中,避免内容全挤在顶部、下方留一大片空白 */
.list-empty {
display: flex;
flex: 1;
flex: none;
align-items: center;
justify-content: center;
padding: 12px 0;
justify-content: flex-start;
min-height: 56px;
padding: 16px 8px;
font-size: 12px;
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 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
flex: none;
margin-top: 12px;
@ -895,7 +891,7 @@ function formatTime(value: string): string {
/* ── ④ 最近动态 ───────────────────────────────────────────────────────── */
.panel--activity {
min-height: 320px;
min-height: 0;
}
.activity-filter {
@ -969,6 +965,8 @@ function formatTime(value: string): string {
.activity-cell--summary {
color: var(--ys-ink-2);
white-space: normal;
line-height: 1.7;
}
.activity-cell--user {
@ -1015,6 +1013,8 @@ function formatTime(value: string): string {
.kpi-row {
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) {
@ -1024,13 +1024,13 @@ function formatTime(value: string): string {
}
.panel--list {
min-height: 360px;
min-height: 0;
}
}
@media (max-width: 720px) {
.kpi-row {
grid-template-columns: minmax(0, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.activity-row {
@ -1043,6 +1043,14 @@ function formatTime(value: string): string {
}
@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 {
padding: 14px;
}

View File

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

View File

@ -1,5 +1,31 @@
<script setup lang="ts">
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 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>
<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 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
v-loading="loading"
class="dense"
@ -228,7 +228,7 @@ function pct(value: string): string {
<template #default="{ row }">
<div class="member-wrap">
<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="performance_sum" label="绩效金额" width="120" align="right">
<template #default="{ row: member }">
@ -325,7 +325,7 @@ function pct(value: string): string {
<div v-if="data && data.unassigned.length" class="unassigned">
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
<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="performance_sum" label="绩效金额" width="130" align="right">
<template #default="{ row }">
@ -363,6 +363,7 @@ function pct(value: string): string {
.company-card {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 24px;
margin-bottom: 12px;
@ -399,4 +400,9 @@ function pct(value: string): string {
.unassigned {
margin-top: 20px;
}
@media (max-width: 600px) {
.company-card { gap: 10px 18px; }
.company-note { width: 100%; line-height: 1.7; }
}
</style>

View File

@ -249,11 +249,11 @@ function today(): string {
size="small"
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_date" label="回款日" width="110" />
<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 label="客户类型" width="100">
<template #default="{ row: detail }">
@ -402,7 +402,7 @@ function today(): string {
.dimension-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;
margin-top: 20px;
}

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { optionPage } from '@/api/metadata'
import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser()
@ -85,7 +86,7 @@ function fetchPage(params: ReportFilterParams) {
}
// 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) => {
summary.value = data.summary
})
@ -193,6 +194,7 @@ function today(): string {
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="search" />
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="月份">
<el-date-picker
@ -278,7 +280,7 @@ function today(): string {
<LedgerCalculationDetails v-else :ledger="snapshots[row.ledger_id]" :loading="snapshotLoading[row.ledger_id]" />
</template>
</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 }">
<span class="cell-code">{{ row.business_no }}</span>
</template>
@ -298,7 +300,7 @@ function today(): string {
<el-table-column label="客户类型" width="100">
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
</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">
<template #default="{ row }">
<span class="cell-num">{{ row.rate_text }}</span>

View File

@ -187,6 +187,7 @@ onMounted(load)
</div>
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
<div class="sheet-viewport" role="region" aria-label="报表预览,可横向滚动" tabindex="0">
<div class="sheet">
<header class="sheet-header">
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
@ -489,6 +490,7 @@ onMounted(load)
<div>审批:____________</div>
</footer>
</div>
</div>
</div>
</template>
@ -563,6 +565,14 @@ onMounted(load)
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 {
text-align: center;
}
@ -661,6 +671,7 @@ onMounted(load)
}
@media print {
.sheet-viewport { overflow: visible; }
.no-print {
display: none !important;
}

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import httpService from '@/api/request'
import ProbationPanel from '@/components/payroll/ProbationPanel.vue'
import { useUserStore as usePermissionUser } from '@/store/user'
@ -35,7 +36,7 @@ const filters = reactive({
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 }))
onMounted(() => {
@ -129,7 +130,6 @@ async function handleSubmit(row: Settlement): Promise<void> {
<template>
<div class="requirements-surface">
<ProbationPanel />
<div class="page">
<div class="page-head">
<h1 class="page-title">月度结算</h1>
@ -178,6 +178,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
-->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
@ -250,6 +251,8 @@ async function handleSubmit(row: Settlement): Promise<void> {
</div>
</div>
<ProbationPanel />
<el-drawer v-model="detailVisible" title="月结详情" size="min(1180px, 96vw)">
<div v-loading="detailLoading">
<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>
<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">
<template #default="{ row }">
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
@ -296,7 +299,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
</template>
</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">
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
</el-table-column>

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser()
@ -43,7 +44,7 @@ const filters = reactive({
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 }>(
listApprovals,
() => ({
@ -185,6 +186,7 @@ async function submitAction(): Promise<void> {
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
失去 gap 排版(el-form 不是 flex 透明容器)。
-->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
@ -337,7 +339,7 @@ async function submitAction(): Promise<void> {
<dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd>
</dl>
<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">
<template #default="{ row }">
<span class="cell-num">{{ row.ratio }}%</span>
@ -371,7 +373,7 @@ async function submitAction(): Promise<void> {
</dd>
</dl>
<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 label="绩效(元)" width="120">
<template #default="{ row }">

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { useUserStore as usePermissionUser } from '@/store/user'
const permissionUser = usePermissionUser()
@ -33,7 +34,7 @@ const filters = reactive({
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>(
businessRoleApi.listBusinessRoles,
() => ({
@ -67,7 +68,7 @@ const templateLoading = ref(false)
const templateSubmitting = ref(false)
const templateDialogVisible = ref(false)
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 templateForm = reactive({
@ -99,10 +100,18 @@ function formulaName(code: string): string {
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 {
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> {
templateLoading.value = true
try {
@ -111,7 +120,12 @@ async function loadTemplateManager(): Promise<void> {
formulas.value = formulaList
if (paramOptions.value.length === 0) {
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) {
handledError(error)
@ -360,6 +374,7 @@ onMounted(() => {
筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块
排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。
-->
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
<el-form class="filter-bar" @submit.prevent>
<el-form-item label="关键词">
<el-input
@ -405,11 +420,11 @@ onMounted(() => {
<span class="cell-code">{{ row.role_code }}</span>
</template>
</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">
<template #default="{ row }">{{ templateName(row.calc_template) }}</template>
</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>
</el-table-column>
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
@ -467,6 +482,9 @@ onMounted(() => {
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:close-on-click-modal="!submitting"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:title="isEdit ? '编辑岗位' : '新增岗位'"
width="540px"
destroy-on-close
@ -498,11 +516,11 @@ onMounted(() => {
</el-form-item>
<el-form-item v-if="selectedTemplate" label-width="90px">
<div class="tpl-info">
<p>公式模板由系统内置,这里选择适用公式。比例、递减斜率和天数等数值在“计提参数”维护。</p>
<router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params" target="_blank" rel="noopener">打开计提参数</router-link>
<p>选择岗位适用的计提公式。比例、斜率和天数等数值在“计提参数”中维护;公式结构可在“公式模板管理”中调整。</p>
<router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params">打开计提参数</router-link>
<div>{{ selectedTemplate.description }}</div>
<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>
</el-form-item>
@ -514,7 +532,7 @@ onMounted(() => {
</el-form-item>
</el-form>
<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>
</template>
</el-dialog>
@ -527,9 +545,9 @@ onMounted(() => {
</div>
<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_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="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="center"><template #default="{ row }">{{ row.is_system === 1 ? '系统' : '自定义' }}</template></el-table-column>
<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>
</el-form-item>
<template v-if="selectedFormula">
<el-form-item v-for="role in selectedFormula.roles" :key="role" :label="`参数·${role}`">
<el-select v-model="templateForm.params[role]" :disabled="editingTemplate?.is_system === 1" placeholder="选择计提参数" style="width: 100%" filterable>
<el-form-item
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-select>
</el-form-item>

View File

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

View File

@ -6,6 +6,7 @@ const permissionUser = usePermissionUser()
import { computed, onMounted, reactive, ref, watch } from 'vue'
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 paramsApi from '@/api/params'
@ -18,6 +19,7 @@ import type {
ParamPreviewResult,
} from '@/api/params'
import AppPagination from '@/components/table/AppPagination.vue'
import ListLoadError from '@/components/feedback/ListLoadError.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { useUserStore } from '@/store/user'
@ -43,10 +45,13 @@ const isAdmin = computed(() => userStore.can('system:params:update'))
const catalog = ref<ParamCatalogItem[]>([])
const catalogLoading = ref(false)
const catalogFailed = ref(false)
const catalogKeyword = ref('')
const expandedGroups = ref(['业务员个人户'])
async function loadCatalog(): Promise<void> {
catalogLoading.value = true
catalogFailed.value = false
try {
catalog.value = await paramsApi.getCatalog()
// 预览「所用参数」按编码取中文名;目录加载后填充映射
@ -54,6 +59,7 @@ async function loadCatalog(): Promise<void> {
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
}
} catch (error) {
catalogFailed.value = true
handledError(error)
} finally {
catalogLoading.value = false
@ -72,6 +78,24 @@ const groupedCatalog = computed(() => {
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 维度为空串;角色维度给出中文岗位名) */
const DIMENSION_LABELS: Record<string, string> = {
'': '默认',
@ -283,6 +307,7 @@ const {
pageNum: batchesPage,
pageSize: batchesPageSize,
loading: batchesLoading,
loadFailed: batchesFailed,
load: loadBatches,
onPageChange: onBatchesPageChange,
onSizeChange: onBatchesSizeChange,
@ -386,6 +411,7 @@ const {
pageNum: historyPage,
pageSize: historyPageSize,
loading: historyLoading,
loadFailed: historyFailed,
load: loadHistory,
onPageChange: onHistoryPageChange,
onSizeChange: onHistorySizeChange,
@ -480,34 +506,47 @@ onMounted(() => {
<template>
<div class="requirements-surface">
<CoefficientPanel />
<div class="page">
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 -->
<div class="page-head">
<h1 class="page-title">计提参数</h1>
<p class="page-desc">
维护绩效计算规则与产品系数,查看当前生效值和变更历史。修改先暂存,提交时可选择生效日期。
</p>
<div class="waterline"></div>
</div>
<div class="page params-page">
<header class="page-head params-head">
<div>
<h1 class="page-title">计提参数</h1>
<p class="page-desc">核对产品系数、维护计提规则,查看变更历史与计算预览。</p>
<div class="waterline"></div>
</div>
<div class="params-actions">
<el-button :icon="Clock" @click="openVersionDrawer">版本管理</el-button>
<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>
<!--
主操作行:这页没有筛选条件(参数按分组浏览而非检索),所以只有工具按钮。
「计算预览」是管理员最常走的一步,用主按钮把它与另两个次要动作区分开。
-->
<div class="toolbar">
<el-button type="primary" @click="openPreviewDrawer" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">计算预览</el-button>
<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>
<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>
<span>规则参数修改先暂存,提交时确认生效日期。</span>
</nav>
<div class="card">
<div class="card-body">
<el-collapse v-model="expandedGroups" v-loading="catalogLoading">
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
<CoefficientPanel id="coefficient-section" />
<section id="rules-section" class="card rules-section" aria-labelledby="rules-title">
<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>
<span class="group-title">{{ group.name }}</span>
<span class="group-title">{{ group.name }} <span>{{ group.items.length }} 项参数</span></span>
</template>
<p v-if="group.name === '审批开关'" class="approval-notice">
{{ approvalEnabled ? '系统审核已开启,以下设置决定各类单据是否进入审核流程。' : '系统审核当前关闭。以下为保留的审核设置;生成工资后直接完成,不产生审核流程记录。' }}
@ -523,16 +562,17 @@ onMounted(() => {
</thead>
<tbody>
<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-desc">{{ item.description || item.param_code }}</div>
</td>
<td>
<td class="param-value-cell">
<span class="mobile-value-label">当前生效值</span>
<template v-if="item.values.length">
<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>
<b class="num">{{ formatValue(item, v.value_text) }}</b>
<span class="eff">{{ v.effective_date }} 起</span>
<el-tag
v-if="pendingOf(item, v.dimension_value)"
size="small"
@ -541,6 +581,9 @@ onMounted(() => {
>
待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
</el-tag>
</div>
<div class="value-meta">
<span v-if="v.effective_date" class="eff">{{ v.effective_date }} 起</span>
<el-button
v-if="isAdmin"
link
@ -550,13 +593,14 @@ onMounted(() => {
:disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">
修改
</el-button>
</div>
</div>
</template>
<span v-else class="no-value">
未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
</span>
</td>
<td>
<td class="param-actions-cell">
<el-button
v-if="isAdmin && !item.values.length"
link
@ -575,7 +619,7 @@ onMounted(() => {
</el-collapse-item>
</el-collapse>
</div>
</div>
</section>
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
<div v-if="isAdmin && pendingList.length" ref="pendingCard" class="card pending-card">
@ -585,7 +629,7 @@ onMounted(() => {
<span class="pending-hint">暂存内容尚未生效。请确认参数值与生效日期后提交。</span>
</div>
</div>
<el-table :data="pendingList" size="small">
<el-table :data="pendingList" size="small" class="pending-table">
<el-table-column label="参数" min-width="160">
<template #default="{ row }">{{ row.item.param_name }}</template>
</el-table-column>
@ -608,26 +652,46 @@ onMounted(() => {
</template>
</el-table-column>
</el-table>
<div class="card-foot">
<div class="submit-row">
<ul class="pending-list" aria-label="待提交参数核对">
<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-button :value="1">立即生效</el-radio-button>
<el-radio-button :value="2">指定日期生效</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="changeType === 2" label="生效日期">
<el-date-picker
v-if="changeType === 2"
v-model="effectiveDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="生效日期"
style="width: 150px"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="变更说明(选填)" class="submit-remark">
<el-input
v-model="remark"
placeholder="变更说明(选填,如:调整第四季度计提比例)"
style="width: 240px"
placeholder="例如:调整第四季度计提比例"
style="width: 100%"
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>
</div>
</div>
@ -708,24 +772,32 @@ onMounted(() => {
<!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
<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
v-model="compareFrom"
type="date"
value-format="YYYY-MM-DD"
placeholder="对比起始日期"
style="width: 150px"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker
v-model="compareTo"
type="date"
value-format="YYYY-MM-DD"
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>
</div>
</div>
</el-form>
<template v-if="diffSearched">
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
<el-table :data="diffResult" size="small" max-height="260">
@ -751,8 +823,9 @@ onMounted(() => {
</div>
<div class="drawer-block">
<div class="drawer-title">变更记录</div>
<el-table v-loading="batchesLoading" :data="batches" size="small">
<h3 class="drawer-section-title">变更记录</h3>
<ListLoadError v-if="batchesFailed" :loading="batchesLoading" @retry="loadBatches" />
<el-table v-loading="batchesLoading" :data="batches" size="small" empty-text="暂无参数变更记录">
<el-table-column type="expand">
<template #default="{ row }">
<div class="batch-items">
@ -798,11 +871,14 @@ onMounted(() => {
@size-change="onBatchesSizeChange"
/>
</div>
<template #footer><el-button @click="versionVisible=false">关闭</el-button></template>
</el-drawer>
<!-- 历史抽屉 -->
<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 label="适用范围" width="90">
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
@ -826,12 +902,14 @@ onMounted(() => {
@page-change="onHistoryPageChange"
@size-change="onHistorySizeChange"
/>
<template #footer><el-button @click="historyVisible=false">关闭</el-button></template>
</el-drawer>
<!-- 计算预览抽屉 -->
<el-drawer v-model="previewVisible" title="计算预览" size="480px">
<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-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" />
@ -868,24 +946,24 @@ onMounted(() => {
style="width: 100%"
/>
</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-form-item>
<el-form-item label="毛利润额">
<el-form-item label="毛利润额(元)">
<el-input v-model="previewForm.profit" placeholder="可为负" />
</el-form-item>
<el-form-item label="本笔回款">
<el-form-item label="本笔回款(元)">
<el-input v-model="previewForm.received_amount" />
</el-form-item>
<div class="preview-flags">
<el-form-item label="以物抵顶">
<el-switch v-model="previewForm.barter" />
</el-form-item>
<el-form-item label="试用期">
<el-switch v-model="previewForm.probation" />
</el-form-item>
<el-form-item>
<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>
</div>
</el-form>
<template v-if="previewResult">
@ -912,21 +990,55 @@ onMounted(() => {
</div>
</div>
</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>
</div>
</div>
</template>
<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 {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
font-weight: 600;
color: var(--ys-ink);
}
.group-title > span { color: var(--ys-ink-3); font-size: 12px; font-weight: 400; }
.param-table {
width: 100%;
min-width: 640px;
border-collapse: collapse;
}
@ -950,22 +1062,27 @@ onMounted(() => {
text-align: left;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
letter-spacing: 0.01em;
color: var(--ys-ink-2);
padding: 6px 8px;
padding: 10px 12px;
background: var(--ys-mist);
border-bottom: 1px solid var(--ys-line);
}
.param-table td {
padding: 10px 8px;
padding: 16px 12px;
border-bottom: 1px solid var(--ys-line);
font-size: 13px;
vertical-align: top;
}
.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 {
font-size: 12px;
@ -976,12 +1093,17 @@ onMounted(() => {
}
.value-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px 16px;
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 {
font-size: 12px;
@ -1005,11 +1127,22 @@ onMounted(() => {
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.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 {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 12px;
}
@ -1019,16 +1152,12 @@ onMounted(() => {
color: var(--ys-ink-3);
}
/*
* 提交区已放进 .card-foot:底栏自带 10px 16px 内边距与右对齐,
* 所以这里去掉原先的 margin-top,否则会与底栏留白叠成双份。
*/
.submit-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.pending-footer { display: block; padding: 20px; }
.submit-options { display: flex; flex-wrap: wrap; gap: 0 20px; }
.submit-options .el-form-item { flex: 0 1 auto; min-width: 0; margin-bottom: 16px; }
.submit-options .submit-remark { flex: 1 1 280px; }
.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); }
.submit-commit > span { color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
.edit-name {
font-size: 15px;
@ -1073,14 +1202,11 @@ onMounted(() => {
.drawer-block {
margin-bottom: 24px;
}
.drawer-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.drawer-section-title { margin: 0 0 12px; color: var(--ys-ink); font-size: 15px; font-weight: 600; }
.drawer-section-note { margin: 0 0 20px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
.compare-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.compare-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; margin-bottom: 8px; }
.preview-flags { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.drawer-title {
font-size: 13px;
@ -1097,6 +1223,7 @@ onMounted(() => {
.batch-item {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: baseline;
font-size: 12.5px;
@ -1125,6 +1252,7 @@ onMounted(() => {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 6px 0;
font-size: 13px;
color: var(--ys-ink-2);
@ -1144,12 +1272,14 @@ onMounted(() => {
.pr-row b {
color: var(--ys-ink);
font-weight: 600;
text-align: right;
overflow-wrap: anywhere;
}
.pr-note {
margin-top: 10px;
font-size: 12px;
color: var(--ys-warn);
color: #895d12;
background: #fbf4e4;
border-radius: 6px;
padding: 8px 12px;
@ -1165,4 +1295,36 @@ onMounted(() => {
line-height: 2;
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>

View File

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

View File

@ -540,4 +540,12 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
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>

View File

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