fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
This commit is contained in:
parent
35fea3802f
commit
b3de35c2ec
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,5 +1,7 @@
|
|||||||
# AI辅助目录(整个目录忽略)
|
# AI辅助目录(整个目录忽略)
|
||||||
.ai/
|
.ai/
|
||||||
|
.agents/
|
||||||
|
/AGENTS.md
|
||||||
|
|
||||||
# 依赖
|
# 依赖
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -53,6 +55,7 @@ frontend/e2e/**/*.png
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
*.cache
|
*.cache
|
||||||
|
.tmp-processes*.txt
|
||||||
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
|
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
|
||||||
~$*
|
~$*
|
||||||
|
|
||||||
@ -67,6 +70,12 @@ backend/uploads/
|
|||||||
db/backups/
|
db/backups/
|
||||||
# 客户发来的原始账套备份压缩包。
|
# 客户发来的原始账套备份压缩包。
|
||||||
/db/*.zip
|
/db/*.zip
|
||||||
|
# 本地恢复与连通性排查脚本/结果,不作为业务交付物。
|
||||||
|
/db/restore_*.ps1
|
||||||
|
/db/restore_*.sql
|
||||||
|
/db/restore_result.txt
|
||||||
|
/db/probe*.ps1
|
||||||
|
/backend/probe_*_status.py
|
||||||
|
|
||||||
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库
|
||||||
docs/**/*.rar
|
docs/**/*.rar
|
||||||
@ -82,3 +91,4 @@ backend/.venv-workbench/
|
|||||||
|
|
||||||
# 本地浏览器验收自动生成的快照
|
# 本地浏览器验收自动生成的快照
|
||||||
/.playwright-mcp/
|
/.playwright-mcp/
|
||||||
|
/phase2-evidence-*/
|
||||||
|
|||||||
@ -43,6 +43,8 @@ T_PLUS_USER=
|
|||||||
T_PLUS_PASSWORD=
|
T_PLUS_PASSWORD=
|
||||||
# 仅用于已确认的开发测试库,默认禁止使用 sa。
|
# 仅用于已确认的开发测试库,默认禁止使用 sa。
|
||||||
T_PLUS_ALLOW_DEV_SA=false
|
T_PLUS_ALLOW_DEV_SA=false
|
||||||
|
# 仅在客户确认只能使用管理员账号时显式开启。
|
||||||
|
T_PLUS_ALLOW_ADMIN_ACCOUNT=false
|
||||||
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
|
# 可显式指定项目外的只读连接 JSON;不从页面接收该路径。
|
||||||
T_PLUS_CONNECTION_FILE=
|
T_PLUS_CONNECTION_FILE=
|
||||||
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
|
# 必须填写在客户 SQL Server 2008 R2 上验证过的驱动名称。
|
||||||
@ -70,5 +72,7 @@ T_PLUS_SCHEDULE_WEEKDAY=0
|
|||||||
T_PLUS_SCHEDULE_TIME=02:00
|
T_PLUS_SCHEDULE_TIME=02:00
|
||||||
# 托管启动器自动设置;发现标记后停止排队和领取新任务
|
# 托管启动器自动设置;发现标记后停止排队和领取新任务
|
||||||
T_PLUS_STOP_FILE=
|
T_PLUS_STOP_FILE=
|
||||||
|
# 仅允许本机 HTTP 健康检查地址;API 更换监听端口时同步修改。
|
||||||
|
T_PLUS_SUPERVISOR_HEALTH_URL=http://127.0.0.1:8000/api/health
|
||||||
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
|
# 可选上传文件根目录;留空使用 backend/uploads,隔离测试由运行器独立指定。
|
||||||
UPLOAD_DIR=
|
UPLOAD_DIR=
|
||||||
|
|||||||
@ -43,12 +43,18 @@ class CalcTemplateOptionOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class FormulaOptionOut(BaseModel):
|
class FormulaOptionOut(BaseModel):
|
||||||
"""公式骨架下拉项(新增模板时选择,受控集合)。"""
|
"""公式骨架下拉项(新增模板时选择,受控集合)。
|
||||||
|
|
||||||
|
role_labels / role_hints 把英文角色键翻译成业务可读的中文标签与提示,
|
||||||
|
模板表单据此渲染每个参数槽位的标签与 placeholder。
|
||||||
|
"""
|
||||||
|
|
||||||
code: str
|
code: str
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
roles: list[str]
|
roles: list[str]
|
||||||
|
role_labels: dict[str, str] = Field(default_factory=dict)
|
||||||
|
role_hints: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class CalcTemplateCreateIn(BaseModel):
|
class CalcTemplateCreateIn(BaseModel):
|
||||||
|
|||||||
@ -45,7 +45,6 @@ from app.schemas.calc import CalcReceiptOut
|
|||||||
from app.services import audit_service, param_service, personnel_service
|
from app.services import audit_service, param_service, personnel_service
|
||||||
from app.services.audit_service import AuditRecord, RequestMeta
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
from app.services.formula import (
|
from app.services.formula import (
|
||||||
apply_barter,
|
|
||||||
clamp_weight_r,
|
clamp_weight_r,
|
||||||
converted_profit,
|
converted_profit,
|
||||||
payment_days,
|
payment_days,
|
||||||
@ -366,12 +365,10 @@ def _compose_amounts(
|
|||||||
performance = (
|
performance = (
|
||||||
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
|
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
|
||||||
)
|
)
|
||||||
barter_applied = receipt.is_barter == 1
|
barter_applied = False
|
||||||
|
# 以物抵顶口径(2026-11-03 决策):抵顶回款线下核算绩效,系统不做自动折算。
|
||||||
|
# 原回款的 is_barter 标记保留;barter_applied=False 表示没有执行折算。
|
||||||
base_amount = performance
|
base_amount = performance
|
||||||
if barter_applied:
|
|
||||||
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
|
|
||||||
base_amount = apply_barter(performance, discount)
|
|
||||||
|
|
||||||
received_total = business.total_received_amount or Decimal("0")
|
received_total = business.total_received_amount or Decimal("0")
|
||||||
start_ratio = parse_fraction(
|
start_ratio = parse_fraction(
|
||||||
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
|
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
|
||||||
|
|||||||
@ -75,7 +75,14 @@ def list_templates(session) -> PageResult[CalcTemplateOut]:
|
|||||||
def list_formula_options() -> list[dict]:
|
def list_formula_options() -> list[dict]:
|
||||||
"""骨架下拉(受控集合)。"""
|
"""骨架下拉(受控集合)。"""
|
||||||
return [
|
return [
|
||||||
{"code": spec.code, "name": spec.name, "description": spec.description, "roles": list(spec.roles)}
|
{
|
||||||
|
"code": spec.code,
|
||||||
|
"name": spec.name,
|
||||||
|
"description": spec.description,
|
||||||
|
"roles": list(spec.roles),
|
||||||
|
"role_labels": dict(spec.role_labels),
|
||||||
|
"role_hints": dict(spec.role_hints),
|
||||||
|
}
|
||||||
for spec in FORMULAS.values()
|
for spec in FORMULAS.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,17 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FormulaSpec:
|
class FormulaSpec:
|
||||||
"""公式骨架元数据:参数角色 → 模板参数映射键。"""
|
"""公式骨架元数据:参数角色 → 模板参数映射键。
|
||||||
|
|
||||||
|
role_labels 把英文角色键翻译成业务可读的中文标签(模板表单展示),
|
||||||
|
role_hints 进一步解释该槽位承载的口径(placeholder 提示)。
|
||||||
|
"""
|
||||||
|
|
||||||
code: str
|
code: str
|
||||||
name: str
|
name: str
|
||||||
roles: tuple[str, ...]
|
roles: tuple[str, ...]
|
||||||
|
role_labels: dict[str, str]
|
||||||
|
role_hints: dict[str, str]
|
||||||
description: str
|
description: str
|
||||||
|
|
||||||
|
|
||||||
@ -26,30 +32,40 @@ FORMULAS: dict[str, FormulaSpec] = {
|
|||||||
code="linear_decay",
|
code="linear_decay",
|
||||||
name="天数线性递减",
|
name="天数线性递减",
|
||||||
roles=("start", "slope", "floor", "boundary"),
|
roles=("start", "slope", "floor", "boundary"),
|
||||||
|
role_labels={"start": "起始比例", "slope": "日递减斜率", "floor": "下限比例", "boundary": "递减截止天数"},
|
||||||
|
role_hints={"start": "回款第 1 天适用的计提比例", "slope": "每延后一天递减的幅度", "floor": "超过截止天数后固定使用的比例", "boundary": "按日递减的天数上限"},
|
||||||
description="按回款天数线性递减,超过截止天数取下限比例",
|
description="按回款天数线性递减,超过截止天数取下限比例",
|
||||||
),
|
),
|
||||||
"fixed_profit": FormulaSpec(
|
"fixed_profit": FormulaSpec(
|
||||||
code="fixed_profit",
|
code="fixed_profit",
|
||||||
name="折算毛利固定比例",
|
name="折算毛利固定比例",
|
||||||
roles=("rate",),
|
roles=("rate",),
|
||||||
|
role_labels={"rate": "计提比例"},
|
||||||
|
role_hints={"rate": "折算毛利润额乘以的固定比例"},
|
||||||
description="折算毛利润额 × 固定比例",
|
description="折算毛利润额 × 固定比例",
|
||||||
),
|
),
|
||||||
"fixed_sales": FormulaSpec(
|
"fixed_sales": FormulaSpec(
|
||||||
code="fixed_sales",
|
code="fixed_sales",
|
||||||
name="销售额固定比例",
|
name="销售额固定比例",
|
||||||
roles=("rate",),
|
roles=("rate",),
|
||||||
|
role_labels={"rate": "计提比例"},
|
||||||
|
role_hints={"rate": "本人销售额乘以的固定比例"},
|
||||||
description="本人销售额 × 固定比例",
|
description="本人销售额 × 固定比例",
|
||||||
),
|
),
|
||||||
"manager_share": FormulaSpec(
|
"manager_share": FormulaSpec(
|
||||||
code="manager_share",
|
code="manager_share",
|
||||||
name="经理团队分成",
|
name="经理团队分成",
|
||||||
roles=("team", "company"),
|
roles=("team", "company"),
|
||||||
|
role_labels={"team": "团队分成比例", "company": "全公司分成比例"},
|
||||||
|
role_hints={"team": "本团队业务员绩效乘以的比例", "company": "全公司业务员绩效乘以的比例"},
|
||||||
description="本团队绩效 × 团队比例 + 全公司绩效 × 公司比例",
|
description="本团队绩效 × 团队比例 + 全公司绩效 × 公司比例",
|
||||||
),
|
),
|
||||||
"prepay_fixed": FormulaSpec(
|
"prepay_fixed": FormulaSpec(
|
||||||
code="prepay_fixed",
|
code="prepay_fixed",
|
||||||
name="预付款固定比例",
|
name="预付款固定比例",
|
||||||
roles=("rate",),
|
roles=("rate",),
|
||||||
|
role_labels={"rate": "计提比例"},
|
||||||
|
role_hints={"rate": "预付回款额乘以的固定比例"},
|
||||||
description="预付回款额 × 固定比例",
|
description="预付回款额 × 固定比例",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,7 +175,7 @@ def confirm_receipt(session, receipt_id, payload, *, operator, meta=None) -> Rec
|
|||||||
if not receipt_confirmation.is_confirmed(receipt):
|
if not receipt_confirmation.is_confirmed(receipt):
|
||||||
receipt.confirmation_status = 1
|
receipt.confirmation_status = 1
|
||||||
receipt.confirmed_by = operator.username
|
receipt.confirmed_by = operator.username
|
||||||
receipt.confirmed_at = datetime.now()
|
receipt.confirmed_at = datetime.now().replace(microsecond=0)
|
||||||
receipt.confirmed_revision = payload.expected_revision
|
receipt.confirmed_revision = payload.expected_revision
|
||||||
out = get_detail(session, receipt_id)
|
out = get_detail(session, receipt_id)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@ -393,3 +393,15 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
- 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。
|
- 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。
|
||||||
- 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。
|
- 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。
|
||||||
- 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。
|
- 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。
|
||||||
|
|
||||||
|
|
||||||
|
## 更正单与备份恢复(2026-11-03)
|
||||||
|
|
||||||
|
105 项定点单元、37 项隔离 MySQL 集成通过。新用例覆盖正负成对核对、回款转原业务、来源防重/原子拒绝/同号实体隔离、来源变化拦截、普通抵顶不打折、备份篡改/断链/服务器更换/无更新不归档。独立 Docker 全量+增量重放逐值核对通过;说明及证据见 `docs/更正单与备份恢复交付_2026-11-03.md`。
|
||||||
|
|
||||||
|
|
||||||
|
## 真实样本闭环与历史岗位(2026-11-03)
|
||||||
|
|
||||||
|
新增 `tests/unit/test_historical_roles.py`,覆盖按月任职区间、重叠拒绝、跨月转岗、经理本人业务排除、正式台账与月结冻结岗位、客户负责人暂缺、带实际成交参照的产品导入。完整单元 961 项、隔离 MySQL 专项 19 项通过;辽宁兴晟达真实样本完成正式导入至月结、四张报表及导出,30 个不同检查点通过。证据与边界见 `docs/真实样本业务闭环验收_2026-11-03.md`。
|
||||||
|
|
||||||
|
定点复验:`python -m pytest tests/unit/test_historical_roles.py -q`。真实样本只证明本组经理本人排除场景,不能据 0 元结果认定人员整月工资为零。
|
||||||
|
|||||||
@ -390,9 +390,11 @@ def tplus_tasks(monkeypatch):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def legacy_calculation(monkeypatch):
|
def legacy_calculation(monkeypatch):
|
||||||
"""明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。"""
|
"""旧引擎回归使用关闭确认开关的口径;确认门槛由独立用例覆盖。"""
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.services import receipt_confirmation
|
||||||
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True)
|
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True)
|
||||||
|
monkeypatch.setattr(receipt_confirmation, "enabled", lambda _session: False)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@ -307,11 +307,10 @@ class TestGuards:
|
|||||||
resp = _calc(client, admin["headers"], receipt["id"])
|
resp = _calc(client, admin["headers"], receipt["id"])
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
ledger = resp.json()["data"]["ledger"]
|
ledger = resp.json()["data"]["ledger"]
|
||||||
# 成本缺失 → 毛利按 0 → 折算 = 销售额×(0−30%) = **−3000**(保守少算
|
# 旧引擎缺成本标记保留,零毛利按现行公式特例折算为零。
|
||||||
# 方向:缺成本的行宁少算不多发),missing_cost=1
|
|
||||||
assert ledger["missing_cost"] == 1
|
assert ledger["missing_cost"] == 1
|
||||||
assert float(ledger["converted_profit"]) == -3000.0
|
assert float(ledger["converted_profit"]) == 0.0
|
||||||
assert float(ledger["performance_amount"]) == pytest.approx(-29.33, abs=0.01)
|
assert float(ledger["performance_amount"]) == 0.0
|
||||||
|
|
||||||
def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None:
|
def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None:
|
||||||
items = [
|
items = [
|
||||||
|
|||||||
@ -30,8 +30,9 @@ def post(client, path, headers, data):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def formal_chain(client, admin):
|
def unconfirmed_formal_chain(client, admin, request):
|
||||||
headers = admin["headers"]
|
headers = admin["headers"]
|
||||||
|
unit_cost = getattr(request, "param", {}).get("unit_cost", "25")
|
||||||
with get_session_factory()() as s:
|
with get_session_factory()() as s:
|
||||||
roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))}
|
roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))}
|
||||||
persons = []
|
persons = []
|
||||||
@ -56,7 +57,7 @@ def formal_chain(client, admin):
|
|||||||
"product_category": "municipal_water",
|
"product_category": "municipal_water",
|
||||||
"quantity": "2",
|
"quantity": "2",
|
||||||
"sale_price": "50",
|
"sale_price": "50",
|
||||||
"guided_price": "25",
|
"guided_price": unit_cost,
|
||||||
}
|
}
|
||||||
business = post(
|
business = post(
|
||||||
client,
|
client,
|
||||||
@ -103,7 +104,7 @@ def formal_chain(client, admin):
|
|||||||
"shipment_item_id": detail["items"][0]["id"],
|
"shipment_item_id": detail["items"][0]["id"],
|
||||||
"personnel_id": owner["id"],
|
"personnel_id": owner["id"],
|
||||||
"manager_id": manager["id"],
|
"manager_id": manager["id"],
|
||||||
"unit_cost": "25",
|
"unit_cost": unit_cost,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@ -111,6 +112,15 @@ def formal_chain(client, admin):
|
|||||||
return business, receipt, setup, owner, manager
|
return business, receipt, setup, owner, manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def formal_chain(client, admin, unconfirmed_formal_chain):
|
||||||
|
business, receipt, setup, owner, manager = unconfirmed_formal_chain
|
||||||
|
confirmed = post(client, f"/api/v1/business/receipts/{receipt['id']}/confirm", admin["headers"], {
|
||||||
|
"expected_revision": receipt["confirmation_revision"],
|
||||||
|
})
|
||||||
|
return business, confirmed, setup, owner, manager
|
||||||
|
|
||||||
|
|
||||||
def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain):
|
def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain):
|
||||||
business, _, setup, owner, manager = formal_chain
|
business, _, setup, owner, manager = formal_chain
|
||||||
headers = admin["headers"]
|
headers = admin["headers"]
|
||||||
@ -321,3 +331,30 @@ def test_无正式资料时默认拒绝旧核算(client, admin, formal_chain):
|
|||||||
f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"]
|
f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"]
|
||||||
)
|
)
|
||||||
assert response.status_code == 400 and "正式核算" in response.json()["message"]
|
assert response.status_code == 400 and "正式核算" in response.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_抵顶标记不阻断正式平账且不自动打八折(client, admin, formal_chain):
|
||||||
|
from app.models import Receipt
|
||||||
|
|
||||||
|
business, cash, setup, _, _ = formal_chain
|
||||||
|
with get_session_factory()() as session:
|
||||||
|
receipt = session.get(Receipt, cash["id"])
|
||||||
|
receipt.is_barter = 1
|
||||||
|
session.commit()
|
||||||
|
post(client, f"{BASE}/businesses/{business['id']}/input", admin["headers"],
|
||||||
|
{**setup, "expected_version": 1})
|
||||||
|
rejected = client.post(f"{BASE}/businesses/{business['id']}/calculate", headers=admin["headers"],
|
||||||
|
json={"month": "2026-03"})
|
||||||
|
assert rejected.status_code == 400 and "人工确认" in rejected.json()["message"]
|
||||||
|
current = client.get(f"/api/v1/business/receipts/{cash['id']}", headers=admin["headers"])
|
||||||
|
assert current.status_code == 200, current.text
|
||||||
|
post(client, f"/api/v1/business/receipts/{cash['id']}/confirm", admin["headers"], {
|
||||||
|
"expected_revision": current.json()["data"]["confirmation_revision"],
|
||||||
|
})
|
||||||
|
result = post(client, f"{BASE}/businesses/{business['id']}/calculate", admin["headers"],
|
||||||
|
{"month": "2026-03"})
|
||||||
|
assert result["performance"] == "0.20" and result["payable"] == "0.20"
|
||||||
|
with get_session_factory()() as session:
|
||||||
|
assert session.get(Receipt, cash["id"]).is_barter == 1
|
||||||
|
assert all(session.get(PerformanceLedger, ledger_id).barter_applied == 0
|
||||||
|
for ledger_id in result["ledger_ids"])
|
||||||
|
|||||||
@ -87,10 +87,11 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
|
|||||||
class TestCatalog:
|
class TestCatalog:
|
||||||
"""参数目录:种子完整性与时点化回溯。"""
|
"""参数目录:种子完整性与时点化回溯。"""
|
||||||
|
|
||||||
def test_目录含27项及产品系数组(self, client: TestClient, admin: dict) -> None:
|
def test_目录含28项及产品系数组(self, client: TestClient, admin: dict) -> None:
|
||||||
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
|
"""目录包含回款人工确认开关和既有参数。"""
|
||||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||||
assert len(items) == 27
|
assert len(items) == 28
|
||||||
|
assert any(item["param_code"] == "receipt.confirmation.enabled" for item in items)
|
||||||
groups = {item["param_group"] for item in items}
|
groups = {item["param_group"] for item in items}
|
||||||
assert groups == {
|
assert groups == {
|
||||||
"业务员个人户",
|
"业务员个人户",
|
||||||
@ -99,6 +100,7 @@ class TestCatalog:
|
|||||||
"企业户",
|
"企业户",
|
||||||
"信息员",
|
"信息员",
|
||||||
"支付环节",
|
"支付环节",
|
||||||
|
"回款确认",
|
||||||
"产品阈值",
|
"产品阈值",
|
||||||
"月工资口径",
|
"月工资口径",
|
||||||
"审批开关",
|
"审批开关",
|
||||||
@ -355,7 +357,7 @@ class TestPreview:
|
|||||||
"salesperson.rate.floor",
|
"salesperson.rate.floor",
|
||||||
"salesperson.days.boundary",
|
"salesperson.days.boundary",
|
||||||
"product.threshold",
|
"product.threshold",
|
||||||
"barter.discount",
|
# 2026-11-03 口径:抵顶线下核算,预览不再读取 barter.discount(参数保留作回退位)。
|
||||||
"payment.start.ratio",
|
"payment.start.ratio",
|
||||||
"probation.annual.threshold",
|
"probation.annual.threshold",
|
||||||
]
|
]
|
||||||
@ -495,7 +497,7 @@ class TestPermission:
|
|||||||
session.commit()
|
session.commit()
|
||||||
resp = client.get(_PARAMS, headers=headers)
|
resp = client.get(_PARAMS, headers=headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert len(resp.json()["data"]) == 27
|
assert len(resp.json()["data"]) == 28
|
||||||
|
|
||||||
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
||||||
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
||||||
|
|||||||
@ -10,6 +10,8 @@ from app.core.db import get_session_factory
|
|||||||
from app.models import OperationLog, PayrollInput, PerformanceLedger, Receipt
|
from app.models import OperationLog, PayrollInput, PerformanceLedger, Receipt
|
||||||
from tests.integration.test_formal_payroll import (
|
from tests.integration.test_formal_payroll import (
|
||||||
post,
|
post,
|
||||||
|
)
|
||||||
|
from tests.integration.test_formal_payroll import (
|
||||||
unconfirmed_formal_chain as unconfirmed_formal_chain,
|
unconfirmed_formal_chain as unconfirmed_formal_chain,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -128,7 +130,7 @@ def test_正式核算零毛利特例但低阈值仍为负(client, admin, unconfi
|
|||||||
@pytest.mark.parametrize("role,profit,converted,performance", [
|
@pytest.mark.parametrize("role,profit,converted,performance", [
|
||||||
("salesperson", "0", "0", "0"),
|
("salesperson", "0", "0", "0"),
|
||||||
("salesperson", "10", "-20", "-0.20"),
|
("salesperson", "10", "-20", "-0.20"),
|
||||||
("info_officer", "0", "0", "0.10"),
|
("info_officer", "0", "100", "0.10"),
|
||||||
])
|
])
|
||||||
def test_试算零毛利与信息员基数保持独立(client, admin, role, profit, converted, performance):
|
def test_试算零毛利与信息员基数保持独立(client, admin, role, profit, converted, performance):
|
||||||
data = post(client, "/api/v1/system/params/preview", admin["headers"], {
|
data = post(client, "/api/v1/system/params/preview", admin["headers"], {
|
||||||
|
|||||||
@ -12,6 +12,9 @@ from app.core.config import get_settings
|
|||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.models import ParamChangeBatch, ProbationPayment
|
from app.models import ParamChangeBatch, ProbationPayment
|
||||||
from tests.integration.test_formal_payroll import formal_chain as formal_chain
|
from tests.integration.test_formal_payroll import formal_chain as formal_chain
|
||||||
|
from tests.integration.test_formal_payroll import (
|
||||||
|
unconfirmed_formal_chain as unconfirmed_formal_chain,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
|
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -24,7 +25,7 @@ MIGRATION_DIR = PROJECT_ROOT / "db" / "migration"
|
|||||||
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
|
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
|
||||||
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
|
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
|
||||||
|
|
||||||
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
|
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL | re.IGNORECASE)
|
||||||
_COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE)
|
_COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE)
|
||||||
# 这些开头的是约束定义,不是列定义
|
# 这些开头的是约束定义,不是列定义
|
||||||
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
||||||
@ -32,6 +33,7 @@ _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "IN
|
|||||||
# MODIFY 只改类型不改列名,对列集合无影响,无需处理
|
# MODIFY 只改类型不改列名,对列集合无影响,无需处理
|
||||||
_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
||||||
_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
|
||||||
|
_RENAME_TABLE = re.compile(r"RENAME TABLE\s+`?(\w+)`?\s+TO\s+`?(\w+)`?", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
||||||
@ -67,6 +69,8 @@ def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
|
|||||||
merged.setdefault(table, set()).add(column)
|
merged.setdefault(table, set()).add(column)
|
||||||
for table, column in _ALTER_DROP.findall(sql):
|
for table, column in _ALTER_DROP.findall(sql):
|
||||||
merged.setdefault(table, set()).discard(column)
|
merged.setdefault(table, set()).discard(column)
|
||||||
|
for old, new in _RENAME_TABLE.findall(sql):
|
||||||
|
merged[new] = merged.pop(old)
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
@ -83,8 +87,15 @@ def _alembic_head() -> str:
|
|||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
|
if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
|
||||||
revisions.add(found.group(1))
|
revisions.add(found.group(1))
|
||||||
if found := re.search(r"^down_revision(?:\s*:.*?)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
|
for node in ast.parse(text).body:
|
||||||
referenced.add(found.group(1))
|
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||||
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||||
|
if any(isinstance(target, ast.Name) and target.id == "down_revision" for target in targets):
|
||||||
|
value = ast.literal_eval(node.value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
referenced.add(value)
|
||||||
|
elif isinstance(value, tuple):
|
||||||
|
referenced.update(value)
|
||||||
|
|
||||||
heads = revisions - referenced
|
heads = revisions - referenced
|
||||||
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
|
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
|
||||||
@ -94,14 +105,14 @@ def _alembic_head() -> str:
|
|||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def migration_tables() -> dict[str, set[str]]:
|
def migration_tables() -> dict[str, set[str]]:
|
||||||
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
|
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
|
||||||
files = sorted(MIGRATION_DIR.glob("V*.sql"))
|
files = sorted(MIGRATION_DIR.glob("V*.sql"), key=lambda path: int(re.match(r"V(\d+)", path.name).group(1)))
|
||||||
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
|
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
|
||||||
return _migration_final_state(files)
|
return _migration_final_state(files)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def init_dev_tables() -> dict[str, set[str]]:
|
def init_dev_tables() -> dict[str, set[str]]:
|
||||||
return _parse_tables(INIT_DEV_FILE.read_text(encoding="utf-8"))
|
return _migration_final_state([INIT_DEV_FILE])
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaSync:
|
class TestSchemaSync:
|
||||||
@ -111,6 +122,16 @@ class TestSchemaSync:
|
|||||||
assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
|
assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
|
||||||
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
|
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
|
||||||
|
|
||||||
|
def test_迁移解析支持建表后增加列与重命名(self, tmp_path: Path) -> None:
|
||||||
|
migration = tmp_path / "V1__example.sql"
|
||||||
|
migration.write_text(
|
||||||
|
"CREATE TABLE old_name (\n id BIGINT\n);\n"
|
||||||
|
"ALTER TABLE old_name ADD extra VARCHAR(8);\n"
|
||||||
|
"RENAME TABLE old_name TO new_name;\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert _migration_final_state([migration]) == {"new_name": {"id", "extra"}}
|
||||||
|
|
||||||
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
||||||
assert set(init_dev_tables) == set(migration_tables), (
|
assert set(init_dev_tables) == set(migration_tables), (
|
||||||
"两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 "
|
"两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 "
|
||||||
|
|||||||
@ -32,6 +32,7 @@ from app.services.payroll_access import can_write_payroll
|
|||||||
from app.services.tplus.business_import import execute_business
|
from app.services.tplus.business_import import execute_business
|
||||||
from app.services.tplus.workbench import digest
|
from app.services.tplus.workbench import digest
|
||||||
from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview
|
from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview
|
||||||
|
from tests.unit.test_tplus_workbench import source as publish_source
|
||||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||||
|
|
||||||
|
|
||||||
@ -192,6 +193,96 @@ def test_正式导入防重与来源可追溯(payroll_case):
|
|||||||
assert record.data_json["source_facts"]
|
assert record.data_json["source_facts"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def correction_case(payroll_case, monkeypatch):
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
|
from app.models import TPlusSyncRecord
|
||||||
|
from app.schemas.tplus_workbench import CorrectionPairIn
|
||||||
|
from app.services.tplus import corrections
|
||||||
|
from app.services.tplus.snapshots import source_records
|
||||||
|
|
||||||
|
s, user, _ = payroll_case
|
||||||
|
original = source_records(s, "test_source", "shipments")[0].payload_json
|
||||||
|
original_line = source_records(s, "test_source", "shipment_items")[0].payload_json
|
||||||
|
# 收款ID与负更正发货ID相同,必须由实体前缀区分。
|
||||||
|
publish_source(s, "shipments", [original,
|
||||||
|
{**original, "id": 30, "code": "A-NEG"},
|
||||||
|
{**original, "id": 40, "code": "B-POS", "idcustomer": 88}])
|
||||||
|
publish_source(s, "shipment_items", [original_line,
|
||||||
|
{**original_line, "id": 31, "source_parent_id": 30, "taxAmount": "-100", "quantity": "-2"},
|
||||||
|
{**original_line, "id": 41, "source_parent_id": 40}])
|
||||||
|
publish_source(s, "receipts", [{"id": 30, "code": "B-CASH", "idpartner": 88,
|
||||||
|
"voucherDate": "2026-03-10", "isReceiveFlag": 1, "voucherstate": 189, "amount": "100",
|
||||||
|
"accounting_voucher_count": 1, "valid_accounting_voucher_count": 1,
|
||||||
|
"accounting_voucher_id": 123, "accounting_voucher_code": "TEST-GL"}])
|
||||||
|
monkeypatch.setattr(corrections, "sync_lock", lambda db: nullcontext())
|
||||||
|
payload = CorrectionPairIn(negative_shipment_id="30", positive_shipment_id="40",
|
||||||
|
receipt_ids=["30"], target_group_key="g", evidence="隔离转客户例单核对")
|
||||||
|
before = s.scalar(select(func.count()).select_from(TPlusSyncRecord))
|
||||||
|
return s, user, corrections, payload, before
|
||||||
|
|
||||||
|
|
||||||
|
def test_更正对贯通正式导入与FIFO_保留日期金额且重复不增加(payroll_case, correction_case):
|
||||||
|
from app.models import TPlusFact, TPlusSyncRecord
|
||||||
|
|
||||||
|
s, user, service, payload, before = correction_case
|
||||||
|
first = service.save_pair(s, payload, user.id)
|
||||||
|
again = service.save_pair(s, payload, user.id)
|
||||||
|
assert first["pair_key"] == again["pair_key"]
|
||||||
|
assert s.scalar(select(func.count()).select_from(TPlusFact).where(TPlusFact.kind == "CORRECTION")) == 3
|
||||||
|
business = imported(payroll_case)
|
||||||
|
assert business.total_sale_amount == Decimal("100")
|
||||||
|
cash = s.scalar(select(Receipt))
|
||||||
|
assert cash.source_no == "B-CASH" and cash.receipt_date == date(2026, 3, 10)
|
||||||
|
assert cash.amount == Decimal("100") and cash.business_id == business.id
|
||||||
|
assert s.scalar(select(func.count()).select_from(Shipment)) == 1
|
||||||
|
assert s.scalar(select(func.count()).select_from(TPlusSyncRecord)) == before
|
||||||
|
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
||||||
|
assert result["performance"] == "0.20" and result["payable"] == "0.20"
|
||||||
|
repeated = business_work(s, user, "BUSINESS_PREVIEW")
|
||||||
|
assert repeated.summary_json["counts"] == {"SKIP": 1}
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", ["wrong_sign", "wrong_group", "wrong_receipt", "missing_source"])
|
||||||
|
def test_无效更正对原子拒绝不留下半边事实(correction_case, change):
|
||||||
|
from app.models import TPlusFact
|
||||||
|
|
||||||
|
s, user, service, payload, _ = correction_case
|
||||||
|
updates = {"wrong_sign": {"negative_shipment_id": "40", "positive_shipment_id": "30"},
|
||||||
|
"wrong_group": {"target_group_key": "unknown"},
|
||||||
|
"wrong_receipt": {"receipt_ids": ["999"]},
|
||||||
|
"missing_source": {"positive_shipment_id": "999"}}
|
||||||
|
with pytest.raises(BadRequestError):
|
||||||
|
service.save_pair(s, payload.model_copy(update=updates[change]), user.id)
|
||||||
|
assert s.scalar(select(func.count()).select_from(TPlusFact).where(TPlusFact.kind == "CORRECTION")) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_更正对后来源变化阻止静默排除(correction_case):
|
||||||
|
from app.services.tplus.snapshots import source_records
|
||||||
|
from app.services.tplus.workbench import latest_facts
|
||||||
|
|
||||||
|
s, user, service, payload, _ = correction_case
|
||||||
|
service.save_pair(s, payload, user.id)
|
||||||
|
rows = [r.payload_json for r in source_records(s, "test_source", "shipments")]
|
||||||
|
publish_source(s, "shipments", [{**r, "code": "CHANGED"} if r["id"] == 30 else r for r in rows])
|
||||||
|
record = source_records(s, "test_source", "shipments", ids=["30"])[0]
|
||||||
|
with pytest.raises(ValueError, match="CORRECTION_RECONFIRM_REQUIRED"):
|
||||||
|
service.verified_correction(s, "test_source", latest_facts(s, "test_source"), "SHIPMENT", record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_抵顶预览显示未自动折算且不读取折扣参数():
|
||||||
|
from app.services.param_service import _preview_barter
|
||||||
|
|
||||||
|
def forbidden(_):
|
||||||
|
raise AssertionError("线下抵顶不得读取自动折扣参数")
|
||||||
|
result = _preview_barter(SimpleNamespace(payload=SimpleNamespace(barter=True), take=forbidden), Decimal("100"))
|
||||||
|
assert result.base_amount == Decimal("100")
|
||||||
|
assert result.applied is False and result.after_barter is None
|
||||||
|
|
||||||
|
|
||||||
def test_正式FIFO台账金额与逐段过程守恒(payroll_case):
|
def test_正式FIFO台账金额与逐段过程守恒(payroll_case):
|
||||||
s, user, manager = payroll_case
|
s, user, manager = payroll_case
|
||||||
business = imported(payroll_case)
|
business = imported(payroll_case)
|
||||||
|
|||||||
@ -113,6 +113,12 @@ class TestConvertedProfit:
|
|||||||
def test_毛利润额为零_折算为零_不除零(self) -> None:
|
def test_毛利润额为零_折算为零_不除零(self) -> None:
|
||||||
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
|
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
|
||||||
|
|
||||||
|
def test_有销售但零毛利润_折算及普通绩效均为零(self) -> None:
|
||||||
|
rate = profit_rate(Decimal("100") - Decimal("100"), Decimal("100"))
|
||||||
|
base = converted_profit(Decimal("100"), rate, Fraction(30))
|
||||||
|
assert base == Decimal("0")
|
||||||
|
assert performance_amount(base, Fraction(1, 100)) == Decimal("0")
|
||||||
|
|
||||||
def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
|
def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
|
||||||
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
|
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
|
||||||
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")
|
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")
|
||||||
|
|||||||
@ -17,6 +17,7 @@ EXPECTED_TABLES = {
|
|||||||
"authorization_settings", "user_permission_overrides",
|
"authorization_settings", "user_permission_overrides",
|
||||||
"tplus_automation_states",
|
"tplus_automation_states",
|
||||||
"tplus_automation_configs",
|
"tplus_automation_configs",
|
||||||
|
"tplus_runtime_controls",
|
||||||
"payroll_months", "payroll_inputs", "payroll_allocations",
|
"payroll_months", "payroll_inputs", "payroll_allocations",
|
||||||
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
|
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
|
||||||
"users",
|
"users",
|
||||||
|
|||||||
@ -207,6 +207,12 @@ class TestExcel模板:
|
|||||||
text = _sheet_text(_build("business-stats", rows))
|
text = _sheet_text(_build("business-stats", rows))
|
||||||
assert "已完成" in text
|
assert "已完成" in text
|
||||||
|
|
||||||
|
def test_业务销售额未知时导出明细和合计不显示零(self):
|
||||||
|
rows = [{"business_no": "YW001", "status": 1, "total_sale_amount": None}]
|
||||||
|
workbook = load_workbook(io.BytesIO(_build("business-stats", rows)))
|
||||||
|
values = [cell.value for row in workbook.active.iter_rows() for cell in row]
|
||||||
|
assert values.count("待核对") >= 2
|
||||||
|
|
||||||
|
|
||||||
def _member(
|
def _member(
|
||||||
personnel_id: int,
|
personnel_id: int,
|
||||||
|
|||||||
@ -4,9 +4,9 @@ from unittest.mock import Mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.api.v1.business.businesses import confirm_source_price
|
from app.api.v1.business.businesses import confirm_guidance, confirm_source_price
|
||||||
from app.core.exceptions import ForbiddenError
|
from app.core.exceptions import ForbiddenError
|
||||||
from app.schemas.business_source import SourcePriceConfirmIn
|
from app.schemas.business_source import GuidanceConfirmIn, SourcePriceConfirmIn
|
||||||
from app.services import authorization_service, business_source
|
from app.services import authorization_service, business_source
|
||||||
from app.services.authorization_policy import permission_for
|
from app.services.authorization_policy import permission_for
|
||||||
|
|
||||||
@ -29,3 +29,46 @@ def test_无核算准备权限不能确认来源单价(monkeypatch):
|
|||||||
with pytest.raises(ForbiddenError):
|
with pytest.raises(ForbiddenError):
|
||||||
confirm_source_price(session, actor, 1, 1, payload, meta)
|
confirm_source_price(session, actor, 1, 1, payload, meta)
|
||||||
confirm.assert_not_called()
|
confirm.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_指导成本登记必须同时具备核算准备和事实维护权限(monkeypatch):
|
||||||
|
session, actor, meta = Mock(), Mock(), Mock()
|
||||||
|
save = Mock(return_value={"status": "CREATED"})
|
||||||
|
permissions = []
|
||||||
|
|
||||||
|
def allow(current_session, current_actor, permission):
|
||||||
|
assert (current_session, current_actor) == (session, actor)
|
||||||
|
permissions.append(permission)
|
||||||
|
|
||||||
|
monkeypatch.setattr(authorization_service, "require_permission", allow)
|
||||||
|
monkeypatch.setattr(business_source, "confirm_guidance", save)
|
||||||
|
payload = GuidanceConfirmIn(shipment_item_id=1, amount="2.1234", evidence="核对月度指导成本")
|
||||||
|
|
||||||
|
result = confirm_guidance(session, actor, 1, payload, meta)
|
||||||
|
|
||||||
|
assert result.data == {"status": "CREATED"}
|
||||||
|
assert permissions == ["payroll:prepare", "tplus:facts"]
|
||||||
|
save.assert_called_once_with(session, 1, payload, actor, meta)
|
||||||
|
|
||||||
|
|
||||||
|
def test_缺事实维护权限不能登记指导成本(monkeypatch):
|
||||||
|
session, actor, meta = Mock(), Mock(), Mock()
|
||||||
|
save = Mock()
|
||||||
|
|
||||||
|
def deny(current_session, current_actor, permission):
|
||||||
|
if permission == "tplus:facts":
|
||||||
|
raise ForbiddenError("无权维护事实")
|
||||||
|
|
||||||
|
monkeypatch.setattr(authorization_service, "require_permission", deny)
|
||||||
|
monkeypatch.setattr(business_source, "confirm_guidance", save)
|
||||||
|
payload = GuidanceConfirmIn(shipment_item_id=1, amount="2", evidence="核对")
|
||||||
|
|
||||||
|
with pytest.raises(ForbiddenError):
|
||||||
|
confirm_guidance(session, actor, 1, payload, meta)
|
||||||
|
save.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("amount", ["abc", "-1", "1.12345", "NaN"])
|
||||||
|
def test_指导成本入参拒绝非法精度和值(amount):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
GuidanceConfirmIn(shipment_item_id=1, amount=amount, evidence="核对")
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
"""业务接入以源单据为边界;核算资料缺失不得阻断建档。"""
|
"""业务接入以源单据为边界;核算资料缺失不得阻断建档。"""
|
||||||
|
|
||||||
from contextlib import nullcontext
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from fractions import Fraction
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -17,7 +14,6 @@ from app.models import (
|
|||||||
BusinessItem,
|
BusinessItem,
|
||||||
BusinessRole,
|
BusinessRole,
|
||||||
Customer,
|
Customer,
|
||||||
OwnershipChangeLog,
|
|
||||||
PayrollInput,
|
PayrollInput,
|
||||||
Personnel,
|
Personnel,
|
||||||
Product,
|
Product,
|
||||||
@ -26,41 +22,32 @@ from app.models import (
|
|||||||
ShipmentItem,
|
ShipmentItem,
|
||||||
TPlusBusinessDocument,
|
TPlusBusinessDocument,
|
||||||
TPlusBusinessSyncConfig,
|
TPlusBusinessSyncConfig,
|
||||||
TPlusSourceHead,
|
|
||||||
TPlusSyncBatch,
|
TPlusSyncBatch,
|
||||||
TPlusWorkRun,
|
TPlusWorkRun,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.repositories import customer_repository, product_repository
|
from app.repositories import customer_repository, product_repository
|
||||||
from app.schemas.business_source import (
|
from app.schemas.business_source import (
|
||||||
SalesRelationConfirmIn,
|
|
||||||
SalesRelationReleaseIn,
|
|
||||||
SourceMaterialsIn,
|
SourceMaterialsIn,
|
||||||
SourcePriceConfirmIn,
|
|
||||||
)
|
)
|
||||||
from app.schemas.category_batch import (
|
from app.schemas.category_batch import (
|
||||||
CustomerCategoryBatch,
|
CustomerCategoryBatch,
|
||||||
CustomerOwnershipBatch,
|
|
||||||
ProductCategoryBatch,
|
ProductCategoryBatch,
|
||||||
)
|
)
|
||||||
from app.schemas.customer import CustomerQuery
|
from app.schemas.customer import CustomerQuery
|
||||||
from app.schemas.payroll import PayrollSetupIn
|
from app.schemas.payroll import PayrollSetupIn
|
||||||
from app.schemas.product import ProductQuery
|
from app.schemas.product import ProductQuery
|
||||||
from app.schemas.report import BusinessStatsQuery
|
|
||||||
from app.services import (
|
from app.services import (
|
||||||
business_service,
|
business_service,
|
||||||
business_source,
|
business_source,
|
||||||
category_batch,
|
category_batch,
|
||||||
customer_service,
|
|
||||||
payroll_months,
|
payroll_months,
|
||||||
receipt_confirmation,
|
receipt_confirmation,
|
||||||
report_service,
|
|
||||||
)
|
)
|
||||||
from app.services.business_source import ensure_source_editable
|
from app.services.tplus import business_sync, business_sync_control
|
||||||
from app.services.tplus import business_sync, business_sync_control, sales_relations
|
|
||||||
from app.services.tplus.snapshots import source_manifest
|
from app.services.tplus.snapshots import source_manifest
|
||||||
from app.services.tplus.workbench import WorkbenchError, digest, execute_next
|
from app.services.tplus.workbench import WorkbenchError, digest, execute_next
|
||||||
from tests.unit.test_tplus_workbench import fact, source
|
from tests.unit.test_tplus_workbench import source
|
||||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@ -54,7 +54,7 @@ Windows 本地开发任务目录为 `\PayrollManagementSystem-Dev\`,新增三
|
|||||||
| `T_PLUS_CONNECT_TIMEOUT/QUERY_TIMEOUT` | 默认 10/120 秒;查询超时范围 1–600 秒 |
|
| `T_PLUS_CONNECT_TIMEOUT/QUERY_TIMEOUT` | 默认 10/120 秒;查询超时范围 1–600 秒 |
|
||||||
| `T_PLUS_QUERY_DIR` | 留空使用仓库 `db/queries/tplus/`;部署时须交付查询文件 |
|
| `T_PLUS_QUERY_DIR` | 留空使用仓库 `db/queries/tplus/`;部署时须交付查询文件 |
|
||||||
|
|
||||||
连接文件只接受 `host`、`port`(整数)、`database`、`user`、`password`、`driver` 六个键,最大 16 KB;拒绝 `sa` 和额外配置项。文件覆盖源库连接字段,TLS 和超时仍取应用配置。连接文件、真实密码与完整连接串不入 Git、不贴到确认单或报告。
|
连接文件只接受 `host`、`port`(整数)、`database`、`user`、`password`、`driver` 六个键,最大 16 KB,拒绝额外配置项。默认拒绝 `sa`;客户确认只能使用管理员账号时,须在应用环境中显式设置 `T_PLUS_ALLOW_ADMIN_ACCOUNT=true`,不能在连接文件内设置。文件覆盖源库连接字段,TLS 和超时仍取应用配置。连接文件、真实密码与完整连接串不入 Git、不贴到确认单或报告。
|
||||||
|
|
||||||
默认加密可能使旧驱动或未配置 TLS 的实例连接失败。先核对驱动与服务端能力;确需临时回退时,仅在隔离开发环境显式设置 `T_PLUS_ENCRYPT=false`,记录原因、范围和复测结果。此次变更只更新代码默认值与模板,既有私有配置不会自动被覆盖。真实 10.50.1600 的 TLS 兼容仍待实测。
|
默认加密可能使旧驱动或未配置 TLS 的实例连接失败。先核对驱动与服务端能力;确需临时回退时,仅在隔离开发环境显式设置 `T_PLUS_ENCRYPT=false`,记录原因、范围和复测结果。此次变更只更新代码默认值与模板,既有私有配置不会自动被覆盖。真实 10.50.1600 的 TLS 兼容仍待实测。
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# 辽宁兴晟达物资有限公司 2026 年 7 月实际样本核对
|
# 辽宁兴晟达物资有限公司 2026 年 7 月实际样本核对
|
||||||
|
|
||||||
|
> 2026-11-03 后续:已修复历史岗位衔接及真实产品导入阻塞,并使用本组真实数据在隔离 MySQL 完成正式导入、核算、月结、报表及导出。结果见[真实样本业务闭环验收](真实样本业务闭环验收_2026-11-03.md)。下文保留 11 月 2 日只读核对时的阶段记录;本次未向原开发库生成工资。
|
||||||
|
|
||||||
记录日期:2026-11-02。来源:本次会话中用户转述的客户回复及本地已发布 T+ 源快照。当前阶段:单据、FIFO、参数及经理本人排除分支的只读核对完成;未执行正式导入或工资生成,也未将本组结果代作整月验收。
|
记录日期:2026-11-02。来源:本次会话中用户转述的客户回复及本地已发布 T+ 源快照。当前阶段:单据、FIFO、参数及经理本人排除分支的只读核对完成;未执行正式导入或工资生成,也未将本组结果代作整月验收。
|
||||||
|
|
||||||
## 客户已提供的资料
|
## 客户已提供的资料
|
||||||
|
|||||||
@ -1,5 +1,43 @@
|
|||||||
# 前端
|
# 前端
|
||||||
|
|
||||||
|
## 工作台、同步页与进度动效(2026-11-03)
|
||||||
|
|
||||||
|
工作台与 T+ 数据同步页沿用应用内容区全宽,桌面保持 22px 常规留白,与用户管理等页面对齐。工作台改为连续指标区,收紧待办与风险空态,采集状态集中展示,图表缺失月份保留空缺。同步页整理计划、记录、资料导入与关联核验,核验明细与单据分布分开呈现。
|
||||||
|
|
||||||
|
进度区分排队、读取或核验、处理结束三个阶段。已知总量按实际百分比以 `scaleX` 过渡;未知总量的轨道保持静止,仅执行指示点淡入淡出。核验没有数量时显示“核验中”。完成、异常或状态更新中断后停止执行动画,减少动态效果模式下停用动画和过渡;任务详情继续只显示所选任务的依据,不重复大进度条。
|
||||||
|
|
||||||
|
从项目根执行 `node frontend/e2e/operations_pages.mjs`。本轮 8 组合成 API 回归通过,覆盖两页 320 至 2560px 十二档宽度、68 组页面与弹层布局、七种任务状态、动态独立分页、关联核验、四类工作区、创建范围、计划开关、只读限制及失联恢复。实测比例由 40% 更新至 70%,执行点透明度变化且在减少动态效果时停用;页面错误和布局溢出均为 0,类型检查及生产构建通过。
|
||||||
|
|
||||||
|
证据在 `.ai/operations-pages-20261103/`,设计约定见 `docs/design/operations/DESIGN.md`。接口全部拦截为合成响应,未触发真实采集、自动计划设置、资料导入或工资生成。可用 `OPS_UI_URL`、`OPS_UI_OUTPUT` 和 `PLAYWRIGHT_MODULE` 调整验收环境。
|
||||||
|
|
||||||
|
## 计提参数整页打磨(2026-11-03)
|
||||||
|
|
||||||
|
页面按产品系数、计提规则、待提交变更组织,版本管理、导出记录和计算预览入口放到页头。系数表按连续的产品类别与户型合并重复单元格,精确分数仍直接展示;工具栏与表格保持 16px 间距。
|
||||||
|
|
||||||
|
规则支持按名称、编码、说明或分组搜索,以及全部展开/收起。窄屏纵向展示规则详情,待提交变更直接呈现原值和新值;版本、历史与试算抽屉提供独立关闭区,试算操作固定在底部。规则与系数加载失败时分别提示并支持重试,读取和修改权限沿用现有约定。
|
||||||
|
|
||||||
|
从项目根执行 `node frontend/e2e/params_page.mjs`。合成 API 回归通过,覆盖 320 至 2560px 九档宽度、21 组页面与弹层布局检查,以及搜索、暂存不写入、版本对比、试算、中文导入预览、故障恢复和只读限制。页面错误 0,类型检查及生产构建通过。截图与结果位于 `.ai/params-page-20261103/`;未写真实参数或工资。
|
||||||
|
|
||||||
|
## 全站界面打磨(2026-11-03)
|
||||||
|
|
||||||
|
沿用灰白底与靛蓝重点色,统一页面标题、说明、筛选区、表单分组、状态对比度和键盘焦点。产品、人员、客户、业务和用户档案按内容分组;弹窗明确区分标题、可滚动正文和固定操作区,常用编辑弹窗提交中禁止关闭。
|
||||||
|
|
||||||
|
表格补齐长字段提示、金额与数量右对齐,修正操作列宽度。窄屏筛选控件铺满行,分页的前后按钮与页码保持同行,表格操作列随表格横向滚动;日期范围双月改为纵向排列并在视口内滚动。14 个列表页接入持续错误提示和重新加载。工作台手机指标采用两列,月结主流程位于年度结清之前,参数目录保留可读列宽,四种打印预览支持独立横向查看。
|
||||||
|
|
||||||
|
登录页支持验证码加载失败后重试;无权限页面重新检查成功后跳转可访问页面。产品与客户的唯一组合条件保留为字段下方说明,金额仍按接口字符串展示。
|
||||||
|
|
||||||
|
回归命令从项目根执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node frontend/e2e/ui_polish.mjs
|
||||||
|
node frontend/e2e/ledger-dialog-regression.mjs
|
||||||
|
node frontend/e2e/authorization_roles.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
`ui_polish.mjs` 使用合成 API,覆盖 22 个主页面的 1440/768/390/320px 布局、档案与专项弹层、产品提取、四类打印预览、列表失败重试、授权刷新和验证码故障恢复。可通过 `UI_POLISH_URL`、`UI_POLISH_OUTPUT`、`PLAYWRIGHT_MODULE` 指定前端地址、证据目录与浏览器依赖。本轮三组浏览器回归通过,页面脚本错误和检测到的横向溢出均为 0;类型检查、生产构建与 Impeccable 静态扫描通过。证据在 `.ai/ui-polish-20261103/`。
|
||||||
|
|
||||||
|
本轮浏览器验收拦截全部业务 API,不修改真实账号、业务记录或工资,不替代真实金额联调。当前本机 Rolldown 可选依赖缺少包入口文件,验证时临时设置 `NAPI_RS_NATIVE_LIBRARY_PATH` 指向已有原生二进制,未改依赖版本或锁文件;构建仍有部分分包超过 500kB 的体积提示。
|
||||||
|
|
||||||
## 产品、人员及首页数据体验(2026-11-02)
|
## 产品、人员及首页数据体验(2026-11-02)
|
||||||
|
|
||||||
产品提取入口移到操作栏,来源空值改为明确说明;产品和人员长标签、日期控件及手机布局已修复。人员经理参照只列在职且绑定启用经理计提模板的人员,旧参考关系保留;实际绩效仍按业务分管。岗位编辑说明内置模板与参数维护的区别,提供参数入口。首页以趋势金额是否为 `null` 区分尚无台账/回款与真实零值,并说明正式导入和核算步骤。
|
产品提取入口移到操作栏,来源空值改为明确说明;产品和人员长标签、日期控件及手机布局已修复。人员经理参照只列在职且绑定启用经理计提模板的人员,旧参考关系保留;实际绩效仍按业务分管。岗位编辑说明内置模板与参数维护的区别,提供参数入口。首页以趋势金额是否为 `null` 区分尚无台账/回款与真实零值,并说明正式导入和核算步骤。
|
||||||
@ -332,3 +370,26 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
|||||||
业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。
|
业务列表增加正式核算,台账可看逐段依据和创建当月分配调整;T+页面增加正式业务导入预览/执行。会话返回can_write_payroll控制按钮,后端仍逐请求校验指定制作人。审核能力默认关闭,不展示提交审核步骤或审批单列;开启后原节点可用。
|
||||||
|
|
||||||
本轮vue-tsc与Vite构建通过;真实浏览器使用合成接口验证资料表单、封存确认请求及768宽弹窗、默认无审核入口,控制台错误0。界面检查不替代后端真实金额与客户样本验收;详情见 `docs/正式工资闭环开发记录_2026-10-30.md`。
|
本轮vue-tsc与Vite构建通过;真实浏览器使用合成接口验证资料表单、封存确认请求及768宽弹窗、默认无审核入口,控制台错误0。界面检查不替代后端真实金额与客户样本验收;详情见 `docs/正式工资闭环开发记录_2026-10-30.md`。
|
||||||
|
|
||||||
|
|
||||||
|
## 更正单成对处理(2026-11-03)
|
||||||
|
|
||||||
|
T+ 数据应用工作区的补充资料增加“更正单成对处理”。运行 `node frontend/e2e/correction-pairs.mjs`(项目根)隔离验证真实组件,合成接口不访问业务库;4 组检查覆盖必填、完整成对提交、只读和 1440/768/390 布局,脚本错误 0,类型及构建通过。后台真实链路验证另见交付记录。
|
||||||
|
|
||||||
|
|
||||||
|
## 产品提取按钮禁用原因(2026-11-03)
|
||||||
|
|
||||||
|
“确认执行提取”须在勾选明细并成功预览后启用,管理员同样遵循该流程。弹窗已增加操作步骤及按钮旁的具体原因,区分未选、未预览、处理中、已完成与真实权限不足。前端构建通过;隔离 Edge 的 5 组检查通过,覆盖管理员预览后可执行、执行后防重、权限说明及 1440/768/390 三宽度,脚本错误 0。证据 `.ai/product-extract-disabled-20261103/result.json`;仅合成接口,未修改真实产品。
|
||||||
|
|
||||||
|
## 基础导入明细展示(2026-11-03)
|
||||||
|
|
||||||
|
人员、产品、客户基础任务显示源编号、名称、处理结果及原因,产品额外显示规格和单位;金额、发货单、回款天数仅在核算类明细显示。跳过明确区分名单外记录与已导入无变化,旧任务缺少名称时由后端从原始快照补齐展示。过期导入提供“重新生成预览”,沿用原范围,不自动执行导入。
|
||||||
|
|
||||||
|
运行 `node frontend/e2e/workbench-details.mjs` 验证真实组件;8 组隔离接口检查覆盖人员/产品/客户、核算列、筛选、原范围重建、过期恢复、明细失败恢复及 1440/768/390 三宽度,结果位于 `.ai/workbench-details-browser/`。已另在实际 T+ 页面只读确认 2026-10-29 历史人员任务的姓名、工号和跳过原因。
|
||||||
|
|
||||||
|
|
||||||
|
## 历史岗位资料(2026-11-03)
|
||||||
|
|
||||||
|
T+ 数据同步 → 资料与导入 → 补充资料 → 人员名单,支持保留当前岗位并按开始、结束月份追加已确认历史岗位;起止月份均包含在内,区间重叠由后端拒绝。月份未填完整时前端阻止提交,保存沿用资料版本和权限。客户公司负责人姓名在源导入资料中可暂缺,人工新建客户的必填规则不变。
|
||||||
|
|
||||||
|
浏览器隔离组件验证通过:月份回显、完整性校验、实际提交内容、只读保护和 1440/390 宽度,页面错误 0;类型与生产构建通过。真实样本 MySQL 全链路与界面合成接口验证分别记录于 `docs/真实样本业务闭环验收_2026-11-03.md`,证据 `.ai/liaoning-full-chain-20261103/`。
|
||||||
|
|||||||
@ -123,6 +123,10 @@ export interface FormulaOption {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
/** 角色 → 中文标签(模板表单展示) */
|
||||||
|
role_labels: Record<string, string>
|
||||||
|
/** 角色 → 业务口径提示(placeholder) */
|
||||||
|
role_hints: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalcTemplateCreateParams {
|
export interface CalcTemplateCreateParams {
|
||||||
|
|||||||
@ -143,6 +143,10 @@ export function confirmMissingSourcePrice(id: number, documentId: number, data:
|
|||||||
return request({ url: `/v1/business/businesses/${id}/source-documents/${documentId}/missing-price`, method: 'put', data })
|
return request({ url: `/v1/business/businesses/${id}/source-documents/${documentId}/missing-price`, method: 'put', data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function confirmGuidance(id: number, data: { shipment_item_id: number; amount: string; evidence: string }): Promise<{ status: 'CREATED' | 'REUSED'; fact_id: number; product_id: string; month: string; unit: string; amount: string }> {
|
||||||
|
return request({ url: `/v1/business/businesses/${id}/guidance`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
export interface SourceQuantity {
|
export interface SourceQuantity {
|
||||||
product_id: number | null
|
product_id: number | null
|
||||||
product_name: string
|
product_name: string
|
||||||
|
|||||||
@ -211,15 +211,16 @@ body {
|
|||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-desc {
|
.page-desc {
|
||||||
margin: 6px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
@ -249,6 +250,7 @@ body {
|
|||||||
|
|
||||||
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
|
/* 卡片:纸白微渐变 + 半透明描边 + 双层柔影 */
|
||||||
.card {
|
.card {
|
||||||
|
min-width: 0;
|
||||||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
||||||
border: 1px solid var(--ys-line);
|
border: 1px solid var(--ys-line);
|
||||||
border-radius: var(--ys-radius);
|
border-radius: var(--ys-radius);
|
||||||
@ -257,6 +259,7 @@ body {
|
|||||||
|
|
||||||
.card-head {
|
.card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -276,6 +279,8 @@ body {
|
|||||||
|
|
||||||
.card-foot {
|
.card-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
@ -299,6 +304,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar .el-form-item {
|
.filter-bar .el-form-item {
|
||||||
|
min-width: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@ -319,10 +325,20 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-note {
|
.toolbar-note {
|
||||||
|
line-height: 1.7;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px 20px;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* 编号/代码类单元格:等宽字体,弱一档颜色 */
|
/* 编号/代码类单元格:等宽字体,弱一档颜色 */
|
||||||
.cell-code {
|
.cell-code {
|
||||||
font-family: var(--ys-font-mono);
|
font-family: var(--ys-font-mono);
|
||||||
@ -388,13 +404,13 @@ span.cell-num {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ys-pill--ok {
|
.ys-pill--ok {
|
||||||
color: var(--ys-ok);
|
color: #18764f;
|
||||||
background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2));
|
background: linear-gradient(180deg, var(--ys-ok-wash), var(--ys-ok-wash-2));
|
||||||
border-color: rgba(30, 158, 106, 0.2);
|
border-color: rgba(30, 158, 106, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ys-pill--wait {
|
.ys-pill--wait {
|
||||||
color: var(--ys-warn);
|
color: #895d12;
|
||||||
background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2));
|
background: linear-gradient(180deg, var(--ys-warn-wash), var(--ys-warn-wash-2));
|
||||||
border-color: rgba(201, 138, 27, 0.22);
|
border-color: rgba(201, 138, 27, 0.22);
|
||||||
}
|
}
|
||||||
@ -406,7 +422,7 @@ span.cell-num {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ys-pill--reject {
|
.ys-pill--reject {
|
||||||
color: var(--ys-danger);
|
color: #ac3d3d;
|
||||||
background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2));
|
background: linear-gradient(180deg, var(--ys-danger-wash), var(--ys-danger-wash-2));
|
||||||
border-color: rgba(201, 79, 79, 0.2);
|
border-color: rgba(201, 79, 79, 0.2);
|
||||||
}
|
}
|
||||||
@ -569,7 +585,7 @@ span.cell-num {
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.01em;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
background-image: linear-gradient(180deg, #fafbfc, #f5f6f9);
|
background-image: linear-gradient(180deg, #fafbfc, #f5f6f9);
|
||||||
}
|
}
|
||||||
@ -589,6 +605,39 @@ span.cell-num {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-table th.el-table__cell .cell {
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table td.is-right .cell,
|
||||||
|
.el-descriptions__content,
|
||||||
|
.el-statistic__content {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table .el-input__inner {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table .el-table__empty-block {
|
||||||
|
min-height: 144px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table .el-table__empty-text {
|
||||||
|
width: min(100%, 440px);
|
||||||
|
padding: 24px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table .el-scrollbar__bar.is-horizontal { opacity: 1; height: 8px; }
|
||||||
|
|
||||||
|
.el-alert--info.is-light { color: var(--ys-ink-2); }
|
||||||
|
.el-alert--warning.is-light { color: #895d12; }
|
||||||
|
.el-alert--success.is-light { color: #18764f; }
|
||||||
|
.el-alert--error.is-light { color: #ac3d3d; }
|
||||||
|
.el-alert .el-alert__description { color: inherit; line-height: 1.7; }
|
||||||
|
|
||||||
.el-table__body tr:hover > td.el-table__cell {
|
.el-table__body tr:hover > td.el-table__cell {
|
||||||
transition: background-color 120ms var(--ys-ease);
|
transition: background-color 120ms var(--ys-ease);
|
||||||
}
|
}
|
||||||
@ -634,7 +683,19 @@ span.cell-num {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-drawer__body {
|
.el-drawer__body {
|
||||||
|
min-height: 0;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-drawer__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--ys-line-soft);
|
||||||
|
background: var(--ys-mist);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹窗共享同一组留白;长表单在正文内滚动,操作按钮始终可见。 */
|
/* 弹窗共享同一组留白;长表单在正文内滚动,操作按钮始终可见。 */
|
||||||
@ -654,7 +715,8 @@ span.cell-num {
|
|||||||
.el-dialog__header {
|
.el-dialog__header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
padding: 20px 52px 16px 24px;
|
padding: 20px 56px 18px 24px;
|
||||||
|
border-bottom: 1px solid var(--ys-line-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog__title {
|
.el-dialog__title {
|
||||||
@ -662,6 +724,7 @@ span.cell-num {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog__headerbtn {
|
.el-dialog__headerbtn {
|
||||||
@ -673,10 +736,11 @@ span.cell-num {
|
|||||||
|
|
||||||
.el-dialog__body {
|
.el-dialog__body {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 4px 24px 20px;
|
padding: 20px 24px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
color: var(--ys-ink-2);
|
color: var(--ys-ink-2);
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog__footer {
|
.el-dialog__footer {
|
||||||
@ -703,6 +767,13 @@ span.cell-num {
|
|||||||
|
|
||||||
.el-message-box {
|
.el-message-box {
|
||||||
max-width: min(var(--el-messagebox-width, 420px), calc(100vw - 32px));
|
max-width: min(var(--el-messagebox-width, 420px), calc(100vw - 32px));
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: var(--ys-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box__message {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message-box__btns {
|
.el-message-box__btns {
|
||||||
@ -726,6 +797,72 @@ span.cell-num {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表单分组用于长档案;说明保持在控件外,输入后仍可查阅。 */
|
||||||
|
.form-section-title {
|
||||||
|
margin: 24px 0 16px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--ys-line-soft);
|
||||||
|
color: var(--ys-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__body > .el-form .el-form-item__label {
|
||||||
|
height: auto;
|
||||||
|
min-height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__body .el-form-item__content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__body .form-hint { flex-basis: 100%; }
|
||||||
|
|
||||||
|
.el-dialog__body .el-form-item .el-select,
|
||||||
|
.el-dialog__body .el-form-item .el-date-editor,
|
||||||
|
.el-drawer__body .el-form-item .el-select {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__body .el-checkbox,
|
||||||
|
.el-drawer__body .el-checkbox {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__body .el-checkbox__label,
|
||||||
|
.el-drawer__body .el-checkbox__label {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
color: var(--ys-ink);
|
||||||
|
background: var(--el-color-primary-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea { caret-color: var(--ys-indigo); }
|
||||||
|
|
||||||
|
:where(button, a, [tabindex]):focus-visible {
|
||||||
|
outline: 2px solid var(--ys-indigo);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button.is-link.el-button--warning { --el-button-text-color: #895d12; }
|
||||||
|
.el-button.is-link.el-button--success { --el-button-text-color: #18764f; }
|
||||||
|
.el-button.is-link.el-button--danger { --el-button-text-color: #ac3d3d; }
|
||||||
|
.el-tag--success { --el-tag-text-color: #18764f; }
|
||||||
|
.el-tag--warning { --el-tag-text-color: #895d12; }
|
||||||
|
.el-tag--danger { --el-tag-text-color: #ac3d3d; }
|
||||||
|
|
||||||
/* 长字段标签由本行撑开高度,窄屏切到控件上方,避免换行侵入下一行。 */
|
/* 长字段标签由本行撑开高度,窄屏切到控件上方,避免换行侵入下一行。 */
|
||||||
.record-form .el-form-item__label {
|
.record-form .el-form-item__label {
|
||||||
height: auto;
|
height: auto;
|
||||||
@ -754,7 +891,7 @@ span.cell-num {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog__body {
|
.el-dialog__body {
|
||||||
padding: 4px 16px 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog__footer {
|
.el-dialog__footer {
|
||||||
@ -764,6 +901,74 @@ span.cell-num {
|
|||||||
.el-dialog__footer > .el-button {
|
.el-dialog__footer > .el-button {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-form-item {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-form-item__label,
|
||||||
|
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item__label {
|
||||||
|
display: block;
|
||||||
|
width: auto !important;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
padding: 0 0 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-form-item__content,
|
||||||
|
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item__content {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-form-item__content > .el-input,
|
||||||
|
.filter-bar .el-form-item__content > .el-select,
|
||||||
|
.filter-bar .el-form-item__content > .el-date-editor {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar__actions { flex-wrap: wrap; width: 100%; margin-left: 0; }
|
||||||
|
.filter-bar__actions > .el-button { flex: 1 1 auto; min-height: 38px; }
|
||||||
|
.toolbar { gap: 8px; }
|
||||||
|
.toolbar-note { flex-basis: 100%; }
|
||||||
|
.card-foot { padding: 12px; }
|
||||||
|
.el-dialog__body .el-form:not(.el-form--inline) .el-form-item { display: block; }
|
||||||
|
.el-dialog__body .el-col-12 { max-width: 100%; flex: 0 0 100%; }
|
||||||
|
.el-dialog__body .el-checkbox { height: auto; min-height: 34px; }
|
||||||
|
.el-drawer__header { padding: 16px; }
|
||||||
|
.el-drawer__body { padding: 16px; }
|
||||||
|
.el-drawer__footer { padding: 12px 16px; }
|
||||||
|
|
||||||
|
.el-picker__popper.el-popper { max-width: calc(100vw - 24px); }
|
||||||
|
.el-picker__popper.el-popper:has(.el-date-range-picker) {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: auto 12px 12px !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
.el-picker__popper:has(.el-date-range-picker) .el-popper__arrow { display: none; }
|
||||||
|
.el-date-range-picker { width: min(646px, calc(100vw - 24px)); max-height: 70dvh; overflow-y: auto; }
|
||||||
|
.el-date-range-picker .el-picker-panel__body { min-width: 0; display: block; }
|
||||||
|
.el-date-range-picker__content { display: block; width: 100%; padding: 12px; }
|
||||||
|
.el-date-range-picker__content.is-left { border-right: 0; border-bottom: 1px solid var(--ys-line-soft); }
|
||||||
|
.el-date-range-picker__time-header { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.el-date-range-picker__time-header > .el-date-range-picker__editors-wrap { width: 100%; }
|
||||||
|
|
||||||
|
/* 手机依旧横向查看完整数据,操作列随表滚动,避免遮住整张表。 */
|
||||||
|
.el-table .el-table__cell.el-table-fixed-column--right,
|
||||||
|
.el-table .el-table__cell.el-table-fixed-column--left { position: static !important; }
|
||||||
|
.el-table .el-table-fixed-column--right::before,
|
||||||
|
.el-table .el-table-fixed-column--left::before { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 空状态:弱墨、留白,不喊叫 */
|
/* 空状态:弱墨、留白,不喊叫 */
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Download, EditPen, Upload } from '@element-plus/icons-vue'
|
||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
import service from '@/api/request'
|
import service from '@/api/request'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
@ -11,6 +12,7 @@ interface Catalog { as_of?:string; rows: Row[]; fingerprint: string; average_rul
|
|||||||
const user = useUserStore()
|
const user = useUserStore()
|
||||||
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` }
|
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` }
|
||||||
const asOf = ref(today()), effective = ref(today()), loading = ref(false), saving = ref(false)
|
const asOf = ref(today()), effective = ref(today()), loading = ref(false), saving = ref(false)
|
||||||
|
const failed = ref(false)
|
||||||
const data = ref<Catalog>({ rows: [], fingerprint: '', average_rule: '' })
|
const data = ref<Catalog>({ rows: [], fingerprint: '', average_rule: '' })
|
||||||
const editing = ref(false), edited = ref<Row[]>([]), pending = ref<Row[]>([]), errors = ref<{row:number;message:string}[]>([])
|
const editing = ref(false), edited = ref<Row[]>([]), pending = ref<Row[]>([]), errors = ref<{row:number;message:string}[]>([])
|
||||||
const baseDate=ref(today())
|
const baseDate=ref(today())
|
||||||
@ -19,7 +21,16 @@ const importFileName = ref('')
|
|||||||
const categories: Record<string,string> = { municipal_water:'市政水利', rail_transit:'轨道交通' }
|
const categories: Record<string,string> = { municipal_water:'市政水利', rail_transit:'轨道交通' }
|
||||||
const customers: Record<string,string> = { personal:'个人户', enterprise:'企业户', manager_personal:'经理个人户' }
|
const customers: Record<string,string> = { personal:'个人户', enterprise:'企业户', manager_personal:'经理个人户' }
|
||||||
const stages: Record<string,string> = { first:'首日', linear:'线性递减', after:'超期', fixed:'固定', excluded:'不单笔计提' }
|
const stages: Record<string,string> = { first:'首日', linear:'线性递减', after:'超期', fixed:'固定', excluded:'不单笔计提' }
|
||||||
async function load() { loading.value = true; try { data.value = await request<Catalog>({url:'/v1/system/coefficients',params:{as_of:asOf.value}}) } catch(e) { handledError(e) } finally { loading.value=false } }
|
async function load() { loading.value = true; failed.value = false; try { data.value = await request<Catalog>({url:'/v1/system/coefficients',params:{as_of:asOf.value}}) } catch(e) { failed.value = true; handledError(e) } finally { loading.value=false } }
|
||||||
|
function spanMethod({ rowIndex, columnIndex }: { rowIndex: number; columnIndex: number }): [number, number] {
|
||||||
|
if (columnIndex > 1) return [1, 1]
|
||||||
|
const rows = data.value.rows
|
||||||
|
const same = (a: Row, b: Row) => a.product_category === b.product_category && (columnIndex === 0 || a.customer_type === b.customer_type)
|
||||||
|
if (rowIndex > 0 && same(rows[rowIndex - 1]!, rows[rowIndex]!)) return [0, 0]
|
||||||
|
let count = 1
|
||||||
|
while (rowIndex + count < rows.length && same(rows[rowIndex]!, rows[rowIndex + count]!)) count++
|
||||||
|
return [count, 1]
|
||||||
|
}
|
||||||
function edit() { edited.value = data.value.rows.filter(r=>!r.readonly).map(r=>({...r})); fingerprint.value=data.value.fingerprint; baseDate.value=asOf.value; editing.value=true }
|
function edit() { edited.value = data.value.rows.filter(r=>!r.readonly).map(r=>({...r})); fingerprint.value=data.value.fingerprint; baseDate.value=asOf.value; editing.value=true }
|
||||||
async function apply(rows: Row[]) {
|
async function apply(rows: Row[]) {
|
||||||
try { await ElMessageBox.confirm(`共 ${rows.length} 项阶段规则,${effective.value} 起生效。历史工资保持原快照。`, '确认系数变更') } catch { return }
|
try { await ElMessageBox.confirm(`共 ${rows.length} 项阶段规则,${effective.value} 起生效。历史工资保持原快照。`, '确认系数变更') } catch { return }
|
||||||
@ -37,42 +48,115 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="coefficient-panel" aria-label="完整绩效系数表">
|
<section class="coefficient-panel" aria-label="完整绩效系数表">
|
||||||
<div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段配置计提系数,页面展示与正式核算使用同一版本。</p></div></div>
|
<div class="panel-head"><div><h2>产品系数表</h2><p>按产品类别、户型和回款阶段核对计提系数,正式核算使用对应生效版本。</p></div><span v-if="!loading && !failed" class="panel-count">{{ data.rows.length }} 项阶段规则</span></div>
|
||||||
<el-form inline label-position="top">
|
<div class="coefficient-toolbar" role="group" aria-label="产品系数表工具栏">
|
||||||
<el-form-item label="查询生效日期"><el-date-picker v-model="asOf" value-format="YYYY-MM-DD" @change="load" /></el-form-item>
|
<div class="coefficient-toolbar__query">
|
||||||
<el-form-item label="表格操作"><el-button :disabled="!user.can('system:params:update') || asOf!==today()" @click="edit">维护系数表</el-button><el-button :disabled="!user.can('system:params:export')" @click="download">下载 Excel</el-button></el-form-item>
|
<label for="coefficient-as-of">查询日期</label>
|
||||||
<el-form-item v-if="user.can('system:params:update')" label="Excel 导入">
|
<el-date-picker
|
||||||
<label class="file-button">
|
id="coefficient-as-of"
|
||||||
|
v-model="asOf"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="生效日期"
|
||||||
|
style="width: 160px"
|
||||||
|
aria-label="查询生效日期"
|
||||||
|
@change="load"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="coefficient-toolbar__actions" role="group" aria-label="系数表操作">
|
||||||
|
<el-button type="primary" :icon="EditPen" :disabled="loading || failed || !user.can('system:params:update') || asOf!==today()" :title="asOf!==today() ? '仅可维护当天生效的系数' : ''" @click="edit">维护系数表</el-button>
|
||||||
|
<el-button :icon="Download" :disabled="!user.can('system:params:export')" @click="download">下载 Excel</el-button>
|
||||||
|
<label v-if="user.can('system:params:update')" class="file-button">
|
||||||
<input aria-label="选择系数表 Excel" type="file" accept=".xlsx" @change="upload" />
|
<input aria-label="选择系数表 Excel" type="file" accept=".xlsx" @change="upload" />
|
||||||
<span>选择文件</span>
|
<el-icon aria-hidden="true"><Upload /></el-icon>
|
||||||
|
<span>导入 Excel</span>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
<span v-if="importFileName" class="file-name">{{ importFileName }}</span>
|
<span v-if="importFileName" class="file-name">{{ importFileName }}</span>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
<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" border>
|
<el-table v-loading="loading" :data="data.rows" :span-method="spanMethod" class="coefficient-table" border empty-text="当前日期没有可显示的产品系数">
|
||||||
<el-table-column label="产品类别" width="110"><template #default="{row}">{{categories[row.product_category]}}</template></el-table-column>
|
<el-table-column label="产品类别" width="120" class-name="coefficient-category"><template #default="{row}">{{categories[row.product_category]}}</template></el-table-column>
|
||||||
<el-table-column label="户型" width="110"><template #default="{row}">{{customers[row.customer_type]}}</template></el-table-column>
|
<el-table-column label="户型" width="120"><template #default="{row}">{{customers[row.customer_type]}}</template></el-table-column>
|
||||||
<el-table-column label="阶段" width="120"><template #default="{row}">{{stages[row.stage]}}</template></el-table-column>
|
<el-table-column label="阶段" width="120"><template #default="{row}">{{stages[row.stage]}}</template></el-table-column>
|
||||||
<el-table-column prop="value" label="给付标准/起点(‰)" min-width="165" />
|
<el-table-column prop="value" label="给付标准/起点(‰)" min-width="165" align="right" />
|
||||||
<el-table-column prop="slope" label="日斜率(‰)" min-width="125" />
|
<el-table-column prop="slope" label="日斜率(‰)" min-width="125" align="right"><template #default="{row}">{{ row.slope ?? '—' }}</template></el-table-column>
|
||||||
<el-table-column prop="boundary" label="截止天数" width="95" />
|
<el-table-column prop="boundary" label="截止天数" width="100" align="right"><template #default="{row}">{{ row.boundary ?? '—' }}</template></el-table-column>
|
||||||
<el-table-column label="系统系数" min-width="150"><template #default="{row}">{{row.system_coefficient ?? '不适用'}}</template></el-table-column>
|
<el-table-column label="系统系数" min-width="150" align="right"><template #default="{row}">{{row.system_coefficient ?? '不适用'}}</template></el-table-column>
|
||||||
<el-table-column label="360 天平均系数" min-width="180"><template #default="{row}">{{row.average_coefficient_360 ?? '不适用'}}</template></el-table-column>
|
<el-table-column label="360 天平均系数" min-width="180" align="right"><template #default="{row}">{{row.average_coefficient_360 ?? '不适用'}}</template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<p class="coefficient-note">{{ data.average_rule }}。线性阶段的系统系数按第 2 天口径展示;比例和斜率支持精确分数,导入前请先下载 Excel 核对格式。</p>
|
<p class="coefficient-note"><template v-if="data.average_rule">{{ data.average_rule }}。</template>线性阶段的系统系数按第 2 天口径展示;比例和斜率支持精确分数,导入前请先下载 Excel 核对格式。</p>
|
||||||
<el-dialog v-model="editing" title="维护绩效系数表" width="min(1050px, 96vw)">
|
<el-dialog v-model="editing" title="维护绩效系数表" width="min(1050px, 96vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving">
|
||||||
|
<p class="dialog-intro">逐项修改给付标准、日斜率和截止天数,再确认版本生效日期。</p>
|
||||||
<el-table :data="edited" border><el-table-column label="产品/户型/阶段" min-width="210"><template #default="{row}">{{categories[row.product_category]}} · {{customers[row.customer_type]}} · {{stages[row.stage]}}</template></el-table-column><el-table-column label="给付标准/起点(‰)" min-width="150"><template #default="{row}"><el-input v-model="row.value" aria-label="给付标准" /></template></el-table-column><el-table-column label="日斜率(‰)" min-width="140"><template #default="{row}"><el-input v-if="row.stage==='linear'" v-model="row.slope" aria-label="日斜率" /></template></el-table-column><el-table-column label="截止天数" min-width="130"><template #default="{row}"><el-input-number v-if="row.stage==='linear'" v-model="row.boundary" :min="1" :max="3660" :controls="false" aria-label="截止天数" /></template></el-table-column></el-table>
|
<el-table :data="edited" border><el-table-column label="产品/户型/阶段" min-width="210"><template #default="{row}">{{categories[row.product_category]}} · {{customers[row.customer_type]}} · {{stages[row.stage]}}</template></el-table-column><el-table-column label="给付标准/起点(‰)" min-width="150"><template #default="{row}"><el-input v-model="row.value" aria-label="给付标准" /></template></el-table-column><el-table-column label="日斜率(‰)" min-width="140"><template #default="{row}"><el-input v-if="row.stage==='linear'" v-model="row.slope" aria-label="日斜率" /></template></el-table-column><el-table-column label="截止天数" min-width="130"><template #default="{row}"><el-input-number v-if="row.stage==='linear'" v-model="row.boundary" :min="1" :max="3660" :controls="false" aria-label="截止天数" /></template></el-table-column></el-table>
|
||||||
<el-form label-position="top"><el-form-item label="生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" maxlength="255" /></el-form-item></el-form>
|
<el-form label-position="top" class="coefficient-version-form" :disabled="saving"><el-form-item label="生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" maxlength="255" /></el-form-item></el-form>
|
||||||
<template #footer><el-button @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template>
|
<template #footer><el-button :disabled="saving" @click="editing=false">取消</el-button><el-button type="primary" :loading="saving" @click="apply(edited)">确认保存版本</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<el-dialog v-model="importVisible" title="系数表导入预览" width="min(1000px, 96vw)"><p>工作簿基准日期:{{baseDate}}。确认后另存为所选生效日期的新版本。</p>
|
<el-dialog v-model="importVisible" title="系数表导入预览" width="min(1000px, 96vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving"><p class="dialog-intro">工作簿基准日期:{{baseDate}}。确认后另存为所选生效日期的新版本。</p>
|
||||||
<el-alert v-if="errors.length" type="error" :closable="false" title="校验未通过,整批不能写入" /><el-table v-if="errors.length" :data="errors"><el-table-column prop="row" label="Excel 行" width="100" /><el-table-column prop="message" label="问题" /></el-table>
|
<el-alert v-if="errors.length" type="error" :closable="false" title="校验未通过,整批不能写入" /><el-table v-if="errors.length" :data="errors"><el-table-column prop="row" label="Excel 行" width="100" /><el-table-column prop="message" label="问题" /></el-table>
|
||||||
<el-table :data="pending"><el-table-column v-for="key in ['product_category','customer_type','stage','value','slope','boundary']" :key="key" :prop="key" :label="({product_category:'产品类别',customer_type:'户型',stage:'阶段',value:'给付标准(‰)',slope:'日斜率',boundary:'截止天数'} as Record<string,string>)[key]" min-width="120" /></el-table>
|
<el-table :data="pending"><el-table-column label="产品类别" min-width="120"><template #default="{row}">{{ categories[row.product_category] ?? row.product_category }}</template></el-table-column><el-table-column label="户型" min-width="120"><template #default="{row}">{{ customers[row.customer_type] ?? row.customer_type }}</template></el-table-column><el-table-column label="阶段" min-width="120"><template #default="{row}">{{ stages[row.stage] ?? row.stage }}</template></el-table-column><el-table-column prop="value" label="给付标准(‰)" min-width="140" align="right" /><el-table-column prop="slope" label="日斜率" min-width="120" align="right" /><el-table-column prop="boundary" label="截止天数" min-width="120" align="right" /></el-table>
|
||||||
<el-form label-position="top"><el-form-item label="导入生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" /></el-form-item></el-form>
|
<el-form label-position="top" class="coefficient-version-form" :disabled="saving"><el-form-item label="导入生效日期"><el-date-picker v-model="effective" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="变更说明"><el-input v-model="remark" /></el-form-item></el-form>
|
||||||
<template #footer><el-button @click="importVisible=false">取消</el-button><el-button type="primary" :disabled="errors.length>0 || !pending.length" :loading="saving" @click="apply(pending)">确认导入版本</el-button></template>
|
<template #footer><el-button :disabled="saving" @click="importVisible=false">取消</el-button><el-button type="primary" :disabled="errors.length>0 || !pending.length" :loading="saving" @click="apply(pending)">确认导入版本</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.coefficient-panel{margin-block:24px;padding:20px;border:1px solid var(--el-border-color);background:var(--el-bg-color)}.panel-head{display:flex;justify-content:space-between;align-items:start;gap:16px}.panel-head h2{margin:0;font-size:18px}.panel-head p,.coefficient-note{color:var(--el-text-color-secondary);font-size:13px;line-height:1.7}.coefficient-panel .el-form{margin-top:16px}.file-button{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border:1px solid var(--ys-indigo);border-radius:6px;color:var(--ys-indigo);background:var(--ys-paper);font-size:13px;line-height:1.5;cursor:pointer;transition:background-color 120ms var(--ys-ease), color 120ms var(--ys-ease)}.file-button:hover{background:var(--ys-indigo-wash)}.file-button:focus-within{outline:2px solid var(--ys-indigo);outline-offset:2px}.file-button input{position:absolute;width:1px;height:1px;margin:-1px;clip:rect(0 0 0 0);overflow:hidden}.file-name{color:var(--ys-ink-2);font-size:12px;line-height:1.6}@media(max-width:768px){.coefficient-panel{padding:12px}.panel-head{flex-direction:column}}
|
.coefficient-panel { margin-block: 0; padding: 20px; border: 1px solid var(--ys-line); border-radius: var(--ys-radius); background: var(--ys-paper); box-shadow: var(--ys-shadow-card); }
|
||||||
|
.panel-head { display: flex; justify-content: space-between; align-items: start; gap: 16px; }
|
||||||
|
.panel-head h2 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.6; }
|
||||||
|
.panel-head p { margin: 6px 0 0; }
|
||||||
|
.panel-count { flex: none; color: var(--ys-ink-3); font-size: 12px; line-height: 1.7; font-variant-numeric: tabular-nums; }
|
||||||
|
.coefficient-table :deep(.coefficient-category) { background: #fafbfd; font-weight: 600; }
|
||||||
|
.coefficient-table :deep(.cell) { font-variant-numeric: tabular-nums; }
|
||||||
|
.coefficient-note { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid var(--ys-line-soft); }
|
||||||
|
.coefficient-error { margin-bottom: 16px; }
|
||||||
|
.dialog-intro { margin: 0 0 18px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
|
||||||
|
.coefficient-version-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 16px; margin-top: 20px; }
|
||||||
|
.coefficient-version-form :deep(.el-date-editor) { width: 100%; }
|
||||||
|
.panel-head p, .coefficient-note { color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.7; }
|
||||||
|
|
||||||
|
.coefficient-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px 24px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.coefficient-toolbar__query { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
|
.coefficient-toolbar__query > label { color: var(--ys-ink-2); font-size: 13px; white-space: nowrap; }
|
||||||
|
.coefficient-toolbar__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: auto; }
|
||||||
|
.file-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: var(--el-component-size);
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: var(--ys-radius-sm);
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
background: var(--ys-paper);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms var(--ys-ease), color 120ms var(--ys-ease), border-color 120ms var(--ys-ease);
|
||||||
|
}
|
||||||
|
.file-button:hover { color: var(--ys-indigo); border-color: var(--ys-indigo); background: var(--ys-indigo-wash); }
|
||||||
|
.file-button:focus-within { outline: 2px solid var(--ys-indigo); outline-offset: 2px; }
|
||||||
|
.file-button input { position: absolute; width: 1px; height: 1px; margin: -1px; clip: rect(0 0 0 0); overflow: hidden; }
|
||||||
|
.file-name { flex-basis: 100%; min-width: 0; color: var(--ys-ink-2); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.coefficient-panel { padding: 12px; }
|
||||||
|
.panel-head { flex-direction: column; }
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.coefficient-version-form { grid-template-columns: minmax(0, 1fr); gap: 0; }
|
||||||
|
.coefficient-toolbar__query { width: 100%; }
|
||||||
|
.coefficient-toolbar__query :deep(.el-date-editor) { flex: 1; min-width: 0; }
|
||||||
|
.coefficient-toolbar__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
|
||||||
|
.coefficient-toolbar__actions > .el-button:first-child { grid-column: 1 / -1; }
|
||||||
|
.coefficient-toolbar__actions > .el-button, .file-button { min-height: 38px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ const { topLevelMenus, groupedMenus } = useAppMenu()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-menu class="app-menu" :default-active="route.path" router>
|
<el-menu class="app-menu" :default-active="route.path" aria-label="主导航" router>
|
||||||
<el-menu-item
|
<el-menu-item
|
||||||
v-for="item in topLevelMenus"
|
v-for="item in topLevelMenus"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
|
|||||||
@ -69,6 +69,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-container class="app-layout">
|
<el-container class="app-layout">
|
||||||
|
<a class="skip-link" href="#main-content">跳到页面内容</a>
|
||||||
<el-aside class="app-aside" width="220px">
|
<el-aside class="app-aside" width="220px">
|
||||||
<div class="app-brand">
|
<div class="app-brand">
|
||||||
<span class="app-brand__name">衍水集团</span>
|
<span class="app-brand__name">衍水集团</span>
|
||||||
@ -115,7 +116,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
<el-main class="app-main">
|
<el-main id="main-content" class="app-main" tabindex="-1">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<Transition name="ys-page" mode="out-in">
|
<Transition name="ys-page" mode="out-in">
|
||||||
<div :key="route.path" class="app-page-view">
|
<div :key="route.path" class="app-page-view">
|
||||||
@ -129,6 +130,8 @@ async function handleLogout(): Promise<void> {
|
|||||||
<el-drawer
|
<el-drawer
|
||||||
v-model="drawerVisible"
|
v-model="drawerVisible"
|
||||||
class="app-drawer"
|
class="app-drawer"
|
||||||
|
title="功能导航"
|
||||||
|
aria-label="功能导航"
|
||||||
direction="ltr"
|
direction="ltr"
|
||||||
size="240px"
|
size="240px"
|
||||||
:with-header="false"
|
:with-header="false"
|
||||||
@ -148,6 +151,11 @@ async function handleLogout(): Promise<void> {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-body { min-width: 0; }
|
||||||
|
|
||||||
|
.skip-link { position: fixed; top: -80px; left: 16px; z-index: 4000; padding: 12px 18px; color: var(--ys-indigo); background: var(--ys-paper); border: 1px solid var(--ys-indigo); border-radius: var(--ys-radius-sm); }
|
||||||
|
.skip-link:focus { top: 12px; }
|
||||||
|
|
||||||
.app-aside {
|
.app-aside {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -283,6 +291,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
background-color: var(--ys-mist);
|
background-color: var(--ys-mist);
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-page-view {
|
.app-page-view {
|
||||||
@ -321,6 +330,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
.app-main { padding: 16px 12px; }
|
||||||
.app-header {
|
.app-header {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
@ -374,6 +384,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-main { scroll-behavior: auto; }
|
||||||
.app-header__menu-toggle,
|
.app-header__menu-toggle,
|
||||||
.ys-page-enter-active,
|
.ys-page-enter-active,
|
||||||
.ys-page-leave-active {
|
.ys-page-leave-active {
|
||||||
|
|||||||
@ -17,14 +17,21 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<section class="probation-panel" aria-label="试用期缓发与年度结清">
|
<section class="probation-panel" aria-label="试用期缓发与年度结清">
|
||||||
<div class="panel-head"><div><h2>试用期缓发与年度结清</h2><p>{{rule}}</p></div><el-button :disabled="!user.can('reports:personal:export')" @click="download">导出年度余额及明细</el-button></div>
|
<div class="panel-head"><div><h2>试用期缓发与年度结清</h2><p>{{rule}}</p></div><el-button :disabled="!user.can('reports:personal:export')" @click="download">导出年度余额及明细</el-button></div>
|
||||||
<el-form inline label-position="top"><el-form-item label="工资月份/结清预览"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" @change="load" /></el-form-item><el-form-item label="人员筛选"><el-input v-model="keyword" placeholder="姓名" clearable /></el-form-item><el-form-item label="原累计年度"><el-input v-model="year" placeholder="如 2026" clearable /></el-form-item><el-form-item label="查询"><el-button :loading="loading" @click="load">刷新结清预览</el-button></el-form-item></el-form>
|
<el-form class="filter-bar" label-position="top" @submit.prevent="load"><el-form-item label="工资月份/结清预览"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" :clearable="false" @change="load" /></el-form-item><el-form-item label="人员筛选"><el-input v-model="keyword" placeholder="姓名" clearable /></el-form-item><el-form-item label="原累计年度"><el-input v-model="year" placeholder="如 2026" clearable /></el-form-item><div class="filter-bar__actions"><el-button :loading="loading" native-type="submit">刷新结清预览</el-button></div></el-form>
|
||||||
<el-table v-loading="loading" :data="rows.filter(r=>(!keyword||r.personnel_name.includes(keyword))&&(!year||r.year===year))" border empty-text="该月没有试用期缓发记录">
|
<el-table v-loading="loading" :data="rows.filter(r=>(!keyword||r.personnel_name.includes(keyword))&&(!year||r.year===year))" border empty-text="该月没有试用期缓发记录">
|
||||||
<el-table-column prop="personnel_name" label="人员" width="110" /><el-table-column prop="year" label="原年度" width="90" />
|
<el-table-column prop="personnel_name" label="人员" width="110" /><el-table-column prop="year" label="原年度" width="90" />
|
||||||
<el-table-column v-for="[key,label] in [['accrued','试用期累计计提'],['eligible','符合支付条件'],['threshold','年度门槛'],['entitlement','超额权益'],['paid','已补发'],['reserved','待审核占用'],['delta','本月补发/扣回'],['remaining','剩余权益']]" :key="key" :prop="key" :label="label" min-width="140" align="right" />
|
<el-table-column v-for="[key,label] in [['accrued','试用期累计计提'],['eligible','符合支付条件'],['threshold','年度门槛'],['entitlement','超额权益'],['paid','已补发'],['reserved','待审核占用'],['delta','本月补发/扣回'],['remaining','剩余权益']]" :key="key" :prop="key" :label="label" min-width="140" align="right" />
|
||||||
<el-table-column label="依据/结清" min-width="210"><template #default="{row}"><el-button link type="primary" @click="detail=row;visible=true">台账明细</el-button><el-button link type="primary" :disabled="!user.can('adjustments:create') || row.delta==='0.00' || row.delta==='0'" @click="adjust(row)">生成当月调整</el-button></template></el-table-column>
|
<el-table-column label="依据/结清" min-width="230"><template #default="{row}"><div class="table-actions"><el-button link type="primary" @click="detail=row;visible=true">台账明细</el-button><el-button link type="primary" :disabled="!user.can('adjustments:create') || row.delta==='0.00' || row.delta==='0'" @click="adjust(row)">生成当月调整</el-button></div></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<p class="panel-note">这里只查询及预览。实际补发随月结在同一事务中固化;生成失败不占余额,已生成工资保留历史结果。</p>
|
<p class="panel-note">这里只查询及预览。实际补发随月结在同一事务中固化;生成失败不占余额,已生成工资保留历史结果。</p>
|
||||||
<el-dialog v-model="visible" :title="`${detail?.personnel_name ?? ''} · ${detail?.year ?? ''} 年缓发依据`" width="min(950px, 96vw)"><el-table :data="detail?.details ?? []"><el-table-column prop="ledger_id" label="台账 ID" width="100" /><el-table-column prop="business_no" label="业务编号" min-width="160" /><el-table-column prop="month" label="归属月" width="110" /><el-table-column prop="receipt_date" label="到账日" width="120" /><el-table-column prop="accrued" label="计提金额" /><el-table-column prop="eligible" label="应付变化" /></el-table></el-dialog>
|
<el-dialog v-model="visible" :title="`${detail?.personnel_name ?? ''} · ${detail?.year ?? ''} 年缓发依据`" width="min(950px, 96vw)"><el-table :data="detail?.details ?? []"><el-table-column prop="ledger_id" label="台账 ID" width="100" /><el-table-column prop="business_no" label="业务编号" min-width="160" show-overflow-tooltip /><el-table-column prop="month" label="归属月" width="110" /><el-table-column prop="receipt_date" label="到账日" width="120" /><el-table-column prop="accrued" label="计提金额(元)" min-width="140" align="right" /><el-table-column prop="eligible" label="应付变化(元)" min-width="140" align="right" /></el-table><template #footer><el-button @click="visible=false">关闭</el-button></template></el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>.probation-panel{margin:24px 0;padding:20px;border:1px solid var(--el-border-color);background:var(--el-bg-color)}.panel-head{display:flex;justify-content:space-between;gap:16px}.panel-head h2{font-size:18px;margin:0}.panel-head p,.panel-note{font-size:13px;color:var(--el-text-color-secondary);line-height:1.7}.el-form{margin-top:16px}@media(max-width:768px){.probation-panel{padding:12px}.panel-head{flex-direction:column}}</style>
|
<style scoped>
|
||||||
|
.probation-panel { margin: 24px 0; padding: 20px; min-width: 0; border: 1px solid var(--ys-line); border-radius: var(--ys-radius); background: var(--ys-paper); }
|
||||||
|
.panel-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; }
|
||||||
|
.panel-head h2 { font-size: 16px; margin: 0; }
|
||||||
|
.panel-head p, .panel-note { font-size: 13px; color: var(--ys-ink-2); line-height: 1.7; }
|
||||||
|
.filter-bar { margin-top: 16px; padding: 0 0 16px; border: 0; border-radius: 0; border-bottom: 1px solid var(--ys-line-soft); box-shadow: none; background: none; align-items: flex-end; }
|
||||||
|
@media (max-width: 768px) { .probation-panel { padding: 16px; } .panel-head { flex-direction: column; } .panel-head > .el-button { align-self: flex-start; } }
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
@ -7,6 +7,14 @@ import { useUserStore } from '@/store/user'
|
|||||||
interface Candidate {id:number;business_no:string;product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string}
|
interface Candidate {id:number;business_no:string;product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string}
|
||||||
interface PlanRow {product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string;action:string;reason:string|null;target_id:number|null}
|
interface PlanRow {product_name:string;spec_model:string;unit:string;product_category:string;quantity:string;sale_price:string;action:string;reason:string|null;target_id:number|null}
|
||||||
const emit=defineEmits<{done:[]}>(),user=useUserStore(),visible=ref(false),loading=ref(false),keyword=ref(''),page=ref(1),more=ref(false),rows=ref<Candidate[]>([]),selected=ref<Candidate[]>([]),result=ref<{rows:PlanRow[];fingerprint:string}|null>(null),executed=ref(false)
|
const emit=defineEmits<{done:[]}>(),user=useUserStore(),visible=ref(false),loading=ref(false),keyword=ref(''),page=ref(1),more=ref(false),rows=ref<Candidate[]>([]),selected=ref<Candidate[]>([]),result=ref<{rows:PlanRow[];fingerprint:string}|null>(null),executed=ref(false)
|
||||||
|
const confirmDisabledReason = computed(() => {
|
||||||
|
if (!user.can('base:products:create')) return user.permissionReason('base:products:create')
|
||||||
|
if (loading.value) return '正在处理,请稍候。'
|
||||||
|
if (!selected.value.length) return '请先勾选业务明细,再点击“预览所选”。'
|
||||||
|
if (!result.value) return `已选 ${selected.value.length} 条,请先点击“预览所选 ${selected.value.length} 条”,核对结果后再执行提取。`
|
||||||
|
if (executed.value) return '本次提取已完成;如需继续,请重新勾选明细并预览。'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
async function load(){loading.value=true;result.value=null;try{const data=await request<{rows:Candidate[];has_more:boolean}>({url:'/v1/base/products/extract/candidates',params:{keyword:keyword.value,page_num:page.value}});rows.value=data.rows;more.value=data.has_more}catch(e){handledError(e)}finally{loading.value=false}}
|
async function load(){loading.value=true;result.value=null;try{const data=await request<{rows:Candidate[];has_more:boolean}>({url:'/v1/base/products/extract/candidates',params:{keyword:keyword.value,page_num:page.value}});rows.value=data.rows;more.value=data.has_more}catch(e){handledError(e)}finally{loading.value=false}}
|
||||||
async function open(){visible.value=true;page.value=1;selected.value=[];await load()}
|
async function open(){visible.value=true;page.value=1;selected.value=[];await load()}
|
||||||
async function preview(){loading.value=true;try{result.value=await request({url:'/v1/base/products/extract/preview',method:'post',data:{item_ids:selected.value.map(r=>r.id)}});executed.value=false}catch(e){handledError(e)}finally{loading.value=false}}
|
async function preview(){loading.value=true;try{result.value=await request({url:'/v1/base/products/extract/preview',method:'post',data:{item_ids:selected.value.map(r=>r.id)}});executed.value=false}catch(e){handledError(e)}finally{loading.value=false}}
|
||||||
@ -14,14 +22,60 @@ async function apply(){if(!result.value)return;try{await ElMessageBox.confirm('
|
|||||||
defineExpose({open})
|
defineExpose({open})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="visible" title="从业务明细提取产品" width="min(1180px, 96vw)">
|
<el-dialog v-model="visible" title="从业务明细提取产品" width="min(1180px, 96vw)"
|
||||||
<el-form inline><el-form-item label="名称/业务编号"><el-input v-model="keyword" clearable @keyup.enter="page=1;load()" /></el-form-item><el-form-item><el-button @click="page=1;load()">查询</el-button></el-form-item></el-form>
|
:close-on-click-modal="!loading" :close-on-press-escape="!loading" :show-close="!loading">
|
||||||
<p>先在业务管理录入产品明细,或将 T+ 数据正式导入业务,再勾选未关联产品档案的明细,点击“预览所选”并确认提取。T+ 原始采集成功不会自动建产品档案。</p>
|
<p class="extract-intro">操作步骤:勾选业务明细 → 点击“预览所选” → 核对预览结果 → 确认执行提取。</p>
|
||||||
<p>同名同规格合并,数量与单价取所选最新明细;已有档案只建立关联,不覆盖人工信息。</p>
|
<el-form class="filter-bar" @submit.prevent="page=1;load()">
|
||||||
<el-table v-loading="loading" :data="rows" row-key="id" empty-text="暂无可提取明细。请先创建含未关联产品的业务,或完成 T+ 正式业务导入。" @selection-change="(values:Candidate[])=>{selected=values;result=null}"><el-table-column type="selection" width="50" /><el-table-column prop="business_no" label="业务编号" min-width="155" /><el-table-column prop="product_name" label="产品名称" min-width="150" /><el-table-column prop="spec_model" label="规格" min-width="110" /><el-table-column prop="unit" label="单位" width="80" /><el-table-column label="类别" width="110"><template #default="{row}">{{row.product_category==='municipal_water'?'市政水利':'轨道交通'}}</template></el-table-column><el-table-column prop="quantity" label="数量" width="100" /><el-table-column prop="sale_price" label="成交单价" width="120" /></el-table>
|
<el-form-item label="名称/业务编号">
|
||||||
<div class="extract-actions"><el-button :disabled="page<=1" @click="page--;load()">上一页</el-button><span>第 {{page}} 页</span><el-button :disabled="!more" @click="page++;load()">下一页</el-button><el-button :disabled="!selected.length" :loading="loading" @click="preview">预览所选 {{selected.length}} 条</el-button></div>
|
<el-input v-model="keyword" placeholder="搜索产品名称或业务编号" clearable />
|
||||||
<el-table v-if="result" :data="result.rows" border><el-table-column prop="product_name" label="产品" /><el-table-column prop="spec_model" label="规格" /><el-table-column label="处理结果" width="130"><template #default="{row}">{{({CREATE:executed?'已创建':'将创建',LINK:executed?'已关联':'关联已有',CONFLICT:'冲突待处理'} as Record<string,string>)[row.action]}}</template></el-table-column><el-table-column prop="reason" label="说明" min-width="210" /><el-table-column prop="target_id" label="产品 ID" width="100" /></el-table>
|
</el-form-item>
|
||||||
<template #footer><el-button @click="visible=false">关闭</el-button><el-button type="primary" :disabled="!result||executed||!user.can('base:products:create')" :loading="loading" @click="apply">确认执行提取</el-button></template>
|
<div class="filter-bar__actions"><el-button :loading="loading" native-type="submit">查询</el-button></div>
|
||||||
|
</el-form>
|
||||||
|
<el-table v-loading="loading" :data="rows" row-key="id"
|
||||||
|
empty-text="暂无可提取明细。请先创建含未关联产品的业务,或完成 T+ 正式业务导入。"
|
||||||
|
@selection-change="(values:Candidate[])=>{selected=values;result=null}">
|
||||||
|
<el-table-column type="selection" width="50" />
|
||||||
|
<el-table-column prop="business_no" label="业务编号" min-width="155" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="product_name" label="产品名称" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="spec_model" label="规格" min-width="110" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="unit" label="单位" width="80" />
|
||||||
|
<el-table-column label="类别" width="110"><template #default="{row}">{{row.product_category==='municipal_water'?'市政水利':'轨道交通'}}</template></el-table-column>
|
||||||
|
<el-table-column prop="quantity" label="数量" width="110" align="right" />
|
||||||
|
<el-table-column prop="sale_price" label="成交单价(元)" width="140" align="right" />
|
||||||
|
</el-table>
|
||||||
|
<div class="extract-actions">
|
||||||
|
<span class="selection-note" role="status">已选 {{selected.length}} 条</span>
|
||||||
|
<el-button :disabled="page<=1 || loading" @click="page--;load()">上一页</el-button>
|
||||||
|
<span>第 {{page}} 页</span>
|
||||||
|
<el-button :disabled="!more || loading" @click="page++;load()">下一页</el-button>
|
||||||
|
<el-button :disabled="!selected.length" :loading="loading" @click="preview">预览所选 {{selected.length}} 条</el-button>
|
||||||
|
</div>
|
||||||
|
<template v-if="result">
|
||||||
|
<h3 class="form-section-title">{{ executed ? '提取结果' : '提取预览' }}</h3>
|
||||||
|
<el-table :data="result.rows" border>
|
||||||
|
<el-table-column prop="product_name" label="产品" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="spec_model" label="规格" min-width="110" show-overflow-tooltip />
|
||||||
|
<el-table-column label="处理结果" width="130"><template #default="{row}"><el-tag :type="row.action==='CONFLICT'?'warning':'info'">{{({CREATE:executed?'已创建':'将创建',LINK:executed?'已关联':'关联已有',CONFLICT:'冲突待处理'} as Record<string,string>)[row.action]}}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column prop="reason" label="说明" min-width="210" />
|
||||||
|
<el-table-column prop="target_id" label="产品 ID" width="100" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<p class="form-hint">同名同规格的产品合并,数量和单价取所选最新明细;已有档案只建立关联,保留人工信息。T+ 原始数据需正式导入业务后才能提取。</p>
|
||||||
|
<template #footer>
|
||||||
|
<div class="extract-footer">
|
||||||
|
<span class="extract-footer__reason" role="status">{{ confirmDisabledReason || '预览已就绪,请核对处理结果后确认执行。' }}</span>
|
||||||
|
<div class="extract-footer__buttons"><el-button :disabled="loading" @click="visible=false">关闭</el-button><el-button type="primary" :disabled="!!confirmDisabledReason" :title="confirmDisabledReason" :loading="loading" @click="apply">确认执行提取</el-button></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>.extract-actions{display:flex;gap:12px;align-items:center;justify-content:flex-end;margin:16px 0}p{font-size:13px;line-height:1.7;color:var(--el-text-color-secondary)}@media(max-width:600px){.extract-actions{flex-wrap:wrap}}</style>
|
<style scoped>
|
||||||
|
.extract-intro { margin: 0 0 16px; color: var(--ys-ink-2); font-size: 13px; }
|
||||||
|
.extract-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: flex-end; margin: 16px 0; font-size: 13px; }
|
||||||
|
.selection-note { margin-right: auto; color: var(--ys-ink-2); }
|
||||||
|
.extract-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; text-align: left; }
|
||||||
|
.extract-footer__reason { flex: 1 1 260px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.6; }
|
||||||
|
.extract-footer__buttons { display: flex; flex-wrap: wrap; gap: 12px; margin-left: auto; }
|
||||||
|
.extract-footer__buttons .el-button + .el-button { margin-left: 0; }
|
||||||
|
@media (max-width: 600px) { .selection-note { flex-basis: 100%; } }
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分页条:统一各列表页的布局与页码尺寸选项。
|
* 分页条:统一各列表页的布局与页码尺寸选项。
|
||||||
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
|
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
|
||||||
@ -16,13 +18,26 @@ defineEmits<{
|
|||||||
(e: 'page-change', page: number): void
|
(e: 'page-change', page: number): void
|
||||||
(e: 'size-change', size: number): void
|
(e: 'size-change', size: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const compact = ref(false)
|
||||||
|
let query: MediaQueryList | undefined
|
||||||
|
const layout = computed(() => compact.value ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper')
|
||||||
|
function updateLayout(): void { compact.value = query?.matches ?? false }
|
||||||
|
onMounted(() => {
|
||||||
|
query = window.matchMedia('(max-width: 600px)')
|
||||||
|
updateLayout()
|
||||||
|
query.addEventListener('change', updateLayout)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => query?.removeEventListener('change', updateLayout))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
class="app-pager"
|
class="app-pager"
|
||||||
background
|
background
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
:layout="layout"
|
||||||
|
:pager-count="5"
|
||||||
|
:size="compact ? 'small' : 'default'"
|
||||||
:total="total"
|
:total="total"
|
||||||
:current-page="pageNum"
|
:current-page="pageNum"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
@ -34,7 +49,18 @@ defineEmits<{
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-pager {
|
.app-pager {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.app-pager { display: grid; grid-template-columns: auto auto auto; width: 100%; justify-content: center; gap: 8px 4px; }
|
||||||
|
.app-pager :deep(.el-pagination__total) { grid-column: 1 / -1; justify-content: center; margin: 0; }
|
||||||
|
.app-pager.el-pagination.is-background :deep(button),
|
||||||
|
.app-pager.el-pagination.is-background :deep(.el-pager li) { min-width: 28px; width: 28px; height: 36px; margin: 0; }
|
||||||
|
.app-pager :deep(.el-pager) { display: flex; gap: 2px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -547,6 +547,9 @@ function removeGrade(row: RatingGradeItem): void {
|
|||||||
|
|
||||||
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.calc-form { min-width: 0; }
|
||||||
|
.score-row { gap: 8px; justify-content: space-between; }
|
||||||
|
.score-row :deep(.el-input-number) { width: 120px; flex: none; }
|
||||||
.score-label {
|
.score-label {
|
||||||
width: auto;
|
width: auto;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { optionPage } from '@/api/metadata'
|
import { optionPage } from '@/api/metadata'
|
||||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||||
const permissionUser = usePermissionUser()
|
const permissionUser = usePermissionUser()
|
||||||
@ -47,7 +48,7 @@ const filters = reactive({
|
|||||||
business_role_id: undefined as number | undefined,
|
business_role_id: undefined as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||||
usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>(
|
usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>(
|
||||||
personnelApi.listPersonnel,
|
personnelApi.listPersonnel,
|
||||||
() => ({
|
() => ({
|
||||||
@ -403,7 +404,7 @@ onMounted(() => {
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1 class="page-title">人员管理</h1>
|
<h1 class="page-title">人员管理</h1>
|
||||||
<p class="page-desc">
|
<p class="page-desc">
|
||||||
人员岗位与适用计提参数在这里定;改岗位会改变后续回款的绩效口径,设为离职后不可再被业务与客户归属引用
|
维护人员档案、任职日期与工资标准。岗位变更影响后续回款的绩效核算,离职人员不能再分配到业务或客户。
|
||||||
</p>
|
</p>
|
||||||
<span class="waterline"></span>
|
<span class="waterline"></span>
|
||||||
</div>
|
</div>
|
||||||
@ -412,6 +413,7 @@ onMounted(() => {
|
|||||||
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||||
-->
|
-->
|
||||||
|
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||||
<el-form class="filter-bar" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="姓名">
|
<el-form-item label="姓名">
|
||||||
<el-input
|
<el-input
|
||||||
@ -456,7 +458,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:personnel:create')" :title="permissionUser.permissionReason('base:personnel:create')">新增人员</el-button>
|
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:personnel:create')" :title="permissionUser.permissionReason('base:personnel:create')">新增人员</el-button>
|
||||||
<span class="toolbar-note">辐射区域与拓展计划在行内「区域与计划」维护,保存即全量替换</span>
|
<span class="toolbar-note">在“区域与计划”维护辐射区域和拓展计划,保存后替换原有内容。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
|
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
|
||||||
@ -465,10 +467,10 @@ onMounted(() => {
|
|||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="records"
|
:data="records"
|
||||||
stripe
|
stripe
|
||||||
empty-text="还没有人员 —— 点「新增人员」建第一个,或换个关键词再查"
|
empty-text="暂无符合条件的人员,可调整筛选条件。"
|
||||||
>
|
>
|
||||||
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
|
<el-table-column prop="personnel_name" label="姓名" min-width="100" show-overflow-tooltip />
|
||||||
<el-table-column prop="role_name" label="岗位" min-width="100">
|
<el-table-column prop="role_name" label="岗位" min-width="100" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
|
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="manager_name" label="直属经理" min-width="100">
|
<el-table-column prop="manager_name" label="直属经理" min-width="100">
|
||||||
@ -499,7 +501,7 @@ onMounted(() => {
|
|||||||
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
|
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="270" fixed="right" align="center">
|
<el-table-column label="操作" width="350" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||||
@ -531,11 +533,15 @@ onMounted(() => {
|
|||||||
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
|
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
|
:close-on-click-modal="!submitting"
|
||||||
|
:close-on-press-escape="!submitting"
|
||||||
|
:show-close="!submitting"
|
||||||
:title="isEdit ? '编辑人员' : '新增人员'"
|
:title="isEdit ? '编辑人员' : '新增人员'"
|
||||||
width="640px"
|
width="640px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" class="record-form" :disabled="!permissionUser.can(isEdit ? 'base:personnel:update' : 'base:personnel:create')" :model="form" :rules="rules" label-width="132px">
|
<el-form ref="formRef" class="record-form" :disabled="!permissionUser.can(isEdit ? 'base:personnel:update' : 'base:personnel:create')" :model="form" :rules="rules" label-width="132px">
|
||||||
|
<h2 class="form-section-title">人员与岗位</h2>
|
||||||
<el-form-item label="姓名" prop="personnel_name">
|
<el-form-item label="姓名" prop="personnel_name">
|
||||||
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
|
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -575,6 +581,7 @@ onMounted(() => {
|
|||||||
<el-form-item label="联系电话" prop="phone">
|
<el-form-item label="联系电话" prop="phone">
|
||||||
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
|
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<h2 class="form-section-title">任职与试用期</h2>
|
||||||
<el-form-item label="入职日期" prop="hire_date">
|
<el-form-item label="入职日期" prop="hire_date">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.hire_date"
|
v-model="form.hire_date"
|
||||||
@ -588,20 +595,21 @@ onMounted(() => {
|
|||||||
<el-form-item label="转正生效日"><el-date-picker v-model="form.regularization_date" value-format="YYYY-MM-DD" placeholder="当日按正式人员计算" /></el-form-item>
|
<el-form-item label="转正生效日"><el-date-picker v-model="form.regularization_date" value-format="YYYY-MM-DD" placeholder="当日按正式人员计算" /></el-form-item>
|
||||||
<el-form-item label="试用期" prop="is_probation">
|
<el-form-item label="试用期" prop="is_probation">
|
||||||
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
|
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
|
||||||
<span class="switch-hint">试用期绩效照算但不发放,年累计超 18000 元部分年底一次性结算</span>
|
<p class="form-hint">试用期绩效照算但不发放,年累计超过 18000 元的部分在年底一次性结算。</p>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="离职生效日"><el-date-picker v-model="form.resignation_date" value-format="YYYY-MM-DD" placeholder="当日起不计基本工资" /></el-form-item>
|
<el-form-item label="离职生效日"><el-date-picker v-model="form.resignation_date" value-format="YYYY-MM-DD" placeholder="当日起不计基本工资" /></el-form-item>
|
||||||
|
<h2 class="form-section-title">工资资料</h2>
|
||||||
<el-form-item label="工资资料生效日"><el-date-picker v-model="form.salary_effective_date" value-format="YYYY-MM-DD" placeholder="选择生效日期" /><div class="form-hint">新增默认入职日期;编辑默认今天,历史资料保留。</div></el-form-item>
|
<el-form-item label="工资资料生效日"><el-date-picker v-model="form.salary_effective_date" value-format="YYYY-MM-DD" placeholder="选择生效日期" /><div class="form-hint">新增默认入职日期;编辑默认今天,历史资料保留。</div></el-form-item>
|
||||||
<el-form-item label="工资标准" prop="salary_standard">
|
<el-form-item label="月工资标准(元)" prop="salary_standard">
|
||||||
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
|
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-collapse v-if="salaryHistory.length"><el-collapse-item title="工资标准与任职资料生效记录" name="salary-history"><el-table :data="salaryHistory"><el-table-column prop="effective_date" label="生效日" width="115" /><el-table-column prop="salary_standard" label="工资标准" /><el-table-column prop="business_role_id" label="岗位 ID" /><el-table-column prop="regularization_date" label="转正日" width="115" /><el-table-column prop="resignation_date" label="离职日" width="115" /></el-table></el-collapse-item></el-collapse>
|
<el-collapse v-if="salaryHistory.length"><el-collapse-item title="工资标准与任职资料生效记录" name="salary-history"><el-table :data="salaryHistory"><el-table-column prop="effective_date" label="生效日" width="115" /><el-table-column prop="salary_standard" label="工资标准" align="right" /><el-table-column prop="business_role_id" label="岗位 ID" /><el-table-column prop="regularization_date" label="转正日" width="115" /><el-table-column prop="resignation_date" label="离职日" width="115" /></el-table></el-collapse-item></el-collapse>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:personnel:update' : 'base:personnel:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:personnel:update' : 'base:personnel:create'))">保存</el-button>
|
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:personnel:update' : 'base:personnel:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:personnel:update' : 'base:personnel:create'))">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||||
import * as payroll from '@/api/payroll'
|
import * as payroll from '@/api/payroll'
|
||||||
|
import * as businesses from '@/api/businesses'
|
||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
|
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
@ -18,7 +19,7 @@ const monthChecked = ref(false), monthLoading = ref(false)
|
|||||||
let monthRequest = 0
|
let monthRequest = 0
|
||||||
const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null)
|
const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null)
|
||||||
const people = ref<{id: number; personnel_name: string}[]>([])
|
const people = ref<{id: number; personnel_name: string}[]>([])
|
||||||
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean})[]>([])
|
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string; unit?: string | null; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean})[]>([])
|
||||||
const rowPage = ref(1)
|
const rowPage = ref(1)
|
||||||
const businessKey = ref('')
|
const businessKey = ref('')
|
||||||
const handovers = ref<payroll.Handover[]>([])
|
const handovers = ref<payroll.Handover[]>([])
|
||||||
@ -27,12 +28,7 @@ const handoversChanged = computed(() => JSON.stringify(handovers.value) !== save
|
|||||||
const visibleRows = computed(() => rows.value.slice((rowPage.value - 1) * 50, rowPage.value * 50))
|
const visibleRows = computed(() => rows.value.slice((rowPage.value - 1) * 50, rowPage.value * 50))
|
||||||
|
|
||||||
type PayrollMetadata = { business_key?: string | null; personnel: typeof people.value; personnel_total?: number; shipments_total?: number;
|
type PayrollMetadata = { business_key?: string | null; personnel: typeof people.value; personnel_total?: number; shipments_total?: number;
|
||||||
shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_unit_cost?: string | null; guidance_line_total?: string | null}[] }
|
shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string; unit?: string | null; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_unit_cost?: string | null; guidance_line_total?: string | null}[] }
|
||||||
|
|
||||||
function guidanceHint(row: {guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean}): string {
|
|
||||||
const labels: Record<string, string> = {FINALIZED_UNIT: row.frozen ? '指导价已封存,历史成本以原版本为准' : '使用已封存的月度指导单价', FINALIZED_LINE_TOTAL: `已封存明细总成本:${row.guidance_line_total ?? '待核对'} 元`, AMBIGUOUS: '存在多条指导成本,请先核对', UNIT_MISMATCH: '指导成本单位不一致,请先核对', UNFINALIZED: '指导成本尚未封存'}
|
|
||||||
return labels[row.guidance_status ?? ''] ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMetadata(): Promise<PayrollMetadata> {
|
async function loadMetadata(): Promise<PayrollMetadata> {
|
||||||
const pageSize = 200
|
const pageSize = 200
|
||||||
@ -62,7 +58,7 @@ async function load(): Promise<void> {
|
|||||||
const data = await loadMetadata()
|
const data = await loadMetadata()
|
||||||
people.value = data.personnel
|
people.value = data.personnel
|
||||||
businessKey.value = data.business_key ?? ''
|
businessKey.value = data.business_key ?? ''
|
||||||
rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, source_personnel_name: s.source_personnel_name, matched_personnel_id: s.matched_personnel_id, personnel_id: s.matched_personnel_id ?? undefined, guidance_status: s.guidance_status, guidance_line_total: s.guidance_line_total, unit_cost: s.guidance_unit_cost ?? '', freight_amount: ''}))
|
rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, unit: s.unit, source_personnel_name: s.source_personnel_name, matched_personnel_id: s.matched_personnel_id, guidance_status: s.guidance_status, guidance_line_total: s.guidance_line_total, personnel_id: s.matched_personnel_id ?? undefined, unit_cost: s.guidance_unit_cost ?? '', freight_amount: ''}))
|
||||||
rowPage.value = 1
|
rowPage.value = 1
|
||||||
if (input.value) {
|
if (input.value) {
|
||||||
const data = input.value.data as {group?: {opening_balance?: string}; facts?: {handovers?: payroll.Handover[]}; shipments?: {line_id: string; personnel_id: string; manager_id: string | null; cost_total: string; quantity: string; unit_cost?: string; freight_amount?: string}[]; evidence?: string}
|
const data = input.value.data as {group?: {opening_balance?: string}; facts?: {handovers?: payroll.Handover[]}; shipments?: {line_id: string; personnel_id: string; manager_id: string | null; cost_total: string; quantity: string; unit_cost?: string; freight_amount?: string}[]; evidence?: string}
|
||||||
@ -112,6 +108,22 @@ async function save(): Promise<void> {
|
|||||||
ElMessage.success('核算资料已保存为新版本')
|
ElMessage.success('核算资料已保存为新版本')
|
||||||
} catch (e) { handledError(e) } finally { busy.value = false }
|
} catch (e) { handledError(e) } finally { busy.value = false }
|
||||||
}
|
}
|
||||||
|
async function registerGuidance(row: typeof rows.value[number]): Promise<void> {
|
||||||
|
if (!row.unit_cost || !evidence.value.trim()) {
|
||||||
|
ElMessage.warning('请先填写单位指导成本和核对依据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await businesses.confirmGuidance(props.businessId, {
|
||||||
|
shipment_item_id: row.shipment_item_id,
|
||||||
|
amount: row.unit_cost,
|
||||||
|
evidence: evidence.value.trim(),
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
ElMessage.success('指导成本已登记并封存')
|
||||||
|
} catch (e) { handledError(e) } finally { busy.value = false }
|
||||||
|
}
|
||||||
function addHandover(): void {
|
function addHandover(): void {
|
||||||
handovers.value.push({business_key: businessKey.value, mode: 'RESIGNATION', from_personnel_id: '', to_personnel_id: '', effective_date: '', shipping_ownership_confirmed: false})
|
handovers.value.push({business_key: businessKey.value, mode: 'RESIGNATION', from_personnel_id: '', to_personnel_id: '', effective_date: '', shipping_ownership_confirmed: false})
|
||||||
}
|
}
|
||||||
@ -146,8 +158,9 @@ watch(open, value => { monthRequest++; if (value) { month.value = ''; opening.va
|
|||||||
<el-table-column v-if="sourceManaged" label="来源经办人" min-width="140"><template #default="{row}">{{ row.source_personnel_name || '待确认' }}{{ row.source_personnel_name && !row.matched_personnel_id ? '(待匹配)' : '' }}</template></el-table-column>
|
<el-table-column v-if="sourceManaged" label="来源经办人" min-width="140"><template #default="{row}">{{ row.source_personnel_name || '待确认' }}{{ row.source_personnel_name && !row.matched_personnel_id ? '(待匹配)' : '' }}</template></el-table-column>
|
||||||
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||||
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :clearable="sourceManaged" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :clearable="sourceManaged" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||||
<el-table-column label="含税单位指导成本" min-width="170"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen || row.guidance_status === 'FINALIZED_UNIT'" aria-label="含税单位指导成本" /><small v-if="guidanceHint(row)">{{ guidanceHint(row) }}</small></template></el-table-column>
|
<el-table-column label="含税单位指导成本" min-width="170"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen || row.guidance_status === 'FINALIZED_UNIT'" aria-label="含税单位指导成本" /></template></el-table-column>
|
||||||
<el-table-column v-if="sourceManaged" label="运费(已核实无运费填 0)" min-width="170"><template #default="{row}"><el-input v-model="row.freight_amount" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货明细运费" /></template></el-table-column>
|
<el-table-column v-if="sourceManaged" label="运费(已核实无运费填 0)" min-width="170"><template #default="{row}"><el-input v-model="row.freight_amount" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货明细运费" /></template></el-table-column>
|
||||||
|
<el-table-column v-if="sourceManaged" label="指导成本登记" width="130"><template #default="{row}"><el-button v-if="row.guidance_status !== 'FINALIZED_UNIT'" link :disabled="!permissionUser.can('payroll:prepare') || locked || busy" @click="registerGuidance(row)">登记并封存</el-button><span v-else class="form-hint">已封存</span></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-pagination v-if="rows.length > 50" v-model:current-page="rowPage" :page-size="50" :total="rows.length" layout="prev, pager, next, total" />
|
<el-pagination v-if="rows.length > 50" v-model:current-page="rowPage" :page-size="50" :total="rows.length" layout="prev, pager, next, total" />
|
||||||
<section v-if="sourceManaged" class="handover-section" aria-label="业务交接">
|
<section v-if="sourceManaged" class="handover-section" aria-label="业务交接">
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { optionPage } from '@/api/metadata'
|
import { optionPage } from '@/api/metadata'
|
||||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||||
const permissionUser = usePermissionUser()
|
const permissionUser = usePermissionUser()
|
||||||
@ -32,7 +33,7 @@ const filters = reactive({
|
|||||||
is_active: 1 as number | undefined,
|
is_active: 1 as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
|
||||||
usePagedList<LedgerRow, { settlement_month?: string; is_active?: number }>(calcApi.listLedger, () => ({
|
usePagedList<LedgerRow, { settlement_month?: string; is_active?: number }>(calcApi.listLedger, () => ({
|
||||||
settlement_month: filters.settlement_month.trim() || undefined,
|
settlement_month: filters.settlement_month.trim() || undefined,
|
||||||
is_active: filters.is_active,
|
is_active: filters.is_active,
|
||||||
@ -132,6 +133,7 @@ async function submitRecalc(): Promise<void> {
|
|||||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||||
-->
|
-->
|
||||||
|
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||||
<el-form class="filter-bar" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="归属月">
|
<el-form-item label="归属月">
|
||||||
<el-input
|
<el-input
|
||||||
@ -179,7 +181,7 @@ async function submitRecalc(): Promise<void> {
|
|||||||
<span class="cell-code">{{ row.business_no }}</span>
|
<span class="cell-code">{{ row.business_no }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="personnel_name" label="归属人员" width="100" />
|
<el-table-column prop="personnel_name" label="归属人员" width="100" show-overflow-tooltip />
|
||||||
<el-table-column label="岗位" width="110"><template #default="{ row }">{{ ({salesperson: '业务员', sales_manager: '营销经理', info_officer: '信息员'} as Record<string, string>)[row.role_code] ?? '自定义岗位' }}</template></el-table-column>
|
<el-table-column label="岗位" width="110"><template #default="{ row }">{{ ({salesperson: '业务员', sales_manager: '营销经理', info_officer: '信息员'} as Record<string, string>)[row.role_code] ?? '自定义岗位' }}</template></el-table-column>
|
||||||
<el-table-column prop="receipt_date" label="回款日" width="100" />
|
<el-table-column prop="receipt_date" label="回款日" width="100" />
|
||||||
<el-table-column label="天数" width="70" align="right">
|
<el-table-column label="天数" width="70" align="right">
|
||||||
@ -265,8 +267,8 @@ async function submitRecalc(): Promise<void> {
|
|||||||
<el-table v-loading="allocationLoading" :data="allocationRows" empty-text="暂无正式分配明细">
|
<el-table v-loading="allocationLoading" :data="allocationRows" empty-text="暂无正式分配明细">
|
||||||
<el-table-column prop="receipt_no" label="回款单" min-width="130" /><el-table-column prop="shipment_no" label="发货单" min-width="130" />
|
<el-table-column prop="receipt_no" label="回款单" min-width="130" /><el-table-column prop="shipment_no" label="发货单" min-width="130" />
|
||||||
<el-table-column prop="shipment_date" label="发货日期" width="110" /><el-table-column prop="receipt_date" label="到账日期" width="110" />
|
<el-table-column prop="shipment_date" label="发货日期" width="110" /><el-table-column prop="receipt_date" label="到账日期" width="110" />
|
||||||
<el-table-column prop="amount" label="本次分配金额" width="120" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" />
|
<el-table-column prop="amount" label="本次分配金额" width="120" align="right" /><el-table-column prop="allocated_cost" label="指导成本分摊" width="120" align="right" />
|
||||||
<el-table-column prop="performance_delta" label="本次计提" width="100" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" />
|
<el-table-column prop="performance_delta" label="本次计提" width="100" align="right" /><el-table-column prop="payable_delta" label="本期应付差额" width="120" align="right" />
|
||||||
<el-table-column prop="input_id" label="资料版本编号" width="120" />
|
<el-table-column prop="input_id" label="资料版本编号" width="120" />
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
@ -6,10 +6,12 @@ import { BarChart, LineChart } from 'echarts/charts'
|
|||||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||||
import { SVGRenderer } from 'echarts/renderers'
|
import { SVGRenderer } from 'echarts/renderers'
|
||||||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||||
|
import { Refresh, ArrowRight } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
import * as dashboardApi from '@/api/dashboard'
|
import * as dashboardApi from '@/api/dashboard'
|
||||||
import type { ActivityItem, ActivityPage, DashboardData, TrendPoint } from '@/api/dashboard'
|
import type { ActivityItem, ActivityPage, DashboardData, TrendPoint } from '@/api/dashboard'
|
||||||
import AppPagination from '@/components/table/AppPagination.vue'
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { usePagedList } from '@/composables/usePagedList'
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
import { handledError } from '@/utils/error'
|
import { handledError } from '@/utils/error'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
@ -66,6 +68,7 @@ const data = ref<DashboardData | null>(null)
|
|||||||
const hasPerformanceTrend = computed(() => data.value?.overview.trend.some(point => point.performance_total != null) ?? false)
|
const hasPerformanceTrend = computed(() => data.value?.overview.trend.some(point => point.performance_total != null) ?? false)
|
||||||
const hasReceiptTrend = computed(() => data.value?.overview.trend.some(point => point.receipt_total != null) ?? false)
|
const hasReceiptTrend = computed(() => data.value?.overview.trend.some(point => point.receipt_total != null) ?? false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const failed = ref(false)
|
||||||
/** 快速切换月份时,只允许最新请求写入数据与 loading */
|
/** 快速切换月份时,只允许最新请求写入数据与 loading */
|
||||||
let loadSeq = 0
|
let loadSeq = 0
|
||||||
|
|
||||||
@ -78,6 +81,7 @@ let receiptChart: ECharts | null = null
|
|||||||
* 用 ResizeObserver 监听图表容器本身,布局一变就同步图表尺寸。
|
* 用 ResizeObserver 监听图表容器本身,布局一变就同步图表尺寸。
|
||||||
*/
|
*/
|
||||||
let chartObserver: ResizeObserver | null = null
|
let chartObserver: ResizeObserver | null = null
|
||||||
|
let resizeFrame: number | undefined
|
||||||
|
|
||||||
function currentMonth(): string {
|
function currentMonth(): string {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@ -87,6 +91,7 @@ function currentMonth(): string {
|
|||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
const seq = ++loadSeq
|
const seq = ++loadSeq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
failed.value = false
|
||||||
try {
|
try {
|
||||||
const result = await dashboardApi.getDashboard({ month: month.value })
|
const result = await dashboardApi.getDashboard({ month: month.value })
|
||||||
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
|
if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应
|
||||||
@ -94,7 +99,7 @@ async function load(): Promise<void> {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
renderCharts()
|
renderCharts()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (seq === loadSeq) handledError(error)
|
if (seq === loadSeq) { failed.value = true; handledError(error) }
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === loadSeq) loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
@ -124,12 +129,13 @@ function axisCommon(trend: TrendPoint[]) {
|
|||||||
data: trend.map((item) => item.month.slice(5)),
|
data: trend.map((item) => item.month.slice(5)),
|
||||||
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
|
axisLine: { lineStyle: { color: 'rgba(28,35,51,.15)' } },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false },
|
||||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
axisLabel: { fontFamily: CHART_FONT, color: '#657086', fontSize: 11, margin: 8 },
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value' as const,
|
type: 'value' as const,
|
||||||
splitLine: { show: false },
|
splitNumber: 3,
|
||||||
axisLabel: { fontFamily: CHART_FONT, color: '#9aa3b2', fontSize: 9, margin: 6 },
|
splitLine: { show: true, lineStyle: { color: '#edf0f5', type: 'dashed' as const } },
|
||||||
|
axisLabel: { fontFamily: CHART_FONT, color: '#657086', fontSize: 11, margin: 8 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -153,11 +159,18 @@ function verticalGradient(top: string, bottom: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chartValue(value: unknown): string {
|
||||||
|
if (value == null || value === '-') return '暂无记录'
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number.toFixed(2) : '暂无记录'
|
||||||
|
}
|
||||||
|
|
||||||
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
||||||
return {
|
return {
|
||||||
|
animation: false,
|
||||||
textStyle: { fontFamily: CHART_FONT },
|
textStyle: { fontFamily: CHART_FONT },
|
||||||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||||||
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
|
tooltip: { trigger: 'axis', valueFormatter: chartValue },
|
||||||
...axisCommon(trend),
|
...axisCommon(trend),
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@ -165,7 +178,7 @@ function performanceOption(trend: TrendPoint[]): EChartsCoreOption {
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: 0.4,
|
smooth: 0.4,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
data: trend.map((item) => Number(item.performance_total ?? 0)),
|
data: trend.map((item) => item.performance_total == null ? null : Number(item.performance_total)),
|
||||||
lineStyle: { color: '#2e4ba8', width: 2 },
|
lineStyle: { color: '#2e4ba8', width: 2 },
|
||||||
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
|
areaStyle: { color: verticalGradient('rgba(46,75,168,.22)', 'rgba(46,75,168,0)') },
|
||||||
},
|
},
|
||||||
@ -177,9 +190,10 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
|||||||
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
|
// 最近一个月用饱和靛蓝强调,其余压成浅靛蓝:一眼就能读出「本月」在哪
|
||||||
const lastIndex = trend.length - 1
|
const lastIndex = trend.length - 1
|
||||||
return {
|
return {
|
||||||
|
animation: false,
|
||||||
textStyle: { fontFamily: CHART_FONT },
|
textStyle: { fontFamily: CHART_FONT },
|
||||||
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
grid: { left: 8, right: 12, top: 18, bottom: 4, containLabel: true },
|
||||||
tooltip: { trigger: 'axis', valueFormatter: (value: unknown) => Number(value).toFixed(2) },
|
tooltip: { trigger: 'axis', valueFormatter: chartValue },
|
||||||
...axisCommon(trend),
|
...axisCommon(trend),
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@ -187,7 +201,7 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
|||||||
type: 'bar',
|
type: 'bar',
|
||||||
barMaxWidth: 22,
|
barMaxWidth: 22,
|
||||||
data: trend.map((item, index) => ({
|
data: trend.map((item, index) => ({
|
||||||
value: Number(item.receipt_total ?? 0),
|
value: item.receipt_total == null ? null : Number(item.receipt_total),
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color:
|
color:
|
||||||
index === lastIndex
|
index === lastIndex
|
||||||
@ -202,27 +216,28 @@ function receiptOption(trend: TrendPoint[]): EChartsCoreOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleResize(): void {
|
function handleResize(): void {
|
||||||
|
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||||
|
resizeFrame = requestAnimationFrame(() => {
|
||||||
|
resizeFrame = undefined
|
||||||
perfChart?.resize()
|
perfChart?.resize()
|
||||||
receiptChart?.resize()
|
receiptChart?.resize()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load()
|
void load()
|
||||||
void loadActivities()
|
void loadActivities()
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
if (perfChartRef.value) {
|
chartObserver = new ResizeObserver(handleResize)
|
||||||
chartObserver = new ResizeObserver(() => {
|
if (perfChartRef.value) chartObserver.observe(perfChartRef.value)
|
||||||
perfChart?.resize()
|
if (receiptChartRef.value) chartObserver.observe(receiptChartRef.value)
|
||||||
receiptChart?.resize()
|
|
||||||
})
|
|
||||||
chartObserver.observe(perfChartRef.value)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
chartObserver?.disconnect()
|
chartObserver?.disconnect()
|
||||||
chartObserver = null
|
chartObserver = null
|
||||||
|
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||||
perfChart?.dispose()
|
perfChart?.dispose()
|
||||||
receiptChart?.dispose()
|
receiptChart?.dispose()
|
||||||
})
|
})
|
||||||
@ -239,6 +254,7 @@ const {
|
|||||||
pageNum: activityPageNum,
|
pageNum: activityPageNum,
|
||||||
pageSize: activityPageSize,
|
pageSize: activityPageSize,
|
||||||
loading: activityLoading,
|
loading: activityLoading,
|
||||||
|
loadFailed: activityFailed,
|
||||||
load: loadActivities,
|
load: loadActivities,
|
||||||
search: searchActivities,
|
search: searchActivities,
|
||||||
onPageChange: onActivityPageChange,
|
onPageChange: onActivityPageChange,
|
||||||
@ -269,24 +285,28 @@ function formatTime(value: string): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-loading="loading" class="workbench">
|
<div v-loading="loading" class="workbench">
|
||||||
<div class="page-head">
|
<header class="page-head dashboard-head">
|
||||||
|
<div>
|
||||||
<h1 class="page-title">工作台</h1>
|
<h1 class="page-title">工作台</h1>
|
||||||
<p class="page-desc">经营绩效总览 · 待办与风险集中处置 · 最近动态</p>
|
<p class="page-desc">查看本月绩效与回款,处理待办并核对业务风险。</p>
|
||||||
<span class="waterline"></span>
|
<span class="waterline"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dashboard-filter">
|
||||||
<div class="toolbar">
|
<span>统计月份</span>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="month"
|
v-model="month"
|
||||||
type="month"
|
type="month"
|
||||||
value-format="YYYY-MM"
|
value-format="YYYY-MM"
|
||||||
:clearable="false"
|
:clearable="false"
|
||||||
style="width: 140px"
|
style="width: 140px"
|
||||||
|
aria-label="统计月份"
|
||||||
@change="load"
|
@change="load"
|
||||||
/>
|
/>
|
||||||
<el-button type="primary" :loading="loading" @click="load">刷新</el-button>
|
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
|
||||||
<span class="toolbar-note">{{ data?.overview.note ?? '' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<el-alert v-if="failed" class="dashboard-notice" title="工作台更新失败,显示内容可能不是最新结果" type="warning" :closable="false" show-icon><el-button link @click="load">重新加载工作台</el-button></el-alert>
|
||||||
|
<p v-if="data?.overview.note" class="overview-note">{{ data.overview.note }}</p>
|
||||||
|
|
||||||
<!-- ① 经营总览:KPI 四格 -->
|
<!-- ① 经营总览:KPI 四格 -->
|
||||||
<section class="kpi-row" aria-label="经营总览指标">
|
<section class="kpi-row" aria-label="经营总览指标">
|
||||||
@ -333,7 +353,7 @@ function formatTime(value: string): string {
|
|||||||
<span class="panel-title">近 6 个月绩效</span>
|
<span class="panel-title">近 6 个月绩效</span>
|
||||||
<span class="panel-hint">按台账归属月</span>
|
<span class="panel-hint">按台账归属月</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="hasPerformanceTrend" ref="perfChartRef" class="chart"></div>
|
<div v-show="hasPerformanceTrend" ref="perfChartRef" class="chart" role="img" aria-label="近六个月绩效趋势,按台账归属月统计"></div>
|
||||||
<div v-if="data && !hasPerformanceTrend" class="chart-empty" role="status"><strong>该时段还没有绩效台账</strong><p>采集成功后,仍需导入正式业务、准备核算资料并执行核算,才会形成绩效统计。</p><el-button v-if="user.hasPermission('business:businesses:view')" link type="primary" @click="router.push('/business/businesses')">查看业务与核算</el-button></div>
|
<div v-if="data && !hasPerformanceTrend" class="chart-empty" role="status"><strong>该时段还没有绩效台账</strong><p>采集成功后,仍需导入正式业务、准备核算资料并执行核算,才会形成绩效统计。</p><el-button v-if="user.hasPermission('business:businesses:view')" link type="primary" @click="router.push('/business/businesses')">查看业务与核算</el-button></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@ -341,7 +361,7 @@ function formatTime(value: string): string {
|
|||||||
<span class="panel-title">近 6 个月回款</span>
|
<span class="panel-title">近 6 个月回款</span>
|
||||||
<span class="panel-hint">按到账月</span>
|
<span class="panel-hint">按到账月</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="hasReceiptTrend" ref="receiptChartRef" class="chart"></div>
|
<div v-show="hasReceiptTrend" ref="receiptChartRef" class="chart" role="img" aria-label="近六个月回款趋势,按到账月统计"></div>
|
||||||
<div v-if="data && !hasReceiptTrend" class="chart-empty" role="status"><strong>该时段还没有正式回款记录</strong><p>T+ 原始回款需正式导入,或在回款管理登记后,才会进入这里的统计。</p><el-button v-if="user.hasPermission('business:receipts:view')" link type="primary" @click="router.push('/business/receipts')">查看回款管理</el-button></div>
|
<div v-if="data && !hasReceiptTrend" class="chart-empty" role="status"><strong>该时段还没有正式回款记录</strong><p>T+ 原始回款需正式导入,或在回款管理登记后,才会进入这里的统计。</p><el-button v-if="user.hasPermission('business:receipts:view')" link type="primary" @click="router.push('/business/receipts')">查看回款管理</el-button></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -358,7 +378,7 @@ function formatTime(value: string): string {
|
|||||||
<div class="list-block__head">
|
<div class="list-block__head">
|
||||||
<span class="list-block__title">待审批</span>
|
<span class="list-block__title">待审批</span>
|
||||||
<span class="list-block__count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
|
<span class="list-block__count">{{ data?.todos.pending_approval_total ?? 0 }}</span>
|
||||||
<button class="list-block__more" type="button" @click="go('/system/approvals')">
|
<button v-if="data?.todos.pending_approval_total" class="list-block__more" type="button" @click="go('/system/approvals')">
|
||||||
全部 →
|
全部 →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -389,7 +409,7 @@ function formatTime(value: string): string {
|
|||||||
<div class="list-block__head">
|
<div class="list-block__head">
|
||||||
<span class="list-block__title">未指定批次的回款(供核对)</span>
|
<span class="list-block__title">未指定批次的回款(供核对)</span>
|
||||||
<span class="list-block__count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
|
<span class="list-block__count">{{ data?.todos.unassigned_receipt_total ?? 0 }}</span>
|
||||||
<button class="list-block__more" type="button" @click="go('/business/receipts')">
|
<button v-if="data?.todos.unassigned_receipt_total" class="list-block__more" type="button" @click="go('/business/receipts')">
|
||||||
全部 →
|
全部 →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -410,17 +430,6 @@ function formatTime(value: string): string {
|
|||||||
<div v-else class="list-empty">无未指定批次的回款</div>
|
<div v-else class="list-empty">无未指定批次的回款</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-strip">
|
|
||||||
<span class="status-strip__label">T+ 数据采集</span>
|
|
||||||
<span class="status-strip__value">
|
|
||||||
{{
|
|
||||||
!data ? '正在加载采集状态…' : !data.sync.available ? '采集记录暂不可查询'
|
|
||||||
: data.sync.last_success_at
|
|
||||||
? `最近成功:${formatDateTime(data.sync.last_success_at)}`
|
|
||||||
: '尚无成功采集记录'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel panel--list">
|
<div class="panel panel--list">
|
||||||
@ -465,19 +474,20 @@ function formatTime(value: string): string {
|
|||||||
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
|
仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-strip">
|
</div>
|
||||||
<span class="status-strip__label">采集运行状态</span>
|
</section>
|
||||||
<span class="status-strip__value">
|
|
||||||
|
<section class="sync-context" aria-label="T+ 数据采集状态">
|
||||||
|
<strong>T+ 数据采集</strong>
|
||||||
<template v-if="data?.sync.available">
|
<template v-if="data?.sync.available">
|
||||||
{{ SYNC_STATUS_LABELS[data.sync.latest_status ?? ''] ?? '尚无采集任务' }};
|
<span class="sync-context__state">{{ SYNC_STATUS_LABELS[data.sync.latest_status ?? ''] ?? '尚无采集任务' }}</span>
|
||||||
{{ data.sync.enabled ? '源读取已启用' : '源读取未启用' }};
|
<span>{{ data.sync.enabled ? '源读取已启用' : '源读取未启用' }}</span>
|
||||||
{{ data.sync.automation_enabled ? '自动采集已开启' : '自动采集未开启' }}
|
<span>{{ data.sync.automation_enabled ? '自动采集已开启' : '自动采集未开启' }}</span>
|
||||||
<span v-if="data.sync.historical_failure_total">;历史异常 {{ data.sync.historical_failure_total }} 批次</span>
|
<span>最近成功:{{ formatDateTime(data.sync.last_success_at, '尚无成功记录') }}</span>
|
||||||
|
<span v-if="data.sync.historical_failure_total">历史异常 {{ data.sync.historical_failure_total }} 批次</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ data ? '采集记录暂不可查询' : '正在加载采集状态…' }}</template>
|
<span v-else>{{ data ? '采集记录暂不可查询' : '正在加载采集状态…' }}</span>
|
||||||
</span>
|
<el-button v-if="user.hasPermission('tplus:view')" link type="primary" :icon="ArrowRight" @click="go('/system/tplus')">查看同步</el-button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ④ 最近动态(服务端分页:列表有边界,翻页只重取这张表) -->
|
<!-- ④ 最近动态(服务端分页:列表有边界,翻页只重取这张表) -->
|
||||||
@ -490,6 +500,7 @@ function formatTime(value: string): string {
|
|||||||
clearable
|
clearable
|
||||||
placeholder="全部模块"
|
placeholder="全部模块"
|
||||||
class="activity-filter"
|
class="activity-filter"
|
||||||
|
aria-label="动态模块"
|
||||||
@change="searchActivities"
|
@change="searchActivities"
|
||||||
>
|
>
|
||||||
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
|
<el-option v-for="item in activityModules" :key="item" :label="item" :value="item" />
|
||||||
@ -503,6 +514,7 @@ function formatTime(value: string): string {
|
|||||||
观感就是「数据反复变化」。
|
观感就是「数据反复变化」。
|
||||||
-->
|
-->
|
||||||
<div v-loading="activityLoading" class="activity-list-wrap">
|
<div v-loading="activityLoading" class="activity-list-wrap">
|
||||||
|
<ListLoadError v-if="activityFailed" :loading="activityLoading" @retry="loadActivities" />
|
||||||
<div v-if="activities.length" class="activity-list">
|
<div v-if="activities.length" class="activity-list">
|
||||||
<div class="activity-row activity-row--head">
|
<div class="activity-row activity-row--head">
|
||||||
<span>时间</span>
|
<span>时间</span>
|
||||||
@ -529,7 +541,7 @@ function formatTime(value: string): string {
|
|||||||
<span class="activity-cell activity-cell--user">{{ item.username ?? '—' }}</span>
|
<span class="activity-cell activity-cell--user">{{ item.username ?? '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="list-empty list-empty--block">暂无动态</div>
|
<div v-else-if="!activityLoading && !activityFailed" class="list-empty list-empty--block">暂无动态</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppPagination
|
<AppPagination
|
||||||
@ -550,10 +562,10 @@ function formatTime(value: string): string {
|
|||||||
<span class="panel-title">常用操作</span>
|
<span class="panel-title">常用操作</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<button class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button>
|
<button v-if="user.hasPermission('business:receipts:view')" class="quick-btn" type="button" @click="go('/business/receipts')">录入回款</button>
|
||||||
<button class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button>
|
<button v-if="user.hasPermission('payroll:view')" class="quick-btn" type="button" @click="go('/calc/ledger')">绩效台账</button>
|
||||||
<button class="quick-btn" type="button" @click="go('/settlements')">月度结算</button>
|
<button v-if="user.hasPermission('settlements:view')" class="quick-btn" type="button" @click="go('/settlements')">月度结算</button>
|
||||||
<button class="quick-btn" type="button" @click="go('/reports/personal-detail')">
|
<button v-if="user.hasPermission('reports:personal:view')" class="quick-btn" type="button" @click="go('/reports/personal-detail')">
|
||||||
报表统计
|
报表统计
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -562,6 +574,16 @@ function formatTime(value: string): string {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.workbench { min-width: 0; }
|
||||||
|
.dashboard-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 16px 24px; }
|
||||||
|
.dashboard-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||||
|
.dashboard-filter > span { font-size: 13px; color: var(--ys-ink-2); }
|
||||||
|
.overview-note { margin: 0 0 14px; color: var(--ys-ink-3); font-size: 12px; line-height: 1.7; }
|
||||||
|
.dashboard-notice { margin-bottom: 16px; }
|
||||||
|
.sync-context { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 20px; padding: 16px 0; border-top: 1px solid var(--ys-line); border-bottom: 1px solid var(--ys-line); color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||||||
|
.sync-context strong { color: var(--ys-ink); font-size: 13px; font-weight: 600; }
|
||||||
|
.sync-context__state { color: var(--ys-indigo); }
|
||||||
|
.sync-context .el-button { margin-left: auto; }
|
||||||
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
|
/* 页面头(.page-head/.page-title/.page-desc/.waterline)、.toolbar 均来自全局 index.css */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -581,7 +603,10 @@ function formatTime(value: string): string {
|
|||||||
.kpi-row {
|
.kpi-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 0;
|
||||||
|
background: var(--ys-paper);
|
||||||
|
border: 1px solid var(--ys-line);
|
||||||
|
border-radius: var(--ys-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-card {
|
.kpi-card {
|
||||||
@ -589,22 +614,11 @@ function formatTime(value: string): string {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 116px;
|
min-height: 108px;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
background: none;
|
||||||
border: 1px solid var(--ys-line);
|
|
||||||
border-radius: var(--ys-radius);
|
|
||||||
box-shadow: var(--ys-shadow-card);
|
|
||||||
transition:
|
|
||||||
transform 180ms var(--ys-ease),
|
|
||||||
box-shadow 180ms var(--ys-ease);
|
|
||||||
animation: ys-rise 320ms var(--ys-ease) backwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--ys-shadow-raise);
|
|
||||||
}
|
}
|
||||||
|
.kpi-card + .kpi-card { border-left: 1px solid var(--ys-line-soft); }
|
||||||
|
|
||||||
.kpi-label {
|
.kpi-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -647,36 +661,10 @@ function formatTime(value: string): string {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
background: linear-gradient(180deg, var(--ys-paper), #fcfcfe);
|
background: var(--ys-paper);
|
||||||
border: 1px solid var(--ys-line);
|
border: 1px solid var(--ys-line);
|
||||||
border-radius: var(--ys-radius);
|
border-radius: var(--ys-radius);
|
||||||
box-shadow: var(--ys-shadow-card);
|
box-shadow: var(--ys-shadow-card);
|
||||||
transition:
|
|
||||||
transform 180ms var(--ys-ease),
|
|
||||||
box-shadow 180ms var(--ys-ease);
|
|
||||||
animation: ys-rise 320ms var(--ys-ease) backwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--ys-shadow-raise);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 交错入场:区块之间延后 60ms,避免整屏同时闪一下 */
|
|
||||||
.workbench > section:nth-of-type(2) .panel {
|
|
||||||
animation-delay: 60ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workbench > section:nth-of-type(3) .panel {
|
|
||||||
animation-delay: 120ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workbench > section:nth-of-type(4) .panel {
|
|
||||||
animation-delay: 180ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workbench > section:nth-of-type(5) .panel {
|
|
||||||
animation-delay: 240ms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-head {
|
.panel-head {
|
||||||
@ -690,14 +678,14 @@ function formatTime(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
color: var(--ys-ink-2);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-hint {
|
.panel-hint {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -724,12 +712,12 @@ function formatTime(value: string): string {
|
|||||||
/* ── ③ 待办 / 预警 ────────────────────────────────────────────────────── */
|
/* ── ③ 待办 / 预警 ────────────────────────────────────────────────────── */
|
||||||
/* 高度下限保证列表区有 4~5 行可视;内容更长时由内部滚动消化 */
|
/* 高度下限保证列表区有 4~5 行可视;内容更长时由内部滚动消化 */
|
||||||
.panel--list {
|
.panel--list {
|
||||||
min-height: 420px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-block {
|
.list-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 0;
|
flex: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@ -740,6 +728,7 @@ function formatTime(value: string): string {
|
|||||||
|
|
||||||
.list-block__head {
|
.list-block__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: none;
|
flex: none;
|
||||||
@ -784,6 +773,7 @@ function formatTime(value: string): string {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
max-height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -824,15 +814,17 @@ function formatTime(value: string): string {
|
|||||||
.row__main {
|
.row__main {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
overflow: hidden;
|
overflow-wrap: anywhere;
|
||||||
text-overflow: ellipsis;
|
line-height: 1.6;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row__meta {
|
.row__meta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 28ch;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预警行:标签 | 标题 | 详情 三列并排 */
|
/* 预警行:标签 | 标题 | 详情 三列并排 */
|
||||||
@ -840,6 +832,7 @@ function formatTime(value: string): string {
|
|||||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 0.85fr);
|
grid-template-columns: auto minmax(0, 1fr) minmax(0, 0.85fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.row--warn .row__meta { white-space: normal; line-height: 1.7; }
|
||||||
|
|
||||||
.row__tag {
|
.row__tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -848,13 +841,15 @@ function formatTime(value: string): string {
|
|||||||
/* 空态在卡片剩余高度里居中,避免内容全挤在顶部、下方留一大片空白 */
|
/* 空态在卡片剩余高度里居中,避免内容全挤在顶部、下方留一大片空白 */
|
||||||
.list-empty {
|
.list-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
padding: 12px 0;
|
min-height: 56px;
|
||||||
|
padding: 16px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
text-align: center;
|
text-align: left;
|
||||||
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 面板级空态(预警/动态无数据时独占一屏高度) */
|
/* 面板级空态(预警/动态无数据时独占一屏高度) */
|
||||||
@ -873,6 +868,7 @@ function formatTime(value: string): string {
|
|||||||
.status-strip {
|
.status-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@ -895,7 +891,7 @@ function formatTime(value: string): string {
|
|||||||
|
|
||||||
/* ── ④ 最近动态 ───────────────────────────────────────────────────────── */
|
/* ── ④ 最近动态 ───────────────────────────────────────────────────────── */
|
||||||
.panel--activity {
|
.panel--activity {
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-filter {
|
.activity-filter {
|
||||||
@ -969,6 +965,8 @@ function formatTime(value: string): string {
|
|||||||
|
|
||||||
.activity-cell--summary {
|
.activity-cell--summary {
|
||||||
color: var(--ys-ink-2);
|
color: var(--ys-ink-2);
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-cell--user {
|
.activity-cell--user {
|
||||||
@ -1015,6 +1013,8 @@ function formatTime(value: string): string {
|
|||||||
.kpi-row {
|
.kpi-row {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.kpi-card:nth-child(3) { border-left: 0; }
|
||||||
|
.kpi-card:nth-child(n+3) { border-top: 1px solid var(--ys-line-soft); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
@ -1024,13 +1024,13 @@ function formatTime(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel--list {
|
.panel--list {
|
||||||
min-height: 360px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.kpi-row {
|
.kpi-row {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-row {
|
.activity-row {
|
||||||
@ -1043,6 +1043,14 @@ function formatTime(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.row:not(.row--warn) { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||||
|
.row__meta { max-width: none; }
|
||||||
|
.dashboard-filter { width: 100%; }
|
||||||
|
.sync-context { gap: 8px 16px; }
|
||||||
|
.sync-context strong { flex-basis: 100%; }
|
||||||
|
.kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.kpi-card { padding: 16px; }
|
||||||
|
.kpi-value { font-size: clamp(18px, 5vw, 26px); overflow-wrap: anywhere; }
|
||||||
.panel {
|
.panel {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const userStore = useUserStore()
|
|||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const captchaLoading = ref(false)
|
const captchaLoading = ref(false)
|
||||||
|
const captchaFailed = ref(false)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
@ -61,7 +62,9 @@ const rules: FormRules = {
|
|||||||
|
|
||||||
/** 加载或刷新验证码。点击图片亦可触发。 */
|
/** 加载或刷新验证码。点击图片亦可触发。 */
|
||||||
async function refreshCaptcha(): Promise<void> {
|
async function refreshCaptcha(): Promise<void> {
|
||||||
|
if (captchaLoading.value) return
|
||||||
captchaLoading.value = true
|
captchaLoading.value = true
|
||||||
|
captchaFailed.value = false
|
||||||
try {
|
try {
|
||||||
const data = await getCaptcha()
|
const data = await getCaptcha()
|
||||||
captchaId.value = data.captcha_id
|
captchaId.value = data.captcha_id
|
||||||
@ -69,6 +72,9 @@ async function refreshCaptcha(): Promise<void> {
|
|||||||
captchaImage.value = `data:image/png;base64,${data.image_base64}`
|
captchaImage.value = `data:image/png;base64,${data.image_base64}`
|
||||||
form.captcha_code = ''
|
form.captcha_code = ''
|
||||||
} catch {
|
} catch {
|
||||||
|
captchaFailed.value = true
|
||||||
|
captchaId.value = ''
|
||||||
|
captchaImage.value = ''
|
||||||
// 失败提示已由 axios 拦截器统一处理,这里只需保证界面回到可用状态
|
// 失败提示已由 axios 拦截器统一处理,这里只需保证界面回到可用状态
|
||||||
} finally {
|
} finally {
|
||||||
captchaLoading.value = false
|
captchaLoading.value = false
|
||||||
@ -76,7 +82,11 @@ async function refreshCaptcha(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(): Promise<void> {
|
async function handleSubmit(): Promise<void> {
|
||||||
if (!formRef.value) return
|
if (!formRef.value || submitting.value || captchaLoading.value) return
|
||||||
|
if (!captchaId.value) {
|
||||||
|
await refreshCaptcha()
|
||||||
|
return
|
||||||
|
}
|
||||||
const valid = await formRef.value.validate().catch(() => false)
|
const valid = await formRef.value.validate().catch(() => false)
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
|
|
||||||
@ -172,17 +182,19 @@ onMounted(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="captcha__image"
|
class="captcha__image"
|
||||||
:aria-busy="captchaLoading"
|
:aria-busy="captchaLoading"
|
||||||
|
:disabled="captchaLoading || submitting"
|
||||||
|
:aria-label="captchaFailed ? '验证码加载失败,点击重试' : '换一张验证码'"
|
||||||
title="点击换一张"
|
title="点击换一张"
|
||||||
@click="refreshCaptcha"
|
@click="refreshCaptcha"
|
||||||
>
|
>
|
||||||
<img v-if="captchaImage" :src="captchaImage" alt="验证码" draggable="false" />
|
<img v-if="captchaImage" :src="captchaImage" alt="验证码" draggable="false" />
|
||||||
<span v-else class="captcha__placeholder">加载中</span>
|
<span v-else class="captcha__placeholder">{{ captchaFailed ? '点击重试' : '加载中' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item class="form-actions">
|
<el-form-item class="form-actions">
|
||||||
<el-button type="primary" class="submit" :loading="submitting" @click="handleSubmit">
|
<el-button type="primary" class="submit" :loading="submitting" :disabled="captchaLoading" @click="handleSubmit">
|
||||||
登 录
|
登 录
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@ -1,5 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { firstAccessiblePath } from '@/router/access'
|
||||||
|
import { handledError } from '@/utils/error'
|
||||||
const user = useUserStore()
|
const user = useUserStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const checking = ref(false)
|
||||||
|
async function checkAccess(): Promise<void> {
|
||||||
|
if (checking.value) return
|
||||||
|
checking.value = true
|
||||||
|
try {
|
||||||
|
await user.fetchUserInfo()
|
||||||
|
const target = firstAccessiblePath(router, user.roles)
|
||||||
|
if (target !== '/no-access') await router.replace(target)
|
||||||
|
} catch (error) { handledError(error) }
|
||||||
|
finally { checking.value = false }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template><section class="card card-body"><h1 class="page-title">暂无可访问功能</h1><p>当前账号尚未获得功能权限,请联系系统管理员在用户管理中授权。</p><el-button @click="user.fetchUserInfo">重新检查权限</el-button></section></template>
|
<template>
|
||||||
|
<section class="card access-empty" aria-labelledby="access-title">
|
||||||
|
<h1 id="access-title" class="page-title">暂无可访问功能</h1>
|
||||||
|
<p>当前账号尚未分配功能权限。请联系管理员分配角色,授权后可在这里重新检查。</p>
|
||||||
|
<el-button type="primary" :loading="checking" @click="checkAccess">重新检查权限</el-button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.access-empty { max-width: 640px; margin: 48px auto; padding: 40px 24px; text-align: center; }
|
||||||
|
.access-empty p { max-width: 38em; margin: 16px auto 24px; color: var(--ys-ink-2); line-height: 1.8; }
|
||||||
|
</style>
|
||||||
|
|||||||
@ -215,7 +215,7 @@ function pct(value: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<section v-if="data?.salary_rows?.length" class="salary-snapshot"><h2 class="section-title">已生成工资分项(冻结快照)</h2><el-table :data="data.salary_rows" border><el-table-column prop="personnel_name" label="人员" width="120" /><el-table-column v-for="[key,label] in [['basic_amount','基本工资'],['normal_payable_sum','正常绩效'],['deferred_sum','试用期缓发'],['probation_payment_sum','年终/续付差额'],['payable_sum','本月实付绩效'],['net_amount','净工资']]" :key="key" :prop="key" :label="label" min-width="140" align="right" /></el-table></section>
|
<section v-if="data?.salary_rows?.length" class="salary-snapshot"><h2 class="section-title">已生成工资分项(冻结快照)</h2><el-table :data="data.salary_rows" border><el-table-column prop="personnel_name" label="人员" width="120" show-overflow-tooltip /><el-table-column v-for="[key,label] in [['basic_amount','基本工资'],['normal_payable_sum','正常绩效'],['deferred_sum','试用期缓发'],['probation_payment_sum','年终/续付差额'],['payable_sum','本月实付绩效'],['net_amount','净工资']]" :key="key" :prop="key" :label="label" min-width="140" align="right" /></el-table></section>
|
||||||
<el-table
|
<el-table
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
class="dense"
|
class="dense"
|
||||||
@ -228,7 +228,7 @@ function pct(value: string): string {
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="member-wrap">
|
<div class="member-wrap">
|
||||||
<el-table :data="row.members" size="small" stripe>
|
<el-table :data="row.members" size="small" stripe>
|
||||||
<el-table-column prop="personnel_name" label="成员" width="120" />
|
<el-table-column prop="personnel_name" label="成员" width="120" show-overflow-tooltip />
|
||||||
<el-table-column prop="role_code" label="岗位" width="120" />
|
<el-table-column prop="role_code" label="岗位" width="120" />
|
||||||
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right">
|
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right">
|
||||||
<template #default="{ row: member }">
|
<template #default="{ row: member }">
|
||||||
@ -325,7 +325,7 @@ function pct(value: string): string {
|
|||||||
<div v-if="data && data.unassigned.length" class="unassigned">
|
<div v-if="data && data.unassigned.length" class="unassigned">
|
||||||
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
|
<p class="section-title">未纳入团队基数(无经理 / 非营销经理上级的下属)</p>
|
||||||
<el-table :data="data.unassigned" size="small" stripe>
|
<el-table :data="data.unassigned" size="small" stripe>
|
||||||
<el-table-column prop="personnel_name" label="人员" width="130" />
|
<el-table-column prop="personnel_name" label="人员" width="130" show-overflow-tooltip />
|
||||||
<el-table-column prop="role_code" label="岗位" width="120" />
|
<el-table-column prop="role_code" label="岗位" width="120" />
|
||||||
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right">
|
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@ -363,6 +363,7 @@ function pct(value: string): string {
|
|||||||
|
|
||||||
.company-card {
|
.company-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@ -399,4 +400,9 @@ function pct(value: string): string {
|
|||||||
.unassigned {
|
.unassigned {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.company-card { gap: 10px 18px; }
|
||||||
|
.company-note { width: 100%; line-height: 1.7; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -249,11 +249,11 @@ function today(): string {
|
|||||||
size="small"
|
size="small"
|
||||||
stripe
|
stripe
|
||||||
>
|
>
|
||||||
<el-table-column prop="business_no" label="合同编号" min-width="150" />
|
<el-table-column prop="business_no" label="合同编号" min-width="150" show-overflow-tooltip />
|
||||||
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
||||||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||||||
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
|
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
|
||||||
<el-table-column prop="personnel_name" label="归属人员" width="110" />
|
<el-table-column prop="personnel_name" label="归属人员" width="110" show-overflow-tooltip />
|
||||||
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
||||||
<el-table-column label="客户类型" width="100">
|
<el-table-column label="客户类型" width="100">
|
||||||
<template #default="{ row: detail }">
|
<template #default="{ row: detail }">
|
||||||
@ -402,7 +402,7 @@ function today(): string {
|
|||||||
|
|
||||||
.dimension-grid {
|
.dimension-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { optionPage } from '@/api/metadata'
|
import { optionPage } from '@/api/metadata'
|
||||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||||
const permissionUser = usePermissionUser()
|
const permissionUser = usePermissionUser()
|
||||||
@ -85,7 +86,7 @@ function fetchPage(params: ReportFilterParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
const { records, total, pageNum, pageSize, loadFailed, loading, search, onPageChange, onSizeChange } =
|
||||||
usePagedList(fetchPage, getFilters, (data) => {
|
usePagedList(fetchPage, getFilters, (data) => {
|
||||||
summary.value = data.summary
|
summary.value = data.summary
|
||||||
})
|
})
|
||||||
@ -193,6 +194,7 @@ function today(): string {
|
|||||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||||
-->
|
-->
|
||||||
|
<ListLoadError v-if="loadFailed" :loading="loading" @retry="search" />
|
||||||
<el-form class="filter-bar" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="月份">
|
<el-form-item label="月份">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
@ -278,7 +280,7 @@ function today(): string {
|
|||||||
<LedgerCalculationDetails v-else :ledger="snapshots[row.ledger_id]" :loading="snapshotLoading[row.ledger_id]" />
|
<LedgerCalculationDetails v-else :ledger="snapshots[row.ledger_id]" :loading="snapshotLoading[row.ledger_id]" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="business_no" label="合同编号" min-width="160">
|
<el-table-column prop="business_no" label="合同编号" min-width="160" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-code">{{ row.business_no }}</span>
|
<span class="cell-code">{{ row.business_no }}</span>
|
||||||
</template>
|
</template>
|
||||||
@ -298,7 +300,7 @@ function today(): string {
|
|||||||
<el-table-column label="客户类型" width="100">
|
<el-table-column label="客户类型" width="100">
|
||||||
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
|
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="role_name" label="岗位" width="100" />
|
<el-table-column prop="role_name" label="岗位" width="100" show-overflow-tooltip />
|
||||||
<el-table-column prop="rate_text" label="计提比例" width="110">
|
<el-table-column prop="rate_text" label="计提比例" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-num">{{ row.rate_text }}</span>
|
<span class="cell-num">{{ row.rate_text }}</span>
|
||||||
|
|||||||
@ -187,6 +187,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
|
<!-- 稿纸:与屏幕稿不同源 —— 白底、实线表格、章节编号、签字栏,无圆角与阴影 -->
|
||||||
|
<div class="sheet-viewport" role="region" aria-label="报表预览,可横向滚动" tabindex="0">
|
||||||
<div class="sheet">
|
<div class="sheet">
|
||||||
<header class="sheet-header">
|
<header class="sheet-header">
|
||||||
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
|
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
|
||||||
@ -490,6 +491,7 @@ onMounted(load)
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -563,6 +565,14 @@ onMounted(load)
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sheet-viewport { overflow-x: auto; overscroll-behavior-x: contain; }
|
||||||
|
|
||||||
|
@media screen and (max-width: 800px) {
|
||||||
|
.print-bar { height: auto; min-height: 52px; padding: 14px 16px; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.print-bar__actions { flex-wrap: wrap; gap: 8px; }
|
||||||
|
.sheet { min-width: 1000px; margin: 0 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
.sheet-header {
|
.sheet-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@ -661,6 +671,7 @@ onMounted(load)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
.sheet-viewport { overflow: visible; }
|
||||||
.no-print {
|
.no-print {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import httpService from '@/api/request'
|
import httpService from '@/api/request'
|
||||||
import ProbationPanel from '@/components/payroll/ProbationPanel.vue'
|
import ProbationPanel from '@/components/payroll/ProbationPanel.vue'
|
||||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||||
@ -35,7 +36,7 @@ const filters = reactive({
|
|||||||
status: undefined as number | undefined,
|
status: undefined as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
|
||||||
usePagedList<Settlement, { status?: number }>(listSettlements, () => ({ status: filters.status }))
|
usePagedList<Settlement, { status?: number }>(listSettlements, () => ({ status: filters.status }))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -129,7 +130,6 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="requirements-surface">
|
<div class="requirements-surface">
|
||||||
<ProbationPanel />
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1 class="page-title">月度结算</h1>
|
<h1 class="page-title">月度结算</h1>
|
||||||
@ -178,6 +178,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
|
.filter-bar 直接挂到 el-form 上:el-form 不能当 flex 透明容器,
|
||||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||||
-->
|
-->
|
||||||
|
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||||
<el-form class="filter-bar" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态">
|
||||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
||||||
@ -250,6 +251,8 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProbationPanel />
|
||||||
|
|
||||||
<el-drawer v-model="detailVisible" title="月结详情" size="min(1180px, 96vw)">
|
<el-drawer v-model="detailVisible" title="月结详情" size="min(1180px, 96vw)">
|
||||||
<div v-loading="detailLoading">
|
<div v-loading="detailLoading">
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
@ -275,7 +278,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
|
|
||||||
<p class="section-title section-title--gap">按人明细</p><el-button :disabled="!permissionUser.can('reports:personal:export')" @click="exportSalary(detail.settlement_month)">导出工资分项 Excel</el-button>
|
<p class="section-title section-title--gap">按人明细</p><el-button :disabled="!permissionUser.can('reports:personal:export')" @click="exportSalary(detail.settlement_month)">导出工资分项 Excel</el-button>
|
||||||
<el-table :data="detail.items" size="small" stripe>
|
<el-table :data="detail.items" size="small" stripe>
|
||||||
<el-table-column prop="personnel_name" label="人员" min-width="90" />
|
<el-table-column prop="personnel_name" label="人员" min-width="90" show-overflow-tooltip />
|
||||||
<el-table-column label="类型" width="90">
|
<el-table-column label="类型" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
|
{{ row.calc_type === 1 ? '单笔回款' : '经理聚合' }}
|
||||||
@ -296,7 +299,7 @@ async function handleSubmit(row: Settlement): Promise<void> {
|
|||||||
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
|
<span class="cell-num">{{ row.net_amount ?? '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="basic_amount" label="基本工资" min-width="110" /><el-table-column prop="normal_payable_sum" label="正常绩效" min-width="110" /><el-table-column prop="deferred_sum" label="试用期缓发" min-width="120" /><el-table-column prop="probation_payment_sum" label="年终/续付差额" min-width="140" />
|
<el-table-column prop="basic_amount" label="基本工资" min-width="110" align="right" /><el-table-column prop="normal_payable_sum" label="正常绩效" min-width="110" /><el-table-column prop="deferred_sum" label="试用期缓发" min-width="120" /><el-table-column prop="probation_payment_sum" label="年终/续付差额" min-width="140" />
|
||||||
<el-table-column label="试用期" width="70">
|
<el-table-column label="试用期" width="70">
|
||||||
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
|
<template #default="{ row }">{{ row.is_probation === 1 ? '是' : '否' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||||
const permissionUser = usePermissionUser()
|
const permissionUser = usePermissionUser()
|
||||||
|
|
||||||
@ -43,7 +44,7 @@ const filters = reactive({
|
|||||||
order_type: undefined as number | undefined,
|
order_type: undefined as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange } =
|
||||||
usePagedList<ApprovalOrder, { keyword?: string; status?: number; order_type?: number }>(
|
usePagedList<ApprovalOrder, { keyword?: string; status?: number; order_type?: number }>(
|
||||||
listApprovals,
|
listApprovals,
|
||||||
() => ({
|
() => ({
|
||||||
@ -185,6 +186,7 @@ async function submitAction(): Promise<void> {
|
|||||||
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
|
.filter-bar 直接挂到 el-form 上:再包一层 div 会让 el-form-item
|
||||||
失去 gap 排版(el-form 不是 flex 透明容器)。
|
失去 gap 排版(el-form 不是 flex 透明容器)。
|
||||||
-->
|
-->
|
||||||
|
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||||
<el-form class="filter-bar" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
@ -337,7 +339,7 @@ async function submitAction(): Promise<void> {
|
|||||||
<dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd>
|
<dd>{{ allocationData.cost_amount ? `${allocationData.cost_amount} 元` : '—' }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
|
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
|
||||||
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" />
|
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column label="分配比例" width="120">
|
<el-table-column label="分配比例" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="cell-num">{{ row.ratio }}%</span>
|
<span class="cell-num">{{ row.ratio }}%</span>
|
||||||
@ -371,7 +373,7 @@ async function submitAction(): Promise<void> {
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<el-table :data="settlementData.items" stripe size="small" class="content-table">
|
<el-table :data="settlementData.items" stripe size="small" class="content-table">
|
||||||
<el-table-column prop="personnel_name" label="人员" min-width="110" />
|
<el-table-column prop="personnel_name" label="人员" min-width="110" show-overflow-tooltip />
|
||||||
<el-table-column prop="role_code" label="岗位" width="110" />
|
<el-table-column prop="role_code" label="岗位" width="110" />
|
||||||
<el-table-column label="绩效(元)" width="120">
|
<el-table-column label="绩效(元)" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||||
const permissionUser = usePermissionUser()
|
const permissionUser = usePermissionUser()
|
||||||
|
|
||||||
@ -33,7 +34,7 @@ const filters = reactive({
|
|||||||
status: undefined as number | undefined,
|
status: undefined as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||||
usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>(
|
usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>(
|
||||||
businessRoleApi.listBusinessRoles,
|
businessRoleApi.listBusinessRoles,
|
||||||
() => ({
|
() => ({
|
||||||
@ -67,7 +68,7 @@ const templateLoading = ref(false)
|
|||||||
const templateSubmitting = ref(false)
|
const templateSubmitting = ref(false)
|
||||||
const templateDialogVisible = ref(false)
|
const templateDialogVisible = ref(false)
|
||||||
const templateEditingId = ref<number | null>(null)
|
const templateEditingId = ref<number | null>(null)
|
||||||
const paramOptions = ref<{ code: string; name: string; group: string }[]>([])
|
const paramOptions = ref<{ code: string; name: string; group: string; current: string }[]>([])
|
||||||
|
|
||||||
const templateFormRef = ref<FormInstance>()
|
const templateFormRef = ref<FormInstance>()
|
||||||
const templateForm = reactive({
|
const templateForm = reactive({
|
||||||
@ -99,10 +100,18 @@ function formulaName(code: string): string {
|
|||||||
return formulas.value.find((f) => f.code === code)?.name ?? code
|
return formulas.value.find((f) => f.code === code)?.name ?? code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formulaRoleLabel(formulaType: string, role: string): string {
|
||||||
|
return formulas.value.find((f) => f.code === formulaType)?.role_labels[role] ?? role
|
||||||
|
}
|
||||||
|
|
||||||
function paramName(code: string): string {
|
function paramName(code: string): string {
|
||||||
return paramOptions.value.find((item) => item.code === code)?.name ?? code
|
return paramOptions.value.find((item) => item.code === code)?.name ?? code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paramCurrentValue(code: string): string {
|
||||||
|
return paramOptions.value.find((item) => item.code === code)?.current ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTemplateManager(): Promise<void> {
|
async function loadTemplateManager(): Promise<void> {
|
||||||
templateLoading.value = true
|
templateLoading.value = true
|
||||||
try {
|
try {
|
||||||
@ -111,7 +120,12 @@ async function loadTemplateManager(): Promise<void> {
|
|||||||
formulas.value = formulaList
|
formulas.value = formulaList
|
||||||
if (paramOptions.value.length === 0) {
|
if (paramOptions.value.length === 0) {
|
||||||
const catalog = await paramsApi.getCatalog()
|
const catalog = await paramsApi.getCatalog()
|
||||||
paramOptions.value = catalog.map((item) => ({ code: item.param_code, name: item.param_name, group: item.param_group }))
|
paramOptions.value = catalog.map((item) => ({
|
||||||
|
code: item.param_code,
|
||||||
|
name: item.param_name,
|
||||||
|
group: item.param_group,
|
||||||
|
current: item.values[0]?.value_text ?? item.default_value ?? '未设置',
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handledError(error)
|
handledError(error)
|
||||||
@ -360,6 +374,7 @@ onMounted(() => {
|
|||||||
筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块
|
筛选条:flex 与间距由 .filter-bar 提供,去掉 el-form 的 inline(inline 的行内块
|
||||||
排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。
|
排布会与 gap 叠成不等距);查询/重置由 .filter-bar__actions 推到行尾。
|
||||||
-->
|
-->
|
||||||
|
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||||
<el-form class="filter-bar" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
@ -405,11 +420,11 @@ onMounted(() => {
|
|||||||
<span class="cell-code">{{ row.role_code }}</span>
|
<span class="cell-code">{{ row.role_code }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
|
<el-table-column prop="role_name" label="岗位名称" min-width="110" show-overflow-tooltip />
|
||||||
<el-table-column label="公式模板" min-width="170">
|
<el-table-column label="公式模板" min-width="170">
|
||||||
<template #default="{ row }">{{ templateName(row.calc_template) }}</template>
|
<template #default="{ row }">{{ templateName(row.calc_template) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="description" label="说明" min-width="200">
|
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
|
<!-- 排序是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
|
||||||
@ -467,6 +482,9 @@ onMounted(() => {
|
|||||||
<!-- 新增 / 编辑 -->
|
<!-- 新增 / 编辑 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
|
:close-on-click-modal="!submitting"
|
||||||
|
:close-on-press-escape="!submitting"
|
||||||
|
:show-close="!submitting"
|
||||||
:title="isEdit ? '编辑岗位' : '新增岗位'"
|
:title="isEdit ? '编辑岗位' : '新增岗位'"
|
||||||
width="540px"
|
width="540px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@ -498,11 +516,11 @@ onMounted(() => {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="selectedTemplate" label-width="90px">
|
<el-form-item v-if="selectedTemplate" label-width="90px">
|
||||||
<div class="tpl-info">
|
<div class="tpl-info">
|
||||||
<p>公式模板由系统内置,这里选择适用公式。比例、递减斜率和天数等数值在“计提参数”维护。</p>
|
<p>选择岗位适用的计提公式。比例、斜率和天数等数值在“计提参数”中维护;公式结构可在“公式模板管理”中调整。</p>
|
||||||
<router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params" target="_blank" rel="noopener">打开计提参数</router-link>
|
<router-link v-if="permissionUser.hasPermission('system:params:view')" to="/system/params">打开计提参数</router-link>
|
||||||
<div>{{ selectedTemplate.description }}</div>
|
<div>{{ selectedTemplate.description }}</div>
|
||||||
<div v-if="selectedTemplate.params.length" class="tpl-params">
|
<div v-if="selectedTemplate.params.length" class="tpl-params">
|
||||||
涉及参数:{{ selectedTemplate.params.map((p) => p.name).join('、') }}(在「计提参数」中维护)
|
涉及参数:{{ selectedTemplate.params.map((p) => `${p.name} = ${paramCurrentValue(p.code)}`).join(';') }}(在「计提参数」中维护)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -514,7 +532,7 @@ onMounted(() => {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">保存</el-button>
|
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can('system:business_roles:update')" :title="permissionUser.permissionReason('system:business_roles:update')">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@ -527,9 +545,9 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<el-table v-loading="templateLoading" :data="templates" stripe empty-text="暂无模板定义">
|
<el-table v-loading="templateLoading" :data="templates" stripe empty-text="暂无模板定义">
|
||||||
<el-table-column prop="template_code" label="编码" min-width="180"><template #default="{ row }"><span class="cell-code">{{ row.template_code }}</span></template></el-table-column>
|
<el-table-column prop="template_code" label="编码" min-width="180"><template #default="{ row }"><span class="cell-code">{{ row.template_code }}</span></template></el-table-column>
|
||||||
<el-table-column prop="template_name" label="名称" min-width="160" />
|
<el-table-column prop="template_name" label="名称" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column label="骨架" min-width="130"><template #default="{ row }">{{ formulaName(row.formula_type) }}</template></el-table-column>
|
<el-table-column label="骨架" min-width="130"><template #default="{ row }">{{ formulaName(row.formula_type) }}</template></el-table-column>
|
||||||
<el-table-column label="参数绑定" min-width="240"><template #default="{ row }">{{ (Object.entries(row.params) as [string, string][]).map(([role, code]) => `${role}:${paramName(code)}`).join(';') || '—' }}</template></el-table-column>
|
<el-table-column label="参数绑定" min-width="240"><template #default="{ row }">{{ (Object.entries(row.params) as [string, string][]).map(([role, code]) => `${formulaRoleLabel(row.formula_type, role)}:${paramName(code)}`).join(';') || '—' }}</template></el-table-column>
|
||||||
<el-table-column label="岗位引用" width="90" align="right"><template #default="{ row }"><span class="cell-num">{{ row.role_count }}</span></template></el-table-column>
|
<el-table-column label="岗位引用" width="90" align="right"><template #default="{ row }"><span class="cell-num">{{ row.role_count }}</span></template></el-table-column>
|
||||||
<el-table-column label="状态" width="90" align="center"><template #default="{ row }">{{ row.is_system === 1 ? '系统' : '自定义' }}</template></el-table-column>
|
<el-table-column label="状态" width="90" align="center"><template #default="{ row }">{{ row.is_system === 1 ? '系统' : '自定义' }}</template></el-table-column>
|
||||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||||
@ -564,8 +582,18 @@ onMounted(() => {
|
|||||||
<p v-if="selectedFormula" class="form-hint">{{ selectedFormula.description }}</p>
|
<p v-if="selectedFormula" class="form-hint">{{ selectedFormula.description }}</p>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="selectedFormula">
|
<template v-if="selectedFormula">
|
||||||
<el-form-item v-for="role in selectedFormula.roles" :key="role" :label="`参数·${role}`">
|
<el-form-item
|
||||||
<el-select v-model="templateForm.params[role]" :disabled="editingTemplate?.is_system === 1" placeholder="选择计提参数" style="width: 100%" filterable>
|
v-for="role in selectedFormula.roles"
|
||||||
|
:key="role"
|
||||||
|
:label="selectedFormula.role_labels[role] ?? role"
|
||||||
|
>
|
||||||
|
<el-select
|
||||||
|
v-model="templateForm.params[role]"
|
||||||
|
:disabled="editingTemplate?.is_system === 1"
|
||||||
|
:placeholder="selectedFormula.role_hints[role] ?? '选择计提参数'"
|
||||||
|
style="width: 100%"
|
||||||
|
filterable
|
||||||
|
>
|
||||||
<el-option v-for="item in paramOptions" :key="item.code" :label="`${item.name}(${item.group})`" :value="item.code" />
|
<el-option v-for="item in paramOptions" :key="item.code" :label="`${item.name}(${item.group})`" :value="item.code" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@ -415,6 +415,7 @@ watch(activeTab, (tab) => {
|
|||||||
.log-detail {
|
.log-detail {
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
color: var(--ys-ink-2);
|
color: var(--ys-ink-2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-detail__label {
|
.log-detail__label {
|
||||||
@ -426,6 +427,8 @@ watch(activeTab, (tab) => {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin: 6px 0 12px;
|
margin: 6px 0 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
font-family: Consolas, 'Courier New', monospace;
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background-color: var(--ys-paper);
|
background-color: var(--ys-paper);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const permissionUser = usePermissionUser()
|
|||||||
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type InputInstance, type SelectInstance } from 'element-plus'
|
import { ElMessage, ElMessageBox, type InputInstance, type SelectInstance } from 'element-plus'
|
||||||
|
import { Clock, DataAnalysis, Download, Search } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
import * as businessRoleApi from '@/api/businessRoles'
|
import * as businessRoleApi from '@/api/businessRoles'
|
||||||
import * as paramsApi from '@/api/params'
|
import * as paramsApi from '@/api/params'
|
||||||
@ -18,6 +19,7 @@ import type {
|
|||||||
ParamPreviewResult,
|
ParamPreviewResult,
|
||||||
} from '@/api/params'
|
} from '@/api/params'
|
||||||
import AppPagination from '@/components/table/AppPagination.vue'
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
import { usePagedList } from '@/composables/usePagedList'
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
@ -43,10 +45,13 @@ const isAdmin = computed(() => userStore.can('system:params:update'))
|
|||||||
|
|
||||||
const catalog = ref<ParamCatalogItem[]>([])
|
const catalog = ref<ParamCatalogItem[]>([])
|
||||||
const catalogLoading = ref(false)
|
const catalogLoading = ref(false)
|
||||||
|
const catalogFailed = ref(false)
|
||||||
|
const catalogKeyword = ref('')
|
||||||
const expandedGroups = ref(['业务员个人户'])
|
const expandedGroups = ref(['业务员个人户'])
|
||||||
|
|
||||||
async function loadCatalog(): Promise<void> {
|
async function loadCatalog(): Promise<void> {
|
||||||
catalogLoading.value = true
|
catalogLoading.value = true
|
||||||
|
catalogFailed.value = false
|
||||||
try {
|
try {
|
||||||
catalog.value = await paramsApi.getCatalog()
|
catalog.value = await paramsApi.getCatalog()
|
||||||
// 预览「所用参数」按编码取中文名;目录加载后填充映射
|
// 预览「所用参数」按编码取中文名;目录加载后填充映射
|
||||||
@ -54,6 +59,7 @@ async function loadCatalog(): Promise<void> {
|
|||||||
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
|
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
catalogFailed.value = true
|
||||||
handledError(error)
|
handledError(error)
|
||||||
} finally {
|
} finally {
|
||||||
catalogLoading.value = false
|
catalogLoading.value = false
|
||||||
@ -72,6 +78,24 @@ const groupedCatalog = computed(() => {
|
|||||||
return [...groups.entries()].map(([name, items]) => ({ name, items }))
|
return [...groups.entries()].map(([name, items]) => ({ name, items }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ruleCount = computed(() => groupedCatalog.value.reduce((total, group) => total + group.items.length, 0))
|
||||||
|
const visibleGroups = computed(() => {
|
||||||
|
const keyword = catalogKeyword.value.trim().toLowerCase()
|
||||||
|
if (!keyword) return groupedCatalog.value
|
||||||
|
return groupedCatalog.value.map(group => ({
|
||||||
|
...group,
|
||||||
|
items: group.name.toLowerCase().includes(keyword) ? group.items : group.items.filter(item =>
|
||||||
|
`${item.param_name} ${item.param_code} ${item.description ?? ''}`.toLowerCase().includes(keyword)),
|
||||||
|
})).filter(group => group.items.length)
|
||||||
|
})
|
||||||
|
const allGroupsExpanded = computed(() => visibleGroups.value.length > 0 && visibleGroups.value.every(group => expandedGroups.value.includes(group.name)))
|
||||||
|
function toggleGroups(): void {
|
||||||
|
expandedGroups.value = allGroupsExpanded.value ? [] : visibleGroups.value.map(group => group.name)
|
||||||
|
}
|
||||||
|
watch(catalogKeyword, keyword => {
|
||||||
|
if (keyword.trim()) expandedGroups.value = visibleGroups.value.map(group => group.name)
|
||||||
|
})
|
||||||
|
|
||||||
/** 维度值 → 展示名(NONE 维度为空串;角色维度给出中文岗位名) */
|
/** 维度值 → 展示名(NONE 维度为空串;角色维度给出中文岗位名) */
|
||||||
const DIMENSION_LABELS: Record<string, string> = {
|
const DIMENSION_LABELS: Record<string, string> = {
|
||||||
'': '默认',
|
'': '默认',
|
||||||
@ -283,6 +307,7 @@ const {
|
|||||||
pageNum: batchesPage,
|
pageNum: batchesPage,
|
||||||
pageSize: batchesPageSize,
|
pageSize: batchesPageSize,
|
||||||
loading: batchesLoading,
|
loading: batchesLoading,
|
||||||
|
loadFailed: batchesFailed,
|
||||||
load: loadBatches,
|
load: loadBatches,
|
||||||
onPageChange: onBatchesPageChange,
|
onPageChange: onBatchesPageChange,
|
||||||
onSizeChange: onBatchesSizeChange,
|
onSizeChange: onBatchesSizeChange,
|
||||||
@ -386,6 +411,7 @@ const {
|
|||||||
pageNum: historyPage,
|
pageNum: historyPage,
|
||||||
pageSize: historyPageSize,
|
pageSize: historyPageSize,
|
||||||
loading: historyLoading,
|
loading: historyLoading,
|
||||||
|
loadFailed: historyFailed,
|
||||||
load: loadHistory,
|
load: loadHistory,
|
||||||
onPageChange: onHistoryPageChange,
|
onPageChange: onHistoryPageChange,
|
||||||
onSizeChange: onHistorySizeChange,
|
onSizeChange: onHistorySizeChange,
|
||||||
@ -480,34 +506,47 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="requirements-surface">
|
<div class="requirements-surface">
|
||||||
<CoefficientPanel />
|
<div class="page params-page">
|
||||||
<div class="page">
|
<header class="page-head params-head">
|
||||||
<!-- 页面头:这句描述原本挤在工具栏左侧当说明,现在归位成页面描述,语义更清楚 -->
|
<div>
|
||||||
<div class="page-head">
|
|
||||||
<h1 class="page-title">计提参数</h1>
|
<h1 class="page-title">计提参数</h1>
|
||||||
<p class="page-desc">
|
<p class="page-desc">核对产品系数、维护计提规则,查看变更历史与计算预览。</p>
|
||||||
维护绩效计算规则与产品系数,查看当前生效值和变更历史。修改先暂存,提交时可选择生效日期。
|
|
||||||
</p>
|
|
||||||
<div class="waterline"></div>
|
<div class="waterline"></div>
|
||||||
</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 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>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="card">
|
<nav class="params-outline" aria-label="计提参数页内导航">
|
||||||
<div class="card-body">
|
<a href="#coefficient-section">产品系数</a>
|
||||||
<el-collapse v-model="expandedGroups" v-loading="catalogLoading">
|
<a href="#rules-section">计提规则</a>
|
||||||
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
|
<button v-if="pendingList.length" type="button" class="pending-anchor" @click="showPending">待提交变更 <b>{{ pendingList.length }}</b></button>
|
||||||
|
<span>规则参数修改先暂存,提交时确认生效日期。</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<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>
|
<template #title>
|
||||||
<span class="group-title">{{ group.name }}</span>
|
<span class="group-title">{{ group.name }} <span>{{ group.items.length }} 项参数</span></span>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="group.name === '审批开关'" class="approval-notice">
|
<p v-if="group.name === '审批开关'" class="approval-notice">
|
||||||
{{ approvalEnabled ? '系统审核已开启,以下设置决定各类单据是否进入审核流程。' : '系统审核当前关闭。以下为保留的审核设置;生成工资后直接完成,不产生审核流程记录。' }}
|
{{ approvalEnabled ? '系统审核已开启,以下设置决定各类单据是否进入审核流程。' : '系统审核当前关闭。以下为保留的审核设置;生成工资后直接完成,不产生审核流程记录。' }}
|
||||||
@ -523,16 +562,17 @@ onMounted(() => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in group.items" :key="item.param_code">
|
<tr v-for="item in group.items" :key="item.param_code">
|
||||||
<td>
|
<td class="param-definition-cell">
|
||||||
<div class="param-name">{{ item.param_name }}</div>
|
<div class="param-name">{{ item.param_name }}</div>
|
||||||
<div class="param-desc">{{ item.description || item.param_code }}</div>
|
<div class="param-desc">{{ item.description || item.param_code }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="param-value-cell">
|
||||||
|
<span class="mobile-value-label">当前生效值</span>
|
||||||
<template v-if="item.values.length">
|
<template v-if="item.values.length">
|
||||||
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
|
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
|
||||||
|
<div class="value-info">
|
||||||
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
|
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
|
||||||
<b class="num">{{ formatValue(item, v.value_text) }}</b>
|
<b class="num">{{ formatValue(item, v.value_text) }}</b>
|
||||||
<span class="eff">{{ v.effective_date }} 起</span>
|
|
||||||
<el-tag
|
<el-tag
|
||||||
v-if="pendingOf(item, v.dimension_value)"
|
v-if="pendingOf(item, v.dimension_value)"
|
||||||
size="small"
|
size="small"
|
||||||
@ -541,6 +581,9 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
|
待改为 {{ formatValue(item, pendingOf(item, v.dimension_value)!.new_value) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="value-meta">
|
||||||
|
<span v-if="v.effective_date" class="eff">{{ v.effective_date }} 起</span>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
link
|
link
|
||||||
@ -551,12 +594,13 @@ onMounted(() => {
|
|||||||
修改
|
修改
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="no-value">
|
<span v-else class="no-value">
|
||||||
未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
|
未设置(默认 {{ formatByCode(item.param_code, item.default_value) }})
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="param-actions-cell">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isAdmin && !item.values.length"
|
v-if="isAdmin && !item.values.length"
|
||||||
link
|
link
|
||||||
@ -575,7 +619,7 @@ onMounted(() => {
|
|||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
|
<!-- 待提交批次(仅管理员可见可操作):提交前只是暂存,故用独立的次级卡片承载 -->
|
||||||
<div v-if="isAdmin && pendingList.length" ref="pendingCard" class="card pending-card">
|
<div v-if="isAdmin && pendingList.length" ref="pendingCard" class="card pending-card">
|
||||||
@ -585,7 +629,7 @@ onMounted(() => {
|
|||||||
<span class="pending-hint">暂存内容尚未生效。请确认参数值与生效日期后提交。</span>
|
<span class="pending-hint">暂存内容尚未生效。请确认参数值与生效日期后提交。</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="pendingList" size="small">
|
<el-table :data="pendingList" size="small" class="pending-table">
|
||||||
<el-table-column label="参数" min-width="160">
|
<el-table-column label="参数" min-width="160">
|
||||||
<template #default="{ row }">{{ row.item.param_name }}</template>
|
<template #default="{ row }">{{ row.item.param_name }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -608,26 +652,46 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div class="card-foot">
|
<ul class="pending-list" aria-label="待提交参数核对">
|
||||||
<div class="submit-row">
|
<li v-for="row in pendingList" :key="row.key">
|
||||||
|
<div class="pending-line-head">
|
||||||
|
<div><strong>{{ row.item.param_name }}</strong><span>{{ dimensionLabel(row.dimension_value) }}</span></div>
|
||||||
|
<el-button link type="danger" @click="removePending(row.key)">移除</el-button>
|
||||||
|
</div>
|
||||||
|
<dl class="pending-values">
|
||||||
|
<div><dt>原值</dt><dd>{{ row.old_value ? formatByCode(row.item.param_code, row.old_value) : '—' }}</dd></div>
|
||||||
|
<div><dt>调整为</dt><dd class="pending-new-value">{{ formatByCode(row.item.param_code, row.new_value) }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="card-foot pending-footer">
|
||||||
|
<el-form label-position="top" class="submit-options" :disabled="submitting" @submit.prevent>
|
||||||
|
<el-form-item label="生效方式">
|
||||||
<el-radio-group v-model="changeType">
|
<el-radio-group v-model="changeType">
|
||||||
<el-radio-button :value="1">立即生效</el-radio-button>
|
<el-radio-button :value="1">立即生效</el-radio-button>
|
||||||
<el-radio-button :value="2">指定日期生效</el-radio-button>
|
<el-radio-button :value="2">指定日期生效</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="changeType === 2" label="生效日期">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-if="changeType === 2"
|
|
||||||
v-model="effectiveDate"
|
v-model="effectiveDate"
|
||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
placeholder="生效日期"
|
placeholder="生效日期"
|
||||||
style="width: 150px"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="变更说明(选填)" class="submit-remark">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="remark"
|
v-model="remark"
|
||||||
placeholder="变更说明(选填,如:调整第四季度计提比例)"
|
placeholder="例如:调整第四季度计提比例"
|
||||||
style="width: 240px"
|
style="width: 100%"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
/>
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="submit-commit">
|
||||||
|
<span>请核对以上新值与生效方式,提交后生成变更版本。</span>
|
||||||
<el-button type="primary" :loading="submitting" @click="submitAll" :disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">提交变更</el-button>
|
<el-button type="primary" :loading="submitting" @click="submitAll" :disabled="!permissionUser.can('system:params:update')" :title="permissionUser.permissionReason('system:params:update')">提交变更</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -708,24 +772,32 @@ onMounted(() => {
|
|||||||
<!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
|
<!-- 版本管理抽屉:对比两个日期各自的生效值 + 变更记录 -->
|
||||||
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
|
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
|
||||||
<div class="drawer-block">
|
<div class="drawer-block">
|
||||||
<div class="drawer-row">
|
<h3 class="drawer-section-title">按日期对比</h3>
|
||||||
|
<p class="drawer-section-note">对比两个日期各自生效的参数值,核对调整范围。</p>
|
||||||
|
<el-form class="compare-form" label-position="top" @submit.prevent="doCompare">
|
||||||
|
<el-form-item label="起始日期">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="compareFrom"
|
v-model="compareFrom"
|
||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
placeholder="对比起始日期"
|
placeholder="对比起始日期"
|
||||||
style="width: 150px"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="截止日期">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="compareTo"
|
v-model="compareTo"
|
||||||
type="date"
|
type="date"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
placeholder="对比截止日期"
|
placeholder="对比截止日期"
|
||||||
style="width: 150px"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
<el-button :loading="comparing" @click="doCompare">对比</el-button>
|
</el-form-item>
|
||||||
|
<div class="compare-actions">
|
||||||
|
<el-button type="primary" :loading="comparing" native-type="submit">对比</el-button>
|
||||||
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
|
<el-button :loading="exporting" @click="doExport" :disabled="!permissionUser.can('system:params:export')" :title="permissionUser.permissionReason('system:params:export')">导出记录</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</el-form>
|
||||||
<template v-if="diffSearched">
|
<template v-if="diffSearched">
|
||||||
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
|
<div class="drawer-title">两个日期的参数对比({{ compareFrom }} → {{ compareTo }})</div>
|
||||||
<el-table :data="diffResult" size="small" max-height="260">
|
<el-table :data="diffResult" size="small" max-height="260">
|
||||||
@ -751,8 +823,9 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drawer-block">
|
<div class="drawer-block">
|
||||||
<div class="drawer-title">变更记录</div>
|
<h3 class="drawer-section-title">变更记录</h3>
|
||||||
<el-table v-loading="batchesLoading" :data="batches" size="small">
|
<ListLoadError v-if="batchesFailed" :loading="batchesLoading" @retry="loadBatches" />
|
||||||
|
<el-table v-loading="batchesLoading" :data="batches" size="small" empty-text="暂无参数变更记录">
|
||||||
<el-table-column type="expand">
|
<el-table-column type="expand">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="batch-items">
|
<div class="batch-items">
|
||||||
@ -798,11 +871,14 @@ onMounted(() => {
|
|||||||
@size-change="onBatchesSizeChange"
|
@size-change="onBatchesSizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<template #footer><el-button @click="versionVisible=false">关闭</el-button></template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
<!-- 历史抽屉 -->
|
<!-- 历史抽屉 -->
|
||||||
<el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px">
|
<el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px">
|
||||||
<el-table v-loading="historyLoading" :data="historyRows" size="small">
|
<p class="drawer-section-note">按生效日期查看历次调整,保留修改前后的参数值。</p>
|
||||||
|
<ListLoadError v-if="historyFailed" :loading="historyLoading" @retry="loadHistory" />
|
||||||
|
<el-table v-loading="historyLoading" :data="historyRows" size="small" empty-text="该参数暂无变更记录">
|
||||||
<el-table-column prop="effective_date" label="生效日期" width="105" />
|
<el-table-column prop="effective_date" label="生效日期" width="105" />
|
||||||
<el-table-column label="适用范围" width="90">
|
<el-table-column label="适用范围" width="90">
|
||||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||||
@ -826,12 +902,14 @@ onMounted(() => {
|
|||||||
@page-change="onHistoryPageChange"
|
@page-change="onHistoryPageChange"
|
||||||
@size-change="onHistorySizeChange"
|
@size-change="onHistorySizeChange"
|
||||||
/>
|
/>
|
||||||
|
<template #footer><el-button @click="historyVisible=false">关闭</el-button></template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
<!-- 计算预览抽屉 -->
|
<!-- 计算预览抽屉 -->
|
||||||
<el-drawer v-model="previewVisible" title="计算预览" size="480px">
|
<el-drawer v-model="previewVisible" title="计算预览" size="480px">
|
||||||
<p class="preview-scope-note">按所选日期的参数试算单笔公式,不生成工资或修改业务数据。正式工资请在业务管理中按月核算。</p>
|
<p class="preview-scope-note">按所选日期的参数试算单笔公式,不生成工资或修改业务数据。正式工资请在业务管理中按月核算。</p>
|
||||||
<el-form label-width="90px" @submit.prevent>
|
<el-form label-position="top" class="preview-form" @submit.prevent>
|
||||||
|
<h3 class="form-section-title">业务条件</h3>
|
||||||
<el-form-item label="客户类型">
|
<el-form-item label="客户类型">
|
||||||
<el-select v-model="previewForm.customer_type" style="width: 100%">
|
<el-select v-model="previewForm.customer_type" style="width: 100%">
|
||||||
<el-option v-for="o in CUSTOMER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
<el-option v-for="o in CUSTOMER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||||
@ -868,24 +946,24 @@ onMounted(() => {
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="销售额">
|
<h3 class="form-section-title">金额信息</h3>
|
||||||
|
<el-form-item label="销售额(元)">
|
||||||
<el-input v-model="previewForm.revenue" placeholder="不含运费" />
|
<el-input v-model="previewForm.revenue" placeholder="不含运费" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="毛利润额">
|
<el-form-item label="毛利润额(元)">
|
||||||
<el-input v-model="previewForm.profit" placeholder="可为负" />
|
<el-input v-model="previewForm.profit" placeholder="可为负" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="本笔回款">
|
<el-form-item label="本笔回款(元)">
|
||||||
<el-input v-model="previewForm.received_amount" />
|
<el-input v-model="previewForm.received_amount" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div class="preview-flags">
|
||||||
<el-form-item label="以物抵顶">
|
<el-form-item label="以物抵顶">
|
||||||
<el-switch v-model="previewForm.barter" />
|
<el-switch v-model="previewForm.barter" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="试用期">
|
<el-form-item label="试用期">
|
||||||
<el-switch v-model="previewForm.probation" />
|
<el-switch v-model="previewForm.probation" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
</div>
|
||||||
<el-button type="primary" :loading="previewLoading" @click="runPreview" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">开始试算</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template v-if="previewResult">
|
<template v-if="previewResult">
|
||||||
@ -912,21 +990,55 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="previewVisible=false">关闭</el-button>
|
||||||
|
<el-button type="primary" :loading="previewLoading" @click="runPreview" :disabled="!permissionUser.can('system:params:preview')" :title="permissionUser.permissionReason('system:params:preview')">开始试算</el-button>
|
||||||
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 页面里已经没有 .page-toolbar/.page-desc —— 描述与工具栏改用全局 .page-head/.toolbar */
|
.params-page { --params-section-gap: 24px; }
|
||||||
|
.params-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 16px 24px; }
|
||||||
|
.params-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
.params-outline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; padding: 0 0 16px; margin-bottom: var(--params-section-gap); border-bottom: 1px solid var(--ys-line); font-size: 13px; line-height: 1.7; }
|
||||||
|
.params-outline a, .params-outline button { color: var(--ys-ink-2); text-decoration: none; }
|
||||||
|
.params-outline a:hover, .params-outline button:hover { color: var(--ys-indigo); }
|
||||||
|
.params-outline button { border: 0; padding: 0; background: none; font: inherit; cursor: pointer; }
|
||||||
|
.params-outline > span { margin-left: auto; color: var(--ys-ink-3); font-size: 12px; }
|
||||||
|
.params-outline .pending-anchor { color: #895d12; }
|
||||||
|
.pending-anchor b { margin-left: 4px; font-variant-numeric: tabular-nums; }
|
||||||
|
#coefficient-section, #rules-section, .pending-card { scroll-margin-top: 20px; }
|
||||||
|
.rules-section { container-type: inline-size; margin-top: var(--params-section-gap); }
|
||||||
|
.rules-head { align-items: center; padding: 20px; }
|
||||||
|
.rules-head h2 { margin: 0; color: var(--ys-ink); font-size: 16px; font-weight: 600; line-height: 1.6; }
|
||||||
|
.rules-head h2 > span { margin-left: 10px; color: var(--ys-ink-3); font-size: 12px; font-weight: 400; }
|
||||||
|
.rules-head p { margin: 6px 0 0; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
|
||||||
|
.rule-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
.rule-tools .el-input { width: 260px; }
|
||||||
|
.rule-catalog { padding: 0 20px; min-height: 120px; }
|
||||||
|
.catalog-empty { margin: 0; padding: 40px 16px; color: var(--ys-ink-3); text-align: center; line-height: 1.8; }
|
||||||
|
.rule-catalog :deep(.el-collapse) { border-top: 0; border-bottom: 0; }
|
||||||
|
.rule-catalog :deep(.el-collapse-item:last-child .el-collapse-item__header) { border-bottom: 0; }
|
||||||
|
.rule-catalog :deep(.el-collapse-item__header) { min-height: 56px; height: auto; padding: 12px 0; line-height: 1.6; }
|
||||||
|
.rule-catalog :deep(.el-collapse-item__content) { padding-bottom: 16px; }
|
||||||
|
.rule-catalog :deep(.el-collapse-item__wrap) { border-bottom: 0; }
|
||||||
|
|
||||||
.group-title {
|
.group-title {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
}
|
}
|
||||||
|
.group-title > span { color: var(--ys-ink-3); font-size: 12px; font-weight: 400; }
|
||||||
|
|
||||||
.param-table {
|
.param-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 640px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -950,22 +1062,27 @@ onMounted(() => {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.01em;
|
||||||
color: var(--ys-ink-2);
|
color: var(--ys-ink-2);
|
||||||
padding: 6px 8px;
|
padding: 10px 12px;
|
||||||
|
background: var(--ys-mist);
|
||||||
border-bottom: 1px solid var(--ys-line);
|
border-bottom: 1px solid var(--ys-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.param-table td {
|
.param-table td {
|
||||||
padding: 10px 8px;
|
padding: 16px 12px;
|
||||||
border-bottom: 1px solid var(--ys-line);
|
border-bottom: 1px solid var(--ys-line);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.param-name {
|
.param-name {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.param-actions-cell { white-space: nowrap; }
|
||||||
|
.param-actions-cell .el-button { min-height: 32px; }
|
||||||
|
.param-actions-cell .el-button + .el-button { margin-left: 8px; }
|
||||||
|
.mobile-value-label { display: none; }
|
||||||
|
|
||||||
.param-desc {
|
.param-desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -976,12 +1093,17 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.value-row {
|
.value-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px 16px;
|
||||||
line-height: 1.9;
|
line-height: 1.9;
|
||||||
}
|
}
|
||||||
|
.value-row + .value-row { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--ys-line-soft); }
|
||||||
|
.value-info, .value-meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; min-width: 0; }
|
||||||
|
.value-info .num { color: var(--ys-ink); font-size: 14px; font-weight: 600; }
|
||||||
|
.value-meta { justify-content: flex-end; }
|
||||||
|
.value-meta .el-button { min-height: 32px; }
|
||||||
|
|
||||||
.dim-label {
|
.dim-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -1005,11 +1127,22 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
|
/* 待提交卡片紧跟目录卡,需要与上方卡片拉开距离(.card 自身不带外边距) */
|
||||||
.pending-card {
|
.pending-card {
|
||||||
margin-top: 14px;
|
container-type: inline-size;
|
||||||
|
margin-top: var(--params-section-gap);
|
||||||
}
|
}
|
||||||
|
.pending-list { display: none; margin: 0; padding: 0 16px; list-style: none; }
|
||||||
|
.pending-list li { padding: 16px 0; border-bottom: 1px solid var(--ys-line-soft); }
|
||||||
|
.pending-line-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
|
.pending-line-head strong { color: var(--ys-ink); font-size: 13px; font-weight: 600; }
|
||||||
|
.pending-line-head span { display: block; margin-top: 6px; color: var(--ys-ink-3); font-size: 12px; }
|
||||||
|
.pending-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 16px 0 0; }
|
||||||
|
.pending-values dt { color: var(--ys-ink-3); font-size: 12px; }
|
||||||
|
.pending-values dd { margin: 6px 0 0; color: var(--ys-ink-2); font-size: 14px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||||
|
.pending-values .pending-new-value { color: var(--ys-indigo); font-weight: 600; }
|
||||||
|
|
||||||
.pending-head {
|
.pending-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@ -1019,16 +1152,12 @@ onMounted(() => {
|
|||||||
color: var(--ys-ink-3);
|
color: var(--ys-ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
.pending-footer { display: block; padding: 20px; }
|
||||||
* 提交区已放进 .card-foot:底栏自带 10px 16px 内边距与右对齐,
|
.submit-options { display: flex; flex-wrap: wrap; gap: 0 20px; }
|
||||||
* 所以这里去掉原先的 margin-top,否则会与底栏留白叠成双份。
|
.submit-options .el-form-item { flex: 0 1 auto; min-width: 0; margin-bottom: 16px; }
|
||||||
*/
|
.submit-options .submit-remark { flex: 1 1 280px; }
|
||||||
.submit-row {
|
.submit-commit { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding-top: 16px; border-top: 1px solid var(--ys-line); }
|
||||||
display: flex;
|
.submit-commit > span { color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-name {
|
.edit-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@ -1073,14 +1202,11 @@ onMounted(() => {
|
|||||||
.drawer-block {
|
.drawer-block {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
.drawer-section-title { margin: 0 0 12px; color: var(--ys-ink); font-size: 15px; font-weight: 600; }
|
||||||
.drawer-row {
|
.drawer-section-note { margin: 0 0 20px; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
|
||||||
display: flex;
|
.compare-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||||
flex-wrap: wrap;
|
.compare-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; margin-bottom: 8px; }
|
||||||
gap: 10px;
|
.preview-flags { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-title {
|
.drawer-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -1097,6 +1223,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.batch-item {
|
.batch-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
@ -1125,6 +1252,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ys-ink-2);
|
color: var(--ys-ink-2);
|
||||||
@ -1144,12 +1272,14 @@ onMounted(() => {
|
|||||||
.pr-row b {
|
.pr-row b {
|
||||||
color: var(--ys-ink);
|
color: var(--ys-ink);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pr-note {
|
.pr-note {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ys-warn);
|
color: #895d12;
|
||||||
background: #fbf4e4;
|
background: #fbf4e4;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@ -1165,4 +1295,36 @@ onMounted(() => {
|
|||||||
line-height: 2;
|
line-height: 2;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container (max-width: 650px) {
|
||||||
|
.pending-table { display: none; }
|
||||||
|
.pending-list { display: block; }
|
||||||
|
.rules-head { gap: 16px; }
|
||||||
|
.rule-tools { width: 100%; }
|
||||||
|
.rule-tools .el-input { flex: 1; width: auto; min-width: 140px; }
|
||||||
|
.param-table { min-width: 0; }
|
||||||
|
.param-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||||
|
.param-table, .param-table tbody, .param-table tr, .param-table td { display: block; width: 100%; }
|
||||||
|
.param-table tr { padding: 16px 0; border-bottom: 1px solid var(--ys-line); }
|
||||||
|
.param-table tr:last-child { border-bottom: 0; }
|
||||||
|
.param-table td { padding: 0; border-bottom: 0; }
|
||||||
|
.param-value-cell { margin-top: 14px; }
|
||||||
|
.mobile-value-label { display: block; margin-bottom: 8px; color: var(--ys-ink-3); font-size: 12px; }
|
||||||
|
.param-actions-cell { margin-top: 8px; text-align: right; }
|
||||||
|
.value-row { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
||||||
|
.value-meta { justify-content: space-between; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.params-page { --params-section-gap: 20px; }
|
||||||
|
.params-actions { width: 100%; }
|
||||||
|
.params-outline { gap: 10px 20px; }
|
||||||
|
.params-outline > span { width: 100%; margin-left: 0; }
|
||||||
|
.rules-head { padding: 16px; }
|
||||||
|
.rule-catalog { padding: 0 16px; }
|
||||||
|
.pending-head { gap: 8px; }
|
||||||
|
.pending-footer { padding: 16px; }
|
||||||
|
.submit-options .el-form-item { flex-basis: 100%; }
|
||||||
|
.compare-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
|
||||||
@ -35,7 +36,7 @@ const filters = reactive({
|
|||||||
status: undefined as number | undefined,
|
status: undefined as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||||
usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({
|
usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({
|
||||||
keyword: filters.keyword,
|
keyword: filters.keyword,
|
||||||
status: filters.status,
|
status: filters.status,
|
||||||
@ -177,6 +178,7 @@ onMounted(load)
|
|||||||
筛选条:.filter-bar 自带 flex + gap + 内边距,故 el-form 不再写 inline;
|
筛选条:.filter-bar 自带 flex + gap + 内边距,故 el-form 不再写 inline;
|
||||||
查询/重置收进 .filter-bar__actions,靠 margin-left:auto 贴到行尾。
|
查询/重置收进 .filter-bar__actions,靠 margin-left:auto 贴到行尾。
|
||||||
-->
|
-->
|
||||||
|
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||||
<el-form class="filter-bar" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
@ -230,8 +232,8 @@ onMounted(load)
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="role_name" label="名称" min-width="120" />
|
<el-table-column prop="role_name" label="名称" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column prop="description" label="说明" min-width="180">
|
<el-table-column prop="description" label="说明" min-width="180" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
|
<!-- 排序值与用户数都是用于比较的数字:右对齐 + tabular-nums,纵向扫读时数位对齐 -->
|
||||||
@ -295,6 +297,9 @@ onMounted(load)
|
|||||||
<CreateRoleDrawer v-model="createOpen" @saved="load" />
|
<CreateRoleDrawer v-model="createOpen" @saved="load" />
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
|
:close-on-click-modal="!submitting"
|
||||||
|
:close-on-press-escape="!submitting"
|
||||||
|
:show-close="!submitting"
|
||||||
:title="isEdit ? '编辑角色' : '新增角色'"
|
:title="isEdit ? '编辑角色' : '新增角色'"
|
||||||
width="480px"
|
width="480px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@ -318,7 +323,7 @@ onMounted(load)
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
@ -540,4 +540,12 @@ const previewName = computed(() => form.company_name.trim() || '衍水集团')
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.field-grid { grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||||
|
.field-control__main, .field-control__note { min-width: 0; flex-basis: 100%; }
|
||||||
|
.mock-card { padding: 24px 18px; }
|
||||||
|
.mock-captcha { flex-basis: 80px; }
|
||||||
|
.preview-note { line-height: 1.6; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
|
||||||
@ -42,7 +43,7 @@ const filters = reactive({
|
|||||||
role_code: undefined as string | undefined,
|
role_code: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||||
usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({
|
usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({
|
||||||
username: filters.username,
|
username: filters.username,
|
||||||
real_name: filters.real_name,
|
real_name: filters.real_name,
|
||||||
@ -266,6 +267,7 @@ onMounted(() => {
|
|||||||
inline 模式靠行内块排布,会带基线下沉与固定 right-margin,与 gap 叠成不等距;
|
inline 模式靠行内块排布,会带基线下沉与固定 right-margin,与 gap 叠成不等距;
|
||||||
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
|
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
|
||||||
-->
|
-->
|
||||||
|
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||||
<el-form class="filter-bar" @submit.prevent>
|
<el-form class="filter-bar" @submit.prevent>
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="用户名">
|
||||||
<el-input
|
<el-input
|
||||||
@ -322,7 +324,7 @@ onMounted(() => {
|
|||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="records"
|
:data="records"
|
||||||
stripe
|
stripe
|
||||||
empty-text="还没有用户,点「新增用户」创建第一个账号"
|
empty-text="暂无符合条件的用户,可调整筛选条件。"
|
||||||
>
|
>
|
||||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||||
<el-table-column prop="real_name" label="姓名" min-width="100">
|
<el-table-column prop="real_name" label="姓名" min-width="100">
|
||||||
@ -405,11 +407,15 @@ onMounted(() => {
|
|||||||
<!-- 新增 / 编辑 -->
|
<!-- 新增 / 编辑 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
|
:close-on-click-modal="!submitting"
|
||||||
|
:close-on-press-escape="!submitting"
|
||||||
|
:show-close="!submitting"
|
||||||
:title="isEdit ? '编辑用户' : '新增用户'"
|
:title="isEdit ? '编辑用户' : '新增用户'"
|
||||||
width="520px"
|
width="520px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
|
<h2 class="form-section-title">登录账号</h2>
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item label="用户名" prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
@ -425,6 +431,7 @@ onMounted(() => {
|
|||||||
placeholder="8–72 字节(中文一个字符约 3 字节)"
|
placeholder="8–72 字节(中文一个字符约 3 字节)"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<h2 class="form-section-title">人员信息</h2>
|
||||||
<el-form-item label="姓名" prop="real_name">
|
<el-form-item label="姓名" prop="real_name">
|
||||||
<el-input v-model="form.real_name" placeholder="真实姓名" />
|
<el-input v-model="form.real_name" placeholder="真实姓名" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -449,7 +456,7 @@ onMounted(() => {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user