fix(system): code-review 15 项修复(预览基数/维度校验/脱敏穿透/并发版本号等)
- 信息员预览基数改用本人销售额(原误用折算毛利润额,金额算错且口径自相矛盾) - 产品类别入参与维度值加存在性校验(拼错静默回退默认阈值 30% 会算错轨交工资) - submit 校验顺序前置查库(未注册 param_code 由 500 修正为 400) - 金额拒绝 NaN/Infinity(比较抛 InvalidOperation、计算抛溢出均穿透为 500) - version_no 并发冲突翻译为 400(对齐 user/role service 的 IntegrityError 处理) - 旧值留痕改取条目生效日时点的值(多未来批次下留痕链不再错位) - 脱敏红线:phone/mobile/id_card 键名整值抹除 + 手机号正则兼容分隔符写法 - 前端:撤回按钮与导出文件名改本地日期(UTC/本地混算)、维度行内精确「改」入口、 detailOf 补 catch、抽屉分页 size 生效、blob 错误读出真实 message - e2e 密码移除硬编码 fallback(缺失即报错退出);按钮文案同步「改」 - 全量回归:后端 406 + 集成 104 + 前端 type-check/build + e2e 7 步 全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
6219698a39
commit
fbf044f240
@ -45,6 +45,7 @@ BUSINESS_ROLE_INFO_OFFICER = "info_officer"
|
|||||||
# ===== 产品类别码(与 param_values.dimension_value 口径一致)=====
|
# ===== 产品类别码(与 param_values.dimension_value 口径一致)=====
|
||||||
PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
|
PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
|
||||||
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
|
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
|
||||||
|
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
|
||||||
|
|
||||||
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
|
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
|
||||||
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
||||||
|
|||||||
@ -29,8 +29,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
|
|||||||
),
|
),
|
||||||
# Authorization: Bearer xxx
|
# Authorization: Bearer xxx
|
||||||
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
|
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
|
||||||
# 手机号:138****8888
|
# 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000)
|
||||||
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
|
(re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
|
||||||
# 18 位身份证:保留前 6 位与后 4 位
|
# 18 位身份证:保留前 6 位与后 4 位
|
||||||
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -32,7 +32,21 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
|
|||||||
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
|
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
|
||||||
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
|
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
|
||||||
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
|
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
|
||||||
_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key")
|
# phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 ——
|
||||||
|
# 带分隔符的号码会穿过文本正则,必须在这里按整值抹除
|
||||||
|
_SENSITIVE_KEY_PARTS = (
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"pwd",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"app_key",
|
||||||
|
"access_key",
|
||||||
|
"phone",
|
||||||
|
"mobile",
|
||||||
|
"id_card",
|
||||||
|
"idcard",
|
||||||
|
)
|
||||||
|
|
||||||
_MASK = "***"
|
_MASK = "***"
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,9 @@ def _parse_amount(value: str, *, field_name: str, allow_negative: bool) -> str:
|
|||||||
amount = Decimal(value.strip())
|
amount = Decimal(value.strip())
|
||||||
except InvalidOperation as exc:
|
except InvalidOperation as exc:
|
||||||
raise ValueError(f"{field_name}必须是有效数字:{value}") from exc
|
raise ValueError(f"{field_name}必须是有效数字:{value}") from exc
|
||||||
|
if not amount.is_finite():
|
||||||
|
# 'NaN' 的比较会抛 InvalidOperation、'Infinity' 会在后续计算抛溢出,均须在入口拦下
|
||||||
|
raise ValueError(f"{field_name}必须为有限数字:{value}")
|
||||||
if not allow_negative and amount < 0:
|
if not allow_negative and amount < 0:
|
||||||
raise ValueError(f"{field_name}不得为负数:{value}")
|
raise ValueError(f"{field_name}不得为负数:{value}")
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -14,12 +14,14 @@ from fractions import Fraction
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import (
|
from app.core.constants import (
|
||||||
ACTION_DELETE,
|
ACTION_DELETE,
|
||||||
ACTION_UPDATE,
|
ACTION_UPDATE,
|
||||||
MODULE_PARAM,
|
MODULE_PARAM,
|
||||||
|
PRODUCT_CATEGORIES,
|
||||||
)
|
)
|
||||||
from app.core.exceptions import BadRequestError, NotFoundError
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
from app.core.response import PageResult
|
from app.core.response import PageResult
|
||||||
@ -234,10 +236,11 @@ def submit_change(
|
|||||||
normalized: list[tuple[ParamDefinition, str, str]] = []
|
normalized: list[tuple[ParamDefinition, str, str]] = []
|
||||||
seen: set[tuple[str, str]] = set()
|
seen: set[tuple[str, str]] = set()
|
||||||
for item in payload.items:
|
for item in payload.items:
|
||||||
spec = get_spec(item.param_code) # 未注册编码属代码缺陷
|
|
||||||
definition = param_repository.get_definition_by_code(session, item.param_code)
|
definition = param_repository.get_definition_by_code(session, item.param_code)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
|
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
|
||||||
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
|
||||||
|
spec = get_spec(item.param_code)
|
||||||
dimension = item.dimension_value
|
dimension = item.dimension_value
|
||||||
if spec.dimension_type == "NONE" and dimension != "":
|
if spec.dimension_type == "NONE" and dimension != "":
|
||||||
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
|
||||||
@ -249,6 +252,14 @@ def submit_change(
|
|||||||
raise BadRequestError(
|
raise BadRequestError(
|
||||||
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
spec.dimension_type == "PRODUCT_CATEGORY"
|
||||||
|
and dimension
|
||||||
|
and dimension not in PRODUCT_CATEGORIES
|
||||||
|
):
|
||||||
|
raise BadRequestError(
|
||||||
|
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
|
||||||
|
)
|
||||||
value = validate_and_normalize(spec, item.value)
|
value = validate_and_normalize(spec, item.value)
|
||||||
key = (item.param_code, dimension)
|
key = (item.param_code, dimension)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
@ -258,32 +269,42 @@ def submit_change(
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
normalized.append((definition, dimension, value))
|
normalized.append((definition, dimension, value))
|
||||||
|
|
||||||
batch = param_repository.create_batch(
|
try:
|
||||||
session,
|
batch = param_repository.create_batch(
|
||||||
ParamChangeBatch(
|
|
||||||
version_no=_next_version_no(session),
|
|
||||||
effective_date=effective,
|
|
||||||
change_type=payload.change_type,
|
|
||||||
remark=payload.remark,
|
|
||||||
operator_id=operator.id,
|
|
||||||
operator_name=operator.username,
|
|
||||||
item_count=len(normalized),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
today = date.today()
|
|
||||||
for definition, dimension, value in normalized:
|
|
||||||
old = param_repository.latest_value_before(session, definition.id, dimension, today)
|
|
||||||
param_repository.create_value(
|
|
||||||
session,
|
session,
|
||||||
ParamValue(
|
ParamChangeBatch(
|
||||||
param_id=definition.id,
|
version_no=_next_version_no(session),
|
||||||
dimension_value=dimension,
|
|
||||||
effective_date=effective,
|
effective_date=effective,
|
||||||
value_text=value,
|
change_type=payload.change_type,
|
||||||
old_value_text=old.value_text if old else None,
|
remark=payload.remark,
|
||||||
batch_id=batch.id,
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
item_count=len(normalized),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# 旧值留痕取「本条目生效日时点」的值:存在多个未来生效批次时,
|
||||||
|
# 每条记录的是它实际顶替的那一版(而非提交时刻的当前值)
|
||||||
|
for definition, dimension, value in normalized:
|
||||||
|
old = param_repository.latest_value_before(
|
||||||
|
session, definition.id, dimension, effective
|
||||||
|
)
|
||||||
|
param_repository.create_value(
|
||||||
|
session,
|
||||||
|
ParamValue(
|
||||||
|
param_id=definition.id,
|
||||||
|
dimension_value=dimension,
|
||||||
|
effective_date=effective,
|
||||||
|
value_text=value,
|
||||||
|
old_value_text=old.value_text if old else None,
|
||||||
|
batch_id=batch.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员
|
||||||
|
# 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400
|
||||||
|
session.rollback()
|
||||||
|
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count)
|
logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count)
|
||||||
return ParamChangeBatchOut(
|
return ParamChangeBatchOut(
|
||||||
@ -474,14 +495,16 @@ def _preview_rate(
|
|||||||
snapshot: ParamSnapshot,
|
snapshot: ParamSnapshot,
|
||||||
days: int,
|
days: int,
|
||||||
take,
|
take,
|
||||||
) -> tuple[Fraction, str]:
|
) -> tuple[Fraction, str, Decimal | None]:
|
||||||
"""按岗位模板分派计提比例,返回(纯比例, 人读说明)。
|
"""按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
|
||||||
|
|
||||||
|
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
|
||||||
|
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
|
||||||
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
|
||||||
"""
|
"""
|
||||||
if payload.customer_type == "enterprise":
|
if payload.customer_type == "enterprise":
|
||||||
rate = take("enterprise.rate")
|
rate = take("enterprise.rate")
|
||||||
return rate, "企业户固定计提比例"
|
return rate, "企业户固定计提比例", None
|
||||||
|
|
||||||
role_code = payload.business_role
|
role_code = payload.business_role
|
||||||
template = "TPL_SALESPERSON_DECAY"
|
template = "TPL_SALESPERSON_DECAY"
|
||||||
@ -497,7 +520,8 @@ def _preview_rate(
|
|||||||
)
|
)
|
||||||
if template == "TPL_INFO_SALES_FIXED":
|
if template == "TPL_INFO_SALES_FIXED":
|
||||||
rate = take("info.rate")
|
rate = take("info.rate")
|
||||||
return rate, "信息员:本人销售额 × 计提比例"
|
# 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
|
||||||
|
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
|
||||||
if template == "TPL_SALESPERSON_DECAY":
|
if template == "TPL_SALESPERSON_DECAY":
|
||||||
start = take("salesperson.rate.start")
|
start = take("salesperson.rate.start")
|
||||||
slope = take("salesperson.rate.slope")
|
slope = take("salesperson.rate.slope")
|
||||||
@ -509,6 +533,7 @@ def _preview_rate(
|
|||||||
return (
|
return (
|
||||||
rate,
|
rate,
|
||||||
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
|
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
raise BadRequestError(f"公式模板 {template} 暂不支持预览")
|
||||||
|
|
||||||
@ -541,12 +566,21 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
|
|||||||
)
|
)
|
||||||
return parse_fraction(raw)
|
return parse_fraction(raw)
|
||||||
|
|
||||||
|
if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit"
|
||||||
|
)
|
||||||
|
|
||||||
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
days = formula.payment_days(payload.ship_date, payload.receipt_date)
|
||||||
rate, rate_text = _preview_rate(session, payload, snapshot, days, take)
|
rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take)
|
||||||
|
|
||||||
profit_rate = formula.profit_rate(profit, revenue)
|
profit_rate = formula.profit_rate(profit, revenue)
|
||||||
threshold = take("product.threshold")
|
if base_override is not None:
|
||||||
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
|
||||||
|
converted = base_override
|
||||||
|
else:
|
||||||
|
threshold = take("product.threshold")
|
||||||
|
converted = formula.converted_profit(revenue, profit_rate, threshold)
|
||||||
performance = formula.performance_amount(converted, rate)
|
performance = formula.performance_amount(converted, rate)
|
||||||
|
|
||||||
barter_applied = payload.barter
|
barter_applied = payload.barter
|
||||||
|
|||||||
@ -62,7 +62,20 @@ class TestSerializeRequestData:
|
|||||||
def test_手机号被脱敏(self) -> None:
|
def test_手机号被脱敏(self) -> None:
|
||||||
text = _serialize_request_data({"phone": "13812345678"}) or ""
|
text = _serialize_request_data({"phone": "13812345678"}) or ""
|
||||||
assert "13812345678" not in text
|
assert "13812345678" not in text
|
||||||
assert "138****5678" in text
|
# phone 键已加入结构化抹除清单(整值 ***)—— 带空格/横线分隔的号码
|
||||||
|
# (如 138 0013 8000)会穿过分值文本正则,键名整值抹除是更强的防线
|
||||||
|
assert '"phone": "***"' in text
|
||||||
|
|
||||||
|
def test_带分隔符的手机号被脱敏(self) -> None:
|
||||||
|
"""修复回归:'138 0013 8000' 类分隔符写法曾同时穿过分值正则与键名清单。"""
|
||||||
|
for payload in (
|
||||||
|
{"phone": "138 0013 8000"},
|
||||||
|
{"phone": "138-0013-8000"},
|
||||||
|
{"mobile": "13800138000"},
|
||||||
|
):
|
||||||
|
text = _serialize_request_data(payload) or ""
|
||||||
|
assert "13800138000" not in text.replace(" ", "").replace("-", "")
|
||||||
|
assert "***" in text
|
||||||
|
|
||||||
def test_超长中文按字节边界截断且无残字(self) -> None:
|
def test_超长中文按字节边界截断且无残字(self) -> None:
|
||||||
"""75000 字节(25000 个汉字)必然超限:
|
"""75000 字节(25000 个汉字)必然超限:
|
||||||
|
|||||||
@ -42,7 +42,11 @@ def main() -> int:
|
|||||||
errors = collect_console_errors(page, console_errors)
|
errors = collect_console_errors(page, console_errors)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
login(page, "admin", __import__("os").environ.get("E2E_ADMIN_PASSWORD", "E2ePwd@2026"))
|
admin_password = __import__("os").environ.get("E2E_ADMIN_PASSWORD")
|
||||||
|
if not admin_password:
|
||||||
|
print("✗ 缺少环境变量 E2E_ADMIN_PASSWORD(管理员密码不入库)")
|
||||||
|
return 1
|
||||||
|
login(page, "admin", admin_password)
|
||||||
page.goto(PARAMS_URL, wait_until="networkidle")
|
page.goto(PARAMS_URL, wait_until="networkidle")
|
||||||
|
|
||||||
step("1. 目录分组展示")
|
step("1. 目录分组展示")
|
||||||
@ -54,7 +58,7 @@ def main() -> int:
|
|||||||
|
|
||||||
step("2. 修改预付款比例 10 → 12(立即生效)")
|
step("2. 修改预付款比例 10 → 12(立即生效)")
|
||||||
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
|
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
|
||||||
row.get_by_role("button", name="修改").click()
|
row.get_by_role("button", name="改", exact=True).click()
|
||||||
dialog = page.locator(".el-dialog")
|
dialog = page.locator(".el-dialog")
|
||||||
dialog.locator("input").fill("12")
|
dialog.locator("input").fill("12")
|
||||||
dialog.get_by_role("button", name="加入待提交").click()
|
dialog.get_by_role("button", name="加入待提交").click()
|
||||||
@ -83,7 +87,7 @@ def main() -> int:
|
|||||||
|
|
||||||
step("6. 恢复种子值 10")
|
step("6. 恢复种子值 10")
|
||||||
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
|
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first
|
||||||
row.get_by_role("button", name="修改").click()
|
row.get_by_role("button", name="改", exact=True).click()
|
||||||
dialog = page.locator(".el-dialog")
|
dialog = page.locator(".el-dialog")
|
||||||
dialog.locator("input").fill("10")
|
dialog.locator("input").fill("10")
|
||||||
dialog.get_by_role("button", name="加入待提交").click()
|
dialog.get_by_role("button", name="加入待提交").click()
|
||||||
|
|||||||
@ -65,7 +65,19 @@ service.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
(error: AxiosError<ApiResult>) => {
|
(error: AxiosError<ApiResult>) => {
|
||||||
const status = error.response?.status
|
const status = error.response?.status
|
||||||
const message = error.response?.data?.message ?? '网络异常,请稍后重试'
|
let message = error.response?.data?.message ?? '网络异常,请稍后重试'
|
||||||
|
// blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误),需读出真实 message
|
||||||
|
if (error.response?.data instanceof Blob) {
|
||||||
|
message = '导出失败,请稍后重试'
|
||||||
|
void error.response.data.text().then((text) => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text) as { message?: string }
|
||||||
|
if (body.message) ElMessage.error(body.message)
|
||||||
|
} catch {
|
||||||
|
/* 非 JSON 响应,保持兜底提示 */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,
|
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,
|
||||||
|
|||||||
@ -192,13 +192,14 @@ const versionVisible = ref(false)
|
|||||||
const batches = ref<ParamBatch[]>([])
|
const batches = ref<ParamBatch[]>([])
|
||||||
const batchesTotal = ref(0)
|
const batchesTotal = ref(0)
|
||||||
const batchesPage = ref(1)
|
const batchesPage = ref(1)
|
||||||
|
const batchesPageSize = ref(10)
|
||||||
const batchesLoading = ref(false)
|
const batchesLoading = ref(false)
|
||||||
const detailCache = reactive<Record<number, ParamBatchDetail>>({})
|
const detailCache = reactive<Record<number, ParamBatchDetail>>({})
|
||||||
|
|
||||||
async function loadBatches(): Promise<void> {
|
async function loadBatches(): Promise<void> {
|
||||||
batchesLoading.value = true
|
batchesLoading.value = true
|
||||||
try {
|
try {
|
||||||
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: 10 })
|
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value })
|
||||||
batches.value = page.list
|
batches.value = page.list
|
||||||
batchesTotal.value = page.total
|
batchesTotal.value = page.total
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -219,6 +220,12 @@ function changeTypeText(changeType: number): string {
|
|||||||
return changeType === 1 ? '立即生效' : '指定日期'
|
return changeType === 1 ? '立即生效' : '指定日期'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 本地日期(YYYY-MM-DD):toISOString 是 UTC,北京时间 0–8 点会差一天 */
|
||||||
|
function localToday(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
async function handleWithdraw(batch: ParamBatch): Promise<void> {
|
async function handleWithdraw(batch: ParamBatch): Promise<void> {
|
||||||
await confirmAndRun({
|
await confirmAndRun({
|
||||||
confirmText: `确定撤回批次「${batch.version_no}」(${batch.item_count} 项,${batch.effective_date} 起生效)?撤回后这些变更不再生效。`,
|
confirmText: `确定撤回批次「${batch.version_no}」(${batch.item_count} 项,${batch.effective_date} 起生效)?撤回后这些变更不再生效。`,
|
||||||
@ -271,7 +278,7 @@ async function doExport(): Promise<void> {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `param_changes_${new Date().toISOString().slice(0, 10)}.xlsx`
|
link.download = `param_changes_${localToday()}.xlsx`
|
||||||
link.click()
|
link.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
ElMessage.success('已导出')
|
ElMessage.success('已导出')
|
||||||
@ -295,6 +302,7 @@ const historyItem = ref<ParamCatalogItem | null>(null)
|
|||||||
const historyRows = ref<ParamHistory[]>([])
|
const historyRows = ref<ParamHistory[]>([])
|
||||||
const historyTotal = ref(0)
|
const historyTotal = ref(0)
|
||||||
const historyPage = ref(1)
|
const historyPage = ref(1)
|
||||||
|
const historyPageSize = ref(10)
|
||||||
const historyLoading = ref(false)
|
const historyLoading = ref(false)
|
||||||
|
|
||||||
function openHistory(item: ParamCatalogItem): void {
|
function openHistory(item: ParamCatalogItem): void {
|
||||||
@ -310,7 +318,7 @@ async function loadHistory(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
|
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
|
||||||
pageNum: historyPage.value,
|
pageNum: historyPage.value,
|
||||||
pageSize: 10,
|
pageSize: historyPageSize.value,
|
||||||
})
|
})
|
||||||
historyRows.value = page.list
|
historyRows.value = page.list
|
||||||
historyTotal.value = page.total
|
historyTotal.value = page.total
|
||||||
@ -445,6 +453,14 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
|
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openEdit(item, v.dimension_value)"
|
||||||
|
>
|
||||||
|
改
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="no-value">
|
<span v-else class="no-value">
|
||||||
@ -452,8 +468,14 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<el-button link type="primary" size="small" @click="openEdit(item, item.values[0]?.dimension_value ?? '')">
|
<el-button
|
||||||
修改
|
v-if="!item.values.length"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openEdit(item, '')"
|
||||||
|
>
|
||||||
|
设置
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
|
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
|
||||||
</td>
|
</td>
|
||||||
@ -614,7 +636,9 @@ onMounted(() => {
|
|||||||
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
|
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-button v-else link size="small" @click="detailOf(row)">加载明细…</el-button>
|
<el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
|
||||||
|
加载明细…
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -628,7 +652,7 @@ onMounted(() => {
|
|||||||
<el-table-column label="操作" width="80">
|
<el-table-column label="操作" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.effective_date > new Date().toISOString().slice(0, 10)"
|
v-if="row.effective_date > localToday()"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@ -642,9 +666,9 @@ onMounted(() => {
|
|||||||
<AppPagination
|
<AppPagination
|
||||||
:total="batchesTotal"
|
:total="batchesTotal"
|
||||||
:page-num="batchesPage"
|
:page-num="batchesPage"
|
||||||
:page-size="10"
|
:page-size="batchesPageSize"
|
||||||
@page-change="(p: number) => { batchesPage = p; void loadBatches() }"
|
@page-change="(p: number) => { batchesPage = p; void loadBatches() }"
|
||||||
@size-change="() => {}"
|
@size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@ -667,9 +691,9 @@ onMounted(() => {
|
|||||||
<AppPagination
|
<AppPagination
|
||||||
:total="historyTotal"
|
:total="historyTotal"
|
||||||
:page-num="historyPage"
|
:page-num="historyPage"
|
||||||
:page-size="10"
|
:page-size="historyPageSize"
|
||||||
@page-change="(p: number) => { historyPage = p; void loadHistory() }"
|
@page-change="(p: number) => { historyPage = p; void loadHistory() }"
|
||||||
@size-change="() => {}"
|
@size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }"
|
||||||
/>
|
/>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user