Compare commits

..
Author SHA1 Message Date
wuzilongandCursor 43e240ad6a feat(web-admin): add copy-to-clipboard for attachment images
Let users copy images next to download on shared attachment cards, preview toolbars, and list cells so they can paste into chat or docs without saving files first.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 23:18:34 +08:00
wuzilongandCursor 53987bdf22 fix(web-admin): keep cancelled plan stop feedback and refresh plan detail from API
作废/取消计划仍展示已有分段回传;打开计划详情始终回源,避免列表 seed 滞后。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 21:16:44 +08:00
wuzilongandCursor 85f5f29895 fix(pl): clear plan-cert conversion queue after saved ratio or waiver
证书齐备豁免或已填写完整人工换算的计划不再标「需单位换算」,避免已归档豁免单长期挂在待办列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 21:10:34 +08:00
wuzilongandCursor 8bffb1cbf5 fix(web-admin): restore fold icon in production and toggle archive planNo.
Remove Tailwind inline from el-icon so production CSS no longer collapses the header fold control, and expose 计划编号 in the plan-archive column picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 21:10:15 +08:00
wuzilongandCursor a4d213231c feat(web-admin): split recharge summary by fuel/ETC and fix pnpm workspace
Show fuel-only cards on fuel routes and ETC-only cards on ETC routes with a 5-column layout, and declare packages in pnpm-workspace.yaml so pnpm 9+ can build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 20:39:48 +08:00
wuzilongandCursor 7ebbaa2784 docs: add recharge 发放中 status design and plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:48:55 +08:00
wuzilongandCursor 553c1ef316 feat(web-admin): show recharge pending status as 发放中
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:48:04 +08:00
wuzilongandCursor f75d36e1ff fix(pl): revert disbursed recharge apply to pending and rename label
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:46:57 +08:00
wuzilongandCursor a86ded9ea6 fix(pl): include pending recharge applies in approval menu badge
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:45:51 +08:00
wuzilongandCursor c8174ab3f2 feat(pl): set recharge apply to pending when entering disburse node
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:43:57 +08:00
wuzilongandCursor 01d607d766 feat(web-admin): open cert maintain dialog on reminder page
Keep users on the document reminder list by reusing vehicle/personnel
form dialogs instead of navigating away to base-info pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:29:37 +08:00
wuzilongandCursor fba478dcca docs: add document reminder edit-jump design and plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:24:37 +08:00
wuzilongandCursor c4852bd488 feat(web-admin): open personnel edit dialog from editId query
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:24:27 +08:00
wuzilongandCursor 64f14814bd feat(web-admin): open vehicle edit dialog from editId query
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:24:24 +08:00
wuzilongandCursor 7f54923acf feat(web-admin): add maintain-cert action on document reminder list
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:23:25 +08:00
wuzilongandCursor 0f1446b5d7 feat(web-admin): add useRouteEditIdOpen for deep-link edit dialogs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:23:23 +08:00
wuzilongandCursor c0fa3ccfd9 feat(web-admin): add document reminder jump path resolver
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:22:49 +08:00
wuzilongandCursor ed5784d1f7 docs: add ETC/fuel recharge split design and implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:15:14 +08:00
wuzilongandCursor a4a8c2de58 chore(sql): split ETC and fuel recharge menus with role sync
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:11:49 +08:00
wuzilongandCursor 0a7eb97d14 fix(web-admin): redirect legacy etc-recharge recharge paths to fuel
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:06:08 +08:00
wuzilongandCursor efd7462e35 feat(web-admin): split etc and fuel recharge router groups
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:05:43 +08:00
wuzilongandCursor 345149dd6b feat(web-admin): add dedicated ETC vehicle recharge pages
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:03:00 +08:00
wuzilongandCursor 95a9c04fa6 fix(web-admin): restrict legacy recharge pages to non-ETC types
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:56:19 +08:00
wuzilongandCursor a7938a3b80 fix(web-admin): show recharge summary only on fuel-recharge routes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:53:29 +08:00
wuzilongandCursor 738f76c486 feat(pl): add rechargeTypeNe filter for vehicle recharge lists
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:50:41 +08:00
wuzilong 9f65cd447f merge: 合入证件建档按客户查询合同修复(#24)。 2026-11-12 17:48:18 +08:00
wuzilongandCursor eae5654f01 fix(pl): 证件建档按客户查询可添加合同,避免列表为空。
改用 CRM listContract 按 customerId 拉取,不再只取最近合同再前端过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:48:17 +08:00
wuzilong 71927bedef merge: 合入文档库多选批量设置分享(#25)。 2026-11-12 17:44:52 +08:00
64 changed files with 7800 additions and 136 deletions

No files matched your search

+11 -1
View File
@@ -39,7 +39,7 @@ record_router = APIRouter(prefix="/vehicle-recharge-record", tags=["运输计划
RECHARGE_TYPE_LABELS = {"etc": "ETC", "fuel": "油卡", "shanxia": "山峡", "bailian": "百链"}
RECHARGE_APPLY_STATUS_LABELS = {
"draft": "草稿",
"pending": "待发放",
"pending": "发放中",
"in_approval": "审批中",
"rejected": "已驳回",
"disbursed": "已发放",
@@ -113,6 +113,7 @@ def _apply_list_kwargs(params: dict) -> dict:
license_plate=_param_get(params, "licensePlate", "license_plate"),
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
status=_param_get(params, "status"),
status_in=_param_get(params, "statusIn", "status_in"),
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
@@ -139,6 +140,7 @@ def _disburse_list_kwargs(params: dict) -> dict:
license_plate=_param_get(params, "licensePlate", "license_plate"),
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
apply_status=_param_get(params, "applyStatus", "apply_status"),
apply_status_in=_param_get(params, "applyStatusIn", "apply_status_in"),
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
@@ -170,6 +172,7 @@ def list_applies(
request: Request,
q: Optional[str] = Query(default=None),
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
status: Optional[str] = Query(default=None),
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
@@ -185,6 +188,7 @@ def list_applies(
request,
q=q,
rechargeType=recharge_type,
rechargeTypeNe=recharge_type_ne,
status=status,
applyDateFrom=apply_date_from,
applyDateTo=apply_date_to,
@@ -233,6 +237,7 @@ RECHARGE_APPLY_EXPORT_COLUMNS = [
class RechargeApplyExportBody(BaseModel):
q: Optional[str] = None
recharge_type: Optional[str] = Field(default=None, alias="rechargeType")
recharge_type_ne: Optional[str] = Field(default=None, alias="rechargeTypeNe")
status: Optional[str] = None
apply_date_from: Optional[date] = Field(default=None, alias="applyDateFrom")
apply_date_to: Optional[date] = Field(default=None, alias="applyDateTo")
@@ -422,6 +427,7 @@ def delete_applies(
def list_records(
q: Optional[str] = Query(default=None),
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
recharge_date_from: Optional[date] = Query(default=None, alias="rechargeDateFrom"),
recharge_date_to: Optional[date] = Query(default=None, alias="rechargeDateTo"),
gas_station: Optional[str] = Query(default=None, alias="gasStation"),
@@ -438,6 +444,7 @@ def list_records(
user,
q=q,
recharge_type=recharge_type,
recharge_type_ne=recharge_type_ne,
recharge_date_from=recharge_date_from,
recharge_date_to=recharge_date_to,
gas_station=gas_station,
@@ -455,6 +462,7 @@ def list_disburse_board(
request: Request,
q: Optional[str] = Query(default=None),
recharge_type: Optional[str] = Query(default=None, alias="rechargeType"),
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
apply_status: Optional[str] = Query(default=None, alias="applyStatus"),
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
@@ -470,6 +478,7 @@ def list_disburse_board(
request,
q=q,
rechargeType=recharge_type,
rechargeTypeNe=recharge_type_ne,
applyStatus=apply_status,
applyDateFrom=apply_date_from,
applyDateTo=apply_date_to,
@@ -513,6 +522,7 @@ RECHARGE_DISBURSE_EXPORT_COLUMNS = [
class RechargeDisburseExportBody(BaseModel):
q: Optional[str] = None
recharge_type: Optional[str] = Field(default=None, alias="rechargeType")
recharge_type_ne: Optional[str] = Field(default=None, alias="rechargeTypeNe")
apply_status: Optional[str] = Field(default=None, alias="applyStatus")
apply_date_from: Optional[date] = Field(default=None, alias="applyDateFrom")
apply_date_to: Optional[date] = Field(default=None, alias="applyDateTo")
@@ -42,6 +42,7 @@ def _approval_list_kwargs(params: dict) -> dict:
license_plate=_param_get(params, "licensePlate", "license_plate"),
recharge_type=_param_get(params, "rechargeType", "recharge_type"),
recharge_type_in=_param_get(params, "rechargeTypeIn", "recharge_type_in"),
recharge_type_ne=_param_get(params, "rechargeTypeNe", "recharge_type_ne"),
status=_param_get(params, "status"),
status_in=_param_get(params, "statusIn", "status_in"),
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
@@ -61,6 +62,7 @@ def _approval_list_kwargs(params: dict) -> dict:
@approval_router.get("/pending", response_model=VehicleRechargeApplyListResponse)
def list_pending(
request: Request,
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
page: int = Query(default=1, ge=1),
@@ -71,6 +73,7 @@ def list_pending(
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
params = _merge_query(
request,
rechargeTypeNe=recharge_type_ne,
applyDateFrom=apply_date_from,
applyDateTo=apply_date_to,
)
@@ -90,6 +93,7 @@ def list_pending(
@approval_router.get("/done", response_model=VehicleRechargeApplyListResponse)
def list_done(
request: Request,
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
apply_date_from: Optional[date] = Query(default=None, alias="applyDateFrom"),
apply_date_to: Optional[date] = Query(default=None, alias="applyDateTo"),
page: int = Query(default=1, ge=1),
@@ -100,6 +104,7 @@ def list_done(
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
params = _merge_query(
request,
rechargeTypeNe=recharge_type_ne,
applyDateFrom=apply_date_from,
applyDateTo=apply_date_to,
)
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
RECHARGE_TYPES = {"etc", "fuel", "shanxia", "bailian"}
APPLY_STATUSES = {"draft", "in_approval", "rejected", "disbursed", "cancelled"}
APPLY_STATUSES = {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"}
NODE_ACTION_TYPES = {"approve", "disburse"}
APPROVAL_STATUSES = {"approved", "rejected", "disbursed"}
@@ -25,6 +25,12 @@ def _split_csv(raw: Optional[str]) -> List[str]:
return [s.strip() for s in str(raw).split(",") if s.strip()]
def _append_ne_filter(filters: list, col, raw: Optional[str]) -> None:
value = (raw or "").strip()
if value:
filters.append(col != value)
def _append_enum_filter(
filters: list,
col,
@@ -148,6 +154,7 @@ def build_recharge_apply_column_filters(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_date_from: DateLike = None,
@@ -175,6 +182,7 @@ def build_recharge_apply_column_filters(
single=recharge_type,
multi=recharge_type_in,
)
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
_append_enum_filter(
filters, PlVehicleRechargeApply.status, single=status, multi=status_in
)
@@ -223,6 +231,7 @@ def build_recharge_disburse_board_column_filters(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
apply_status: Optional[str] = None,
apply_status_in: Optional[str] = None,
apply_date_from: DateLike = None,
@@ -256,6 +265,7 @@ def build_recharge_disburse_board_column_filters(
single=recharge_type,
multi=recharge_type_in,
)
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
_append_enum_filter(
filters,
PlVehicleRechargeApply.status,
@@ -164,7 +164,7 @@ def count_recharge_approval_pending(db: Session, tenant_no: str, user_id: str) -
.filter(
PlVehicleRechargeApply.tenant_no == tenant_no,
PlVehicleRechargeApply.del_flag == 0,
PlVehicleRechargeApply.status == "in_approval",
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
BxWorkflowNode.tenant_no == tenant_no,
BxWorkflowNode.del_flag == 0,
BxWorkflowNode.approver_id == user_id,
+52 -13
View File
@@ -1,4 +1,6 @@
"""计划段次是否需要把计划单位换算成证面单位。"""
from decimal import Decimal, InvalidOperation
from app.models.pl_models import (
PlPlan, PlPlanDelivery, PlPlanDeliveryProduct, PlPlanLoad, PlPlanLoadProduct, PlPlanProduct,
)
@@ -63,12 +65,45 @@ def line_needs_conversion(unit, cert_units):
)
def plan_needs_unit_conversion(plan, lines, cert_units, snapshots):
def _positive_decimal(value):
try:
number = Decimal(str(value).strip())
except (InvalidOperation, AttributeError, TypeError):
return False
return number > 0
def _saved_manual_keys(configuration):
"""已填写完整人工换算的 (sourceId, permitKind, permitId)。"""
rows = (configuration or {}).get('manualAllocations') or (configuration or {}).get('manual_allocations') or []
keys = set()
for row in rows:
if not isinstance(row, dict):
continue
source_id = row.get('sourceId') or row.get('source_id')
kind = row.get('permitKind') or row.get('permit_kind')
permit_id = row.get('permitId') or row.get('permit_id')
source_qty = row.get('sourceQuantity') if 'sourceQuantity' in row else row.get('source_quantity')
permit_qty = row.get('permitQuantity') if 'permitQuantity' in row else row.get('permit_quantity')
if source_id and kind and permit_id and _positive_decimal(source_qty) and _positive_decimal(permit_qty):
keys.add((str(source_id), str(kind), str(permit_id)))
return keys
def plan_needs_unit_conversion(plan, lines, cert_bindings, snapshots):
"""证书齐备豁免或异单位段已保存完整人工换算时,不再进入待换算待办。"""
if int(getattr(plan, 'quota_cert_waived', 0) or 0) == 1:
return False
version = measurement_version(plan, snapshots)
saved = _saved_manual_keys(plan.cert_configuration)
for line in measured_lines(version, lines, plan.cert_configuration):
units = cert_units.get(line['contractProductId'], [])
if line_needs_conversion(line['unit'], units):
return True
bindings = cert_bindings.get(line['contractProductId'], [])
source_id = f"{line['side']}:{line['id']}"
for binding in bindings:
if not line_needs_conversion(line['unit'], [binding['unit']]):
continue
if (source_id, binding['kind'], binding['certId']) not in saved:
return True
return False
@@ -104,12 +139,14 @@ def _stop_lines(db, tenant_no, plan_ids):
return grouped
def _cert_units(db, tenant_no, contract_product_ids):
units = {}
def _cert_bindings(db, tenant_no, contract_product_ids):
bindings = {}
if not contract_product_ids:
return units
return bindings
for kind, cert_model in (('purchase', PlPurchaseCert), ('transport', PlTransportCert)):
rows = db.query(PlContractCertProduct.contract_product_id, cert_model.quantity_unit).join(
rows = db.query(
PlContractCertProduct.contract_product_id, cert_model.id, cert_model.quantity_unit,
).join(
PlContractCertLink, PlContractCertLink.id == PlContractCertProduct.contract_cert_link_id,
).join(cert_model, cert_model.id == PlContractCertLink.cert_id).filter(
PlContractCertLink.tenant_no == tenant_no, PlContractCertLink.del_flag == 0,
@@ -119,9 +156,11 @@ def _cert_units(db, tenant_no, contract_product_ids):
cert_model.tenant_no == tenant_no, cert_model.del_flag == 0,
PlContractCertProduct.contract_product_id.in_(contract_product_ids),
).all()
for product_id, unit in rows:
units.setdefault(product_id, []).append(unit)
return units
for product_id, cert_id, unit in rows:
bindings.setdefault(product_id, []).append({
'kind': kind, 'certId': cert_id, 'unit': unit,
})
return bindings
def plans_needing_unit_conversion(db, tenant_no, plan_ids):
@@ -137,7 +176,7 @@ def plans_needing_unit_conversion(db, tenant_no, plan_ids):
product_ids = {
line['contractProductId'] for rows in lines.values() for line in rows if line['contractProductId']
}
cert_units = _cert_units(db, tenant_no, product_ids)
cert_bindings = _cert_bindings(db, tenant_no, product_ids)
snapshots = {plan_id: [] for plan_id in ids}
usage_rows = db.query(PlPlanCertUsage.plan_id, PlPlanCertUsage.confirmation_snapshot).filter(
PlPlanCertUsage.tenant_no == tenant_no, PlPlanCertUsage.del_flag == 0,
@@ -146,6 +185,6 @@ def plans_needing_unit_conversion(db, tenant_no, plan_ids):
for plan_id, snapshot in usage_rows:
snapshots[plan_id].append(snapshot)
for plan in plans:
if plan_needs_unit_conversion(plan, lines.get(plan.id, []), cert_units, snapshots.get(plan.id, [])):
if plan_needs_unit_conversion(plan, lines.get(plan.id, []), cert_bindings, snapshots.get(plan.id, [])):
needed.add(plan.id)
return needed
@@ -58,8 +58,8 @@ def _current_node(
user: DbUserContext,
tenant_no: str,
) -> BxWorkflowNode:
if apply.status != "in_approval" or not apply.current_node:
raise HTTPException(status_code=400, detail="申请单不在审批中")
if apply.status not in ("in_approval", "pending") or not apply.current_node:
raise HTTPException(status_code=400, detail="申请单不在可审批/发放状态")
node = (
db.query(BxWorkflowNode)
.filter(
@@ -119,6 +119,7 @@ def list_pending(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_date_from=None,
@@ -144,7 +145,7 @@ def list_pending(
.filter(
PlVehicleRechargeApply.tenant_no == tenant_no,
PlVehicleRechargeApply.del_flag == 0,
PlVehicleRechargeApply.status == "in_approval",
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
BxWorkflowNode.tenant_no == tenant_no,
BxWorkflowNode.del_flag == 0,
BxWorkflowNode.approver_id == user.id,
@@ -155,6 +156,7 @@ def list_pending(
license_plate=license_plate,
recharge_type=recharge_type,
recharge_type_in=recharge_type_in,
recharge_type_ne=recharge_type_ne,
status=status,
status_in=status_in,
apply_date_from=apply_date_from,
@@ -192,6 +194,7 @@ def list_done(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_date_from=None,
@@ -233,6 +236,7 @@ def list_done(
license_plate=license_plate,
recharge_type=recharge_type,
recharge_type_in=recharge_type_in,
recharge_type_ne=recharge_type_ne,
status=status,
status_in=status_in,
apply_date_from=apply_date_from,
@@ -290,7 +294,11 @@ def approve_apply(
opinion=opinion,
now=now,
)
apply.current_node = nodes[idx + 1].id
next_node = nodes[idx + 1]
apply.current_node = next_node.id
next_action = (next_node.action_type or "approve").strip() or "approve"
if next_action == "disburse":
apply.status = "pending"
apply.update_by = user.id
apply.update_time = now
sync_approval_notifications(
@@ -481,7 +481,7 @@ def _resolve_apply_for_record(
detail="审批中的申请请在「ETC/油卡充值审批」中完成发放登记",
)
if apply.status != "pending":
raise HTTPException(status_code=400, detail="仅待发放申请可关联")
raise HTTPException(status_code=400, detail="仅发放中申请可关联")
existing = (
db.query(PlVehicleRechargeRecord)
.filter(
@@ -513,6 +513,7 @@ def list_applies(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
status: Optional[str] = None,
status_in: Optional[str] = None,
apply_date_from: Optional[date] = None,
@@ -555,6 +556,7 @@ def list_applies(
license_plate=license_plate,
recharge_type=recharge_type,
recharge_type_in=recharge_type_in,
recharge_type_ne=recharge_type_ne,
status=status,
status_in=status_in,
apply_date_from=apply_date_from,
@@ -854,6 +856,7 @@ def list_records(
*,
q: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
recharge_date_from: Optional[date] = None,
recharge_date_to: Optional[date] = None,
gas_station: Optional[str] = None,
@@ -875,6 +878,10 @@ def list_records(
)
if recharge_type:
query = query.filter(PlVehicleRechargeRecord.recharge_type == recharge_type)
if recharge_type_ne and str(recharge_type_ne).strip():
query = query.filter(
PlVehicleRechargeRecord.recharge_type != str(recharge_type_ne).strip()
)
if recharge_date_from:
query = query.filter(PlVehicleRechargeRecord.recharge_date >= recharge_date_from)
if recharge_date_to:
@@ -937,6 +944,7 @@ def list_disburse_board(
license_plate: Optional[str] = None,
recharge_type: Optional[str] = None,
recharge_type_in: Optional[str] = None,
recharge_type_ne: Optional[str] = None,
apply_status: Optional[str] = None,
apply_status_in: Optional[str] = None,
apply_date_from: Optional[date] = None,
@@ -997,6 +1005,7 @@ def list_disburse_board(
license_plate=license_plate,
recharge_type=recharge_type,
recharge_type_in=recharge_type_in,
recharge_type_ne=recharge_type_ne,
apply_status=apply_status,
apply_status_in=apply_status_in,
apply_date_from=apply_date_from,
@@ -1124,7 +1133,7 @@ def update_record(
),
nodes[-1] if nodes else None,
)
old_apply.status = "in_approval"
old_apply.status = "pending"
old_apply.current_node = disburse.id if disburse else None
else:
old_apply.status = "pending"
@@ -1193,7 +1202,7 @@ def delete_records(
),
nodes[-1] if nodes else None,
)
apply.status = "in_approval"
apply.status = "pending"
apply.current_node = disburse.id if disburse else None
else:
apply.status = "pending"
@@ -75,6 +75,7 @@ def test_recharge_approval_badge_counts_current_approver_pending(monkeypatch):
add_apply("a3", status="in_approval", current_node="node-other")
add_apply("a4", status="rejected", current_node="node-me")
add_apply("a5", status="draft", current_node=None)
add_apply("a6", status="pending", current_node="node-me")
db.commit()
monkeypatch.setattr(
@@ -89,4 +90,4 @@ def test_recharge_approval_badge_counts_current_approver_pending(monkeypatch):
user_id="u-me",
menu_codes=[MENU_CODE_RECHARGE_APPROVAL],
)
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 2
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 3
+40 -10
View File
@@ -23,47 +23,77 @@ def line(line_id, side, unit, contract_product_id='cp', customer='客户', toxic
}
def bind(unit, kind='purchase', cert_id='buy'):
return {'kind': kind, 'certId': cert_id, 'unit': unit}
def plan(**values):
return SimpleNamespace(
cert_configuration=values.get('configuration'),
transport_status=values.get('transport_status', 'pending'),
archive_status=values.get('archive_status', 'unarchived'),
quota_cert_waived=values.get('quota_cert_waived', 0),
)
def test_same_label_and_code_do_not_need_conversion():
current = plan()
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': ['dun']}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨', toxic=False)], {'cp': ['kg']}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': [bind('dun')]}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨', toxic=False)], {'cp': [bind('kg')]}, []) is False
assert plan_needs_unit_conversion(
current, [line('L', 'load', '吨', customer='安庆市山峡化工有限公司')], {'cp': ['ping']}, [],
current, [line('L', 'load', '吨', customer='安庆市山峡化工有限公司')], {'cp': [bind('ping')]}, [],
) is True
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': ['kg']}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '升')], {'cp': ['毫升']}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '吨')], {'cp': [bind('kg')]}, []) is False
assert plan_needs_unit_conversion(current, [line('L', 'load', '升')], {'cp': [bind('毫升')]}, []) is False
def test_unbound_and_unselected_legacy_sides_are_display_only():
both = [line('L', 'load', '吨'), line('D', 'delivery', '吨')]
arranged = plan(transport_status='arranged')
assert plan_needs_unit_conversion(arranged, both, {'cp': ['kg']}, []) is False
assert plan_needs_unit_conversion(arranged, both, {'cp': [bind('kg')]}, []) is False
chosen = plan(transport_status='arranged', configuration={'sourceMapping': [
{'sourceStopType': 'load', 'sourceStopProductId': 'L', 'relatedStopProductIds': ['D']},
]})
assert plan_needs_unit_conversion(chosen, both, {'cp': ['瓶']}, []) is True
assert plan_needs_unit_conversion(chosen, both, {'cp': ['kg']}, []) is False
assert plan_needs_unit_conversion(chosen, both, {'cp': [bind('瓶')]}, []) is True
assert plan_needs_unit_conversion(chosen, both, {'cp': [bind('kg')]}, []) is False
def test_new_plan_counts_either_stop_when_a_bound_certificate_uses_another_unit():
current = plan()
assert plan_needs_unit_conversion(
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': ['kg']}, [],
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': [bind('kg')]}, [],
) is False
assert plan_needs_unit_conversion(
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': ['ping']}, [],
current, [line('L', 'load', 'dun'), line('D', 'delivery', 'dun')], {'cp': [bind('ping')]}, [],
) is True
assert plan_needs_unit_conversion(current, [line('L', 'load', 'dun')], {}, []) is False
def test_waived_or_saved_manual_conversion_clears_pending_flag():
current = plan(quota_cert_waived=1)
assert plan_needs_unit_conversion(
current, [line('L', 'load', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
) is False
saved = plan(configuration={
'manualAllocations': [{
'permitKind': 'purchase', 'sourceId': 'delivery:D', 'permitId': 'buy',
'sourceQuantity': '10', 'permitQuantity': '2.5', 'note': '1瓶=0.25升',
}],
})
assert plan_needs_unit_conversion(
saved, [line('D', 'delivery', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
) is False
incomplete = plan(configuration={
'manualAllocations': [{
'permitKind': 'purchase', 'sourceId': 'delivery:D', 'permitId': 'buy',
'sourceQuantity': '10', 'permitQuantity': '', 'note': '',
}],
})
assert plan_needs_unit_conversion(
incomplete, [line('D', 'delivery', 'ping')], {'cp': [bind('sheng', cert_id='buy')]}, [],
) is True
def test_list_and_badge_count_only_plans_with_a_real_unit_mismatch(monkeypatch):
engine = create_engine('sqlite://')
models = (
@@ -119,6 +119,20 @@ def test_maintenance_process_board_filters():
assert "handler_id" in joined
def test_recharge_apply_type_ne_filter():
filters = build_recharge_apply_column_filters(recharge_type_ne="etc")
assert len(filters) == 1
sql = _compile(filters[0])
assert "recharge_type" in sql
assert "!=" in sql or "<>" in sql
assert "etc" in sql
def test_recharge_disburse_board_type_ne_filter():
filters = build_recharge_disburse_board_column_filters(recharge_type_ne="etc")
assert any("recharge_type" in _compile(f) for f in filters)
def test_empty():
assert build_recharge_apply_column_filters() == []
assert build_recharge_disburse_board_column_filters() == []
@@ -20,7 +20,7 @@ from app.schemas.pl.vehicle_recharge import APPLY_STATUSES, NODE_ACTION_TYPES, R
def test_apply_statuses_include_workflow_states():
assert {"draft", "in_approval", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
assert {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
assert {"approve", "disburse"} <= NODE_ACTION_TYPES
assert {"etc", "fuel"} <= RECHARGE_TYPES
@@ -98,6 +98,40 @@ def _seed_workflow(db) -> tuple[str, str, str]:
return wf_id, node_gm, node_fin
def _seed_three_node_workflow(db) -> tuple[str, str, str, str]:
wf_id = "wf-3"
n1, n2, n3 = "node-a1", "node-a2", "node-dis"
db.add(
BxWorkflowDef(
id=wf_id,
biz_type="vehicle_recharge",
wf_name="三级充值流程",
status=1,
tenant_no="t1",
create_dept_id="d1",
)
)
for nid, name, order, uid, uname, action in (
(n1, "一审", 1, "u-a1", "一审", "approve"),
(n2, "二审", 2, "u-a2", "二审", "approve"),
(n3, "发放", 3, "u-fin", "财务", "disburse"),
):
db.add(
BxWorkflowNode(
id=nid,
wf_id=wf_id,
node_name=name,
node_order=order,
approver_id=uid,
approver_name=uname,
action_type=action,
tenant_no="t1",
create_dept_id="d1",
)
)
return wf_id, n1, n2, n3
def _patch_scope(monkeypatch, *modules) -> None:
for mod in modules:
monkeypatch.setattr(mod, "apply_pl_scope", lambda query, *_a, **_k: query)
@@ -272,7 +306,7 @@ def test_approve_advances_to_next_node(monkeypatch):
result = approval_svc.approve_apply(
db, "t1", _user("u-gm", "总经理"), "apply-1", opinion="同意"
)
assert result.status == "in_approval"
assert result.status == "pending"
assert result.current_node == node_fin
assert any(a.approval_status == "approved" for a in result.approvals)
assert len(result.approval_flow) == 2
@@ -285,6 +319,79 @@ def test_approve_advances_to_next_node(monkeypatch):
assert result.approval_history[0].approval_status == "approved"
def test_mid_approve_stays_in_approval_final_approve_sets_pending(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
db = _make_session()
_seed_vehicle(db)
wf_id, n1, n2, n3 = _seed_three_node_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="YK2026092601",
license_plate="鄂A00001",
recharge_type="fuel",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="in_approval",
wf_id=wf_id,
current_node=n1,
approval_round=1,
tenant_no="t1",
create_dept_id="d1",
)
)
db.commit()
_patch_scope(monkeypatch, svc)
mid = approval_svc.approve_apply(db, "t1", _user("u-a1", "一审"), "apply-1", opinion="ok")
assert mid.status == "in_approval"
assert mid.current_node == n2
final = approval_svc.approve_apply(db, "t1", _user("u-a2", "二审"), "apply-1", opinion="ok")
assert final.status == "pending"
assert final.current_node == n3
def test_disburse_accepts_pending_status(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
db = _make_session()
_seed_vehicle(db)
wf_id, _gm, node_fin = _seed_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="ETC2026092601",
license_plate="鄂A00001",
recharge_type="etc",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="pending",
wf_id=wf_id,
current_node=node_fin,
approval_round=1,
tenant_no="t1",
create_dept_id="d1",
)
)
db.commit()
_patch_scope(monkeypatch, svc)
payload = VehicleRechargeRecordCreate(
rechargeDate=date(2026, 9, 26),
rechargeType="etc",
amount=Decimal("50.00"),
applyId="apply-1",
)
result = approval_svc.disburse_apply(
db, "t1", _user("u-fin", "财务"), "apply-1", payload
)
assert result.status == "disbursed"
def test_reject_sets_rejected_and_appends_history(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
@@ -508,3 +615,49 @@ def test_withdraw_rejects_non_in_approval(monkeypatch):
with pytest.raises(HTTPException) as ei:
svc.withdraw_apply(db, "t1", _user("u-applicant"), "a-wd3")
assert ei.value.status_code == 400
def test_delete_record_with_workflow_reverts_to_pending(monkeypatch):
from app.services import pl_vehicle_recharge_service as svc
db = _make_session()
_seed_vehicle(db)
wf_id, _gm, node_fin = _seed_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="ETC2026092601",
license_plate="鄂A00001",
recharge_type="etc",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="disbursed",
wf_id=wf_id,
current_node=None,
approval_round=1,
tenant_no="t1",
create_by="u-applicant",
create_dept_id="d1",
)
)
db.add(
PlVehicleRechargeRecord(
id="rec-1",
recharge_order_no="RO1",
recharge_date=date(2026, 9, 26),
recharge_type="etc",
amount=Decimal("10.00"),
apply_id="apply-1",
license_plate="鄂A00001",
tenant_no="t1",
create_by="u-fin",
create_dept_id="d1",
del_flag=0,
)
)
db.commit()
_patch_scope(monkeypatch, svc)
svc.delete_records(db, "t1", _user("u-fin", "财务"), ["rec-1"])
apply = db.query(PlVehicleRechargeApply).filter_by(id="apply-1").one()
assert apply.status == "pending"
assert apply.current_node == node_fin
@@ -0,0 +1,210 @@
-- 油卡充值与 ETC 充值菜单拆分,并按已有油卡菜单同步角色。
-- MySQL 5.7 / utf8mb4。可重复执行。仅租户 shanxia2.0 的有效菜单。
-- 不移动 PL_VEH_FUEL_CARD,车油卡信息仍挂在油卡分组下。
-- 不改 docs/sql/shanxia2.0.sql 全量快照;快照仍是本迁移之前的数据,不要为这条脚本重导整库。
-- 审批菜单 PL_VEH_RECHARGE_APPROVAL 在快照与迁移中没有 F 按钮,本脚本不为 ETC 审批补按钮。
SET NAMES utf8mb4;
START TRANSACTION;
-- A. 原「ETC/油卡」分组改为油卡,path 让给下面新建的 ETC 分组。
UPDATE `sys_menu`
SET `menu_name` = '油卡充值管理',
`path` = 'fuel-recharge',
`remark` = '油卡充值分组',
`update_time` = NOW()
WHERE `id` = 'PL_GROUP_VEH_RECHARGE'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
UPDATE `sys_menu`
SET `menu_name` = '油卡充值申请', `update_time` = NOW()
WHERE `id` = 'PL_VEH_RECHARGE_APPLY'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
UPDATE `sys_menu`
SET `menu_name` = '油卡充值审批', `update_time` = NOW()
WHERE `id` = 'PL_VEH_RECHARGE_APPROVAL'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
UPDATE `sys_menu`
SET `menu_name` = '油卡充值发放管理', `update_time` = NOW()
WHERE `id` = 'PL_VEH_RECHARGE_RECORD'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
UPDATE `sys_menu`
SET `menu_name` = '油卡充值审批人设置', `update_time` = NOW()
WHERE `id` = 'PL_VEH_RECHARGE_WF'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0;
-- B. ETC 分组插在油卡分组原位置:先记下油卡 order_num,再把车辆管理下
-- order_num >= 该值的有效同级(含油卡本身,以及加油登记、维保等)全部 +1,
-- 然后用记下的序号插入 ETC。这样 ETC 紧挨在油卡之前,且不会和油卡同号。
-- MySQL 5.7 的 UPDATE 不能在子查询里再读 sys_menu,所以序号先放进用户变量。
-- 仅当 ETC 分组尚不存在、且车辆管理与油卡分组都有效时才顺延;重复执行不再 +1。
-- 请在同一会话整文件执行,@oil_order 等用户变量不能跨连接。
SET @etc_group_exists := (
SELECT COUNT(*) FROM `sys_menu` WHERE `id` = 'PL_GROUP_VEH_ETC_RECHARGE'
);
SET @vehicle_exists := (
SELECT COUNT(*) FROM `sys_menu`
WHERE `id` = 'PL_GROUP_VEHICLE' AND `tenant_no` = 'shanxia2.0' AND `del_flag` = 0
);
SET @oil_order := (
SELECT `order_num` FROM `sys_menu`
WHERE `id` = 'PL_GROUP_VEH_RECHARGE' AND `tenant_no` = 'shanxia2.0' AND `del_flag` = 0
LIMIT 1
);
UPDATE `sys_menu`
SET `order_num` = `order_num` + 1,
`update_time` = NOW()
WHERE `parent_id` = 'PL_GROUP_VEHICLE'
AND `tenant_no` = 'shanxia2.0'
AND `del_flag` = 0
AND @etc_group_exists = 0
AND @vehicle_exists = 1
AND @oil_order IS NOT NULL
AND `order_num` >= @oil_order;
INSERT INTO `sys_menu`
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`remark`,`create_by`,`create_dept_id`)
SELECT 'PL_GROUP_VEH_ETC_RECHARGE', 'ETC充值管理', vehicle.id, 'M', 'etc-recharge',
NULL, NULL, 1, '1', '1', vehicle.tenant_no, COALESCE(@oil_order, 1),
'ETC充值分组', 'system', ''
FROM `sys_menu` vehicle
WHERE vehicle.id = 'PL_GROUP_VEHICLE'
AND vehicle.tenant_no = 'shanxia2.0'
AND vehicle.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_menu` existing WHERE existing.id = 'PL_GROUP_VEH_ETC_RECHARGE'
);
-- C. 四个页面菜单。审批人设置复用油卡流程权限,因此只按 id 判重,不能按 perms 判重。
INSERT INTO `sys_menu`
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
SELECT item.id, item.menu_name, parent.id, 'C', item.path, item.component, item.perms,
1, '1', '1', parent.tenant_no, item.order_num, 'system', ''
FROM `sys_menu` parent
JOIN (
SELECT 'PL_ETC_VEH_RECHARGE_APPLY' AS id, 'ETC充值申请' AS menu_name,
'etc-vehicle-recharge-apply' AS path, 'pl/etc-vehicle-recharge-apply/index' AS component,
'pl:etc-vehicle-recharge-apply:list' AS perms, 1 AS order_num
UNION ALL
SELECT 'PL_ETC_VEH_RECHARGE_APPROVAL', 'ETC充值审批',
'etc-vehicle-recharge-approval', 'pl/etc-vehicle-recharge-approval/index',
'pl:etc-vehicle-recharge-approval:list', 2
UNION ALL
SELECT 'PL_ETC_VEH_RECHARGE_RECORD', 'ETC发放管理',
'etc-vehicle-recharge-record', 'pl/etc-vehicle-recharge-record/index',
'pl:etc-vehicle-recharge-record:list', 3
UNION ALL
SELECT 'PL_ETC_VEH_RECHARGE_WF', 'ETC充值审批人设置',
'etc-vehicle-recharge-workflow', 'pl/etc-vehicle-recharge-workflow/index',
'pl:vehicle-recharge-workflow:list', 4
) item
WHERE parent.id = 'PL_GROUP_VEH_ETC_RECHARGE'
AND parent.tenant_no = 'shanxia2.0'
AND parent.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_menu` existing WHERE existing.id = item.id
);
-- D. 申请按钮,对照 PL_VRA_Q/A/E/R/C。
INSERT INTO `sys_menu`
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
SELECT btn.id, btn.menu_name, parent.id, 'F', '', '', btn.perms,
1, '1', '1', parent.tenant_no, btn.order_num, 'system', ''
FROM `sys_menu` parent
JOIN (
SELECT 'PL_ETC_VRA_Q' AS id, '查询' AS menu_name, 'pl:etc-vehicle-recharge-apply:query' AS perms, 1 AS order_num
UNION ALL SELECT 'PL_ETC_VRA_A', '新增', 'pl:etc-vehicle-recharge-apply:add', 2
UNION ALL SELECT 'PL_ETC_VRA_E', '编辑', 'pl:etc-vehicle-recharge-apply:edit', 3
UNION ALL SELECT 'PL_ETC_VRA_R', '删除', 'pl:etc-vehicle-recharge-apply:remove', 4
UNION ALL SELECT 'PL_ETC_VRA_C', '作废', 'pl:etc-vehicle-recharge-apply:cancel', 5
) btn
WHERE parent.id = 'PL_ETC_VEH_RECHARGE_APPLY'
AND parent.tenant_no = 'shanxia2.0'
AND parent.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_menu` existing WHERE existing.id = btn.id
);
-- E. 发放按钮,对照 PL_VRR_Q/A/E/R。审批侧没有对应 F 按钮,这里不补。
INSERT INTO `sys_menu`
(`id`,`menu_name`,`parent_id`,`menu_type`,`path`,`component`,`perms`,
`is_frame`,`visible`,`status`,`tenant_no`,`order_num`,`create_by`,`create_dept_id`)
SELECT btn.id, btn.menu_name, parent.id, 'F', '', '', btn.perms,
1, '1', '1', parent.tenant_no, btn.order_num, 'system', ''
FROM `sys_menu` parent
JOIN (
SELECT 'PL_ETC_VRR_Q' AS id, '查询' AS menu_name, 'pl:etc-vehicle-recharge-record:query' AS perms, 1 AS order_num
UNION ALL SELECT 'PL_ETC_VRR_A', '新增', 'pl:etc-vehicle-recharge-record:add', 2
UNION ALL SELECT 'PL_ETC_VRR_E', '编辑', 'pl:etc-vehicle-recharge-record:edit', 3
UNION ALL SELECT 'PL_ETC_VRR_R', '删除', 'pl:etc-vehicle-recharge-record:remove', 4
) btn
WHERE parent.id = 'PL_ETC_VEH_RECHARGE_RECORD'
AND parent.tenant_no = 'shanxia2.0'
AND parent.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM `sys_menu` existing WHERE existing.id = btn.id
);
-- G. 角色同步。拥有油卡页面(del_flag=0)且角色仍有效的,授予对等 ETC 页面、
-- 其下 F 按钮,以及最多三层祖先(ETC 分组、车辆管理、Web 根)。
-- 同一条 INSERT 里用 DISTINCT,避免一个角色同时拥有多个油卡页面时把祖先插重。
-- 已有任意 role_menu 行(含 del_flag=1)则跳过:不把软删除改回有效,也不另插一条把授权加回来。
INSERT INTO `sys_role_menu`
(`id`,`role_id`,`menu_id`,`tenant_no`,`create_by`,`create_dept_id`)
SELECT UUID(), grant_row.role_id, grant_row.menu_id, grant_row.tenant_no, 'system', ''
FROM (
SELECT DISTINCT rm.role_id, target.id AS menu_id, rm.tenant_no
FROM (
SELECT 'PL_VEH_RECHARGE_APPLY' AS source_id, 'PL_ETC_VEH_RECHARGE_APPLY' AS target_id
UNION ALL SELECT 'PL_VEH_RECHARGE_APPROVAL', 'PL_ETC_VEH_RECHARGE_APPROVAL'
UNION ALL SELECT 'PL_VEH_RECHARGE_RECORD', 'PL_ETC_VEH_RECHARGE_RECORD'
UNION ALL SELECT 'PL_VEH_RECHARGE_WF', 'PL_ETC_VEH_RECHARGE_WF'
) mapping
JOIN `sys_role_menu` rm
ON rm.menu_id = mapping.source_id
AND rm.tenant_no = 'shanxia2.0'
AND rm.del_flag = 0
JOIN `sys_role` r
ON r.id = rm.role_id
AND r.tenant_no = rm.tenant_no
AND r.del_flag = 0
AND r.status = '1'
JOIN `sys_menu` child
ON child.id = mapping.target_id
AND child.tenant_no = rm.tenant_no
AND child.del_flag = 0
LEFT JOIN `sys_menu` p1
ON p1.id = child.parent_id AND p1.tenant_no = rm.tenant_no AND p1.del_flag = 0
LEFT JOIN `sys_menu` p2
ON p2.id = p1.parent_id AND p2.tenant_no = rm.tenant_no AND p2.del_flag = 0
LEFT JOIN `sys_menu` p3
ON p3.id = p2.parent_id AND p3.tenant_no = rm.tenant_no AND p3.del_flag = 0
LEFT JOIN `sys_menu` btn
ON btn.parent_id = child.id
AND btn.tenant_no = rm.tenant_no
AND btn.del_flag = 0
AND btn.menu_type = 'F'
JOIN `sys_menu` target
ON target.tenant_no = rm.tenant_no
AND target.del_flag = 0
AND target.id IN (child.id, p1.id, p2.id, p3.id, btn.id)
) grant_row
WHERE NOT EXISTS (
SELECT 1 FROM `sys_role_menu` existing
WHERE existing.role_id = grant_row.role_id
AND existing.menu_id = grant_row.menu_id
AND existing.tenant_no = grant_row.tenant_no
);
COMMIT;
@@ -0,0 +1,413 @@
# 证件到期提醒跳转编辑弹窗 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在证件到期提醒列表增加「维护证件」操作列,按车辆/人员及自有/三方跳到基础信息库对应页并自动打开编辑弹窗。
**Architecture:** 纯函数根据主体类型与归属字段解析目标 path;提醒页 get 详情后 `router.push({ path, query: { editId } })`;车辆/自有车辆/人员页监听 `editId` 调用现有 `handleEdit`,成功后 `replace` 清除 query。后端不动。
**Tech Stack:** Vue 3 / TypeScript / vue-router / Element Plus;`node:test` + `tsx`。
**Spec:** `docs/superpowers/specs/2026-11-12-document-reminder-edit-jump-design.md`
---
## File map
| File | Responsibility |
| --- | --- |
| `web-admin/src/utils/pl-document-reminder-jump.ts` | 解析目标 path;错误文案常量 |
| `web-admin/src/utils/pl-document-reminder-jump.test.ts` | 路径解析单测 |
| `web-admin/src/composables/useRouteEditIdOpen.ts` | 监听 `editId`、调用 opener、清 query |
| `web-admin/src/views/pl/inventory/document-reminder.vue` | 操作列 + 跳转 |
| `web-admin/src/views/pl/vehicle/index.vue` | 接入 `editId` 打开编辑 |
| `web-admin/src/views/pl/self-vehicle/index.vue` | 同上 |
| `web-admin/src/views/pl/personnel/index.vue` | 同上(覆盖 self / carrier 两路由) |
说明:提交步骤在已获用户授权时执行;否则暂存改动待授权再 commit。
---
### Task 1: 路径解析纯函数 + 单测
**Files:**
- Create: `web-admin/src/utils/pl-document-reminder-jump.ts`
- Create: `web-admin/src/utils/pl-document-reminder-jump.test.ts`
- [ ] **Step 1: 写失败测试**
```ts
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
resolveDocumentReminderEditPath,
DOCUMENT_REMINDER_JUMP_UNSUPPORTED
} from './pl-document-reminder-jump'
describe('证件提醒跳转路径', () => {
it('车辆按 vehicleType 分流自有与三方', () => {
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'self' }),
'/vehicle-mgmt/base-info/self-vehicle'
)
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'carrier' }),
'/vehicle-mgmt/base-info/vehicle'
)
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: '' }),
'/vehicle-mgmt/base-info/vehicle'
)
})
it('人员按 personnelAffiliation 分流', () => {
assert.equal(
resolveDocumentReminderEditPath({
ownerType: 'personnel',
personnelAffiliation: 'self'
}),
'/vehicle-mgmt/base-info/personnel'
)
assert.equal(
resolveDocumentReminderEditPath({
ownerType: 'personnel',
personnelAffiliation: 'carrier'
}),
'/vehicle-mgmt/base-info/personnel-carrier'
)
assert.equal(
resolveDocumentReminderEditPath({
ownerType: 'personnel',
personnelAffiliation: ''
}),
'/vehicle-mgmt/base-info/personnel'
)
})
it('不支持的主体返回 null', () => {
assert.equal(resolveDocumentReminderEditPath({ ownerType: 'carrier' }), null)
assert.equal(resolveDocumentReminderEditPath({ ownerType: '' }), null)
assert.equal(DOCUMENT_REMINDER_JUMP_UNSUPPORTED, '暂不支持该主体类型的证件维护跳转')
})
})
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-document-reminder-jump.test.ts
```
Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现**
```ts
export const DOCUMENT_REMINDER_JUMP_UNSUPPORTED = '暂不支持该主体类型的证件维护跳转'
export type ReminderJumpInput = {
ownerType?: string | null
vehicleType?: string | null
personnelAffiliation?: string | null
}
export function resolveDocumentReminderEditPath(input: ReminderJumpInput): string | null {
const ownerType = String(input.ownerType || '').trim()
if (ownerType === 'vehicle') {
return String(input.vehicleType || '').trim() === 'self'
? '/vehicle-mgmt/base-info/self-vehicle'
: '/vehicle-mgmt/base-info/vehicle'
}
if (ownerType === 'personnel') {
return String(input.personnelAffiliation || '').trim() === 'carrier'
? '/vehicle-mgmt/base-info/personnel-carrier'
: '/vehicle-mgmt/base-info/personnel'
}
return null
}
```
- [ ] **Step 4: 跑测确认通过**
同 Step 2,Expected: PASS
- [ ] **Step 5: Commit(需授权)**
```bash
git add web-admin/src/utils/pl-document-reminder-jump.ts \
web-admin/src/utils/pl-document-reminder-jump.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add document reminder jump path resolver
EOF
)"
```
---
### Task 2: `useRouteEditIdOpen` composable
**Files:**
- Create: `web-admin/src/composables/useRouteEditIdOpen.ts`
- [ ] **Step 1: 实现 composable**
```ts
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
/**
* 监听 route.query.editId,调用 openById;结束后清除 editId,避免刷新重复弹窗。
* openById 应自行处理加载失败提示。
*/
export function useRouteEditIdOpen(openById: (id: string) => void | Promise<void>) {
const route = useRoute()
const router = useRouter()
async function consumeEditId() {
const raw = route.query.editId
const id = Array.isArray(raw) ? String(raw[0] || '') : String(raw || '')
if (!id.trim()) return
try {
await openById(id.trim())
} finally {
if (route.query.editId == null) return
const query = { ...route.query }
delete query.editId
await router.replace({ path: route.path, query })
}
}
watch(
() => route.query.editId,
() => {
void consumeEditId()
},
{ immediate: true }
)
}
```
- [ ] **Step 2: Commit(需授权)**
```bash
git add web-admin/src/composables/useRouteEditIdOpen.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add useRouteEditIdOpen for deep-link edit dialogs
EOF
)"
```
---
### Task 3: 提醒列表操作列与跳转
**Files:**
- Modify: `web-admin/src/views/pl/inventory/document-reminder.vue`
- [ ] **Step 1: 模板 — 在 `</ListTable>` 前增加操作列**
在状态/备注列之后、`</ListTable>` 之前插入:
```vue
<el-table-column label="操作" fixed="right" min-width="140" align="center">
<template #default="{ row }">
<el-button
link
type="primary"
:loading="maintainLoadingId === row.id"
@click="handleMaintainCert(row)"
>
<el-icon><EditPen /></el-icon>维护证件
</el-button>
</template>
</el-table-column>
```
- [ ] **Step 2: script — 导入与跳转逻辑**
1. 图标:`import { CircleCheck, Filter, EditPen } from '@element-plus/icons-vue'`
2. API:`import { inventoryApi, vehicleApi, personnelApi, type DocumentReminder } from '@/api/pl'`
3. `import { useRouter } from 'vue-router'`(若尚未)
4. `import { pickBxData } from '@/utils/http/bx'`
5. `import { resolveDocumentReminderEditPath, DOCUMENT_REMINDER_JUMP_UNSUPPORTED } from '@/utils/pl-document-reminder-jump'`
6. `import type { VehicleDetail, PersonnelDetail } from '@/api/pl'`(若类型从同文件导出则合并 import)
```ts
const router = useRouter()
const maintainLoadingId = ref<string | null>(null)
async function handleMaintainCert(row: DocumentReminder) {
const ownerId = String(row.ownerId || '').trim()
const ownerType = String(row.ownerType || '').trim()
if (!ownerId || !ownerType) {
ElMessage.warning('缺少主体信息,无法跳转维护')
return
}
maintainLoadingId.value = row.id
try {
let path: string | null = null
if (ownerType === 'vehicle') {
const res = await vehicleApi.get(ownerId)
const detail = pickBxData<VehicleDetail>(res) || (res as any)?.data || res
const vehicleType = String((detail as VehicleDetail)?.vehicleType || '')
path = resolveDocumentReminderEditPath({ ownerType, vehicleType })
} else if (ownerType === 'personnel') {
const res = await personnelApi.get(ownerId)
const detail = pickBxData<PersonnelDetail>(res) || (res as any)?.data || res
const personnelAffiliation = String(
(detail as PersonnelDetail)?.personnelAffiliation || ''
)
path = resolveDocumentReminderEditPath({ ownerType, personnelAffiliation })
} else {
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
return
}
if (!path) {
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
return
}
await router.push({ path, query: { editId: ownerId } })
} catch {
ElMessage.error('无法打开主体档案,请确认数据存在且有权限')
} finally {
maintainLoadingId.value = null
}
}
```
确保页面已导入 `ElMessage`(项目若自动导入则无需手写)。
- [ ] **Step 3: Commit(需授权)**
```bash
git add web-admin/src/views/pl/inventory/document-reminder.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add maintain-cert action on document reminder list
EOF
)"
```
---
### Task 4: 车辆 / 自有车辆页响应 `editId`
**Files:**
- Modify: `web-admin/src/views/pl/vehicle/index.vue`
- Modify: `web-admin/src/views/pl/self-vehicle/index.vue`
- [ ] **Step 1: vehicle/index.vue**
在 `handleEdit` 定义之后加入:
```ts
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Vehicle)
})
```
确认 `handleEdit` 仅依赖 `row.id` 拉详情(现有实现用 `vehicleApi.get(row.id)`)。若 TypeScript 抱怨,使用 `as Vehicle` 或最小对象即可。
- [ ] **Step 2: self-vehicle/index.vue**
同样在 `handleEdit` 之后:
```ts
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Vehicle)
})
```
(若自有车页 Vehicle 类型同名,用该页已有类型。)
- [ ] **Step 3: Commit(需授权)**
```bash
git add web-admin/src/views/pl/vehicle/index.vue \
web-admin/src/views/pl/self-vehicle/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): open vehicle edit dialog from editId query
EOF
)"
```
---
### Task 5: 人员页响应 `editId`
**Files:**
- Modify: `web-admin/src/views/pl/personnel/index.vue`
- [ ] **Step 1: 接入 composable**
在 `handleEdit` 之后:
```ts
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Personnel)
})
```
该组件同时挂 `personnel` 与 `personnel-carrier`,两路由都会响应 `editId`。提醒页已按 affiliation 选对 path;若进错页,`handleEdit`/列表数据范围会失败——保持现有错误提示即可,必要时在 `openById` 外包一层 try/catch 用 `ElMessage.error`。
- [ ] **Step 2: Commit(需授权)**
```bash
git add web-admin/src/views/pl/personnel/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): open personnel edit dialog from editId query
EOF
)"
```
---
### Task 6: 验证
**Files:** 无必须改动。
- [ ] **Step 1: 单元测试**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-document-reminder-jump.test.ts
```
Expected: PASS
- [ ] **Step 2: 手工冒烟(需登录)**
1. 打开 `/#/pl/document-reminder-module/document-reminder`
2. 点车辆行「维护证件」→ 进入对应自有/三方车辆页且编辑弹窗打开,证件可编辑
3. 点人员行同理
4. 刷新目标页不重复弹窗(query 已清)
5. 原「处理」按钮仍可用
- [ ] **Step 3: 记录未跑项**
若无法登录 UI,在交付说明写明仅完成单测与代码接入,UI 冒烟待用户验证。
---
## Spec coverage
| 设计项 | 任务 |
| --- | --- |
| 操作列「维护证件」 | Task 3 |
| 按类型/归属分流 path | Task 1 + 3 |
| `editId` 打开编辑并清 query | Task 2 + 4 + 5 |
| 后端不动 / 不自动 resolve | 全程遵守 |
| 仅 Web | 不改 miniprogram |
## Placeholder scan
无 TBD;路径、文件、测试命令、代码块已钉死。
@@ -0,0 +1,543 @@
# ETC / 油卡充值管理拆分 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Web 端将混合的「ETC/油卡充值管理」拆成并列的【ETC充值管理】与【油卡充值管理】;前端复制页面并按类型过滤,后端共用接口并新增 `rechargeTypeNe`;审批流程与审批人配置保持一套。
**Architecture:** 后端在现有 list/export/approval/board 过滤链增加 `recharge_type_ne`;油卡前端固定传 `rechargeTypeNe=etc`,ETC 前端固定传 `rechargeType=etc` 并复制四套页面。原分组 path 改为 `fuel-recharge`,新建 `etc-recharge` 分组;顶部 `PlRechargeSummary` 仅油卡路径显示。工作流 `biz_type=vehicle_recharge` 不拆。
**Tech Stack:** FastAPI / SQLAlchemy;Vue 3 / TypeScript / Element Plus;pytest;`node:test` + `tsx`;MySQL 菜单迁移 SQL。
**Spec:** `docs/superpowers/specs/2026-11-12-pl-etc-fuel-recharge-split-design.md`
---
## File map
| File | Responsibility |
| --- | --- |
| `backend/app/services/pl/vehicle_recharge_list_filters.py` | `recharge_type_ne` → `col != value` |
| `backend/app/services/pl_vehicle_recharge_service.py` | `list_applies` / `list_disburse_board`(及 list_records 若已接类型)透传 ne |
| `backend/app/services/pl_vehicle_recharge_approval_service.py` | pending/done 透传 ne |
| `backend/app/api/pl/vehicle_recharge.py` | Query/export body 增加 `rechargeTypeNe` |
| `backend/app/api/pl/vehicle_recharge_approval.py` | pending/done 增加 `rechargeTypeNe` |
| `backend/tests/test_pl_vehicle_ops_list_filters.py` | ne 过滤单测 |
| `web-admin/src/utils/pl-recharge-summary.ts` | 仅 `fuel-recharge` 显示统计 |
| `web-admin/src/utils/pl-recharge-summary.test.ts` | 路由断言更新 |
| `web-admin/src/views/pl/vehicle-recharge-{apply,approval,record,workflow}/` | 油卡侧:排除 etc、文案、默认类型 |
| `web-admin/src/views/pl/etc-vehicle-recharge-{apply,approval,record,workflow}/` | ETC 复制页:固定 etc、无油卡字段、新 table-key |
| `web-admin/src/router/modules/pl.ts` | 分组 path、标题、ETC 子路由、旧 URL redirect |
| `docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql` | 改名、改 path、新建 ETC 菜单、角色同步 |
| `web-admin/src/components/core/layouts/art-page-content/index.vue` | 确认 summary 仍指向油卡申请 menuCode(一般无需改逻辑) |
**说明:**
- 「车油卡信息」`PL_VEH_FUEL_CARD` 继续留在油卡分组下(现挂在原充值分组),不迁入 ETC。
- 写入接口本期不按域拆分;类型由前端固定。
- 提交前须有用户明确授权再 `git commit`;下列 Commit 步骤在已授权时执行。
---
### Task 1: 后端 `rechargeTypeNe` 过滤
**Files:**
- Modify: `backend/app/services/pl/vehicle_recharge_list_filters.py`
- Modify: `backend/tests/test_pl_vehicle_ops_list_filters.py`
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`
- Modify: `backend/app/services/pl_vehicle_recharge_approval_service.py`
- Modify: `backend/app/api/pl/vehicle_recharge.py`
- Modify: `backend/app/api/pl/vehicle_recharge_approval.py`
- [ ] **Step 1: 写失败单测**
在 `test_pl_vehicle_ops_list_filters.py` 追加:
```python
def test_recharge_apply_type_ne_filter():
filters = build_recharge_apply_column_filters(recharge_type_ne="etc")
assert len(filters) == 1
sql = _compile(filters[0])
assert "recharge_type" in sql
assert "!=" in sql or "<>" in sql
assert "etc" in sql
def test_recharge_disburse_board_type_ne_filter():
filters = build_recharge_disburse_board_column_filters(recharge_type_ne="etc")
assert any("recharge_type" in _compile(f) for f in filters)
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd backend && source venv/bin/activate
pytest tests/test_pl_vehicle_ops_list_filters.py::test_recharge_apply_type_ne_filter tests/test_pl_vehicle_ops_list_filters.py::test_recharge_disburse_board_type_ne_filter -v
```
Expected: FAIL(未知参数或无 ne 条件)
- [ ] **Step 3: 实现 filter 层**
在 `vehicle_recharge_list_filters.py` 增加:
```python
def _append_ne_filter(filters: list, col, raw: Optional[str]) -> None:
value = (raw or "").strip()
if value:
filters.append(col != value)
```
`build_recharge_apply_column_filters` / `build_recharge_disburse_board_column_filters` 增加参数 `recharge_type_ne: Optional[str] = None`,在现有 `_append_enum_filter(... recharge_type ...)` 之后调用:
```python
_append_ne_filter(filters, PlVehicleRechargeApply.recharge_type, recharge_type_ne)
```
(发放看板同样用 `PlVehicleRechargeApply.recharge_type`。)
- [ ] **Step 4: 透传 service + API**
对以下调用链增加同名可选参数并向下传递(alias `rechargeTypeNe`):
1. `list_applies`(service + `_apply_list_kwargs` / export 解析 + `list_applies` Query)
2. `list_disburse_board`(及 export)
3. `list_records`(若当前已支持 `recharge_type`,一并加 ne;否则仅保证 apply/board/approval)
4. approval `list_pending` / `list_done`(`vehicle_recharge_approval.py` 的 `_param_get` 与 Query)
示例(API Query):
```python
recharge_type_ne: Optional[str] = Query(default=None, alias="rechargeTypeNe"),
```
export body Field 同样加 `rechargeTypeNe`。
- [ ] **Step 5: 跑测确认通过**
```bash
cd backend && source venv/bin/activate
pytest tests/test_pl_vehicle_ops_list_filters.py -v
```
Expected: PASS
- [ ] **Step 6: Commit(需用户授权)**
```bash
git add backend/app/services/pl/vehicle_recharge_list_filters.py \
backend/app/services/pl_vehicle_recharge_service.py \
backend/app/services/pl_vehicle_recharge_approval_service.py \
backend/app/api/pl/vehicle_recharge.py \
backend/app/api/pl/vehicle_recharge_approval.py \
backend/tests/test_pl_vehicle_ops_list_filters.py
git commit -m "$(cat <<'EOF'
feat(pl): add rechargeTypeNe filter for vehicle recharge lists
EOF
)"
```
---
### Task 2: 顶部统计仅油卡路由
**Files:**
- Modify: `web-admin/src/utils/pl-recharge-summary.ts`
- Modify: `web-admin/src/utils/pl-recharge-summary.test.ts`
- [ ] **Step 1: 更新失败测试**
替换 `isRechargeSummaryRoute` 相关用例为:
```ts
it('只对油卡充值业务子页显示,ETC 与其它车辆页不显示', () => {
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-record'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
false
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-apply'),
false
)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle-fuel-card'), false)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle'), false)
})
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-recharge-summary.test.ts
```
Expected: FAIL(旧路径仍为 true / 新 fuel 路径未匹配)
- [ ] **Step 3: 实现**
```ts
export function isRechargeSummaryRoute(path: string) {
return /\/fuel-recharge\/vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(
path
)
}
```
确认 `art-page-content` 仍用 `menuCode === 'pl:vehicle-recharge-apply:list'` 解析跳转(油卡申请 perms 不变),一般无需改文件。
- [ ] **Step 4: 跑测确认通过**
同 Step 2,Expected: PASS
- [ ] **Step 5: Commit(需用户授权)**
```bash
git add web-admin/src/utils/pl-recharge-summary.ts web-admin/src/utils/pl-recharge-summary.test.ts
git commit -m "$(cat <<'EOF'
fix(web-admin): show recharge summary only on fuel-recharge routes
EOF
)"
```
---
### Task 3: 油卡侧原页面改造(排除 ETC)
**Files:**
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/index.vue`
- Modify: `web-admin/src/views/pl/vehicle-recharge-approval/index.vue`
- Modify: `web-admin/src/views/pl/vehicle-recharge-record/index.vue`
- Modify: `web-admin/src/views/pl/vehicle-recharge-workflow/index.vue`
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts`(若枚举选项在此定义)
- [ ] **Step 1: 申请页强制排除 etc**
在 `buildListQueryParams`(约 L715)末尾增加:
```ts
params.rechargeTypeNe = 'etc'
return params
```
若用户列筛选选了 `rechargeType` 且包含 `etc`,以 ne 为准仍排除 etc(后端 `!=` 与 `IN` 同时生效时需确认 SQLAlchemy 为 AND)。为避免矛盾:油卡页 `enumOptionsFor('rechargeType')` **过滤掉** `etc` 选项;表单 `<el-option>` 同样不渲染 `etc`。
默认新建类型:
```ts
// handleAdd / formData 初始
formData.rechargeType = 'fuel'
```
文案:文件头注释、导出 filename、提示里的「ETC/油卡」改为「油卡」(审批跳转文案等同理)。
- [ ] **Step 2: 审批页 / 发放页同样传 `rechargeTypeNe=etc`**
在各自组装 list/export/board 参数处增加 `rechargeTypeNe: 'etc'`;充值类型筛选项去掉 etc;文案改为油卡。
发放页中类似:
```ts
ElMessage.warning('审批中的申请请到「油卡充值审批」菜单完成发放登记')
```
- [ ] **Step 3: 审批人设置页**
顶部 `wf-info-bar` 旁或下方增加一行提示(简短即可):
```vue
<span class="form-tip">ETC 与油卡共用同一套审批人配置</span>
```
不改 `biz_type` / API。
- [ ] **Step 4: 本地点验(开发服)**
打开 `/#/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply`(路由 Task 5 完成前可暂用旧 path 带 `rechargeTypeNe` 联调)。确认列表无 etc 行;新建无 ETC 选项。
- [ ] **Step 5: Commit(需用户授权)**
```bash
git add web-admin/src/views/pl/vehicle-recharge-apply \
web-admin/src/views/pl/vehicle-recharge-approval \
web-admin/src/views/pl/vehicle-recharge-record \
web-admin/src/views/pl/vehicle-recharge-workflow
git commit -m "$(cat <<'EOF'
fix(web-admin): restrict legacy recharge pages to non-ETC types
EOF
)"
```
---
### Task 4: 复制并改造 ETC 四套页面
**Files:**
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-apply/`(含 `index.vue`、`columnFilterDefs.ts`)
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-approval/index.vue`
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue`
- Create: `web-admin/src/views/pl/etc-vehicle-recharge-workflow/index.vue`
- [ ] **Step 1: 复制目录**
```bash
cd web-admin/src/views/pl
cp -R vehicle-recharge-apply etc-vehicle-recharge-apply
cp -R vehicle-recharge-approval etc-vehicle-recharge-approval
cp -R vehicle-recharge-record etc-vehicle-recharge-record
cp -R vehicle-recharge-workflow etc-vehicle-recharge-workflow
```
- [ ] **Step 2: ETC 申请页改造清单(在复制后的文件上改)**
1. 注释与标题文案 →「ETC充值申请」等。
2. `buildListQueryParams`:删除 `rechargeTypeNe`;固定 `params.rechargeType = 'etc'`(若用户又选了类型筛选,仍强制 etc,或隐藏类型筛选项)。
3. 表单:充值类型固定 `etc`,可用只读展示或隐藏 select 并在提交时写死;去掉油卡卡号相关表单项与 `useVehicleFuelCard` 展示(ETC 不需要)。
4. `table-key`、`usePersistedListColumns` 的 localStorage key、导出列定义 key → 加 `etc-` 前缀,例如 `pl-etc-vehicle-recharge-apply`、`pl-etc-vehicle-recharge-apply-list-columns-v2`。
5. `columnFilterDefs` 中若 import 路径指向旧目录,改为相对本目录或 `@/views/pl/etc-vehicle-recharge-apply/columnFilterDefs`。
6. 权限指令 / `menuCode` 若页面内有硬编码,改为 ETC 新 perms(与菜单 SQL 一致,见 Task 6):`pl:etc-vehicle-recharge-apply:*`。
7. 统计刷新:可保留 `mittBus.emit('rechargeSummaryRefresh')`(无害);ETC 路由本就不显示 summary。
- [ ] **Step 3: ETC 审批 / 发放页**
- 列表参数固定 `rechargeType=etc`。
- 文案「ETC充值审批」「ETC发放管理」。
- 新 `table-key` / 列偏好 key。
- 发放页跳转提示改为「ETC充值审批」。
- 隐藏或固定类型筛选为 etc;无油卡卡号列亦可保留显示为 —。
- [ ] **Step 4: ETC 审批人设置页**
- 仍调用现有工作流 API / `vehicle_recharge` biz_type(与油卡页相同 service 调用)。
- 增加共用说明文案。
- `table-key` 改为 `pl-etc-vehicle-recharge-workflow`(仅前端偏好隔离)。
- 页面内 perms 复用 `pl:vehicle-recharge-workflow:*`(与设计一致)。
- [ ] **Step 5: Commit(需用户授权)**
```bash
git add web-admin/src/views/pl/etc-vehicle-recharge-apply \
web-admin/src/views/pl/etc-vehicle-recharge-approval \
web-admin/src/views/pl/etc-vehicle-recharge-record \
web-admin/src/views/pl/etc-vehicle-recharge-workflow
git commit -m "$(cat <<'EOF'
feat(web-admin): add dedicated ETC vehicle recharge pages
EOF
)"
```
---
### Task 5: 静态路由与 redirect
**Files:**
- Modify: `web-admin/src/router/modules/pl.ts`
- [ ] **Step 1: 原分组改为油卡**
将 `PL_GROUP_VEH_RECHARGE`:
- `path: 'etc-recharge'` → `'fuel-recharge'`
- `meta.title` → `'油卡充值管理'`
- 子菜单 title:`油卡充值申请` / `油卡充值审批` / `油卡充值发放管理` / `油卡充值审批人设置`
- **保留** `PL_VEH_FUEL_CARD`(车油卡信息)在此分组下
- component 仍指向原 `pl/vehicle-recharge-*`
- [ ] **Step 2: 新增 ETC 分组(插在油卡分组旁)**
```ts
{
id: 'PL_GROUP_VEH_ETC_RECHARGE',
name: 'PlVehicleEtcRechargeGroup',
path: 'etc-recharge',
meta: { title: 'ETC充值管理', keepAlive: false },
children: [
{
id: 'PL_ETC_VEH_RECHARGE_APPLY',
name: 'PlEtcVehicleRechargeApply',
path: 'etc-vehicle-recharge-apply',
component: '/pl/etc-vehicle-recharge-apply/index',
meta: {
title: 'ETC充值申请',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-apply:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_APPROVAL',
name: 'PlEtcVehicleRechargeApproval',
path: 'etc-vehicle-recharge-approval',
component: '/pl/etc-vehicle-recharge-approval/index',
meta: {
title: 'ETC充值审批',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-approval:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_RECORD',
name: 'PlEtcVehicleRechargeRecord',
path: 'etc-vehicle-recharge-record',
component: '/pl/etc-vehicle-recharge-record/index',
meta: {
title: 'ETC发放管理',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-record:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_WF',
name: 'PlEtcVehicleRechargeWorkflow',
path: 'etc-vehicle-recharge-workflow',
component: '/pl/etc-vehicle-recharge-workflow/index',
meta: {
title: 'ETC充值审批人设置',
keepAlive: false,
menuCode: 'pl:vehicle-recharge-workflow:list'
}
}
]
}
```
- [ ] **Step 3: 更新隐藏 redirect**
所有原指向 `/vehicle-mgmt/etc-recharge/vehicle-recharge-*` 的 redirect,改为:
`/vehicle-mgmt/fuel-recharge/vehicle-recharge-*`
(书签落到油卡侧。)
- [ ] **Step 4: Commit(需用户授权)**
```bash
git add web-admin/src/router/modules/pl.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): split etc and fuel recharge router groups
EOF
)"
```
---
### Task 6: 菜单迁移 SQL + 角色同步
**Files:**
- Create: `docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql`
- [ ] **Step 1: 编写可重复执行的迁移**
要点(MySQL 5.7,`SET NAMES utf8mb4;` + 事务):
1. 更新原分组与子菜单名称:
- `PL_GROUP_VEH_RECHARGE`:`menu_name='油卡充值管理'`,`path='fuel-recharge'`,remark 更新
- 原子菜单名改为「油卡充值申请/审批/发放管理/审批人设置」
2. 插入 ETC 分组(`parent_id=PL_GROUP_VEHICLE`,`path='etc-recharge'`,`order_num` 建议小于油卡分组以便并列靠前,或紧挨油卡):
- id: `PL_GROUP_VEH_ETC_RECHARGE`
3. 插入 4 个 C 菜单(component / path / perms 与 Task 5 一致):
- Apply/Approval/Record:新 perms `pl:etc-vehicle-recharge-*:list`
- WF:`perms='pl:vehicle-recharge-workflow:list'`(复用)
4. 为 Apply/Approval/Record 复制对应 F 按钮菜单(对照现有 `PL_VRA_*` / `PL_VRR_*` / 审批按钮),id 前缀 `PL_ETC_…`,perms 换新前缀。
5. 角色同步:对已拥有对应油卡菜单(如 `PL_VEH_RECHARGE_APPLY`)的角色,插入 ETC 对等菜单及祖先(模式参考 `docs/sql/migrations/2026-10-25-pl-vehicle-fuel-card.sql` 的 `sys_role_menu` 段);`NOT EXISTS` 防重复;不恢复管理员已软删授权。
6. WF 菜单:对已有 `PL_VEH_RECHARGE_WF` 授权的角色补 `PL_ETC_VEH_RECHARGE_WF` + 分组祖先。
- [ ] **Step 2: 在本地库执行并抽查**
```bash
# 使用项目既有 mysql 方式执行该 migration;执行后:
# SELECT id,menu_name,path,component,perms FROM sys_menu
# WHERE id LIKE 'PL_%RECHARGE%' OR parent_id IN ('PL_GROUP_VEH_RECHARGE','PL_GROUP_VEH_ETC_RECHARGE');
```
Expected: 两组菜单并存;油卡 path=`fuel-recharge`;ETC 指向 `pl/etc-vehicle-recharge-*`。
- [ ] **Step 3: Commit(需用户授权)**
```bash
git add docs/sql/migrations/2026-11-12-pl-etc-fuel-recharge-split-menus.sql
git commit -m "$(cat <<'EOF'
chore(sql): split ETC and fuel recharge menus with role sync
EOF
)"
```
---
### Task 7: 联调验收与回归
**Files:** 无必须改动;按需修漏网文案 / 参数。
- [ ] **Step 1: 后端过滤回归**
```bash
cd backend && source venv/bin/activate
pytest tests/test_pl_vehicle_ops_list_filters.py -v
```
Expected: PASS
- [ ] **Step 2: 前端 summary 单测**
```bash
cd web-admin && ./node_modules/.bin/tsx --test src/utils/pl-recharge-summary.test.ts
```
Expected: PASS
- [ ] **Step 3: 类型检查(可选但推荐)**
```bash
cd web-admin && pnpm build:typecheck
```
Expected: 与本改动相关的错误为 0
- [ ] **Step 4: 手工验收对照设计「验收」清单**
1. 侧栏并列【ETC充值管理】【油卡充值管理】,ETC 4 子菜单;油卡含原 4 项 +「车油卡信息」。
2. ETC 列表仅 etc;新建固定 ETC;无顶部统计。
3. 油卡列表无 etc;有顶部统计;卡片跳转进油卡申请。
4. 两边审批人设置改同一套节点。
5. 旧 URL `/#/vehicle-mgmt/etc-recharge/vehicle-recharge-apply` redirect 到 fuel 路径。
6. 小程序未改。
- [ ] **Step 5: 若有修复,再提交(需用户授权)**
---
## Spec coverage(自检)
| 设计要求 | 任务 |
| --- | --- |
| 并列两模块 + 子菜单 | Task 5–6 |
| 油卡排除 etc、界面现状 | Task 3 |
| ETC 复制页 + 固定 etc + 无统计 | Task 2、4 |
| 后端不整拆 + `rechargeTypeNe` | Task 1 |
| 审批/审批人保持一套 + 双入口 | Task 3–4 workflow |
| 仅 Web | 全程不改 miniprogram |
| 旧 URL redirect | Task 5 |
| 角色默认同步 | Task 6 |
| 车油卡信息归属油卡组 | Task 5–6 明确保留 |
## Placeholder scan
无 TBD/TODO;油卡/ETC 路径、菜单 id、perms、测试命令已钉死。
@@ -0,0 +1,498 @@
# 充值申请「发放中」状态 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 末级审批通过进入发放节点后,申请状态变为 `pending`,Web 展示为「发放中」;待办/徽章/发放登记与撤销回退一并兼容。
**Architecture:** 油卡/ETC 共用后端状态机。`approve_apply` 在下一节点为 `disburse` 时写入 `pending`;`_current_node` / `list_pending` / 菜单徽章同时接受 `in_approval` 与 `pending`;撤销发放有流程时回退 `pending`。前端仅改充值申请文案与筛选项(尿素等其它业务不动)。
**Tech Stack:** FastAPI / SQLAlchemy、pytest、Vue 3 + Element Plus(web-admin)
**Spec:** `docs/superpowers/specs/2026-11-12-pl-recharge-pending-disburse-status-design.md`
---
## File map
| 文件 | 职责 |
| --- | --- |
| `backend/app/schemas/pl/vehicle_recharge.py` | `APPLY_STATUSES` 纳入 `pending` |
| `backend/app/services/pl_vehicle_recharge_approval_service.py` | 审批推进切 `pending`;待办/当前节点准入 |
| `backend/app/services/pl_vehicle_recharge_service.py` | 撤销/换绑回退;关联提示文案 |
| `backend/app/services/pl_menu_badge_service.py` | 徽章计数含 `pending` |
| `backend/app/api/pl/vehicle_recharge.py` | 导出状态标签「发放中」 |
| `backend/tests/test_pl_vehicle_recharge_workflow.py` | 状态机单测 |
| `backend/tests/test_pl_menu_badge_recharge_approval.py` | 徽章含 pending |
| `web-admin/src/utils/pl-display.ts` | 充值申请 `pending` → 发放中 |
| `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts` | 筛选项 |
| `web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts` | 筛选项 |
| `web-admin/src/views/pl/vehicle-recharge-record/index.vue` | 撤销确认文案 |
| `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue` | 撤销确认文案 |
---
### Task 1: Schema + 审批推进写 pending(TDD)
**Files:**
- Modify: `backend/app/schemas/pl/vehicle_recharge.py`
- Modify: `backend/app/services/pl_vehicle_recharge_approval_service.py`
- Modify: `backend/tests/test_pl_vehicle_recharge_workflow.py`
- [ ] **Step 1: 改现有断言并加失败用例**
在 `test_apply_statuses_include_workflow_states` 中让集合包含 `pending`:
```python
assert {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"} <= APPLY_STATUSES
```
将 `test_approve_advances_to_next_node` 中通过后断言改为(默认种子流程下一节点是发放):
```python
assert result.status == "pending"
assert result.current_node == node_fin
```
新增三级流程用例(一级通过仍审批中,二级通过变发放中):
```python
def _seed_three_node_workflow(db) -> tuple[str, str, str, str]:
wf_id = "wf-3"
n1, n2, n3 = "node-a1", "node-a2", "node-dis"
db.add(
BxWorkflowDef(
id=wf_id,
biz_type="vehicle_recharge",
wf_name="三级充值流程",
status=1,
tenant_no="t1",
create_dept_id="d1",
)
)
for nid, name, order, uid, uname, action in (
(n1, "一审", 1, "u-a1", "一审", "approve"),
(n2, "二审", 2, "u-a2", "二审", "approve"),
(n3, "发放", 3, "u-fin", "财务", "disburse"),
):
db.add(
BxWorkflowNode(
id=nid,
wf_id=wf_id,
node_name=name,
node_order=order,
approver_id=uid,
approver_name=uname,
action_type=action,
tenant_no="t1",
create_dept_id="d1",
)
)
return wf_id, n1, n2, n3
def test_mid_approve_stays_in_approval_final_approve_sets_pending(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
db = _make_session()
_seed_vehicle(db)
wf_id, n1, n2, n3 = _seed_three_node_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="YK2026092601",
license_plate="鄂A00001",
recharge_type="fuel",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="in_approval",
wf_id=wf_id,
current_node=n1,
approval_round=1,
tenant_no="t1",
create_dept_id="d1",
)
)
db.commit()
_patch_scope(monkeypatch, svc)
mid = approval_svc.approve_apply(db, "t1", _user("u-a1", "一审"), "apply-1", opinion="ok")
assert mid.status == "in_approval"
assert mid.current_node == n2
final = approval_svc.approve_apply(db, "t1", _user("u-a2", "二审"), "apply-1", opinion="ok")
assert final.status == "pending"
assert final.current_node == n3
```
新增:`pending` 状态下可发放:
```python
def test_disburse_accepts_pending_status(monkeypatch):
from app.services import pl_vehicle_recharge_approval_service as approval_svc
from app.services import pl_vehicle_recharge_service as svc
from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
db = _make_session()
_seed_vehicle(db)
wf_id, _gm, node_fin = _seed_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="ETC2026092601",
license_plate="鄂A00001",
recharge_type="etc",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="pending",
wf_id=wf_id,
current_node=node_fin,
approval_round=1,
tenant_no="t1",
create_dept_id="d1",
)
)
db.commit()
_patch_scope(monkeypatch, svc)
payload = VehicleRechargeRecordCreate(
rechargeDate=date(2026, 9, 26),
rechargeType="etc",
amount=Decimal("50.00"),
applyId="apply-1",
)
result = approval_svc.disburse_apply(
db, "t1", _user("u-fin", "财务"), "apply-1", payload
)
assert result.status == "disbursed"
```
- [ ] **Step 2: 跑测确认失败**
Run(在 `backend/`,已激活项目 venv):
```bash
pytest tests/test_pl_vehicle_recharge_workflow.py::test_apply_statuses_include_workflow_states \
tests/test_pl_vehicle_recharge_workflow.py::test_approve_advances_to_next_node \
tests/test_pl_vehicle_recharge_workflow.py::test_mid_approve_stays_in_approval_final_approve_sets_pending \
tests/test_pl_vehicle_recharge_workflow.py::test_disburse_accepts_pending_status -v
```
Expected: FAIL(`pending` 不在集合 / 状态仍为 `in_approval` / `申请单不在审批中`)
- [ ] **Step 3: 最小实现**
`vehicle_recharge.py` schema:
```python
APPLY_STATUSES = {"draft", "in_approval", "pending", "rejected", "disbursed", "cancelled"}
```
`pl_vehicle_recharge_approval_service.py` 中 `_current_node`:
```python
if apply.status not in ("in_approval", "pending") or not apply.current_node:
raise HTTPException(status_code=400, detail="申请单不在可审批/发放状态")
```
`list_pending` 过滤:
```python
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
```
`approve_apply` 在推进节点后:
```python
next_node = nodes[idx + 1]
apply.current_node = next_node.id
next_action = (next_node.action_type or "approve").strip() or "approve"
if next_action == "disburse":
apply.status = "pending"
```
(勿改中间 approve 的 status。)
- [ ] **Step 4: 再跑 Step 2 命令**
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/schemas/pl/vehicle_recharge.py \
backend/app/services/pl_vehicle_recharge_approval_service.py \
backend/tests/test_pl_vehicle_recharge_workflow.py
git commit -m "$(cat <<'EOF'
feat(pl): set recharge apply to pending when entering disburse node
EOF
)"
```
---
### Task 2: 徽章计数含 pending
**Files:**
- Modify: `backend/app/services/pl_menu_badge_service.py`
- Modify: `backend/tests/test_pl_menu_badge_recharge_approval.py`
- [ ] **Step 1: 扩展徽章测试**
在现有 `add_apply` 种子后增加:
```python
add_apply("a6", status="pending", current_node="node-me")
```
并将断言改为:
```python
assert counts[MENU_CODE_RECHARGE_APPROVAL] == 3
```
- [ ] **Step 2: 跑测确认失败**
```bash
pytest tests/test_pl_menu_badge_recharge_approval.py -v
```
Expected: FAIL(仍为 2)
- [ ] **Step 3: 实现**
`count_recharge_approval_pending` 中:
```python
PlVehicleRechargeApply.status.in_(("in_approval", "pending")),
```
- [ ] **Step 4: 再跑测**
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/pl_menu_badge_service.py \
backend/tests/test_pl_menu_badge_recharge_approval.py
git commit -m "$(cat <<'EOF'
fix(pl): include pending recharge applies in approval menu badge
EOF
)"
```
---
### Task 3: 撤销发放回退为 pending + 文案「发放中」
**Files:**
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`(`delete_records` / 换绑旧申请回退两处:约 1136、1205 行,有 `wf_id` 时现为 `in_approval`)
- Modify: `backend/app/api/pl/vehicle_recharge.py`(`RECHARGE_APPLY_STATUS_LABELS`)
- Modify: `backend/app/services/pl_vehicle_recharge_service.py`(`仅待发放申请可关联` → `仅发放中申请可关联`)
- [ ] **Step 1: 写回退单测(可放在 workflow 测试文件)**
```python
def test_delete_record_with_workflow_reverts_to_pending(monkeypatch):
from app.services import pl_vehicle_recharge_service as svc
db = _make_session()
_seed_vehicle(db)
wf_id, _gm, node_fin = _seed_workflow(db)
db.add(
PlVehicleRechargeApply(
id="apply-1",
apply_no="ETC2026092601",
license_plate="鄂A00001",
recharge_type="etc",
apply_date=date(2026, 9, 26),
applicant_id="u-applicant",
status="disbursed",
wf_id=wf_id,
current_node=None,
approval_round=1,
tenant_no="t1",
create_by="u-applicant",
create_dept_id="d1",
)
)
db.add(
PlVehicleRechargeRecord(
id="rec-1",
recharge_order_no="RO1",
recharge_date=date(2026, 9, 26),
recharge_type="etc",
amount=Decimal("10.00"),
apply_id="apply-1",
license_plate="鄂A00001",
tenant_no="t1",
create_by="u-fin",
create_dept_id="d1",
del_flag=0,
)
)
db.commit()
_patch_scope(monkeypatch, svc)
svc.delete_records(db, "t1", _user("u-fin", "财务"), ["rec-1"])
apply = db.query(PlVehicleRechargeApply).filter_by(id="apply-1").one()
assert apply.status == "pending"
assert apply.current_node == node_fin
```
若 `PlVehicleRechargeRecord` 构造缺必填字段,按模型补齐后保持断言不变。
- [ ] **Step 2: 跑测确认失败**
```bash
pytest tests/test_pl_vehicle_recharge_workflow.py::test_delete_record_with_workflow_reverts_to_pending -v
```
Expected: FAIL(仍为 `in_approval`)
- [ ] **Step 3: 实现回退与文案**
两处有 wf 回退改为:
```python
old_apply.status = "pending" # 或 apply.status = "pending"
```
API 标签:
```python
"pending": "发放中",
```
服务提示:
```python
detail="仅发放中申请可关联",
```
- [ ] **Step 4: 跑回退测 + 相关 workflow 测**
```bash
pytest tests/test_pl_vehicle_recharge_workflow.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/pl_vehicle_recharge_service.py \
backend/app/api/pl/vehicle_recharge.py \
backend/tests/test_pl_vehicle_recharge_workflow.py
git commit -m "$(cat <<'EOF'
fix(pl): revert disbursed recharge apply to pending and rename label
EOF
)"
```
---
### Task 4: Web 文案与筛选项
**Files:**
- Modify: `web-admin/src/utils/pl-display.ts`(仅 `getPlRechargeApplyStatusLabel`,**不要**改 `getPlUreaApplyStatusLabel`)
- Modify: `web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts`
- Modify: `web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts`
- Modify: `web-admin/src/views/pl/vehicle-recharge-record/index.vue`
- Modify: `web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue`
- [ ] **Step 1: 改充值状态文案**
```typescript
pending: '发放中',
```
- [ ] **Step 2: 筛选项插入 pending(建议放在审批中之后)**
油卡与 ETC 的 `RECHARGE_STATUS_OPTIONS`:
```typescript
{ value: 'draft', label: '草稿' },
{ value: 'in_approval', label: '审批中' },
{ value: 'pending', label: '发放中' },
{ value: 'rejected', label: '已驳回' },
{ value: 'disbursed', label: '已发放' },
{ value: 'cancelled', label: '已作废' }
```
- [ ] **Step 3: 撤销确认文案**
两处 record 页:
```typescript
'确定撤销该发放登记吗?关联申请将回退为发放中。'
```
全文检索 `web-admin` 内充值相关「待发放」字样,仅改充值申请/发放语境;跳过尿素、运费、备用金。
- [ ] **Step 4: 静态核对**
```bash
# 在 web-admin/
rg "待发放" src/utils/pl-display.ts src/views/pl/vehicle-recharge-apply src/views/pl/etc-vehicle-recharge-apply src/views/pl/vehicle-recharge-record src/views/pl/etc-vehicle-recharge-record
```
Expected: 上述路径无残留「待发放」(尿素等其它目录可仍有)
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/utils/pl-display.ts \
web-admin/src/views/pl/vehicle-recharge-apply/columnFilterDefs.ts \
web-admin/src/views/pl/etc-vehicle-recharge-apply/columnFilterDefs.ts \
web-admin/src/views/pl/vehicle-recharge-record/index.vue \
web-admin/src/views/pl/etc-vehicle-recharge-record/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): show recharge pending status as 发放中
EOF
)"
```
---
### Task 5: 回归验证
**Files:** 无新文件
- [ ] **Step 1: 后端相关测试全跑**
```bash
pytest tests/test_pl_vehicle_recharge_workflow.py \
tests/test_pl_menu_badge_recharge_approval.py \
tests/test_pl_vehicle_recharge_summary.py -v
```
Expected: PASS
- [ ] **Step 2: 手工冒烟清单(记结果,未跑勿称通过)**
1. 两级审批 + 发放节点:一审后「审批中」;二审后「发放中」;发放后「已发放」
2. 发放节点人审批待办仍可见并发放成功
3. 申请列表可用「发放中」筛选
4. 油卡、ETC 各至少一单(或同后端断言后单侧冒烟 + 说明)
- [ ] **Step 3: 若用户要求再 push;默认本任务不 push**
---
## Spec coverage check
| Spec 项 | Task |
| --- | --- |
| 末级 approve → pending | Task 1 |
| 中间仍 in_approval | Task 1 |
| 待办/准入认 pending | Task 1 |
| 徽章含 pending | Task 2 |
| 撤销回退 pending | Task 3 |
| 文案「发放中」 | Task 3–4 |
| 筛选项 | Task 4 |
| 不做历史刷库 / 不改尿素文案 | 明确不做 |
| 回归 | Task 5 |
@@ -0,0 +1,77 @@
# Web 附件图片复制到剪贴板 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 Web 管理端公共附件展示处提供「复制」按钮,将图片写入系统剪贴板。
**Architecture:** 复用 `fetchAttachmentBlob` 取得图片 Blob,经 Clipboard API(`ClipboardItem` / PNG)写入剪贴板;在 `attachment-upload-field`、`attachment-preview-dialog` 及主要列表附件单元格与「下载」同级增加显式按钮。不改后端、不改小程序。
**Tech Stack:** Vue 3、TypeScript、Element Plus、现有 `web-admin/src/utils/download-attachment.ts` / `clipboard.ts`
**Spec:** `docs/superpowers/specs/2026-11-14-web-attachment-copy-clipboard-design.md`
**Issue:** https://gitea.aichengyan.com/cykj/shanxia2.0/issues/28
---
## File map
| 文件 | 职责 |
| --- | --- |
| `web-admin/src/utils/clipboard.ts` | 新增图片 Blob / 附件图片复制 API;保留现有文本复制 |
| `web-admin/src/utils/clipboard.image.test.ts`(或同目录既有测试风格) | 工具函数单测(mock clipboard / fetch) |
| `web-admin/src/components/business/attachment-upload-field/index.vue` | 卡片操作区 + 预览工具栏「复制」 |
| `web-admin/src/components/business/attachment-preview-dialog/index.vue` | 预览工具栏「复制」 |
| `web-admin/src/components/business/pl-plan-document-attachment-cell/index.vue` 等列表单元格 | 下载旁图片复制(按现有下载模式) |
---
### Task 1: 剪贴板图片工具函数
**Files:**
- Modify: `web-admin/src/utils/clipboard.ts`
- Create/Modify: 同目录或 `web-admin` 既有测试约定下的单测文件
- [x] **Step 1:** 实现 `ensurePngBlob(blob)`(已是 png 则直通,否则 ImageBitmap/canvas → PNG)
- [x] **Step 2:** 实现 `copyImageBlobToClipboard(blob): Promise<boolean>`(`navigator.clipboard.write` + `ClipboardItem`)
- [x] **Step 3:** 实现 `copyAttachmentImageToClipboard(file, options?)`:非图片返回 false;否则 `fetchAttachmentBlob` → `copyImageBlobToClipboard`
- [x] **Step 4:** 补充单元测试(mock `navigator.clipboard.write`、非图片短路、PNG 直通)
- [x] **Step 5:** 运行相关测试通过
### Task 2: 公共附件卡与内嵌预览
**Files:**
- Modify: `web-admin/src/components/business/attachment-upload-field/index.vue`
- [x] **Step 1:** 卡片 footer / compact 下载旁增加复制按钮(图片用图片复制;非图片可用 `copyTextToClipboard` 复制 URL 或隐藏复制)
- [x] **Step 2:** 图片预览工具栏增加「复制」按钮与 loading/禁用态
- [x] **Step 3:** 成功/失败 `ElMessage` 文案与设计一致
### Task 3: 独立预览弹窗
**Files:**
- Modify: `web-admin/src/components/business/attachment-preview-dialog/index.vue`
- [x] **Step 1:** 图片工具栏增加「复制」,调用同一工具函数
- [x] **Step 2:** 仅 `kind === 'image'` 显示
### Task 4: 主要列表附件单元格
**Files:**
- Modify: `pl-plan-document-attachment-cell`、`pl-cert-list-attachment-cell`、`pl-carrier-list-attachment-cell`、`pl-plan-list-cert-cell`(有下载角标处)
- [x] **Step 1:** 图片项在下载旁增加复制,`@click.stop`
- [x] **Step 2:** 非图片不显示图片复制(或仅复制链接,与 Task 2 策略一致)
### Task 5: 验证
- [x] **Step 1:** `pnpm exec tsx --test src/utils/clipboard.image.test.ts` — 5 pass
- [x] **Step 2:** `vue-tsc --noEmit`:本次改动文件无新增相关错误;全仓仍有 workflow 等既有错误与缺失 `.auto-import.json` 导致的 `reactive` 误报
- [ ] **Step 3:** 手工:预览弹窗复制图片后粘贴到微信/Word(待本机验收)
---
## 统一交互策略(实现锁定)
- 图片:复制位图到剪贴板;成功「已复制到剪贴板」
- 非图片:卡片/列表显示「复制链接」(`copyTextToClipboard`),成功「已复制链接」;预览弹窗非图片无复制按钮
- 失败:统一「复制失败,请允许剪贴板权限或改用下载」
@@ -0,0 +1,80 @@
# 证件到期提醒本页维护证件弹窗(Web)
## 背景与问题
Web【证件到期提醒】展示车辆、人员证件过期信息。用户需要补录/更新证件时,若整页跳到【基础信息库】再返回,连续处理多条很麻烦。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 交互(方案 A) | 在提醒页点「维护证件」,**本页弹出**与基础信息库同款编辑弹窗;关闭后仍停在提醒列表 |
| 车辆弹窗 | 复用 `PlVehicleFormDialog`;按 `vehicleType` 传 `self` / `carrier` |
| 人员弹窗 | 复用 `PlPersonnelFormDialog`;按 `personnelAffiliation` 传 `self` / `carrier` |
| 打开前 | `get` 主体详情以判定归属;缺 ID / 失败则提示,不弹窗 |
| 保存后 | 刷新提醒列表与菜单角标;**不**自动将提醒标为已处理 |
| 整页跳转 | 提醒页「维护证件」**不再** `router.push` 离开本页 |
| `editId` 深链 | 车辆/人员档案页已有的 `editId` 能力可保留(书签/外链),与本页弹窗无关 |
| 后端 | 不改 |
| 范围 | 仅 Web |
## 范围
**包含**
- `document-reminder.vue`:操作列改为打开本页 `PlVehicleFormDialog` / `PlPersonnelFormDialog`
- `pl-document-reminder-jump.ts`:增加表单模式解析(或复用归属判定);路径函数可保留给深链/兼容
**不包含**
- 小程序
- 保存后自动 resolve 提醒
- 抽离档案列表页内联大表单(已有独立 dialog 组件则直接复用)
## 前端设计
### 提醒页
1. 模板挂载:
```vue
<PlVehicleFormDialog
v-model="vehicleFormVisible"
:vehicle-type="vehicleFormType"
:vehicle-id="editingOwnerId"
@saved="handleOwnerFormSaved"
/>
<PlPersonnelFormDialog
v-model="personnelFormVisible"
:affiliation="personnelFormAffiliation"
:personnel-id="editingOwnerId"
@saved="handleOwnerFormSaved"
/>
```
2. `handleMaintainCert`:校验 → get → `resolveDocumentReminderFormMode` → 设置 `editingOwnerId` 与 type/affiliation → `vehicleFormVisible` 或 `personnelFormVisible = true`。
3. `@saved`:`fetchData()` + `refreshMenuBadges()`;成功提示可选。
4. 文案:「缺少主体信息,无法维护」等,不再提「跳转」。
### 模式解析
```ts
type ReminderFormMode =
| { kind: 'vehicle'; vehicleType: 'self' | 'carrier' }
| { kind: 'personnel'; affiliation: 'self' | 'carrier' }
| null
```
- vehicle + vehicleType self → self,否则 carrier
- personnel + affiliation carrier → carrier,否则 self
## 验收
1. 提醒页点「维护证件」不离开当前路由,弹出编辑框(含证件)。
2. 关窗后列表仍在,可立刻点下一条。
3. 保存后列表/角标刷新;提醒状态仍须手动「处理」。
4. 车辆自有/三方、人员山峡/三方弹窗标题与校验与基础信息库一致。
## 状态
用户确认方案 A(本页弹窗)。实现于 `V3.0`。
@@ -0,0 +1,177 @@
# ETC / 油卡充值管理菜单拆分(Web)
## 背景与问题
Web【车辆管理】下现有【ETC/油卡充值管理】(路由前缀 `/vehicle-mgmt/etc-recharge/`)将 ETC 与油卡(及山峡、百链等非 ETC 类型)混在同一套申请 / 审批 / 发放 / 审批人设置页面中。布局层在匹配充值路由时还会展示 `PlRechargeSummary` 顶部统计,该统计面向油卡场景,不适合挂在独立 ETC 模块上。
用户希望:ETC 与油卡分成两个并列模块,前端可独立演进;后端表与接口暂不整模块拆分;审批流程与审批人配置继续共用现有 `biz_type=vehicle_recharge`。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 产品结构 | 并列【ETC充值管理】与【油卡充值管理】(原名改名) |
| ETC 子菜单 | 申请、审批、发放管理、审批人设置 |
| 油卡侧 | 界面保持现状;列表等不再展示 `rechargeType=etc` |
| ETC 顶部统计 | ETC 分组展示 `PlRechargeSummary`(仅 ETC 信息);油卡分组仅展示加油卡信息(验收调整) |
| 前端实现 | 复制现有 4 个页面目录再改,便于后续各自演进 |
| 后端实现 | 不拆服务/表;列表与写入靠类型过滤/约束;小改即可 |
| 审批流程 / 审批人 | 保持现状,仍一套 `vehicle_recharge`;两边菜单都进同一配置(方案 A) |
| 修改端 | 仅 Web 管理端;小程序不改 |
| 非 ETC 类型归属 | `fuel` / `shanxia` / `bailian` 等非 `etc` 均留在油卡模块 |
## 范围
**包含**
- `web-admin`:菜单与路由、页面复制与类型固化、顶部统计路由判定、文案与筛选
- `backend`:列表/导出/看板/审批查询落实类型过滤(见后端设计);不拆接口与表
- `docs/sql/migrations/`:菜单改名、新建 ETC 分组与子菜单、角色菜单同步;旧 path 由路由 redirect 兼容
**不包含**
- 小程序 / 移动 H5
- 拆分审批流程 `biz_type`、复制工作流定义、在途单迁移
- 拆分数据表或新建独立后端模块
- 本期不要求油卡与 ETC 审批人可分别配置(后续若需要再单独立项)
## 菜单与路由
### 目标结构(均挂在【车辆管理】`PL_GROUP_VEHICLE` 下)
1. **【ETC充值管理】**(新建分组)
- ETC充值申请
- ETC充值审批
- ETC发放管理
- ETC充值审批人设置
2. **【油卡充值管理】**(原 `PL_GROUP_VEH_RECHARGE` 改名)
- 油卡充值申请 / 审批 / 发放管理 / 审批人设置(原子菜单改名)
### 路径约定
| 模块 | 分组 path | 说明 |
| --- | --- | --- |
| ETC | `etc-recharge` | 语义与产品名一致;组件指向**复制后的** ETC 页面 |
| 油卡 | `fuel-recharge` | 原分组 path 由 `etc-recharge` 改为 `fuel-recharge` |
完整路径(菜单 SQL 与此对齐):
- ETC:`/#/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-{apply|approval|record|workflow}`
- 油卡:`/#/vehicle-mgmt/fuel-recharge/vehicle-recharge-{apply|approval|record|workflow}`
### 兼容
- `web-admin/src/router/modules/pl.ts` 中现有指向 `/vehicle-mgmt/etc-recharge/vehicle-recharge-*` 的 redirect,改为指向 **油卡** 新路径 `/vehicle-mgmt/fuel-recharge/vehicle-recharge-*`(保持书签可用且落到油卡侧)。
- 若另有文档/外链写死旧地址,实现计划中列出并改 redirect 或文档。
### 权限
- 油卡:保留现有菜单 id 与 `perms`(如 `pl:vehicle-recharge-apply:list`),仅改展示名与分组 path/component 若需要。
- ETC:新建菜单 id 与独立 `perms`(如 `pl:etc-vehicle-recharge-apply:list` 等),便于日后分开授权。
- 迁移 SQL:对已拥有原充值相关菜单的角色,同步挂上对应 ETC 菜单,保证上线后默认两边都可用;之后可由管理员单独收回。
- 审批人设置:ETC / 油卡各一条菜单,component 分别指向复制页与原页,均调用同一工作流 API / `biz_type=vehicle_recharge`。ETC 菜单的 `perms` **复用**现有 `pl:vehicle-recharge-workflow:*`,避免配置权被拆成两套。
## 前端设计
### 页面复制
在 `web-admin/src/views/pl/` 复制:
| 原路径 | ETC 复制目标(示例名) |
| --- | --- |
| `vehicle-recharge-apply/` | `etc-vehicle-recharge-apply/` |
| `vehicle-recharge-approval/` | `etc-vehicle-recharge-approval/` |
| `vehicle-recharge-record/` | `etc-vehicle-recharge-record/` |
| `vehicle-recharge-workflow/` | `etc-vehicle-recharge-workflow/`(复制后仅改文案/说明;仍调用同一工作流 API) |
油卡侧继续使用原目录,做「排除 etc」与文案改名。审批人设置菜单的 `component` 分别指向上述两套页面,避免一边改提示文案影响另一边;业务数据仍是同一 `biz_type`。
`columnFilterDefs`、本地 `table-key` / 列偏好 localStorage key:ETC 页使用**新 key**,避免与油卡列配置互相污染。
API 封装:优先继续共用 `web-admin/src/api/pl` 现有 `vehicleRecharge*Api`,在页面请求参数中带类型约束;不为本期再复制一套 API 模块,除非出现仅 ETC 需要的入参。
### 类型与交互
**ETC 页**
- 列表 / 导出 / 待办 / 已办 / 发放看板:固定只查 `rechargeType=etc`(或后端等价过滤)。
- 新增/编辑:充值类型固定为 `etc`,不提供油卡/山峡/百链选项;隐藏油卡卡号等仅油卡字段。
- 文案、导出文件名、空态、跳转提示改为「ETC…」。
- **不展示**顶部 `PlRechargeSummary`。
**油卡页**
- 列表 / 导出 / 审批 / 发放:**排除** `rechargeType=etc`;保留 `fuel` / `shanxia` / `bailian` 等现有非 ETC 类型与筛选(筛选中去掉或禁用单独再选 ETC,避免空结果误导)。
- 顶部统计:**保留**(仍走现有 summary 组件与接口)。
- 其余交互、按钮、审批发放流程与现网一致。
### 顶部统计路由
`isRechargeSummaryRoute`(`web-admin/src/utils/pl-recharge-summary.ts`)及 `art-page-content` 中 `showRechargeSummary` / `rechargeApplyPath`:
- 仅对**油卡**充值相关 path 为 true(例如匹配 `fuel-recharge` 下的 apply/approval/record/workflow)。
- ETC 路径一律不展示。
- `rechargeApplyPath` 解析菜单 code 时指向油卡申请菜单,保证统计卡片跳转仍进入油卡申请页。
相关单测(`pl-recharge-summary.test.ts`)同步改断言。
### 审批人设置(方案 A)
- 两菜单入口都打开同一业务配置(`biz_type=vehicle_recharge`)。
- UI 可增加简短说明:「ETC 与油卡共用同一套审批人配置」,降低「改了一边另一边不变」的误解。
- 流程显示名可暂保持「ETC/油卡充值审批流程」,本期不强制改名。
## 后端设计
### 查询
现有列表已支持 `rechargeType` / `rechargeTypeIn`(见 `vehicle_recharge` API 与 `vehicle_recharge_list_filters`)。本期落实:
- ETC 前端固定传 `rechargeType=etc`。
- 油卡「非 etc」过滤:**采用新增可选查询参数 `rechargeTypeNe=etc`**(filter 层 `!=`),避免字典新增类型时维护白名单;保留现有 `rechargeType` / `rechargeTypeIn` 兼容。
- 审批 pending/done、发放看板、导出与列表使用同一套类型约束,避免一侧漏过滤。
### 写入约束
- 本期**不**引入域请求头,**不**按菜单拆 create/update 接口。
- 类型正确性由前端保证:ETC 页只提交 `etc`;油卡页表单选项与默认值不含 `etc`。
- 后端 create/update 维持现有字段校验即可;防串改列为后续增强,不阻塞本期验收。
### 审批与工作流
- `_BIZ_TYPE_VEHICLE_RECHARGE = "vehicle_recharge"` 不变。
- 不新增 workflow 定义,不做在途迁移。
- summary 接口可保持现行为(油卡统计);不强制为 ETC 提供第二套 summary。
## 数据与兼容
- 表结构不改;历史 `etc` 单据仅在 ETC 菜单可见,非 etc 仅在油卡菜单可见。
- 小程序仍使用原混合入口与文案;可能仍能看到/提交两类数据,与 Web 分栏并存,接受为已知差异,后续另项处理。
## UI 规范
- 沿用现页模式(复杂业务列表)与 Fresh Mint;不新造布局与主色。
- 复制页保持与原页相同的 panel / 工具栏 / 表格 / 弹窗结构;仅改类型范围、文案与统计显隐。
- 菜单标题变更后,若申请页有 `panelTitleBarTextRules` 注册需求,按 Web AGENTS 补齐完整文案。
## 验收
1. 侧栏可见并列【ETC充值管理】【油卡充值管理】,各含 4 个子菜单(文案正确)。
2. ETC 申请/审批/发放仅出现 `rechargeType=etc` 数据;新建只能选/固定 ETC。
3. 油卡对应页面不出现 ETC 数据;`fuel` / `shanxia` / `bailian` 仍可见可操作(在权限与数据允许时)。
4. ETC 各子路由无顶部充值统计;油卡各子路由仍有统计,且卡片跳转进油卡申请。
5. 两边「审批人设置」打开后编辑的是同一套节点;保存一侧,另一侧刷新可见。
6. 旧 URL `/vehicle-mgmt/etc-recharge/vehicle-recharge-apply` 等 redirect 到油卡新路径且可用。
7. 原已授权充值菜单的角色,上线后默认可进 ETC 新菜单(迁移 SQL 生效后)。
8. 小程序行为与菜单未改。
## 风险
- 前端双份页面:同类 Bug 可能需改两处;接受为独立演进的代价。
- 审批人双入口同配置:需文案提示,否则易误解为已拆分。
- 小程序仍混合:与 Web 不一致,需在发布说明中写明。
- `rechargeTypeNe` 需在 list/export/approval/board 全路径接通,漏接会导致某一页仍混入 ETC。
## 状态
会话中已确认:复制前端、后端不整拆、审批配置保持现状且双菜单进同一配置(A)、仅 Web。实现计划:`docs/superpowers/plans/2026-11-12-pl-etc-fuel-recharge-split.md`。
@@ -0,0 +1,124 @@
# 充值申请「发放中」状态(审批通过后)
## 背景与问题
车辆充值(油卡 / ETC 共用后端)走审批流程时,单据从提交起一直是 `in_approval`(审批中),直到发放登记才变为 `disbursed`(已发放)。
用户配置多级审批人后,期望:
- 中间审批级等待处理:仍显示「审批中」
- **末级审批人通过、进入发放节点后**:显示「发放中」
- 发放登记完成后:显示「已发放」
系统里已有 `pending` 枚举(原前端文案「待发放」),但有工作流时主路径几乎不会写入该状态;发放撤销在有流程时还会回退为 `in_approval`。申请列表筛选项也未暴露该状态。
**文案约定(已确认)**:充值申请 `pending` 的对外名称统一为 **「发放中」**(不再用「待发放」)。状态码仍为 `pending`,不改库字段。尿素 / 运费等其它业务的「待发放」文案不在本期范围。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 切换时机 | 末级 `approve` 节点通过后,若下一节点为 `disburse`(发放登记),将 `status` 设为 `pending` |
| 展示文案 | `pending` → **发放中**(Web `getPlRechargeApplyStatusLabel`、导出/后端 `RECHARGE_APPLY_STATUS_LABELS`、筛选项、相关提示语) |
| 中间审批 | 仍保持 `in_approval` |
| 发放完成 | 仍为 `disbursed` |
| 撤销发放 | 有工作流时回退为 `pending`(停在发放节点),不再回退为 `in_approval`;提示改为「回退为发放中」 |
| 无工作流路径 | 维持现有 `pending` 语义不变,仅文案改为「发放中」 |
| 历史数据 | 本期**不做**批量刷库;已停在发放节点但仍为 `in_approval` 的单据继续可在审批待办发放,列表可能仍显示「审批中」直至被处理 |
| 范围 | 油卡 + ETC 共用后端一并生效;Web 列表文案/筛选项同步;小程序不单独改(若共用展示映射则自然带上) |
| 不另拆 | 不新建独立「发放中」菜单;发放仍在现有「充值审批」待办中对发放节点操作 |
## 目标状态机(有工作流)
```text
draft / rejected
└─提交─► in_approval # 当前节点为 approve*
│
├─中间 approve 通过──► in_approval(推进 current_node)
│
└─末级 approve 通过且下一节点为 disburse
─► pending # 发放中;current_node = 发放节点
│
└─发放登记─► disbursed
任意审批节点驳回 ─► rejected
撤回(规则不变)─► draft
撤销发放登记 ─► pending(有 wf)或 pending(无 wf,保持现状)
```
\* 流程末节点必须仍是 `disburse`(现有约束:`approve_apply` 不得收尾)。
## 后端改动要点
### 1. 状态常量与文案
- `APPLY_STATUSES` 正式纳入 `pending`(schema / 单测断言同步)
- `RECHARGE_APPLY_STATUS_LABELS["pending"]`:`待发放` → `发放中`
- 关联申请错误提示等用户可见「待发放」字样同步改为「发放中」
### 2. `approve_apply`
通过当前 `approve` 节点后:
- `apply.current_node = nodes[idx + 1].id`(不变)
- 若下一节点 `action_type == "disburse"`:`apply.status = "pending"`
- 否则:保持 `in_approval`
### 3. 待办与发放登记准入
当前 `_current_node` / `list_pending` / 菜单徽章只认 `status == "in_approval"`。改为认:
`status in ("in_approval", "pending")`
且仍要求 `current_node` 存在、当前用户为该节点审批人。这样「发放中」单据继续出现在审批待办,发放人可完成登记。
错误文案可区分「不在可处理状态」,但行为上两者同等可办。
### 4. 撤销 / 换绑发放记录回退
`delete_records` / 换绑旧申请回退时:有 `wf_id` 时改为 `status = "pending"` + `current_node = 发放节点`(与文案「回退为发放中」一致);无流程仍 `pending`。
### 5. 发放登记页关联申请
现有 `_resolve_apply_for_record`:拒绝 `in_approval`、仅允许 `pending`。与新状态机一致——走审批发放应走审批菜单的 `disburse_apply`;记录页手动关联仍只认 `pending` 单。无需为发放节点旁路放开记录页。
### 6. 看板 / 汇总
`pl_vehicle_recharge_summary_service` 已将 `pending` 与 `in_approval` 计入 pending 类统计,保持即可;回归确认油卡看板数字仍合理。
## 前端改动要点
- `getPlRechargeApplyStatusLabel`:`pending` → `发放中`(仅充值申请映射;尿素等其它模块不动)
- 油卡 / ETC 申请列表列头筛选项:增加 `{ value: 'pending', label: '发放中' }`
- 发放页撤销确认等「回退为待发放」→「回退为发放中」
- 发放页提示「审批中请到审批菜单发放」逻辑可保留(`in_approval`);`pending` 仍可按现有关联逻辑处理
- 审批页「待我审批」Tab 不改名(含审批 + 发放节点待办)
## 测试与验收
**后端单测(优先)**
- 两级 approve + 末节点 disburse:一级通过后仍 `in_approval`;二级通过后 `pending` 且 `current_node` 为发放节点
- `list_pending` / 徽章计数包含 `pending` 且当前节点审批人匹配的单据
- `disburse_apply` 在 `pending` 状态下成功 → `disbursed`
- 撤销发放回退为 `pending`(有流程)
- 导出/标签映射:`pending` 文案为「发放中」
**手工验收**
- 申请列表:二级通过后状态显示「发放中」,可按「发放中」筛选
- 发放节点处理人在审批待办仍能看到并完成发放 →「已发放」
- 油卡与 ETC 模块各走一单冒烟
## 明确不做
- 历史 `in_approval` + 已在发放节点的数据迁移脚本
- 拆分发放独立菜单 / 独立待办 API
- 修改流程配置 UI(仍要求末节点为发放登记)
- 小程序专项改造
- 尿素 / 运费 / 备用金等其它业务「待发放」文案改名
## 风险
- 若漏改徽章或 `list_pending` 状态条件,会出现「列表已是发放中但待办/角标消失」;实现时与 `approve_apply` 同批改、同批测。
- 历史停在发放节点的 `in_approval` 单据列表文案仍为「审批中」,验收时需知悉;若后续需要可另开刷数任务。
@@ -0,0 +1,98 @@
# Web 附件图片复制到剪贴板
工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/28
## 背景与问题
系统多处图片/文件展示提供「下载」,用户希望再提供「复制」,将图片快速写入操作系统剪贴板,便于粘贴到微信、Word 等。场景分布在列表缩略图、表单附件卡、详情只读与预览弹窗,入口需统一,避免逐页发明交互。
## 已确认决策
| 项 | 结论 | 依据 |
| --- | --- | --- |
| 修改端 | 仅 `web-admin`(桌面 Web;移动 H5 若复用同一附件组件则自动获得) | 用户 2026-11-14 确认「只修改 web 端,不改小程序端」 |
| 主入口 | 与「下载」同级的显式「复制」按钮;预览弹窗工具栏必有 | 对话方案获用户认可 |
| 图片行为 | 写入系统剪贴板为图片(`image/png` 优先) | Clipboard API + 用户诉求 |
| 非图片 | 不写入文件本体;可「复制链接」或仅保留下载 | 浏览器对任意文件剪贴板支持差 |
| 右键 / 快捷键 | 本期不做主入口(可不做) | 发现成本高、易与表格/输入焦点冲突 |
| 小程序 | 本期不改 | 用户明确排除;且无法位图复制 |
| Figma 原型 | 免除,按现有下载旁按钮样式实现 | 用户 2026-11-14 选择「1」;此前推荐亦到期可采用 |
## 方案对比
| 方案 | 做法 | 优点 | 缺点 |
| --- | --- | --- | --- |
| A(推荐) | 公共工具函数 + 公共附件/预览组件加按钮 | 一处覆盖列表/表单/详情多数场景;与下载同发现路径 | 少数自建附件 UI 需二次补齐 |
| B | 各业务页单独加复制 | 可控精细 | 遗漏多、风格易漂 |
| C | 仅右键 / 快捷键 | UI 干净 | B 端发现差,触控与 H5 弱 |
**采用方案 A。**
## 范围
**包含**
- `web-admin`:剪贴板工具(图片 Blob → `navigator.clipboard.write`)
- 复用现有 `fetchAttachmentBlob`(`download-attachment.ts`)取内容
- 公共组件入口:
- `attachment-upload-field`:卡片操作区 / compact 下载旁;图片预览工具栏
- `attachment-preview-dialog`:图片预览工具栏
- 主要列表附件单元格(如 `pl-*-attachment-cell`、证书列表单元格等已有下载角标处)按同一模式补「复制」(仅图片显示图片复制;非图片可选复制链接或隐藏)
- 成功 / 失败 `ElMessage` 提示;安全上下文或权限失败时明确提示
**不包含**
- `miniprogram/` 任何改动
- 后端 API / schema 变更(沿用现有预签名下载)
- 右键菜单、全局快捷键作为主入口
- PDF/Office 等「复制文件本体」进剪贴板
- 强制改造所有业务页内联重复附件 UI(优先公共组件;遗漏页可后续补)
## 前端设计
### 工具函数
在 `web-admin/src/utils/clipboard.ts`(或同目录新建 `copy-attachment.ts` 并由 clipboard 导出)增加:
- `copyImageBlobToClipboard(blob: Blob): Promise<boolean>`
- 优先 `ClipboardItem` 写入;若类型非 `image/png`,经 canvas/`createImageBitmap` 转为 PNG 再写(兼容 JPEG 等)
- 无 Clipboard API / 非安全上下文 / 权限拒绝 → 返回 false
- `copyAttachmentImageToClipboard(file, options?): Promise<boolean>`
- 内部:`detectAttachmentPreviewKind` 非 image 则失败或降级复制 URL 文本(由调用方决定文案)
- `fetchAttachmentBlob` → `copyImageBlobToClipboard`
非图片「复制链接」可复用已有 `copyTextToClipboard`。
### UI 放置
1. **附件卡片**(`attachment-upload-field`):`下载` 旁增加复制图标按钮;仅当判定为图片时执行图片复制,其它类型可复制链接或只显示下载。
2. **预览弹窗工具栏**(upload-field 内嵌预览 + `attachment-preview-dialog`):在重置等按钮后增加「复制」;仅 `kind === 'image'` 时显示。
3. **列表缩略图角标**:已有下载角标的单元格,图片项增加复制角标或与下载并列,点击 `@click.stop`,避免触发行预览。
按钮样式、图标遵循 Fresh Mint / 现有 `action-btn` 与预览工具栏按钮规范(图标 + title 或短文案「复制」)。
### 数据与网络
- 剪贴板需要 Blob,不能只拷 DOM。
- 使用现有预签名下载路径取 Blob;常命中浏览器缓存,用户体感可接受。
- 不新增后端接口。
### 兼容与降级
| 情况 | 行为 |
| --- | --- |
| 现代桌面 Chrome / Edge | 图片写入剪贴板,成功提示「已复制到剪贴板」 |
| 权限 / 非 HTTPS / 旧浏览器 | 失败提示「复制失败,请允许剪贴板权限或改用下载」;可选降级复制图片 URL |
| 移动 H5(同组件) | 尽力而为;失败走同一提示,不单独做小程序方案 |
## 验收条件
1. Web 管理端:在公共附件卡、图片预览弹窗可见「复制」,点击后可将图片粘贴到本机支持图片剪贴板的应用(如预览/Word/微信桌面,视 OS 而定)。
2. 非图片附件:不出现误导性的「复制图片」成功;链接复制或仅下载,行为与提示一致。
3. 小程序目录无本次改动。
4. 无后端变更;相关前端 lint / typecheck 通过(按实施计划执行)。
## 风险与后续
- MinIO / 预签名 CORS 若阻止 `fetch` Blob,复制会失败(下载走 `<a download>` 可能仍可用)→ 失败提示引导改用下载;必要时再评估代理拉取。
- 少数未走公共组件的页面可能暂无按钮 → 后续按反馈补齐。
+4
View File
@@ -1,3 +1,7 @@
# 单包工程也须声明 packages,否则 pnpm 9+ 报 “packages field missing or empty”
packages:
- '.'
allowBuilds:
'@parcel/watcher': true
'@tailwindcss/oxide': true
@@ -0,0 +1,36 @@
/**
* #26 回归:
* 1) ArtSvgIcon 的 el-icon 不得带 Tailwind `inline`(生产 CSS 会覆盖 display)
* 2) 运输归档列展示含 planNo,且表格列受 isColumnVisible 控制
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from '@vue/compiler-sfc'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('ArtSvgIcon: el-icon branch must not use Tailwind inline class', () => {
const src = readFileSync(join(root, 'src/components/core/base/art-svg-icon/index.vue'), 'utf8')
const { descriptor } = parse(src)
const template = descriptor.template?.content || ''
assert.match(template, /<el-icon\b[^>]*class="[^"]*art-svg-icon/)
assert.doesNotMatch(
template,
/<el-icon\b[^>]*class="[^"]*\binline\b/,
'el-icon must not include Tailwind inline (breaks display:inline-flex in prod CSS order)'
)
})
test('plan-archive: planNo is in column defs and table visibility gate', () => {
const src = readFileSync(join(root, 'src/views/pl/plan-archive/index.vue'), 'utf8')
assert.match(src, /prop:\s*'planNo',\s*label:\s*'计划编号'/)
assert.match(src, /pl-plan-archive-list-columns-v17/)
assert.match(
src,
/v-if="isColumnVisible\('planNo'\)"[\s\S]*?prop="planNo"/,
'planNo table column must be gated by isColumnVisible'
)
})
@@ -45,6 +45,16 @@
<el-icon v-if="!isMpPreviewToolbar"><Refresh /></el-icon>
重置
</el-button>
<span class="preview-toolbar-sep" aria-hidden="true" />
<el-button
:size="mpBtnSize"
:loading="copyBusy"
:disabled="!props.fileUrl || copyBusy"
@click="handleCopyImage"
>
<el-icon v-if="!isMpPreviewToolbar && !copyBusy"><DocumentCopy /></el-icon>
复制
</el-button>
<span class="preview-status-chip">{{ previewStatusText }}</span>
</div>
<div
@@ -98,17 +108,20 @@
import { useRoute } from 'vue-router'
import {
Document,
DocumentCopy,
Refresh,
RefreshLeft,
RefreshRight,
ZoomIn,
ZoomOut
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
detectAttachmentPreviewKind,
resolveAttachmentPreviewUrl,
type AttachmentPreviewKind
} from '@/utils/download-attachment'
import { copyAttachmentImageToClipboard } from '@/utils/clipboard'
import {
IMAGE_PREVIEW_ZOOM_MIN,
IMAGE_PREVIEW_ZOOM_MAX,
@@ -159,6 +172,7 @@
})
const loading = ref(false)
const copyBusy = ref(false)
const pdfSrc = ref('')
const previewStageRef = ref<HTMLElement>()
const previewImgRef = ref<HTMLImageElement>()
@@ -229,6 +243,21 @@
nextTick(() => refreshPreviewFitBase())
}
async function handleCopyImage() {
if (kind.value !== 'image' || !props.fileUrl || copyBusy.value) return
copyBusy.value = true
try {
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: props.fileUrl, fileName: props.fileName },
{ fullFileUrl: props.resolveUrl }
)
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
copyBusy.value = false
}
}
function resetPreviewZoomPan() {
previewZoom.value = 1
previewOffsetX.value = 0
@@ -59,6 +59,20 @@
>
<span aria-hidden="true">↓</span>
</button>
<button
v-if="file.fileUrl && !file._uploading"
type="button"
class="att-icon att-icon--copy"
:aria-label="
isImageFile(file)
? `复制图片:${file.fileName || '未命名附件'}`
: `复制链接:${file.fileName || '未命名附件'}`
"
:disabled="copyBusyId === (file.id || String(index))"
@click.stop="handleCopyAttachment(file)"
>
<span aria-hidden="true">⧉</span>
</button>
<button
class="att-preview"
type="button"
@@ -145,6 +159,16 @@
>
<el-icon :size="12"><Delete /></el-icon>
</button>
<button
v-if="compact && disabled && file.fileUrl"
type="button"
class="attachment-compact-copy"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
:disabled="copyBusyId === (file.id || String(index))"
@click.stop="handleCopyAttachment(file)"
>
<el-icon :size="12"><DocumentCopy /></el-icon>
</button>
<button
v-if="compact && disabled && file.fileUrl"
type="button"
@@ -157,6 +181,13 @@
<div class="attachment-footer">
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div v-if="!disabled" class="attachment-actions">
<el-icon
class="action-btn"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
@click="handleCopyAttachment(file)"
>
<DocumentCopy />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
<Download />
</el-icon>
@@ -169,6 +200,13 @@
</el-icon>
</div>
<div v-else class="attachment-actions">
<el-icon
class="action-btn"
:title="isImageFile(file) ? '复制图片' : '复制链接'"
@click="handleCopyAttachment(file)"
>
<DocumentCopy />
</el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
<Download />
</el-icon>
@@ -222,6 +260,16 @@
<el-icon v-if="!isMpPreviewToolbar"><Refresh /></el-icon>
重置
</el-button>
<span class="preview-toolbar-sep" aria-hidden="true" />
<el-button
:size="mpBtnSize"
:loading="previewCopyBusy"
:disabled="!previewFile?.fileUrl || previewCopyBusy"
@click="handleCopyPreviewImage"
>
<el-icon v-if="!isMpPreviewToolbar && !previewCopyBusy"><DocumentCopy /></el-icon>
复制
</el-button>
<span class="preview-status-chip">{{ previewStatusText }}</span>
</div>
<div
@@ -278,6 +326,7 @@ import {
Loading,
VideoPlay,
Document,
DocumentCopy,
Download,
Delete,
RefreshLeft,
@@ -293,6 +342,10 @@ import {
downloadAttachmentFile,
resolveAttachmentPreviewUrl
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { buildAttachmentUploadFormData, type CrmUploadBizType } from '@/utils/crm-upload'
import {
@@ -798,6 +851,47 @@ function handleDownloadAttachment(file: AttachmentItem) {
downloadAttachmentFile(file, { fullFileUrl: resolveFileUrl })
}
const copyBusyId = ref('')
const previewCopyBusy = ref(false)
async function handleCopyAttachment(file: AttachmentItem) {
const url = String(file.fileUrl || '').trim()
if (!url) {
ElMessage.warning('暂无可复制内容')
return
}
const busyKey = file.id || url
if (copyBusyId.value === busyKey) return
copyBusyId.value = busyKey
try {
if (isImageFile(file)) {
const ok = await copyAttachmentImageToClipboard(file, { fullFileUrl: resolveFileUrl })
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolveFileUrl(url)
const ok = await copyTextToClipboard(href || url)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
if (copyBusyId.value === busyKey) copyBusyId.value = ''
}
}
async function handleCopyPreviewImage() {
const file = previewFile.value
if (!file?.fileUrl || previewCopyBusy.value) return
previewCopyBusy.value = true
try {
const ok = await copyAttachmentImageToClipboard(file, { fullFileUrl: resolveFileUrl })
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
} finally {
previewCopyBusy.value = false
}
}
function handlePreviewAttachment(file: AttachmentItem) {
if (!canPreview(file)) return
previewFile.value = file
@@ -1269,10 +1363,10 @@ defineExpose({ hasUploading })
border-radius: 6px;
}
.attachment-compact-download {
.attachment-compact-download,
.attachment-compact-copy {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
display: flex;
align-items: center;
@@ -1290,6 +1384,19 @@ defineExpose({ hasUploading })
&:hover {
background: #1ca851;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.attachment-compact-download {
right: 2px;
}
.attachment-compact-copy {
right: 22px;
}
}
</style>
@@ -31,6 +31,13 @@
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
</div>
<el-icon
class="pl-carrier-list-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-carrier-list-attachment-cell__download"
title="下载"
@@ -50,7 +57,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { Carrier } from '@/api/pl'
import {
@@ -58,6 +65,10 @@
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -160,6 +171,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const defs = [
{
@@ -286,9 +317,9 @@
color: #0284c7;
}
.pl-carrier-list-attachment-cell__download {
.pl-carrier-list-attachment-cell__download,
.pl-carrier-list-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -306,6 +337,14 @@
}
}
.pl-carrier-list-attachment-cell__download {
right: 2px;
}
.pl-carrier-list-attachment-cell__copy {
right: 22px;
}
.pl-carrier-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
@@ -40,6 +40,13 @@
}}</span>
</div>
</div>
<el-icon
class="pl-cert-list-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-cert-list-attachment-cell__download"
title="下载"
@@ -60,7 +67,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download, Picture } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { CertRead } from '@/api/pl'
import {
@@ -68,6 +75,10 @@
normalizeAttachmentUrls,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { PL_VEHICLE_CERT_PART_TRAILER, normalizeVehicleCertPart } from '@/utils/pl-vehicle-cert'
@@ -181,6 +192,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const certs = props.certList || []
if (!certs.length) return []
@@ -305,9 +336,9 @@
color: #0284c7;
}
.pl-cert-list-attachment-cell__download {
.pl-cert-list-attachment-cell__download,
.pl-cert-list-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -325,6 +356,14 @@
}
}
.pl-cert-list-attachment-cell__download {
right: 2px;
}
.pl-cert-list-attachment-cell__copy {
right: 22px;
}
.pl-cert-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
@@ -452,9 +452,15 @@
resolveStopProductsForView(mapLoadFromApi(plan.value?.load).products, productRows.value)
)
const showStopFeedback = computed(
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
)
const showStopFeedback = computed(() => {
const hasStopFeedback = Boolean(
(transport.value?.driverStopFeedbacks || []).length ||
(transport.value?.siteStopFeedbacks || []).length
)
// 作废/取消单仍展示已有分段回传;无数据时仅在已安排且未取消时显示空态入口
if (hasStopFeedback) return true
return plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
})
const summaryCarrierName = computed(
() => transport.value?.carrierName || vehicleBundle.value?.detail?.carrierName || ''
@@ -40,6 +40,13 @@
}}</span>
</div>
</div>
<el-icon
class="pl-plan-document-attachment-cell__copy"
:title="item.isImage ? '复制图片' : '复制链接'"
@click.stop="handleCopy(item)"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-plan-document-attachment-cell__download"
title="下载"
@@ -63,7 +70,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Document, Download, Picture } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanDocumentAttachmentItem } from '@/api/pl'
import AttachmentPreviewDialog from '@/components/business/attachment-preview-dialog/index.vue'
@@ -71,6 +78,10 @@
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -170,6 +181,26 @@
}
}
async function handleCopy(item: ItemDef) {
if (!item.url) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) }
if (item.isImage) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolvePhotoDisplayUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolvePhotoDisplayUrl(item.url) || item.url
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
const items = computed<ItemDef[]>(() => {
const list = props.attachments || []
return list
@@ -260,9 +291,9 @@
color: #64748b;
}
.pl-plan-document-attachment-cell__download {
.pl-plan-document-attachment-cell__download,
.pl-plan-document-attachment-cell__copy {
position: absolute;
right: 2px;
top: 2px;
z-index: 2;
width: 18px;
@@ -281,6 +312,14 @@
}
}
.pl-plan-document-attachment-cell__download {
right: 2px;
}
.pl-plan-document-attachment-cell__copy {
right: 22px;
}
.pl-plan-document-attachment-cell__caption {
position: absolute;
right: 0;
@@ -142,6 +142,17 @@
<el-icon :size="20"><Document /></el-icon>
</div>
</div>
<el-icon
class="pl-plan-list-cert-cell__copy"
:title="
isCertImageUrl(getPrimaryProductCertUrl(product, slot.key))
? '复制图片'
: '复制链接'
"
@click.stop="handleCopyCert(getPrimaryProductCertUrl(product, slot.key))"
>
<DocumentCopy />
</el-icon>
<el-icon
class="pl-plan-list-cert-cell__download"
title="下载"
@@ -167,7 +178,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download, Upload } from '@element-plus/icons-vue'
import { Document, DocumentCopy, Download, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { PlanProduct } from '@/api/pl'
import {
@@ -183,6 +194,10 @@
downloadAttachmentFile,
openAttachmentPreviewInNewTab
} from '@/utils/download-attachment'
import {
copyAttachmentImageToClipboard,
copyTextToClipboard
} from '@/utils/clipboard'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = withDefaults(
@@ -295,6 +310,27 @@
ElMessage.error('下载失败,请稍后重试')
}
}
async function handleCopyCert(url: string) {
const fileUrl = String(url || '').trim()
if (!fileUrl) {
ElMessage.warning('暂无可复制内容')
return
}
const file = { fileUrl, fileName: getCertFileName(fileUrl) || '附件' }
if (isCertImageUrl(fileUrl)) {
const ok = await copyAttachmentImageToClipboard(file, {
fullFileUrl: resolveCertFileUrl
})
if (ok) ElMessage.success('已复制到剪贴板')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
return
}
const href = resolveCertFileUrl(fileUrl) || fileUrl
const ok = await copyTextToClipboard(href)
if (ok) ElMessage.success('已复制链接')
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
}
</script>
<style scoped lang="scss">
@@ -420,10 +456,10 @@
pointer-events: none;
}
.pl-plan-list-cert-cell__download {
.pl-plan-list-cert-cell__download,
.pl-plan-list-cert-cell__copy {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
width: 18px;
height: 18px;
@@ -440,6 +476,14 @@
}
}
.pl-plan-list-cert-cell__download {
right: 2px;
}
.pl-plan-list-cert-cell__copy {
right: 22px;
}
.pl-plan-list-cert-cell__cert-caption {
position: absolute;
right: 0;
@@ -133,10 +133,13 @@
prefetchedArrange.value = null
bootstrappedPlan.value = null
let data: Plan | null = props.seedPlan?.id === props.planId ? (props.seedPlan as Plan) : null
if (!data) {
// 打开详情始终回源,避免列表 seed 数量/装卸段滞后于库内已修正数据
let data: Plan | null = null
try {
const res = await planApi.get(props.planId)
data = pickBxData<Plan>(res) || ((res as { data?: Plan }).data ?? null)
} catch {
data = props.seedPlan?.id === props.planId ? (props.seedPlan as Plan) : null
}
if (!data) {
throw new Error('运输计划数据无效')
@@ -779,7 +779,14 @@
transport.value?.feedbackRemark ||
feedbackImageList.value.length ||
transport.value?.feedbackQty != null ||
(transport.value?.driverProductFeedbacks || []).length
(transport.value?.driverProductFeedbacks || []).length ||
(transport.value?.driverStopFeedbacks || []).some(
(row) =>
(row.attachmentUrls && row.attachmentUrls.length > 0) ||
row.remark ||
row.feedbackTime ||
(row.products && row.products.length > 0)
)
)
)
@@ -1406,9 +1413,15 @@
)
}
const showStopFeedback = computed(
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
)
const showStopFeedback = computed(() => {
const hasStopFeedback = Boolean(
(transport.value?.driverStopFeedbacks || []).length ||
(transport.value?.siteStopFeedbacks || []).length
)
// 作废/取消单仍展示已有分段回传;无数据时仅在已安排且未取消时显示空态入口
if (hasStopFeedback) return true
return plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
})
const feedbackBlocksBind = computed(() => ({
feedbackEditable: props.feedbackEditable,
@@ -1435,7 +1448,27 @@
ioOrderViewFields: ioOrderViewFields.value,
ioOrderImageCsv: ioOrderImageCsv.value,
siteFeedbackTableRows: siteFeedbackTableRows.value,
dibangPlanRows: dibangPlanRows.value
dibangPlanRows: dibangPlanRows.value,
driverStopFeedbacks: (transport.value?.driverStopFeedbacks || []).map((row) => ({
stopType: row.stopType,
stopId: row.stopId,
label: row.label,
customerName: row.customerName,
attachmentUrls: row.attachmentUrls,
remark: row.remark,
feedbackTime: row.feedbackTime
})),
siteStopFeedbacks: (transport.value?.siteStopFeedbacks || []).map((row) => ({
stopType: row.stopType,
stopId: row.stopId,
label: row.label,
customerName: row.customerName,
businessType: row.businessType,
quantity: row.quantity != null ? Number(row.quantity) : undefined,
attachmentUrls: row.attachmentUrls,
remark: row.remark,
feedbackTime: row.feedbackTime
}))
}))
const feedbackBlocksOn = {
@@ -9,15 +9,15 @@
<article class="fuel-plate-card recharge-card recharge-notice">
<div class="recharge-notice-head"> <div class="plate">消息通知</div><em>最新</em> </div>
<div class="recharge-notice-list">
<div v-for="notice in summary.notices" :key="notice.id" class="recharge-notice-item">
<div v-for="notice in displayedNotices" :key="notice.id" class="recharge-notice-item">
<time :datetime="notice.occurredAt">{{ notice.occurredAt.slice(0, 10) }}</time>
<span
>{{ notice.actorName }}{{ notice.roleName ? `(${notice.roleName})` : ''
}}{{ notice.action }}{{ notice.licensePlate
}}{{ notice.rechargeType === 'etc' ? 'ETC' : '加油卡' }}充值;</span
}}{{ noticeTypeLabel(notice.rechargeType) }}充值;</span
>
</div>
<div v-if="!summary.notices.length" class="recharge-notice-item muted">暂无消息</div>
<div v-if="!displayedNotices.length" class="recharge-notice-item muted">暂无消息</div>
</div>
</article>
<article
@@ -45,7 +45,7 @@
>{{ statusLabels[vehicle.vehicleStatus] || vehicle.vehicleStatus || '—' }}</el-tag
>
</div>
<div class="recharge-split">
<div class="recharge-split" :class="{ 'is-single': kinds.length === 1 }">
<div v-for="kind in kinds" :key="kind.value" class="recharge-pane">
<div class="recharge-pane-h">
<h4>{{ kind.label }}</h4>
@@ -94,10 +94,17 @@
} from '@/api/pl/recharge-summary'
import { pickBxData } from '@/utils/http/bx'
import { formatPlVehiclePlateDisplay } from '@/utils/pl-display'
import { rechargeComparison, rechargeSummaryQuery } from '@/utils/pl-recharge-summary'
import {
rechargeComparison,
rechargeSummaryQuery,
type RechargeSummaryMode
} from '@/utils/pl-recharge-summary'
import mittBus from '@/utils/sys/mittBus'
const props = defineProps<{ applyPath: string }>()
const props = withDefaults(
defineProps<{ applyPath: string; mode?: RechargeSummaryMode }>(),
{ mode: 'fuel' }
)
const router = useRouter()
const route = useRoute()
const summary = ref<RechargeSummary | null>(null)
@@ -107,12 +114,22 @@
vehicle.vehicleType === 'self' && !['油桶', '叉车'].includes(vehicle.licensePlate.trim())
)
)
const displayedNotices = computed(() => {
const notices = summary.value?.notices || []
if (props.mode === 'etc') return notices.filter((n) => n.rechargeType === 'etc')
return notices.filter((n) => n.rechargeType !== 'etc')
})
const loading = ref(false)
const error = ref(false)
const kinds = [
const kindDefs = [
{ value: 'etc', label: 'ETC' },
{ value: 'fuel', label: '加油卡' }
] as const
const kinds = computed(() =>
kindDefs.filter((kind) =>
props.mode === 'etc' ? kind.value === 'etc' : kind.value === 'fuel'
)
)
const statusLabels: Record<string, string> = {
available: '可用',
maintaining: '维修中',
@@ -122,6 +139,11 @@
Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
let requestVersion = 0
function noticeTypeLabel(rechargeType: string) {
if (rechargeType === 'etc') return 'ETC'
return '加油卡'
}
function metrics(amounts: RechargeAmounts) {
return [
{
@@ -186,11 +186,13 @@
}
.recharge-grid {
grid-template-columns: repeat(3, 1fr);
/* 单栏(油卡/ETC 分开展示):固定一行 5 张 */
grid-template-columns: repeat(5, 1fr);
}
.recharge-card {
cursor: default;
padding: 12px 14px;
}
.recharge-split {
@@ -199,6 +201,10 @@
gap: 10px;
}
.recharge-split.is-single {
grid-template-columns: 1fr;
}
.recharge-pane {
min-width: 0;
padding: 10px 12px;
@@ -1,6 +1,8 @@
<!-- 图标组件:支持 Element Plus 图标(无 : 前缀)与 Iconify 图标(含 : 前缀) -->
<template>
<el-icon v-if="epIcon" v-bind="iconAttrs" class="art-svg-icon inline">
<!-- el-icon 勿加 Tailwind `inline`:生产 CSS 中 `.inline{display:inline}` 会覆盖
`.el-icon{display:inline-flex}`,导致 Fold 等图标在打包后不可见 -->
<el-icon v-if="epIcon" v-bind="iconAttrs" class="art-svg-icon">
<component :is="epIcon" />
</el-icon>
<Icon v-else-if="icon" :icon="icon" v-bind="iconAttrs" class="art-svg-icon inline" />
@@ -14,7 +14,11 @@
</div>
</div>
<PlRechargeSummary v-if="!isFullPage && showRechargeSummary" :apply-path="rechargeApplyPath" />
<PlRechargeSummary
v-if="!isFullPage && showRechargeSummary && rechargeSummaryMode"
:apply-path="rechargeApplyPath"
:mode="rechargeSummaryMode"
/>
<ArtGroupLeafTabs v-if="!isFullPage && showGroupLeafTabs" />
<!-- 勿强制 minHeight:calc(100vh-…),否则门户布局会叠出多余纵向滚动条 -->
@@ -71,7 +75,11 @@
import { layoutOutletCacheKey, resolveLeafGroup } from '@/utils/router/leaf-group'
import ArtGroupLeafTabs from '@/components/core/layouts/art-group-leaf-tabs/index.vue'
import PlRechargeSummary from '@/components/business/pl-recharge-summary/index.vue'
import { isRechargeSummaryRoute } from '@/utils/pl-recharge-summary'
import {
isRechargeSummaryRoute,
rechargeSummaryModeFromPath,
type RechargeSummaryMode
} from '@/utils/pl-recharge-summary'
defineOptions({ name: 'ArtPageContent' })
@@ -93,12 +101,29 @@
const showGroupLeafTabs = computed(() => (leafGroup.value?.leaves.length || 0) > 1)
const leafGroupId = computed(() => leafGroup.value?.groupId || '')
const rechargeApplyPath = computed(() =>
leafGroup.value?.leaves.find((leaf) => leaf.meta.menuCode === 'pl:vehicle-recharge-apply:list')?.path || ''
const rechargeSummaryMode = computed<RechargeSummaryMode | null>(() => {
const fromRoute = rechargeSummaryModeFromPath(route.path)
if (fromRoute) return fromRoute
for (const leaf of leafGroup.value?.leaves || []) {
const mode = rechargeSummaryModeFromPath(leaf.path)
if (mode) return mode
}
return null
})
const rechargeApplyMenuCode = computed(() =>
rechargeSummaryMode.value === 'etc'
? 'pl:etc-vehicle-recharge-apply:list'
: 'pl:vehicle-recharge-apply:list'
)
const showRechargeSummary = computed(() =>
isRechargeSummaryRoute(route.path) ||
!!leafGroup.value?.leaves.some((leaf) => isRechargeSummaryRoute(leaf.path))
const rechargeApplyPath = computed(
() =>
leafGroup.value?.leaves.find((leaf) => leaf.meta.menuCode === rechargeApplyMenuCode.value)
?.path || ''
)
const showRechargeSummary = computed(
() =>
isRechargeSummaryRoute(route.path) ||
!!leafGroup.value?.leaves.some((leaf) => isRechargeSummaryRoute(leaf.path))
)
const useGroupOutletCache = computed(
@@ -0,0 +1,33 @@
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
/**
* 监听 route.query.editId,调用 openById;结束后清除 editId,避免刷新重复弹窗。
* openById 应自行处理加载失败提示。
*/
export function useRouteEditIdOpen(openById: (id: string) => void | Promise<void>) {
const route = useRoute()
const router = useRouter()
async function consumeEditId() {
const raw = route.query.editId
const id = Array.isArray(raw) ? String(raw[0] || '') : String(raw || '')
if (!id.trim()) return
try {
await openById(id.trim())
} finally {
if (route.query.editId == null) return
const query = { ...route.query }
delete query.editId
await router.replace({ path: route.path, query })
}
}
watch(
() => route.query.editId,
() => {
void consumeEditId()
},
{ immediate: true }
)
}
+102 -22
View File
@@ -411,57 +411,57 @@ export const plRoutes: AppRouteRecord[] = [
id: 'PL_REDIRECT_VEH_RECHARGE_APPLY',
name: 'PlVehicleRechargeApplyRedirect',
path: 'vehicle-recharge-apply',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-apply',
meta: { title: 'ETC/油卡充值申请', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply',
meta: { title: '油卡充值申请', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_APPLY_NESTED',
name: 'PlVehicleRechargeApplyNestedRedirect',
path: 'vehicle-mgmt/vehicle-recharge-apply',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-apply',
meta: { title: 'ETC/油卡充值申请', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply',
meta: { title: '油卡充值申请', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_RECORD',
name: 'PlVehicleRechargeRecordRedirect',
path: 'vehicle-recharge-record',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-record',
meta: { title: 'ETC/油卡充值发放管理', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-record',
meta: { title: '油卡充值发放管理', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_RECORD_NESTED',
name: 'PlVehicleRechargeRecordNestedRedirect',
path: 'vehicle-mgmt/vehicle-recharge-record',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-record',
meta: { title: 'ETC/油卡充值发放管理', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-record',
meta: { title: '油卡充值发放管理', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_APPROVAL',
name: 'PlVehicleRechargeApprovalRedirect',
path: 'vehicle-recharge-approval',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-approval',
meta: { title: 'ETC/油卡充值审批', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval',
meta: { title: '油卡充值审批', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_APPROVAL_NESTED',
name: 'PlVehicleRechargeApprovalNestedRedirect',
path: 'vehicle-mgmt/vehicle-recharge-approval',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-approval',
meta: { title: 'ETC/油卡充值审批', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval',
meta: { title: '油卡充值审批', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_WORKFLOW',
name: 'PlVehicleRechargeWorkflowRedirect',
path: 'vehicle-recharge-workflow',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-workflow',
meta: { title: 'ETC/油卡充值审批人设置', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow',
meta: { title: '油卡充值审批人设置', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_RECHARGE_WORKFLOW_NESTED',
name: 'PlVehicleRechargeWorkflowNestedRedirect',
path: 'vehicle-mgmt/vehicle-recharge-workflow',
redirect: '/vehicle-mgmt/etc-recharge/vehicle-recharge-workflow',
meta: { title: 'ETC/油卡充值审批人设置', isHide: true }
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow',
meta: { title: '油卡充值审批人设置', isHide: true }
},
{
id: 'PL_REDIRECT_VEH_MAINT_APPLY',
@@ -566,15 +566,15 @@ export const plRoutes: AppRouteRecord[] = [
{
id: 'PL_GROUP_VEH_RECHARGE',
name: 'PlVehicleRechargeGroup',
path: 'etc-recharge',
meta: { title: 'ETC/油卡充值管理', keepAlive: false },
path: 'fuel-recharge',
meta: { title: '油卡充值管理', keepAlive: false },
children: [
{
id: 'PL_VEH_RECHARGE_APPLY',
name: 'PlVehicleRechargeApply',
path: 'vehicle-recharge-apply',
component: '/pl/vehicle-recharge-apply/index',
meta: { title: 'ETC/油卡充值申请', keepAlive: false, menuCode: 'pl:vehicle-recharge-apply:list' }
meta: { title: '油卡充值申请', keepAlive: false, menuCode: 'pl:vehicle-recharge-apply:list' }
},
{
id: 'PL_VEH_RECHARGE_APPROVAL',
@@ -582,7 +582,7 @@ export const plRoutes: AppRouteRecord[] = [
path: 'vehicle-recharge-approval',
component: '/pl/vehicle-recharge-approval/index',
meta: {
title: 'ETC/油卡充值审批',
title: '油卡充值审批',
keepAlive: false,
menuCode: 'pl:vehicle-recharge-approval:list'
}
@@ -592,7 +592,7 @@ export const plRoutes: AppRouteRecord[] = [
name: 'PlVehicleRechargeRecord',
path: 'vehicle-recharge-record',
component: '/pl/vehicle-recharge-record/index',
meta: { title: 'ETC/油卡充值发放管理', keepAlive: false, menuCode: 'pl:vehicle-recharge-record:list' }
meta: { title: '油卡充值发放管理', keepAlive: false, menuCode: 'pl:vehicle-recharge-record:list' }
},
{
id: 'PL_VEH_RECHARGE_WF',
@@ -600,7 +600,7 @@ export const plRoutes: AppRouteRecord[] = [
path: 'vehicle-recharge-workflow',
component: '/pl/vehicle-recharge-workflow/index',
meta: {
title: 'ETC/油卡充值审批人设置',
title: '油卡充值审批人设置',
keepAlive: false,
menuCode: 'pl:vehicle-recharge-workflow:list'
}
@@ -614,6 +614,86 @@ export const plRoutes: AppRouteRecord[] = [
}
]
},
{
id: 'PL_GROUP_VEH_ETC_RECHARGE',
name: 'PlVehicleEtcRechargeGroup',
path: 'etc-recharge',
meta: { title: 'ETC充值管理', keepAlive: false },
children: [
{
id: 'PL_ETC_VEH_RECHARGE_APPLY',
name: 'PlEtcVehicleRechargeApply',
path: 'etc-vehicle-recharge-apply',
component: '/pl/etc-vehicle-recharge-apply/index',
meta: {
title: 'ETC充值申请',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-apply:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_APPROVAL',
name: 'PlEtcVehicleRechargeApproval',
path: 'etc-vehicle-recharge-approval',
component: '/pl/etc-vehicle-recharge-approval/index',
meta: {
title: 'ETC充值审批',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-approval:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_RECORD',
name: 'PlEtcVehicleRechargeRecord',
path: 'etc-vehicle-recharge-record',
component: '/pl/etc-vehicle-recharge-record/index',
meta: {
title: 'ETC发放管理',
keepAlive: false,
menuCode: 'pl:etc-vehicle-recharge-record:list'
}
},
{
id: 'PL_ETC_VEH_RECHARGE_WF',
name: 'PlEtcVehicleRechargeWorkflow',
path: 'etc-vehicle-recharge-workflow',
component: '/pl/etc-vehicle-recharge-workflow/index',
meta: {
title: 'ETC充值审批人设置',
keepAlive: false,
menuCode: 'pl:vehicle-recharge-workflow:list'
}
},
{
id: 'PL_REDIRECT_ETC_GROUP_OLD_APPLY',
name: 'PlEtcGroupOldApplyRedirect',
path: 'vehicle-recharge-apply',
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply',
meta: { title: '油卡充值申请', isHide: true }
},
{
id: 'PL_REDIRECT_ETC_GROUP_OLD_APPROVAL',
name: 'PlEtcGroupOldApprovalRedirect',
path: 'vehicle-recharge-approval',
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval',
meta: { title: '油卡充值审批', isHide: true }
},
{
id: 'PL_REDIRECT_ETC_GROUP_OLD_RECORD',
name: 'PlEtcGroupOldRecordRedirect',
path: 'vehicle-recharge-record',
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-record',
meta: { title: '油卡充值发放管理', isHide: true }
},
{
id: 'PL_REDIRECT_ETC_GROUP_OLD_WORKFLOW',
name: 'PlEtcGroupOldWorkflowRedirect',
path: 'vehicle-recharge-workflow',
redirect: '/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow',
meta: { title: '油卡充值审批人设置', isHide: true }
}
]
},
{
id: 'PL_GROUP_VEH_FUEL',
name: 'PlVehicleFuelGroup',
@@ -407,6 +407,14 @@
justify-content: flex-end;
}
.mp-crm-att-grid .att-icon--copy {
right: 0;
bottom: 0;
top: auto;
align-items: flex-end;
justify-content: flex-end;
}
.mp-crm-att-grid .att-icon > span {
display: flex;
align-items: center;
@@ -426,6 +434,10 @@
background: #087f57;
}
.mp-crm-att-grid .att-icon--copy > span {
background: #0f766e;
}
.mp-crm-att-grid .att-uploading {
position: absolute;
inset: 0;
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict'
import { afterEach, before, describe, it } from 'node:test'
import {
copyAttachmentImageToClipboard,
copyImageBlobToClipboard,
ensurePngBlob
} from './clipboard'
class FakeClipboardItem {
readonly types: string[]
private readonly data: Record<string, Blob>
constructor(items: Record<string, Blob>) {
this.data = items
this.types = Object.keys(items)
}
async getType(type: string) {
return this.data[type]
}
}
before(() => {
if (typeof globalThis.ClipboardItem === 'undefined') {
;(globalThis as unknown as { ClipboardItem: typeof FakeClipboardItem }).ClipboardItem =
FakeClipboardItem
}
if (typeof globalThis.navigator === 'undefined') {
;(globalThis as unknown as { navigator: Navigator }).navigator = {} as Navigator
}
})
const originalClipboard = globalThis.navigator?.clipboard
afterEach(() => {
if (typeof globalThis.navigator === 'undefined') return
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: originalClipboard
})
})
describe('ensurePngBlob', () => {
it('returns the same blob when type is already image/png', async () => {
const png = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
const out = await ensurePngBlob(png)
assert.equal(out, png)
assert.equal(out.type, 'image/png')
})
})
describe('copyImageBlobToClipboard', () => {
it('writes a ClipboardItem with image/png and returns true', async () => {
const written: ClipboardItem[] = []
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
write: async (items: ClipboardItem[]) => {
written.push(...items)
}
}
})
const png = new Blob([new Uint8Array([9])], { type: 'image/png' })
const ok = await copyImageBlobToClipboard(png)
assert.equal(ok, true)
assert.equal(written.length, 1)
const types = written[0].types
assert.ok(types.includes('image/png'))
})
it('returns false when clipboard.write is unavailable', async () => {
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {}
})
const png = new Blob([new Uint8Array([1])], { type: 'image/png' })
assert.equal(await copyImageBlobToClipboard(png), false)
})
})
describe('copyAttachmentImageToClipboard', () => {
it('returns false for non-image attachments without fetching', async () => {
let fetched = false
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: 'https://example.com/a.pdf', fileName: 'a.pdf' },
{
fetchBlob: async () => {
fetched = true
return new Blob(['x'], { type: 'application/pdf' })
}
}
)
assert.equal(ok, false)
assert.equal(fetched, false)
})
it('fetches image blob and copies via clipboard', async () => {
const written: ClipboardItem[] = []
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
write: async (items: ClipboardItem[]) => {
written.push(...items)
}
}
})
const ok = await copyAttachmentImageToClipboard(
{ fileUrl: 'https://example.com/a.jpg', fileName: 'a.jpg' },
{
fetchBlob: async () => new Blob([new Uint8Array([7])], { type: 'image/png' })
}
)
assert.equal(ok, true)
assert.equal(written.length, 1)
})
})
+85
View File
@@ -2,9 +2,15 @@
* 复制文本到剪贴板。
* 小程序 web-view / H5 iframe 常被 Permissions Policy 拦截 Clipboard API,
* 需回退到 execCommand,必要时再交给壳页复制。
* 另提供图片 Blob / 附件图片写入系统剪贴板(ClipboardItem)。
*/
import { MP_WEBVIEW_COPY_TEXT } from '@/utils/mp-webview'
export type AttachmentClipboardItem = {
fileUrl?: string
fileName?: string
}
function fallbackExecCommandCopy(text: string): boolean {
try {
const textarea = document.createElement('textarea')
@@ -64,3 +70,82 @@ export async function copyTextToClipboard(text: string): Promise<boolean> {
return tryCopyViaMpWebviewHost(value)
}
/** 与 download-attachment 图片判定对齐,避免测试静态拉入整条 API 依赖链 */
function isImageAttachment(file: AttachmentClipboardItem): boolean {
const name = String(file.fileName || '').toLowerCase()
const url = String(file.fileUrl || '')
.split('?')[0]
.toLowerCase()
return (
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name) ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
)
}
/** 已是 PNG 则原样返回;否则经 canvas 转为 image/png。 */
export async function ensurePngBlob(blob: Blob): Promise<Blob> {
if (blob.type === 'image/png') return blob
if (typeof createImageBitmap === 'function' && typeof document !== 'undefined') {
const bitmap = await createImageBitmap(blob)
try {
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('canvas 2d unavailable')
ctx.drawImage(bitmap, 0, 0)
const png = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
})
return png
} finally {
bitmap.close?.()
}
}
// 无 canvas 环境时仍尝试按原类型写入(部分浏览器接受 image/jpeg)
return blob
}
/** 将图片 Blob 写入系统剪贴板;失败返回 false。 */
export async function copyImageBlobToClipboard(blob: Blob): Promise<boolean> {
if (!blob || blob.size <= 0) return false
try {
if (typeof navigator === 'undefined' || !navigator.clipboard?.write) return false
if (typeof ClipboardItem === 'undefined') return false
const png = await ensurePngBlob(blob)
const type = png.type || 'image/png'
await navigator.clipboard.write([new ClipboardItem({ [type]: png })])
return true
} catch {
return false
}
}
export type CopyAttachmentImageOptions = {
fullFileUrl?: (url: string) => string
/** 测试可注入;默认走预签名下载 Blob */
fetchBlob?: (
file: AttachmentClipboardItem,
options?: { fullFileUrl?: (url: string) => string }
) => Promise<Blob>
}
/** 仅图片附件:拉取 Blob 后写入剪贴板;非图片返回 false。 */
export async function copyAttachmentImageToClipboard(
file: AttachmentClipboardItem,
options?: CopyAttachmentImageOptions
): Promise<boolean> {
if (!isImageAttachment(file)) return false
try {
const fetchBlob =
options?.fetchBlob ||
(await import('@/utils/download-attachment')).fetchAttachmentBlob
const blob = await fetchBlob(file, { fullFileUrl: options?.fullFileUrl })
return await copyImageBlobToClipboard(blob)
} catch {
return false
}
}
+1 -1
View File
@@ -661,7 +661,7 @@ export function buildFuelRegisterCopyText(row: {
export function getPlRechargeApplyStatusLabel(status: string) {
const map: Record<string, string> = {
draft: '草稿',
pending: '待发放',
pending: '发放中',
in_approval: '审批中',
rejected: '已驳回',
disbursed: '已发放',
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
resolveDocumentReminderEditPath,
resolveDocumentReminderFormMode,
DOCUMENT_REMINDER_JUMP_UNSUPPORTED
} from './pl-document-reminder-jump'
describe('证件提醒表单模式与路径', () => {
it('车辆按 vehicleType 解析弹窗类型与路径', () => {
assert.deepEqual(
resolveDocumentReminderFormMode({ ownerType: 'vehicle', vehicleType: 'self' }),
{ kind: 'vehicle', vehicleType: 'self' }
)
assert.deepEqual(
resolveDocumentReminderFormMode({ ownerType: 'vehicle', vehicleType: 'carrier' }),
{ kind: 'vehicle', vehicleType: 'carrier' }
)
assert.deepEqual(resolveDocumentReminderFormMode({ ownerType: 'vehicle', vehicleType: '' }), {
kind: 'vehicle',
vehicleType: 'carrier'
})
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'self' }),
'/vehicle-mgmt/base-info/self-vehicle'
)
assert.equal(
resolveDocumentReminderEditPath({ ownerType: 'vehicle', vehicleType: 'carrier' }),
'/vehicle-mgmt/base-info/vehicle'
)
})
it('人员按 personnelAffiliation 解析弹窗归属与路径', () => {
assert.deepEqual(
resolveDocumentReminderFormMode({
ownerType: 'personnel',
personnelAffiliation: 'self'
}),
{ kind: 'personnel', affiliation: 'self' }
)
assert.deepEqual(
resolveDocumentReminderFormMode({
ownerType: 'personnel',
personnelAffiliation: 'carrier'
}),
{ kind: 'personnel', affiliation: 'carrier' }
)
assert.deepEqual(
resolveDocumentReminderFormMode({
ownerType: 'personnel',
personnelAffiliation: ''
}),
{ kind: 'personnel', affiliation: 'self' }
)
assert.equal(
resolveDocumentReminderEditPath({
ownerType: 'personnel',
personnelAffiliation: 'carrier'
}),
'/vehicle-mgmt/base-info/personnel-carrier'
)
})
it('不支持的主体返回 null', () => {
assert.equal(resolveDocumentReminderFormMode({ ownerType: 'carrier' }), null)
assert.equal(resolveDocumentReminderEditPath({ ownerType: '' }), null)
assert.equal(DOCUMENT_REMINDER_JUMP_UNSUPPORTED, '暂不支持该主体类型的证件维护跳转')
})
})
@@ -0,0 +1,46 @@
export const DOCUMENT_REMINDER_JUMP_UNSUPPORTED = '暂不支持该主体类型的证件维护跳转'
export type ReminderJumpInput = {
ownerType?: string | null
vehicleType?: string | null
personnelAffiliation?: string | null
}
export type ReminderFormMode =
| { kind: 'vehicle'; vehicleType: 'self' | 'carrier' }
| { kind: 'personnel'; affiliation: 'self' | 'carrier' }
/** 提醒页本页弹窗:解析车辆/人员表单锁定类型。 */
export function resolveDocumentReminderFormMode(
input: ReminderJumpInput
): ReminderFormMode | null {
const ownerType = String(input.ownerType || '').trim()
if (ownerType === 'vehicle') {
return {
kind: 'vehicle',
vehicleType: String(input.vehicleType || '').trim() === 'self' ? 'self' : 'carrier'
}
}
if (ownerType === 'personnel') {
return {
kind: 'personnel',
affiliation:
String(input.personnelAffiliation || '').trim() === 'carrier' ? 'carrier' : 'self'
}
}
return null
}
/** 可选深链:档案页 path(提醒主流程已改为本页弹窗)。 */
export function resolveDocumentReminderEditPath(input: ReminderJumpInput): string | null {
const mode = resolveDocumentReminderFormMode(input)
if (!mode) return null
if (mode.kind === 'vehicle') {
return mode.vehicleType === 'self'
? '/vehicle-mgmt/base-info/self-vehicle'
: '/vehicle-mgmt/base-info/vehicle'
}
return mode.affiliation === 'carrier'
? '/vehicle-mgmt/base-info/personnel-carrier'
: '/vehicle-mgmt/base-info/personnel'
}
@@ -3,7 +3,10 @@ import { describe, it } from 'node:test'
import {
rechargeComparison,
rechargeSummaryQuery,
isRechargeSummaryRoute
isRechargeSummaryRoute,
isFuelRechargeSummaryRoute,
isEtcRechargeSummaryRoute,
rechargeSummaryModeFromPath
} from './pl-recharge-summary'
describe('充值统计卡片', () => {
@@ -30,10 +33,60 @@ describe('充值统计卡片', () => {
})
})
it('只对充值业务子页显示,其他车辆页面不显示', () => {
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-apply'), true)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-record'), true)
it('油卡分组子页识别为 fuel 模式', () => {
assert.equal(
isFuelRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply'),
true
)
assert.equal(
isFuelRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-record'),
true
)
assert.equal(
isFuelRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-approval'),
true
)
assert.equal(
isFuelRechargeSummaryRoute('/vehicle-mgmt/fuel-recharge/vehicle-recharge-workflow'),
true
)
assert.equal(rechargeSummaryModeFromPath('/vehicle-mgmt/fuel-recharge/vehicle-recharge-apply'), 'fuel')
})
it('ETC 分组子页识别为 etc 模式并展示顶部统计', () => {
assert.equal(
isEtcRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
true
)
assert.equal(
isEtcRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-approval'),
true
)
assert.equal(
isEtcRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-record'),
true
)
assert.equal(
isEtcRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-workflow'),
true
)
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
true
)
assert.equal(
rechargeSummaryModeFromPath('/vehicle-mgmt/etc-recharge/etc-vehicle-recharge-apply'),
'etc'
)
})
it('旧路径与其它车辆页不显示充值统计', () => {
assert.equal(
isRechargeSummaryRoute('/vehicle-mgmt/etc-recharge/vehicle-recharge-apply'),
false
)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle-fuel-card'), false)
assert.equal(isRechargeSummaryRoute('/vehicle-mgmt/vehicle'), false)
assert.equal(rechargeSummaryModeFromPath('/vehicle-mgmt/vehicle'), null)
})
})
+21 -1
View File
@@ -13,10 +13,30 @@ export function rechargeComparison(
}
}
export type RechargeSummaryMode = 'fuel' | 'etc'
export function rechargeSummaryQuery(plate: string, kind: 'etc' | 'fuel') {
return { rechargePlate: plate, rechargeKind: kind }
}
/** 油卡充值分组下的申请/审批/发放/审批人设置 */
export function isFuelRechargeSummaryRoute(path: string) {
return /\/fuel-recharge\/vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(path)
}
/** ETC 充值分组下的申请/审批/发放/审批人设置 */
export function isEtcRechargeSummaryRoute(path: string) {
return /\/etc-recharge\/etc-vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(path)
}
/** 是否展示顶部充值统计(油卡或 ETC 业务子页) */
export function isRechargeSummaryRoute(path: string) {
return /\/vehicle-recharge-(apply|approval|record|workflow)\/?$/.test(path)
return isFuelRechargeSummaryRoute(path) || isEtcRechargeSummaryRoute(path)
}
/** 当前路由对应的统计模式;非充值业务页返回 null */
export function rechargeSummaryModeFromPath(path: string): RechargeSummaryMode | null {
if (isEtcRechargeSummaryRoute(path)) return 'etc'
if (isFuelRechargeSummaryRoute(path)) return 'fuel'
return null
}
@@ -512,6 +512,8 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { fetchCustomerOptions } from '@/api/crm/customer'
import { listContract } from '@/api/crm/contract'
import type { ContractVO } from '@/api/crm/contract/types'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
@@ -540,11 +542,14 @@
} from '@/views/pl/quota-cert/model'
import {
bindingsFromPurchaseCerts,
contractsForCustomer,
exactCustomerMatches,
matchPurchaseCert,
scopeProductsFromLines
} from '@/views/pl/quota-cert/contract-binder-rules'
import {
certArchiveContractListQuery,
filterAvailableBindingContracts
} from '@/views/pl/quota-cert/cert-archive-contract-query'
import { getProductUnitLabel } from '@/utils/crm-product-display'
import { useDictStore } from '@/store/modules/dict'
import { showCrmConfirmDialog } from '@/utils/mp-crm-confirm'
@@ -928,19 +933,39 @@
if (current === epoch) ElMessage.warning('客户匹配失败,请手工选择')
}
}
function toContractOption(row: ContractVO): ContractOption {
return {
id: String(row.id),
contractNo: row.contractNo || '',
contractName: row.contractName || '',
contractType: row.contractType,
customerId: row.customerId != null ? String(row.customerId) : '',
customerName: row.customerName,
status: row.status,
signDate: row.signDate || null
}
}
async function searchContracts(keyword = '') {
const current = epoch
const query = certArchiveContractListQuery(bindingCustomer.value.id, keyword)
if (!query) {
contractOptions.value = []
return
}
try {
const rows = await quotaCertApi.contracts(keyword)
const result = await listContract(query)
if (current !== epoch) return
const taken = new Set(
(form.value.contractBindings || []).map((row: CertificateContractBinding) => row.contractId)
const taken = (form.value.contractBindings || []).map(
(row: CertificateContractBinding) => row.contractId
)
contractOptions.value = contractsForCustomer(rows, bindingCustomer.value).filter(
(row) => !taken.has(row.id)
contractOptions.value = filterAvailableBindingContracts(result.rows || [], taken).map(
toContractOption
)
} catch {
if (current === epoch) contractOptions.value = []
if (current === epoch) {
contractOptions.value = []
ElMessage.warning('合同加载失败,请重试')
}
}
}
function openContracts() {
@@ -0,0 +1,135 @@
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
const RECHARGE_TYPE_OPTIONS = [{ value: 'etc', label: 'ETC' }]
const RECHARGE_STATUS_OPTIONS = [
{ value: 'draft', label: '草稿' },
{ value: 'in_approval', label: '审批中' },
{ value: 'pending', label: '发放中' },
{ value: 'rejected', label: '已驳回' },
{ value: 'disbursed', label: '已发放' },
{ value: 'cancelled', label: '已作废' }
]
/** ETC 申请列表列头漏斗(服务端)。 */
export const plVehicleRechargeApplyColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyNo', label: '申请单号', type: 'text', queryKey: 'applyNo' },
{ prop: 'licensePlate', label: '车牌号', type: 'text', queryKey: 'licensePlate' },
{
prop: 'rechargeType',
label: '充值类型',
type: 'enum',
queryKey: 'rechargeType',
enumOptions: RECHARGE_TYPE_OPTIONS
},
{
prop: 'applyDate',
label: '申请日期',
type: 'datetime',
queryKeyFrom: 'applyDateFrom',
queryKeyTo: 'applyDateTo'
},
{ prop: 'odometerKm', label: '期初公里数', type: 'number', queryKeyPrefix: 'odometerKm' },
{ prop: 'applicantName', label: '申请人', type: 'text', queryKey: 'applicantName' },
{ prop: 'remark', label: '备注', type: 'text', queryKey: 'remark' },
{
prop: 'status',
label: '状态',
type: 'enum',
queryKey: 'status',
enumOptions: RECHARGE_STATUS_OPTIONS
},
{
prop: 'createTime',
label: '创建时间',
type: 'datetime',
queryKeyFrom: 'createTimeFrom',
queryKeyTo: 'createTimeTo'
}
]
/** ETC 审批列表列头漏斗(服务端;在申请 defs 基础上增加当前节点)。 */
export const plVehicleRechargeApprovalColumnFilterDefs: ColumnFilterDef[] = [
...plVehicleRechargeApplyColumnFilterDefs.filter((d) => d.prop !== 'createTime'),
{ prop: 'currentNodeName', label: '当前节点', type: 'text', queryKey: 'currentNodeName' },
{
prop: 'createTime',
label: '创建时间',
type: 'datetime',
queryKeyFrom: 'createTimeFrom',
queryKeyTo: 'createTimeTo'
}
]
/** ETC 发放看板列头漏斗(服务端)。 */
export const plVehicleRechargeDisburseColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyNo', label: '申请单号', type: 'text', queryKey: 'applyNo' },
{
prop: 'applyDate',
label: '申请日期',
type: 'datetime',
queryKeyFrom: 'applyDateFrom',
queryKeyTo: 'applyDateTo'
},
{ prop: 'applicantName', label: '申请人', type: 'text', queryKey: 'applicantName' },
{ prop: 'odometerKm', label: '期初公里数', type: 'number', queryKeyPrefix: 'odometerKm' },
{
prop: 'applyStatus',
label: '申请状态',
type: 'enum',
queryKey: 'applyStatus',
enumOptions: RECHARGE_STATUS_OPTIONS
},
{ prop: 'licensePlate', label: '车牌号', type: 'text', queryKey: 'licensePlate' },
{
prop: 'rechargeType',
label: '充值类型',
type: 'enum',
queryKey: 'rechargeType',
enumOptions: RECHARGE_TYPE_OPTIONS
},
{ prop: 'rechargeOrderNo', label: '充值订单号', type: 'text', queryKey: 'rechargeOrderNo' },
{
prop: 'rechargeDate',
label: '充值日期',
type: 'datetime',
queryKeyFrom: 'rechargeDateFrom',
queryKeyTo: 'rechargeDateTo'
},
{ prop: 'cardNo', label: '卡号', type: 'text', queryKey: 'cardNo' },
{ prop: 'gasStation', label: '加油站/渠道', type: 'text', queryKey: 'gasStation' },
{ prop: 'amount', label: '金额(元)', type: 'number', queryKeyPrefix: 'amount' },
{
prop: 'recordCreateByName',
label: '登记人',
type: 'text',
queryKey: 'recordCreateByName'
}
]
/** ETC 审批人设置(客户端)。与油卡共用同一套节点筛选定义。 */
export const plVehicleRechargeWorkflowColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'nodeName', label: '节点名称', type: 'text' },
{
prop: 'actionType',
label: '节点动作',
type: 'enum',
enumOptions: [
{ value: 'approve', label: '审批' },
{ value: 'disburse', label: '发放登记' }
],
getValue: (row) => row.actionType || row.action_type || 'approve'
},
{ prop: 'nodeOrder', label: '节点顺序', type: 'number' },
{ prop: 'approverName', label: '审批人', type: 'text' },
{
prop: 'configStatus',
label: '配置状态',
type: 'enum',
enumOptions: [
{ value: 'configured', label: '已配置' },
{ value: 'unconfigured', label: '未配置' }
],
getValue: (row) => ((row.approverId ?? row.approver_id) ? 'configured' : 'unconfigured')
}
]
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,835 @@
<!-- ETC充值审批(待办/已办) -->
<template>
<div class="page-container">
<section class="panel">
<PlPlanTypeTabs
:model-value="activeTab"
:tabs="APPROVAL_TABS"
@change="handleTabChange"
/>
<div class="list-toolbar">
<div class="list-toolbar-row">
<span class="list-toolbar-selected">已选 {{ selectedRows.length }} 项</span>
<el-button
v-if="hasActiveFilters"
class="btn-ghost"
@click="clearAllFilters"
>
<el-icon><Filter /></el-icon>清空列过滤
</el-button>
<el-button
v-if="activeTab === 'pending'"
class="btn-main"
type="primary"
:disabled="!canApproveSelected"
@click="openHandleDialog(selectedRows[0])"
>
<el-icon><Check /></el-icon>处理
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="openView(selectedRows[0])">
<el-icon><View /></el-icon>查看
</el-button>
<ListToolbarEndActions :loading="loading" @refresh="fetchData" />
</div>
</div>
<div class="list-body list-wrap">
<ListTable
table-key="pl-etc-vehicle-recharge-approval"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
@selection-change="(rows) => (selectedRows = rows)"
@row-dblclick="openView"
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column type="index" label="序号" width="68" align="center" />
<el-table-column prop="applyNo" label="申请单号" min-width="160" show-overflow-tooltip>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请单号</span>
<ColumnHeaderFilter
prop="applyNo"
label="申请单号"
type="text"
:is-active="isFilterActive('applyNo')"
:model-value="filters.applyNo || null"
@update:model-value="(v) => setFilterState('applyNo', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyNo }}</el-link>
</template>
</el-table-column>
<el-table-column prop="licensePlate" label="车牌号" min-width="160" show-overflow-tooltip align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">车牌号</span>
<ColumnHeaderFilter
prop="licensePlate"
label="车牌号"
type="text"
:is-active="isFilterActive('licensePlate')"
:model-value="filters.licensePlate || null"
@update:model-value="(v) => setFilterState('licensePlate', v)"
/>
</div>
</template>
<template #default="{ row }">
{{ formatPlVehiclePlateDisplay(row.licensePlate, undefined, vehicleTrailerLookup) }}
</template>
</el-table-column>
<el-table-column prop="rechargeType" label="充值类型" min-width="100" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">充值类型</span>
<ColumnHeaderFilter
prop="rechargeType"
label="充值类型"
type="enum"
:enum-options="enumOptionsFor('rechargeType')"
:is-active="isFilterActive('rechargeType')"
:model-value="filters.rechargeType || null"
@update:model-value="(v) => setFilterState('rechargeType', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag :type="getPlRechargeTypeTagType(row.rechargeType)" size="small">
{{ getPlRechargeTypeLabel(row.rechargeType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applyDate" label="申请日期" min-width="120" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请日期</span>
<ColumnHeaderFilter
prop="applyDate"
label="申请日期"
type="datetime"
:is-active="isFilterActive('applyDate')"
:model-value="filters.applyDate || null"
@update:model-value="(v) => setFilterState('applyDate', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="odometerKm" label="期初公里数" min-width="110" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">期初公里数</span>
<ColumnHeaderFilter
prop="odometerKm"
label="期初公里数"
type="number"
:is-active="isFilterActive('odometerKm')"
:model-value="filters.odometerKm || null"
@update:model-value="(v) => setFilterState('odometerKm', v)"
/>
</div>
</template>
<template #default="{ row }">{{ row.odometerKm ?? '—' }}</template>
</el-table-column>
<el-table-column prop="applicantName" label="申请人" min-width="100" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请人</span>
<ColumnHeaderFilter
prop="applicantName"
label="申请人"
type="text"
:is-active="isFilterActive('applicantName')"
:model-value="filters.applicantName || null"
@update:model-value="(v) => setFilterState('applicantName', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="attachmentUrls" label="附件" min-width="220" align="left" header-align="left">
<template #default="{ row }">
<PlPlanDocumentAttachmentCell
:attachments="toRechargeAttachmentItems(row.attachmentUrls)"
/>
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="160"
align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">备注</span>
<ColumnHeaderFilter
prop="remark"
label="备注"
type="text"
:is-active="isFilterActive('remark')"
:model-value="filters.remark || null"
@update:model-value="(v) => setFilterState('remark', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="currentNodeName" label="当前节点" min-width="120" show-overflow-tooltip>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">当前节点</span>
<ColumnHeaderFilter
prop="currentNodeName"
label="当前节点"
type="text"
:is-active="isFilterActive('currentNodeName')"
:model-value="filters.currentNodeName || null"
@update:model-value="(v) => setFilterState('currentNodeName', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">状态</span>
<ColumnHeaderFilter
prop="status"
label="状态"
type="enum"
:enum-options="enumOptionsFor('status')"
:is-active="isFilterActive('status')"
:model-value="filters.status || null"
@update:model-value="(v) => setFilterState('status', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag :type="getPlRechargeApplyStatusTagType(row.status)" size="small">
{{ getPlRechargeApplyStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center">
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">创建时间</span>
<ColumnHeaderFilter
prop="createTime"
label="创建时间"
type="datetime"
:is-active="isFilterActive('createTime')"
:model-value="filters.createTime || null"
@update:model-value="(v) => setFilterState('createTime', v)"
/>
</div>
</template>
<template #default="{ row }">{{ formatPlDateTime(row.createTime) }}</template>
</el-table-column>
</ListTable>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.pageSize"
:total="pager.total"
layout="total, sizes, prev, pager, next"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</section>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewDetail?.applyNo || '申请详情'"
:sections="viewSections"
width="960px"
modal-class="order-module-dialog-mask"
append-to-body
>
<template v-if="activeTab === 'pending' && viewDetail" #hero-actions>
<el-button type="primary" @click="openHandleDialog(viewDetail)">
<el-icon><Check /></el-icon>处理
</el-button>
</template>
<template #approval-flow>
<BxApprovalFlowTabs
section-title="审批记录"
:nodes="viewApprovalFlowNodes"
:history="viewApprovalHistoryItems"
/>
</template>
<template #apply-attachments>
<AttachmentUploadField
:model-value="viewDetail?.attachmentUrls || ''"
disabled
compact
/>
</template>
<template #record-attachments>
<AttachmentUploadField
:model-value="viewDetail?.record?.attachmentUrls || ''"
disabled
compact
/>
</template>
</SystemViewDialog>
<el-dialog
v-model="handleVisible"
:title="handleDetail?.applyNo || '审批处理'"
width="1280px"
align-center
class="dialog-form-layout"
:close-on-click-modal="false"
destroy-on-close
>
<el-form label-position="top" class="dialog-form">
<!-- 上方:审批信息 -->
<div class="handle-section">
<div class="handle-section__title">审批信息</div>
<div class="form-grid-4">
<el-form-item label="当前节点">
<el-input :model-value="handleDetail?.currentNodeName || '—'" disabled />
</el-form-item>
<el-form-item label="节点动作">
<el-input :model-value="isDisburseNode ? '发放登记' : '审批'" disabled />
</el-form-item>
<template v-if="isDisburseNode">
<el-form-item label="充值日期" required>
<el-date-picker
v-model="disburseForm.rechargeDate"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="充值金额" required>
<el-input-number
v-model="disburseForm.amount"
:min="0.01"
:precision="2"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="卡号">
<el-input v-model="disburseForm.cardNo" placeholder="卡号" />
</el-form-item>
<el-form-item label="加油站/渠道">
<el-select
v-model="disburseForm.gasStation"
allow-create
filterable
clearable
placeholder="请选择或输入"
style="width: 100%"
>
<el-option v-for="s in PL_GAS_STATION_PRESETS" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="备注" class="form-col-span-half">
<el-input v-model="opinion" type="textarea" :rows="2" placeholder="选填;驳回时请填写原因" />
</el-form-item>
<el-form-item label="发放附件" class="form-col-span-2">
<AttachmentUploadField
ref="disburseAttachmentRef"
v-model="disburseForm.attachmentUrls"
folder="pl/vehicle-recharge/record"
accept="image/*,.pdf"
:limit="5"
/>
</el-form-item>
</template>
<el-form-item v-else label="备注" class="form-col-span-2">
<el-input
v-model="opinion"
type="textarea"
:rows="3"
placeholder="默认同意;驳回时请说明原因"
/>
</el-form-item>
</div>
</div>
<!-- 充值申请详情(只读信息元素,对齐查看弹窗) -->
<div class="handle-section">
<div class="handle-section__title">ETC充值申请详情</div>
<ViewKvGrid :fields="handleApplyDetailFields" :columns="4" />
<div class="handle-apply-attachments">
<div class="handle-apply-attachments__label">附件</div>
<AttachmentUploadField
:model-value="handleDetail?.attachmentUrls || ''"
disabled
compact
/>
</div>
</div>
<!-- 审批记录 -->
<div class="handle-section">
<BxApprovalFlowTabs
section-title="审批记录"
:nodes="handleApprovalFlowNodes"
:history="handleApprovalHistoryItems"
/>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="handleVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="danger" class="btn-ghost" :loading="submitting" @click="handleReject">
<el-icon><CloseBold /></el-icon>驳回
</el-button>
<el-button
v-if="!isDisburseNode"
type="primary"
class="btn-main"
:loading="submitting"
@click="handleApprove"
>
<el-icon><Check /></el-icon>通过
</el-button>
<el-button
v-else
type="primary"
class="btn-main"
:loading="submitting"
@click="handleDisburse"
>
<el-icon><Check /></el-icon>确认发放
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import mittBus from '@/utils/sys/mittBus'
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Close, CloseBold, Filter, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import PlPlanDocumentAttachmentCell from '@/components/business/pl-plan-document-attachment-cell/index.vue'
import PlPlanTypeTabs from '@/components/business/pl-plan-type-tabs/index.vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import ViewKvGrid from '@/components/business/system-view-dialog/ViewKvGrid.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { plVehicleRechargeApprovalColumnFilterDefs } from '@/views/pl/etc-vehicle-recharge-apply/columnFilterDefs'
import {
vehicleApi,
vehicleRechargeApplyApi,
vehicleRechargeApprovalApi,
type PlanDocumentAttachmentItem,
type Vehicle,
type VehicleRechargeApply
} from '@/api/pl'
import { pickBxPageResult } from '@/utils/http/bx'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import {
buildVehicleTrailerLookup,
formatPlDateTime,
formatPlVehiclePlateDisplay,
getPlRechargeApplyStatusLabel,
getPlRechargeApplyStatusTagType,
getPlRechargeTypeLabel,
getPlRechargeTypeTagType,
mapVehicleRechargeApprovalFlowToNodes,
PL_GAS_STATION_PRESETS
} from '@/utils/pl-display'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import { useMenuStore } from '@/store/modules/menu'
const {
filters,
hasActiveFilters,
setFilterState,
clearAllFilters,
isFilterActive,
toQueryParams
} = useServerColumnFilters(plVehicleRechargeApprovalColumnFilterDefs, {
onChange: () => {
pager.page = 1
fetchData()
}
})
function enumOptionsFor(prop: string) {
const def = plVehicleRechargeApprovalColumnFilterDefs.find((d) => d.prop === prop)
return (def?.enumOptions || []).map((o) => ({ ...o, count: 0 }))
}
const APPROVAL_TABS = [
{ label: '待我审批', name: 'pending' },
{ label: '我已办', name: 'done' }
]
const menuStore = useMenuStore()
async function refreshListAndBadges() {
await fetchData()
void refreshMenuBadges(menuStore.menuList)
}
const DEFAULT_APPROVAL_OPINION = '同意'
const loading = ref(false)
const submitting = ref(false)
const activeTab = ref<'pending' | 'done'>('pending')
const tableData = ref<VehicleRechargeApply[]>([])
const selectedRows = ref<VehicleRechargeApply[]>([])
const pager = reactive({ page: 1, pageSize: 20, total: 0 })
const vehicleOptions = ref<Vehicle[]>([])
const vehicleTrailerLookup = computed(() => buildVehicleTrailerLookup(vehicleOptions.value))
const viewVisible = ref(false)
const viewDetail = ref<VehicleRechargeApply | null>(null)
const handleVisible = ref(false)
const handleDetail = ref<VehicleRechargeApply | null>(null)
let viewRequestId = 0
let handleRequestId = 0
watch(viewVisible, (visible) => {
if (!visible) viewRequestId += 1
}, { flush: 'sync' })
watch(handleVisible, (visible) => {
if (!visible) handleRequestId += 1
}, { flush: 'sync' })
const opinion = ref(DEFAULT_APPROVAL_OPINION)
const disburseForm = reactive({
rechargeDate: '',
amount: null as number | null,
cardNo: '',
gasStation: '',
attachmentUrls: ''
})
const disburseAttachmentRef = ref<InstanceType<typeof AttachmentUploadField>>()
const canApproveSelected = computed(
() => activeTab.value === 'pending' && selectedRows.value.length === 1
)
const isDisburseNode = computed(
() => (handleDetail.value?.currentNodeActionType || 'approve') === 'disburse'
)
const viewApprovalFlowNodes = computed(() =>
mapVehicleRechargeApprovalFlowToNodes(viewDetail.value?.approvalFlow || [])
)
const viewApprovalHistoryItems = computed(() =>
mapVehicleRechargeApprovalFlowToNodes(viewDetail.value?.approvalHistory || [], {
withRoundPrefix: true
})
)
const handleApprovalFlowNodes = computed(() =>
mapVehicleRechargeApprovalFlowToNodes(handleDetail.value?.approvalFlow || [])
)
const handleApprovalHistoryItems = computed(() =>
mapVehicleRechargeApprovalFlowToNodes(handleDetail.value?.approvalHistory || [], {
withRoundPrefix: true
})
)
const handleApplyDetailFields = computed((): ViewField[] => {
const d = handleDetail.value
if (!d) return []
return [
{ label: '申请单号', value: d.applyNo },
{ label: '申请人', value: d.applicantName || '—' },
{
label: '车牌号',
value: formatPlVehiclePlateDisplay(d.licensePlate, undefined, vehicleTrailerLookup.value)
},
{
label: '充值类型',
value: getPlRechargeTypeLabel(d.rechargeType),
tagType: getPlRechargeTypeTagType(d.rechargeType)
},
{ label: '申请日期', value: d.applyDate },
{ label: '期初公里数', value: d.odometerKm != null ? String(d.odometerKm) : '—' },
{
label: '状态',
value: getPlRechargeApplyStatusLabel(d.status),
tagType: getPlRechargeApplyStatusTagType(d.status)
},
{ label: '创建时间', value: formatPlDateTime(d.createTime) },
{ label: '备注', value: d.remark || '—', span: true }
]
})
function toRechargeAttachmentItems(raw?: string | null): PlanDocumentAttachmentItem[] {
return String(raw || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((url, index) => ({
url,
label: `附件${index + 1}`
}))
}
function resetOpinionForDetail(detail: VehicleRechargeApply | null) {
const disburse = (detail?.currentNodeActionType || 'approve') === 'disburse'
opinion.value = disburse ? '' : DEFAULT_APPROVAL_OPINION
}
const viewSections = computed(() => {
const d = viewDetail.value
if (!d) return []
const record = d.record
const sections: Array<Record<string, unknown>> = [
{
title: '申请信息',
fields: [
{ label: '申请单号', value: d.applyNo },
{
label: '车牌号',
value: formatPlVehiclePlateDisplay(d.licensePlate, undefined, vehicleTrailerLookup.value)
},
{
label: '充值类型',
value: getPlRechargeTypeLabel(d.rechargeType),
tagType: getPlRechargeTypeTagType(d.rechargeType)
},
{ label: '申请日期', value: d.applyDate },
{ label: '期初公里数', value: d.odometerKm != null ? String(d.odometerKm) : '—' },
{ label: '申请人', value: d.applicantName || '—' },
{
label: '状态',
value: getPlRechargeApplyStatusLabel(d.status),
tagType: getPlRechargeApplyStatusTagType(d.status)
},
{ label: '当前节点', value: d.currentNodeName || '—' },
{ label: '备注', value: d.remark || '—', span: 2 as const }
]
},
{ title: '申请附件', fields: [], slot: 'apply-attachments' },
{ title: '', fields: [], slot: 'approval-flow' }
]
if (record) {
sections.push({
title: '发放信息',
fields: [
{ label: '充值订单号', value: record.rechargeOrderNo || '—', long: false },
{ label: '充值日期', value: record.rechargeDate || '—' },
{
label: '充值金额',
value: record.amount != null ? `${Number(record.amount).toFixed(2)} 元` : '—'
},
{ label: '卡号', value: record.cardNo || '—', long: false, fullSpan: false },
{ label: '加油站/渠道', value: record.gasStation || '—' },
{ label: '发放人', value: record.createByName || '—' },
{ label: '备注', value: record.remark || '—', span: true }
],
slotTitle: '发放附件',
slot: 'record-attachments'
})
} else {
sections.push({
title: '发放信息',
fields: [{ label: '发放信息', value: '暂无', span: true }]
})
}
return sections
})
async function loadVehicleOptions() {
try {
vehicleOptions.value = await fetchAllPagedRecords<Vehicle>(async (page, pageSize) => {
const res = await vehicleApi.list({ page, pageSize })
return pickBxPageResult<Vehicle>(res)
})
} catch {
vehicleOptions.value = []
}
}
async function fetchData() {
loading.value = true
try {
const api = activeTab.value === 'pending' ? vehicleRechargeApprovalApi.pending : vehicleRechargeApprovalApi.done
const params: Record<string, any> = {
page: pager.page,
pageSize: pager.pageSize,
...toQueryParams()
}
delete params.rechargeTypeNe
delete params.rechargeTypeIn
params.rechargeType = 'etc'
const res = await api(params)
const data: any = (res as any)?.data ?? res
tableData.value = data?.items || []
pager.total = data?.total || 0
mittBus.emit('rechargeSummaryRefresh')
selectedRows.value = []
} finally {
loading.value = false
}
}
function handleTabChange(name: string) {
activeTab.value = name === 'done' ? 'done' : 'pending'
pager.page = 1
fetchData()
}
async function openView(row: VehicleRechargeApply) {
const requestId = ++viewRequestId
viewDetail.value = row
viewVisible.value = true
try {
const res = await vehicleRechargeApplyApi.get(row.id)
if (requestId === viewRequestId && viewVisible.value) viewDetail.value = (res as any)?.data ?? res
} catch {
// keep list row as fallback
}
}
async function openHandleDialog(row: VehicleRechargeApply) {
const requestId = ++handleRequestId
handleDetail.value = row
resetOpinionForDetail(row)
disburseForm.rechargeDate = new Date().toISOString().slice(0, 10)
disburseForm.amount = null
disburseForm.cardNo = ''
disburseForm.gasStation = ''
disburseForm.attachmentUrls = ''
handleVisible.value = true
try {
const res = await vehicleRechargeApplyApi.get(row.id)
if (requestId !== handleRequestId || !handleVisible.value) return
handleDetail.value = (res as any)?.data ?? res
resetOpinionForDetail(handleDetail.value)
} catch {
// keep list row as fallback
}
}
async function handleApprove() {
if (!handleDetail.value) return
submitting.value = true
try {
await vehicleRechargeApprovalApi.approve(handleDetail.value.id, {
opinion: opinion.value.trim() || DEFAULT_APPROVAL_OPINION
})
ElMessage.success('已通过')
handleVisible.value = false
viewVisible.value = false
await refreshListAndBadges()
} finally {
submitting.value = false
}
}
async function handleReject() {
if (!handleDetail.value) return
const text = opinion.value.trim()
if (!text || text === DEFAULT_APPROVAL_OPINION) {
ElMessage.warning('驳回时请填写备注')
return
}
await ElMessageBox.confirm('确定驳回该申请吗?', '提示', { type: 'warning' })
submitting.value = true
try {
await vehicleRechargeApprovalApi.reject(handleDetail.value.id, { opinion: text })
ElMessage.success('已驳回')
handleVisible.value = false
viewVisible.value = false
await refreshListAndBadges()
} finally {
submitting.value = false
}
}
async function handleDisburse() {
if (!handleDetail.value) return
if (!disburseForm.rechargeDate || !disburseForm.amount || disburseForm.amount <= 0) {
ElMessage.warning('请填写充值日期和金额')
return
}
if (disburseAttachmentRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitting.value = true
try {
await vehicleRechargeApprovalApi.disburse(handleDetail.value.id, {
applyId: handleDetail.value.id,
licensePlate: handleDetail.value.licensePlate,
rechargeType: 'etc',
rechargeDate: disburseForm.rechargeDate,
amount: disburseForm.amount,
cardNo: disburseForm.cardNo || undefined,
gasStation: disburseForm.gasStation || undefined,
attachmentUrls: disburseForm.attachmentUrls || undefined
})
ElMessage.success('发放登记成功')
handleVisible.value = false
viewVisible.value = false
await refreshListAndBadges()
} finally {
submitting.value = false
}
}
onMounted(() => {
loadVehicleOptions()
fetchData()
})
</script>
<style scoped lang="scss">
.handle-section {
& + & {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--mint-border, #e2e8f0);
}
&__title {
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
color: var(--mint-text, #1e293b);
line-height: 1.4;
&::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
margin-right: 8px;
vertical-align: -2px;
border-radius: 2px;
background: var(--primary, #10b981);
}
}
}
.handle-apply-attachments {
margin-top: 14px;
&__label {
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--mint-text-muted, #64748b);
line-height: 1.4;
}
}
/* 四列栅格中占两列(如:加油站右侧的备注) */
.form-grid-4 > .form-col-span-half {
grid-column: span 2;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,663 @@
<!-- ETC充值审批人设置 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="wf-info-bar">
<span><b>流程名称:</b>{{ wfDef.wfName || '-' }}</span>
<span>
<b>状态:</b>
<el-tag :type="statusTagType(wfDef.status)" size="small">
{{ statusLabel(wfDef.status) }}
</el-tag>
</span>
<span class="form-tip">ETC 与油卡共用同一套审批人配置</span>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="节点名称/审批人"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>新增节点
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length !== 1"
@click="handleToolbarEdit"
>
<el-icon><EditPen /></el-icon>编辑
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
:title="selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0]) ? CREATOR_DELETE_TIP : ''"
:loading="loadingMap.rowDelete"
@click="handleToolbarDelete"
>
<el-icon><Delete /></el-icon>删除
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="handleToolbarMoveUp">
<el-icon><ArrowUp /></el-icon>上移
</el-button>
<el-button class="btn-ghost" :disabled="selectedRows.length !== 1" @click="handleToolbarMoveDown">
<el-icon><ArrowDown /></el-icon>下移
</el-button>
<el-button class="btn-ghost" :loading="loadingMap.save" @click="saveNodes">
<el-icon><Upload /></el-icon>保存配置
</el-button>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="loadWfDef"
@reset-columns="resetToDefault"
/>
</div>
</div>
<div class="list-body list-wrap">
<div class="list-table-viewport-anchor">
<ListTable
table-key="pl-etc-vehicle-recharge-workflow"
class="list-table"
:data="filteredNodes"
v-loading="loading"
stripe
@selection-change="onSelectionChange"
@row-dblclick="openEditDialog"
>
<el-table-column type="selection" width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column
prop="nodeName"
label="节点名称"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">节点名称</span>
<ColumnHeaderFilter
prop="nodeName"
label="节点名称"
type="text"
:is-active="isFilterActive('nodeName')"
:model-value="filters.nodeName || null"
@update:model-value="(v) => setFilterState('nodeName', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
prop="actionType"
label="节点动作"
min-width="120"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">节点动作</span>
<ColumnHeaderFilter
prop="actionType"
label="节点动作"
type="enum"
:enum-options="collectUniqueOptions('actionType')"
:is-active="isFilterActive('actionType')"
:model-value="filters.actionType || null"
@update:model-value="(v) => setFilterState('actionType', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag
:type="(row.actionType || row.action_type || 'approve') === 'disburse' ? 'success' : 'primary'"
size="small"
>
{{ (row.actionType || row.action_type || 'approve') === 'disburse' ? '发放登记' : '审批' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('nodeOrder')"
prop="nodeOrder"
label="节点顺序"
min-width="100"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">节点顺序</span>
<ColumnHeaderFilter
prop="nodeOrder"
label="节点顺序"
type="number"
:is-active="isFilterActive('nodeOrder')"
:model-value="filters.nodeOrder || null"
@update:model-value="(v) => setFilterState('nodeOrder', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('approverName')"
prop="approverName"
label="审批人"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">审批人</span>
<ColumnHeaderFilter
prop="approverName"
label="审批人"
type="text"
:is-active="isFilterActive('approverName')"
:model-value="filters.approverName || null"
@update:model-value="(v) => setFilterState('approverName', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('configStatus')"
prop="configStatus"
label="配置状态"
min-width="110"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">配置状态</span>
<ColumnHeaderFilter
prop="configStatus"
label="配置状态"
type="enum"
:enum-options="collectUniqueOptions('configStatus')"
:is-active="isFilterActive('configStatus')"
:model-value="filters.configStatus || null"
@update:model-value="(v) => setFilterState('configStatus', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag :type="(row.approverId ?? row.approver_id) ? 'success' : 'warning'" size="small">
{{ (row.approverId ?? row.approver_id) ? '已配置' : '未配置' }}
</el-tag>
</template>
</el-table-column>
</ListTable>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
</div>
</div>
</section>
<el-dialog
v-model="dialogs.addNode"
title="新增节点"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="addFormRef"
:model="formData"
:rules="formRules"
label-position="top"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="节点名称" prop="nodeName">
<el-input v-model="formData.nodeName" placeholder="请输入节点名称" />
</el-form-item>
<el-form-item label="节点顺序">
<el-input-number v-model="formData.nodeOrder" controls-position="right" :min="1" style="width: 100%" />
</el-form-item>
<el-form-item label="节点动作" prop="actionType">
<el-select v-model="formData.actionType" placeholder="请选择" style="width: 100%">
<el-option label="审批" value="approve" />
<el-option label="发放登记" value="disburse" />
</el-select>
</el-form-item>
<el-form-item label="审批人" style="grid-column: 1 / -1">
<el-select
v-model="formData.approverId"
placeholder="请选择审批人"
clearable
filterable
style="width: 100%"
@change="handleApproverChange"
>
<el-option v-for="u in userOptions" :key="u.id" :label="u.displayName" :value="u.id" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.addNode = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="loadingMap.addNodeSubmit" @click="submitAddNode">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="dialogs.editNode"
title="编辑节点"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="editFormRef"
:model="formData"
:rules="formRules"
label-position="top"
class="bx-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="节点名称" prop="nodeName">
<el-input v-model="formData.nodeName" placeholder="请输入节点名称" />
</el-form-item>
<el-form-item label="节点顺序">
<el-input-number v-model="formData.nodeOrder" controls-position="right" :min="1" style="width: 100%" />
</el-form-item>
<el-form-item label="节点动作" prop="actionType">
<el-select v-model="formData.actionType" placeholder="请选择" style="width: 100%">
<el-option label="审批" value="approve" />
<el-option label="发放登记" value="disburse" />
</el-select>
</el-form-item>
<el-form-item label="审批人" style="grid-column: 1 / -1">
<el-select
v-model="formData.approverId"
placeholder="请选择审批人"
clearable
filterable
style="width: 100%"
@change="handleApproverChange"
>
<el-option v-for="u in userOptions" :key="u.id" :label="u.displayName" :value="u.id" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.editNode = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="loadingMap.editNodeSubmit" @click="submitEditNode">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { useClientColumnFilters } from '@/composables/useClientColumnFilters'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
Plus,
EditPen,
Delete,
Close,
Check,
Upload,
ArrowUp,
ArrowDown
} from '@element-plus/icons-vue'
import { workflowApi } from '@/api/bx'
import { fetchUserOptions } from '@/api/admin'
import { statusLabel, statusTagType } from '@/utils/bx/format'
import { plVehicleRechargeWorkflowColumnFilterDefs } from '@/views/pl/etc-vehicle-recharge-apply/columnFilterDefs'
type DialogKey = 'addNode' | 'editNode'
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
addNode: false,
editNode: false
})
const quickSearchKeyword = ref('')
const appliedQuickSearch = ref('')
const listColumnDefs = [
{ prop: 'nodeOrder', label: '节点顺序' },
{ prop: 'approverName', label: '审批人' },
{ prop: 'configStatus', label: '配置状态' }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-etc-vehicle-recharge-workflow-list-columns', listColumnDefs)
const loading = ref(false)
const selectedRows = ref<Record<string, any>[]>([])
const wfDef = ref<Record<string, any>>({})
const allNodes = computed(() => wfDef.value.nodes || [])
const quickFilteredNodes = computed(() => {
const kw = appliedQuickSearch.value.trim().toLowerCase()
if (!kw) return allNodes.value
return allNodes.value.filter(
(n: any) =>
String(n.nodeName || '')
.toLowerCase()
.includes(kw) ||
String(n.approverName || '')
.toLowerCase()
.includes(kw)
)
})
const {
filters,
filteredRows: filteredNodes,
setFilterState,
isFilterActive,
collectUniqueOptions
} = useClientColumnFilters(quickFilteredNodes, plVehicleRechargeWorkflowColumnFilterDefs)
const nodes = allNodes
const userOptions = ref<
{ id: string; realName: string; username: string; roles: string; displayName: string }[]
>([])
const formData = reactive({
nodeName: '',
nodeOrder: 1,
actionType: 'approve' as 'approve' | 'disburse',
approverId: null as string | null,
approverName: ''
})
const formRules = {
nodeName: [{ required: true, message: '节点名称不能为空', trigger: 'blur' }],
actionType: [{ required: true, message: '请选择节点动作', trigger: 'change' }]
}
const addFormRef = ref<FormInstance>()
const editFormRef = ref<FormInstance>()
async function loadUsers() {
try {
const res = await fetchUserOptions()
const data: any = (res as any)?.data ?? res ?? []
userOptions.value = (Array.isArray(data) ? data : []).map((u: any) => {
const name = u.label || u.nickName || ''
const account = u.userNo || ''
return {
id: String(u.value ?? u.id ?? ''),
realName: name,
username: account,
roles: '',
displayName: account ? `${name}(${account})` : name
}
})
} catch {
userOptions.value = []
}
}
async function loadWfDef() {
loading.value = true
try {
const res = await workflowApi.getByBizType('vehicle_recharge')
wfDef.value = res.data ?? {}
} catch {
wfDef.value = {}
} finally {
loading.value = false
}
}
onMounted(() => {
loadWfDef()
loadUsers()
})
function onSelectionChange(rows: Record<string, any>[]) {
selectedRows.value = rows
}
function handleQuickSearch() {
appliedQuickSearch.value = quickSearchKeyword.value.trim()
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
appliedQuickSearch.value = ''
}
function handleToolbarEdit() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
return
}
openEditDialog(selectedRows.value[0])
}
function handleToolbarDelete() {
if (selectedRows.value.length !== 1) {
ElMessage.warning('请先勾选一条节点')
return
}
deleteNode(selectedRows.value[0])
}
function handleToolbarMoveUp() {
if (selectedRows.value.length !== 1) return
moveUp(selectedRows.value[0])
}
function handleToolbarMoveDown() {
if (selectedRows.value.length !== 1) return
moveDown(selectedRows.value[0])
}
function buildNodePayload(list: Record<string, any>[]) {
return list.map((n, i) => ({
id: n.id != null ? String(n.id) : undefined,
nodeName: n.nodeName,
nodeOrder: i + 1,
actionType: n.actionType ?? n.action_type ?? 'approve',
approverId: n.approverId ?? n.approver_id ?? null,
approverName: n.approverName ?? n.approver_name ?? ''
}))
}
async function saveNodes() {
loadingMap.save = true
try {
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(nodes.value))
ElMessage.success('保存成功')
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '保存失败')
} finally {
loadingMap.save = false
}
}
function openAddDialog() {
Object.assign(formData, {
nodeName: '',
nodeOrder: nodes.value.length + 1,
actionType: 'approve',
approverId: null,
approverName: ''
})
dialogs.addNode = true
}
function openEditDialog(node: Record<string, any>) {
Object.assign(formData, {
nodeName: node.nodeName,
nodeOrder: node.nodeOrder,
actionType: (node.actionType ?? node.action_type ?? 'approve') as 'approve' | 'disburse',
approverId: node.approverId ?? node.approver_id ?? null,
approverName: node.approverName ?? node.approver_name ?? ''
})
dialogs.editNode = true
}
function handleApproverChange(userId: string | null) {
if (userId == null) {
formData.approverName = ''
return
}
const found = userOptions.value.find((u) => u.id === userId)
formData.approverName = found ? found.realName : ''
}
async function submitAddNode() {
const valid = await addFormRef.value?.validate().catch(() => false)
if (!valid) return
loadingMap.addNodeSubmit = true
try {
const newNodes = [
...nodes.value,
{
nodeName: formData.nodeName,
nodeOrder: formData.nodeOrder,
actionType: formData.actionType,
approverId: formData.approverId,
approverName: formData.approverName
}
]
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
ElMessage.success('新增成功')
dialogs.addNode = false
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '新增失败')
} finally {
loadingMap.addNodeSubmit = false
}
}
async function submitEditNode() {
const valid = await editFormRef.value?.validate().catch(() => false)
if (!valid) return
loadingMap.editNodeSubmit = true
try {
const newNodes = nodes.value.map((n: Record<string, any>) =>
n.nodeOrder === formData.nodeOrder
? {
...n,
nodeName: formData.nodeName,
actionType: formData.actionType,
approverId: formData.approverId,
approverName: formData.approverName
}
: n
)
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
ElMessage.success('编辑成功')
dialogs.editNode = false
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '编辑失败')
} finally {
loadingMap.editNodeSubmit = false
}
}
async function deleteNode(node: Record<string, any>) {
if (!canDeleteByCreator(node)) {
ElMessage.warning(CREATOR_DELETE_TIP)
return
}
await ElMessageBox.confirm(`确定要删除节点「${node.nodeName}」吗?`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
})
loadingMap.rowDelete = true
try {
const newNodes = nodes.value.filter((n: Record<string, any>) => n.id !== node.id)
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
ElMessage.success('删除成功')
await loadWfDef()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e.message || '删除失败')
} finally {
loadingMap.rowDelete = false
}
}
async function moveUp(node: Record<string, any>) {
const idx = nodes.value.findIndex((n: Record<string, any>) => n.id === node.id)
if (idx <= 0) return
const arr = [...nodes.value]
const temp = arr[idx]
arr[idx] = arr[idx - 1]
arr[idx - 1] = temp
arr.forEach((n: Record<string, any>, i: number) => (n.nodeOrder = i + 1))
try {
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(arr))
ElMessage.success('上移成功')
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '上移失败')
}
}
async function moveDown(node: Record<string, any>) {
const arr = [...nodes.value]
const idx = arr.findIndex((n: Record<string, any>) => n.id === node.id)
if (idx < 0 || idx >= arr.length - 1) return
const temp = arr[idx]
arr[idx] = arr[idx + 1]
arr[idx + 1] = temp
arr.forEach((n: Record<string, any>, i: number) => (n.nodeOrder = i + 1))
try {
await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(arr))
ElMessage.success('下移成功')
await loadWfDef()
} catch (e: any) {
ElMessage.error(e.message || '下移失败')
}
}
</script>
@@ -219,6 +219,18 @@
min-width="160"
show-overflow-tooltip
/>
<el-table-column label="操作" fixed="right" min-width="140" align="center">
<template #default="{ row }">
<el-button
link
type="primary"
:loading="maintainLoadingId === row.id"
@click="handleMaintainCert(row)"
>
<el-icon><EditPen /></el-icon>维护证件
</el-button>
</template>
</el-table-column>
</ListTable>
</div>
<div ref="docReminderBelowRef">
@@ -230,6 +242,19 @@
</div>
</div>
</section>
<PlVehicleFormDialog
v-model="vehicleFormVisible"
:vehicle-type="vehicleFormType"
:vehicle-id="editingOwnerId"
@saved="handleOwnerFormSaved"
/>
<PlPersonnelFormDialog
v-model="personnelFormVisible"
:affiliation="personnelFormAffiliation"
:personnel-id="editingOwnerId"
@saved="handleOwnerFormSaved"
/>
</div>
</template>
@@ -238,8 +263,22 @@ import ListQuickSearchInput from '@/components/business/list-quick-search-input/
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import { CircleCheck, Filter } from '@element-plus/icons-vue'
import { inventoryApi, type DocumentReminder } from '@/api/pl'
import PlVehicleFormDialog from '@/components/business/pl-vehicle-form-dialog/index.vue'
import PlPersonnelFormDialog from '@/components/business/pl-personnel-form-dialog/index.vue'
import { CircleCheck, Filter, EditPen } from '@element-plus/icons-vue'
import {
inventoryApi,
vehicleApi,
personnelApi,
type DocumentReminder,
type VehicleDetail,
type PersonnelDetail
} from '@/api/pl'
import { pickBxData } from '@/utils/http/bx'
import {
resolveDocumentReminderFormMode,
DOCUMENT_REMINDER_JUMP_UNSUPPORTED
} from '@/utils/pl-document-reminder-jump'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { useColumnSort } from '@/composables/useColumnSort'
@@ -330,6 +369,13 @@ const { tableHeight, recalc: recalcTableHeight } = useListTableViewportHeight({
tableRef: docReminderTableRef
})
const maintainLoadingId = ref<string | null>(null)
const vehicleFormVisible = ref(false)
const personnelFormVisible = ref(false)
const vehicleFormType = ref<'self' | 'carrier'>('carrier')
const personnelFormAffiliation = ref<'self' | 'carrier'>('self')
const editingOwnerId = ref<string | null>(null)
const loading = ref(false)
const resolveLoading = ref(false)
const reminderList = ref<DocumentReminder[]>([])
@@ -445,6 +491,59 @@ function handleToolbarResolve() {
handleResolve(selectedRow.value)
}
function handleOwnerFormSaved() {
ElMessage.success('保存成功')
fetchData()
}
async function handleMaintainCert(row: DocumentReminder) {
const ownerId = String(row.ownerId || '').trim()
const ownerType = String(row.ownerType || '').trim()
if (!ownerId || !ownerType) {
ElMessage.warning('缺少主体信息,无法维护证件')
return
}
maintainLoadingId.value = row.id
try {
let mode = null as ReturnType<typeof resolveDocumentReminderFormMode>
if (ownerType === 'vehicle') {
const res = await vehicleApi.get(ownerId)
const detail = pickBxData<VehicleDetail>(res) || (res as any)?.data || res
const vehicleType = String((detail as VehicleDetail)?.vehicleType || '')
mode = resolveDocumentReminderFormMode({ ownerType, vehicleType })
} else if (ownerType === 'personnel') {
const res = await personnelApi.get(ownerId)
const detail = pickBxData<PersonnelDetail>(res) || (res as any)?.data || res
const personnelAffiliation = String(
(detail as PersonnelDetail)?.personnelAffiliation || ''
)
mode = resolveDocumentReminderFormMode({ ownerType, personnelAffiliation })
} else {
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
return
}
if (!mode) {
ElMessage.warning(DOCUMENT_REMINDER_JUMP_UNSUPPORTED)
return
}
editingOwnerId.value = ownerId
vehicleFormVisible.value = false
personnelFormVisible.value = false
await nextTick()
if (mode.kind === 'vehicle') {
vehicleFormType.value = mode.vehicleType
vehicleFormVisible.value = true
} else {
personnelFormAffiliation.value = mode.affiliation
personnelFormVisible.value = true
}
} catch {
ElMessage.error('无法打开主体档案,请确认数据存在且有权限')
} finally {
maintainLoadingId.value = null
}
}
onMounted(() => {
fetchData()
nextTick(() => recalcTableHeight())
@@ -562,6 +562,7 @@ import { useColumnFacetOptions } from '@/composables/useColumnFacetOptions'
import { plPersonnelColumnFilterDefs } from '@/views/pl/personnel/columnFilterDefs'
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
import { useListMediaActive } from '@/composables/useListMediaActive'
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
import { pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
@@ -1172,6 +1173,10 @@ async function handleEdit(row: Personnel) {
}
}
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Personnel)
})
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateCerts()) return
+12 -5
View File
@@ -86,7 +86,13 @@
header-align="center"
:index="getDisplayRowIndex"
/>
<el-table-column prop="planNo" label="计划编号" min-width="180" show-overflow-tooltip>
<el-table-column
v-if="isColumnVisible('planNo')"
prop="planNo"
label="计划编号"
min-width="180"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">计划编号</span>
@@ -1075,6 +1081,7 @@ const planList = ref<Plan[]>([])
const PRICE_COLUMN_PROPS = new Set(['unitPrice', 'amount'])
const allListColumnDefs = [
{ prop: 'planNo', label: '计划编号' },
{ prop: 'planDate', label: '计划日期' },
{ prop: 'deliveryTimes', label: '送货时间' },
{ prop: 'archiveStatus', label: '归档状态' },
@@ -1116,7 +1123,7 @@ const {
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-plan-archive-list-columns-v16', allListColumnDefs)
} = usePersistedListColumns('pl-plan-archive-list-columns-v17', allListColumnDefs)
const {
filters,
@@ -1165,7 +1172,7 @@ function enumOptionsFor(prop: string) {
}
const visibleFilterProps = computed(() => {
const props: string[] = ['planNo']
const props: string[] = []
for (const def of listColumnDefs.value) {
if (isColumnVisible(def.prop)) props.push(def.prop)
}
@@ -1475,10 +1482,10 @@ const planArchiveExportColumns = computed(() => {
const field = PLAN_ARCHIVE_EXPORT_COLUMN_FIELD_MAP[col] || col
if (!cols.includes(field)) cols.push(field)
}
if (!cols.includes('planNo')) cols.unshift('planNo')
if (isColumnVisible('planNo') && !cols.includes('planNo')) cols.unshift('planNo')
const planNoIdx = cols.indexOf('planNo')
if (!cols.includes('stopSegment')) {
cols.splice(planNoIdx + 1, 0, 'stopSegment')
cols.splice((planNoIdx >= 0 ? planNoIdx : -1) + 1, 0, 'stopSegment')
}
if (!cols.includes('rowNo')) cols.unshift('rowNo')
const modelIdx = cols.indexOf('model')
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
certArchiveContractListQuery,
filterAvailableBindingContracts
} from './cert-archive-contract-query'
test('证件建档合同查询必须带客户 ID,不能只靠前端再过滤最近合同', () => {
assert.deepEqual(certArchiveContractListQuery('cust-1', ' HT-01 '), {
pageNum: 1,
pageSize: 50,
keyword: 'HT-01',
customerId: 'cust-1',
excludeStatus: 'invalid',
includeProducts: false
})
assert.equal(certArchiveContractListQuery(''), null)
assert.equal(certArchiveContractListQuery(' '), null)
assert.deepEqual(certArchiveContractListQuery('cust-2', ' ').keyword, undefined)
})
test('已关联合同与不可选状态不进入添加列表', () => {
const rows = [
{ id: 'a', status: 'approved' },
{ id: 'b', status: 'executing' },
{ id: 'c', status: 'draft' },
{ id: 'd', status: 'approved' },
{ id: '', status: 'approved' }
]
assert.deepEqual(
filterAvailableBindingContracts(rows, ['d', '']).map((item) => item.id),
['a', 'b']
)
})
@@ -0,0 +1,49 @@
/** 证件建档添加合同时的 CRM 列表查询与本地过滤。 */
export interface CertArchiveContractListQuery {
pageNum: number
pageSize: number
keyword?: string
customerId: string
excludeStatus: 'invalid'
includeProducts: false
}
/** 已选客户后按客户 ID 分页查询,避免只取最近合同再前端过滤。 */
export function certArchiveContractListQuery(
customerId: string,
keyword = ''
): CertArchiveContractListQuery | null {
const id = String(customerId || '').trim()
if (!id) return null
const text = keyword.trim()
return {
pageNum: 1,
pageSize: 50,
keyword: text || undefined,
customerId: id,
excludeStatus: 'invalid',
includeProducts: false
}
}
const DEFAULT_STATUSES = ['approved', 'executing']
/** 去掉已关联合同和不在可选状态内的行。 */
export function filterAvailableBindingContracts<T extends { id?: string | number; status?: string }>(
rows: T[],
takenIds: Iterable<string>,
selectableStatuses: string[] = DEFAULT_STATUSES
): T[] {
const taken = new Set(
[...takenIds].map((id) => String(id || '').trim()).filter(Boolean)
)
return rows.filter((row) => {
const id = String(row.id || '').trim()
if (!id || taken.has(id)) return false
if (selectableStatuses.length && !selectableStatuses.includes(String(row.status || ''))) {
return false
}
return true
})
}
@@ -556,6 +556,7 @@ import SystemViewDialog, {
} from '@/components/business/system-view-dialog/index.vue'
import { loadPersonnelWithCerts } from '@/composables/usePlVehicleViewDetail'
import { useListMediaActive } from '@/composables/useListMediaActive'
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
import { pickBxData, pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
@@ -1248,6 +1249,10 @@ async function handleEdit(row: Vehicle) {
}
}
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Vehicle)
})
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateSubTables()) return
@@ -1,7 +1,6 @@
import type { ColumnFilterDef } from '@/composables/useClientColumnFilters'
const RECHARGE_TYPE_OPTIONS = [
{ value: 'etc', label: 'ETC' },
{ value: 'fuel', label: '油卡' },
{ value: 'shanxia', label: '山峡' },
{ value: 'bailian', label: '百链' }
@@ -10,12 +9,13 @@ const RECHARGE_TYPE_OPTIONS = [
const RECHARGE_STATUS_OPTIONS = [
{ value: 'draft', label: '草稿' },
{ value: 'in_approval', label: '审批中' },
{ value: 'pending', label: '发放中' },
{ value: 'rejected', label: '已驳回' },
{ value: 'disbursed', label: '已发放' },
{ value: 'cancelled', label: '已作废' }
]
/** ETC 申请列表列头漏斗(服务端)。 */
/** 油卡申请列表列头漏斗(服务端)。 */
export const plVehicleRechargeApplyColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyNo', label: '申请单号', type: 'text', queryKey: 'applyNo' },
{ prop: 'licensePlate', label: '车牌号', type: 'text', queryKey: 'licensePlate' },
@@ -52,7 +52,7 @@ export const plVehicleRechargeApplyColumnFilterDefs: ColumnFilterDef[] = [
}
]
/** ETC 审批列表列头漏斗(服务端;在申请 defs 基础上增加当前节点)。 */
/** 油卡审批列表列头漏斗(服务端;在申请 defs 基础上增加当前节点)。 */
export const plVehicleRechargeApprovalColumnFilterDefs: ColumnFilterDef[] = [
...plVehicleRechargeApplyColumnFilterDefs.filter((d) => d.prop !== 'createTime'),
{ prop: 'currentNodeName', label: '当前节点', type: 'text', queryKey: 'currentNodeName' },
@@ -65,7 +65,7 @@ export const plVehicleRechargeApprovalColumnFilterDefs: ColumnFilterDef[] = [
}
]
/** ETC 发放看板列头漏斗(服务端)。 */
/** 油卡发放看板列头漏斗(服务端)。 */
export const plVehicleRechargeDisburseColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'applyNo', label: '申请单号', type: 'text', queryKey: 'applyNo' },
{
@@ -111,7 +111,7 @@ export const plVehicleRechargeDisburseColumnFilterDefs: ColumnFilterDef[] = [
}
]
/** ETC 审批人设置(客户端)。 */
/** 油卡审批人设置(客户端)。 */
export const plVehicleRechargeWorkflowColumnFilterDefs: ColumnFilterDef[] = [
{ prop: 'nodeName', label: '节点名称', type: 'text' },
{
@@ -1,4 +1,4 @@
<!-- ETC/油卡充值申请 -->
<!-- 油卡充值申请 -->
<template>
<div class="page-container">
<section class="panel">
@@ -369,7 +369,6 @@
</el-form-item>
<el-form-item label="充值类型" prop="rechargeType">
<el-select v-model="formData.rechargeType" placeholder="请选择" style="width: 100%">
<el-option label="ETC" value="etc" />
<el-option label="油卡" value="fuel" />
<el-option label="山峡" value="shanxia" />
<el-option label="百链" value="bailian" />
@@ -716,6 +715,7 @@ function buildListQueryParams(): Record<string, any> {
const params: Record<string, any> = { ...toQueryParams(), ...toSortQueryParams() }
const keyword = quickSearchKeyword.value.trim()
if (keyword) params.q = keyword
params.rechargeTypeNe = 'etc'
return params
}
@@ -768,7 +768,7 @@ const formData = reactive({
applyNo: '',
applicantId: '',
licensePlate: '',
rechargeType: 'etc' as 'etc' | 'fuel' | 'shanxia' | 'bailian',
rechargeType: 'fuel' as 'etc' | 'fuel' | 'shanxia' | 'bailian',
applyDate: '',
odometerKm: null as number | null,
attachmentUrls: '',
@@ -1004,7 +1004,7 @@ function resetForm() {
formData.applyNo = ''
formData.applicantId = ''
formData.licensePlate = ''
formData.rechargeType = 'etc'
formData.rechargeType = 'fuel'
formData.applyDate = ''
formData.odometerKm = null
formData.attachmentUrls = ''
@@ -1244,7 +1244,9 @@ function applySummaryFilter() {
if (typeof plate !== 'string' || !['etc', 'fuel'].includes(String(kind))) return
Object.keys(filters).forEach((key) => delete filters[key])
filters.licensePlate = { text: plate }
filters.rechargeType = { text: '', selected: [String(kind)] }
if (String(kind) !== 'etc') {
filters.rechargeType = { text: '', selected: [String(kind)] }
}
quickSearchKeyword.value = ''
pagination.page = 1
const query = { ...route.query }
@@ -686,7 +686,8 @@
const res = await api({
page: pager.page,
pageSize: pager.pageSize,
...toQueryParams()
...toQueryParams(),
rechargeTypeNe: 'etc'
})
const data: any = (res as any)?.data ?? res
tableData.value = data?.items || []
@@ -1,4 +1,4 @@
<!-- ETC/油卡充值发放管理 -->
<!-- 油卡充值发放管理 -->
<template>
<div class="page-container">
<section class="panel">
@@ -460,7 +460,6 @@
</el-form-item>
<el-form-item label="充值类型" prop="rechargeType">
<el-select v-model="formData.rechargeType" placeholder="请选择" style="width: 100%">
<el-option label="ETC" value="etc" />
<el-option label="油卡" value="fuel" />
<el-option label="山峡" value="shanxia" />
<el-option label="百链" value="bailian" />
@@ -727,6 +726,7 @@ function buildListQueryParams(): Record<string, any> {
const params: Record<string, any> = { ...toQueryParams(), ...toSortQueryParams() }
const keyword = quickSearchKeyword.value.trim()
if (keyword) params.q = keyword
params.rechargeTypeNe = 'etc'
return params
}
@@ -780,7 +780,7 @@ const formData = reactive({
rechargeOrderNo: '',
applyId: '',
licensePlate: '',
rechargeType: 'etc' as 'etc' | 'fuel' | 'shanxia' | 'bailian',
rechargeType: 'fuel' as 'etc' | 'fuel' | 'shanxia' | 'bailian',
rechargeDate: '',
cardNo: '',
gasStation: '',
@@ -985,7 +985,7 @@ function resetForm() {
formData.rechargeOrderNo = ''
formData.applyId = ''
formData.licensePlate = ''
formData.rechargeType = 'etc'
formData.rechargeType = 'fuel'
formData.rechargeDate = ''
formData.cardNo = ''
formData.gasStation = ''
@@ -997,7 +997,7 @@ function resetForm() {
function handleDisburse(row: VehicleRechargeDisburseBoardItem) {
if (row.applyStatus === 'in_approval') {
ElMessage.warning('审批中的申请请到「ETC/油卡充值审批」菜单完成发放登记')
ElMessage.warning('审批中的申请请到「油卡充值审批」菜单完成发放登记')
return
}
resetForm()
@@ -1148,7 +1148,7 @@ async function handleSubmit() {
async function handleDelete(row: VehicleRechargeDisburseBoardItem) {
if (!row.recordId) return
await ElMessageBox.confirm('确定撤销该发放登记吗?关联申请将回退为待发放。', '提示', { type: 'warning' })
await ElMessageBox.confirm('确定撤销该发放登记吗?关联申请将回退为发放中。', '提示', { type: 'warning' })
deleteLoadingMap[row.recordId] = true
try {
await vehicleRechargeRecordApi.delete(row.recordId)
@@ -1,4 +1,4 @@
<!-- 工作流配置(ETC/油卡充值) -->
<!-- 工作流配置(油卡充值) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
@@ -11,6 +11,7 @@
{{ statusLabel(wfDef.status) }}
</el-tag>
</span>
<span class="form-tip">ETC 与油卡共用同一套审批人配置</span>
</div>
<div class="list-toolbar">
+5
View File
@@ -570,6 +570,7 @@ import SystemViewDialog, {
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import { useListMediaActive } from '@/composables/useListMediaActive'
import { useRouteEditIdOpen } from '@/composables/useRouteEditIdOpen'
import { pickBxData, pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
@@ -1303,6 +1304,10 @@ async function handleEdit(row: Vehicle) {
}
}
useRouteEditIdOpen(async (id) => {
await handleEdit({ id } as Vehicle)
})
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateSubTables()) return