fix(pl): 按车辆类型统一派车日历卡片配色以区分自有三方和待派车。
This commit is contained in:
1 parent
cb0344498a
commit
043e2ef661
11 files changed
+268
-27
No files matched your search
@@ -64,21 +64,24 @@ def product_options(q: Optional[str] = None, page: int = Query(1, ge=1),
|
||||
def create_record(body: CreateBody, db: Session = Depends(get_db),
|
||||
user: DbUserContext = Depends(get_db_current_user)):
|
||||
tenant = get_tenant_no(db, user.to_tenant_user_dict())
|
||||
return svc._row_to_dict(svc.create_record(db, tenant, user, body))
|
||||
row = svc.create_record(db, tenant, user, body)
|
||||
return svc._row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/{record_id}")
|
||||
def get_record(record_id: str, db: Session = Depends(get_db),
|
||||
user: DbUserContext = Depends(get_db_current_user)):
|
||||
tenant = get_tenant_no(db, user.to_tenant_user_dict())
|
||||
return svc._row_to_dict(svc.get_record(db, tenant, user, record_id))
|
||||
row = svc.get_record(db, tenant, user, record_id)
|
||||
return svc.serialize_records(db, tenant, user, [row])[0]
|
||||
|
||||
|
||||
@router.put("/{record_id}", dependencies=[Depends(require_manage)])
|
||||
def update_record(record_id: str, body: UpdateBody, db: Session = Depends(get_db),
|
||||
user: DbUserContext = Depends(get_db_current_user)):
|
||||
tenant = get_tenant_no(db, user.to_tenant_user_dict())
|
||||
return svc._row_to_dict(svc.update_record(db, tenant, user, record_id, body))
|
||||
row = svc.update_record(db, tenant, user, record_id, body)
|
||||
return svc._row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/{record_id}", dependencies=[Depends(require_manage)])
|
||||
|
||||
@@ -46,4 +46,4 @@ def get_record(record_id: str, db: Session = Depends(get_db),
|
||||
row = svc.get_record(db, tenant, user, record_id)
|
||||
if row.calendar_kind != "site":
|
||||
raise HTTPException(status_code=404, detail="派车记录不存在或无权访问")
|
||||
return svc._row_to_dict(row)
|
||||
return svc.serialize_records(db, tenant, user, [row])[0]
|
||||
@@ -28,11 +28,14 @@ def _calendar_kind(kind: Optional[str]) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _row_to_dict(row: PlDispatchCalendar) -> dict:
|
||||
def _row_to_dict(row: PlDispatchCalendar, vehicle_type: Optional[str] = None) -> dict:
|
||||
if vehicle_type is None:
|
||||
vehicle_type = getattr(row, "_response_vehicle_type", "unknown")
|
||||
return {
|
||||
"id": row.id,
|
||||
"dispatchDate": row.dispatch_date,
|
||||
"vehicleId": row.vehicle_id,
|
||||
"vehicleType": vehicle_type,
|
||||
"licensePlate": row.license_plate,
|
||||
"productId": row.product_id,
|
||||
"productName": row.product_name,
|
||||
@@ -53,6 +56,23 @@ def _query(db: Session, tenant_no: str, user: DbUserContext, model=PlDispatchCal
|
||||
return apply_pl_scope(query, model, user, db, tenant_no)
|
||||
|
||||
|
||||
def serialize_records(db: Session, tenant_no: str, user: DbUserContext, rows: list[PlDispatchCalendar]) -> list[dict]:
|
||||
"""仅批量读取当前页可见车辆类型;历史失效关联保留记录并回退中性色。"""
|
||||
vehicle_ids = {row.vehicle_id for row in rows if row.vehicle_id != PENDING_VEHICLE_ID}
|
||||
vehicle_types = {}
|
||||
if vehicle_ids:
|
||||
vehicles = _query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id.in_(vehicle_ids)).all()
|
||||
vehicle_types = {
|
||||
vehicle.id: vehicle.vehicle_type if vehicle.vehicle_type in ("self", "carrier") else "unknown"
|
||||
for vehicle in vehicles
|
||||
}
|
||||
return [
|
||||
_row_to_dict(row, "pending" if row.vehicle_id == PENDING_VEHICLE_ID
|
||||
else vehicle_types.get(row.vehicle_id, "unknown"))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _month_bounds(month: str):
|
||||
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", month or ""):
|
||||
raise HTTPException(status_code=422, detail="月份须为 YYYY-MM")
|
||||
@@ -76,7 +96,7 @@ def list_records(db: Session, tenant_no: str, user: DbUserContext, *, month: str
|
||||
rows = query.order_by(PlDispatchCalendar.create_time.desc(), PlDispatchCalendar.id.desc()).offset(
|
||||
(page - 1) * page_size
|
||||
).limit(page_size).all()
|
||||
return {"list": [_row_to_dict(row) for row in rows], "total": total}
|
||||
return {"list": serialize_records(db, tenant_no, user, rows), "total": total}
|
||||
|
||||
|
||||
def get_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str,
|
||||
@@ -91,14 +111,16 @@ def get_record(db: Session, tenant_no: str, user: DbUserContext, record_id: str,
|
||||
return row
|
||||
|
||||
|
||||
def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> dict:
|
||||
def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> tuple[dict, str]:
|
||||
if body.vehicle_id == PENDING_VEHICLE_ID:
|
||||
license_plate = "待派车"
|
||||
vehicle_type = "pending"
|
||||
else:
|
||||
vehicle = _query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id == body.vehicle_id).first()
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=422, detail="车辆不存在、已删除或无权访问,请重新选择")
|
||||
license_plate = vehicle.license_plate
|
||||
vehicle_type = vehicle.vehicle_type if vehicle.vehicle_type in ("self", "carrier") else "unknown"
|
||||
product = (
|
||||
_query(db, tenant_no, user, CrmProduct)
|
||||
.filter(CrmProduct.id == body.product_id, CrmProduct.status == "on")
|
||||
@@ -106,7 +128,7 @@ def _resolve_snapshots(db: Session, tenant_no: str, user: DbUserContext, body: C
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=422, detail="产品不存在、已下架、已删除或无权访问,请重新选择")
|
||||
return {"license_plate": license_plate, "product_name": product.product_name}
|
||||
return {"license_plate": license_plate, "product_name": product.product_name}, vehicle_type
|
||||
|
||||
|
||||
def _site_document_type(origin: str, destination: str) -> Optional[str]:
|
||||
@@ -176,13 +198,15 @@ def _retry_deadlock(action):
|
||||
|
||||
@_retry_deadlock
|
||||
def create_record(db: Session, tenant_no: str, user: DbUserContext, body: CreateBody) -> PlDispatchCalendar:
|
||||
snapshots = _resolve_snapshots(db, tenant_no, user, body)
|
||||
snapshots, vehicle_type = _resolve_snapshots(db, tenant_no, user, body)
|
||||
row = PlDispatchCalendar(
|
||||
id=generate_uuid(), tenant_no=tenant_no, create_by=user.id,
|
||||
create_dept_id=user.dept_id or "", update_by=user.id,
|
||||
site_sync_enabled=1 if body.calendar_kind in ("acid", "alkali", "other") else 0,
|
||||
**body.model_dump(), **snapshots,
|
||||
)
|
||||
# 仅供保存响应使用的非持久化属性,复用提交前已校验的车辆类型。
|
||||
row._response_vehicle_type = vehicle_type
|
||||
try:
|
||||
db.add(row)
|
||||
db.flush()
|
||||
@@ -203,13 +227,15 @@ def update_record(db: Session, tenant_no: str, user: DbUserContext, record_id: s
|
||||
_ensure_manual_record(row)
|
||||
if body.calendar_kind != (row.calendar_kind or "acid"):
|
||||
raise HTTPException(status_code=422, detail="不能把派车计划改到另一本日历")
|
||||
values = {**body.model_dump(), **_resolve_snapshots(db, tenant_no, user, body)}
|
||||
snapshots, vehicle_type = _resolve_snapshots(db, tenant_no, user, body)
|
||||
values = {**body.model_dump(), **snapshots}
|
||||
# 兼容未提交新字段的旧客户端;显式空值仍可清空。
|
||||
if "document_type" not in body.model_fields_set:
|
||||
values.pop("document_type", None)
|
||||
for key, value in values.items():
|
||||
setattr(row, key, value)
|
||||
row.update_by = user.id
|
||||
row._response_vehicle_type = vehicle_type
|
||||
_sync_site_record(db, tenant_no, user, row)
|
||||
db.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -73,7 +73,7 @@ def test_crud_duplicate_trips_snapshots_and_soft_delete(db):
|
||||
assert result['licensePlate'] == '鄂E67890' and result['productName'] == '盐酸'
|
||||
assert result['origin'] == '武汉'
|
||||
assert result['calendarKind'] == 'acid'
|
||||
assert set(result) == {'id', 'dispatchDate', 'vehicleId', 'licensePlate', 'productId', 'productName', 'origin', 'destination', 'quantity', 'calendarKind', 'documentType', 'sourceRecordId', 'remark', 'createTime', 'updateTime'}
|
||||
assert set(result) == {'id', 'dispatchDate', 'vehicleId', 'vehicleType', 'licensePlate', 'productId', 'productName', 'origin', 'destination', 'quantity', 'calendarKind', 'documentType', 'sourceRecordId', 'remark', 'createTime', 'updateTime'}
|
||||
assert svc.delete_record(db, 't1', user(), first.id) == 1
|
||||
assert db.get(model, first.id).del_flag == 1
|
||||
assert svc.list_records(db, 't1', user(), month='2026-10')['total'] == 1
|
||||
@@ -460,3 +460,107 @@ def test_pending_vehicle_still_validates_product(db, product_id):
|
||||
assert exc.value.status_code == 422
|
||||
assert '产品' in exc.value.detail
|
||||
assert db.query(model).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['acid', 'alkali', 'other', 'site'])
|
||||
def test_vehicle_type_classification_is_scoped_and_current(db, monkeypatch, kind):
|
||||
model, _, _, svc = modules()
|
||||
db.add(PlVehicle(id='legacy', license_plate='待派车', vehicle_type='legacy',
|
||||
tenant_no='t1', create_by='u1'))
|
||||
ids = ['v1', 'v2', '__pending_vehicle__', 'v3', 'v4', 'missing', 'legacy']
|
||||
for vehicle_id in ids:
|
||||
db.add(model(id='type-' + vehicle_id, dispatch_date=date(2026, 10, 26),
|
||||
vehicle_id=vehicle_id, license_plate='待派车', product_id='p1',
|
||||
product_name='液碱', origin='', destination='', calendar_kind=kind,
|
||||
tenant_no='t1', create_by='u1'))
|
||||
db.commit()
|
||||
def types(caller):
|
||||
return {r['vehicleId']: r.get('vehicleType') for r in
|
||||
svc.list_records(db, 't1', caller, month='2026-10', calendar_kind=kind)['list']}
|
||||
expected = dict(zip(ids, ['self', 'carrier', 'pending', 'unknown', 'unknown', 'unknown', 'unknown']))
|
||||
assert types(user()) == expected
|
||||
import app.core.data_scope as scope
|
||||
monkeypatch.setattr(scope, '_resolve_user_role_scopes', lambda *a: [{'data_scope': '4', 'details': []}])
|
||||
assert types(user(role='admin')) == {**expected, 'v2': 'unknown'}
|
||||
db.get(PlVehicle, 'v1').vehicle_type = 'carrier'
|
||||
db.commit()
|
||||
assert types(user())['v1'] == 'carrier'
|
||||
|
||||
|
||||
def test_vehicle_types_use_one_query_for_current_page(db):
|
||||
from sqlalchemy import event
|
||||
model, _, _, svc = modules()
|
||||
for i in range(25):
|
||||
db.add(model(id=f'batch-{i:02}', dispatch_date=date(2026, 10, 26),
|
||||
vehicle_id='v1' if i < 20 else 'v2', license_plate='车牌',
|
||||
product_id='p1', product_name='液碱', origin='', destination='',
|
||||
tenant_no='t1', create_by='u1', create_time=datetime(2026, 10, 26, 0, i)))
|
||||
db.commit()
|
||||
statements = []
|
||||
def capture(conn, cursor, statement, parameters, context, executemany):
|
||||
if 'FROM pl_vehicle' in statement:
|
||||
statements.append((statement, parameters))
|
||||
event.listen(db.get_bind(), 'before_cursor_execute', capture)
|
||||
try:
|
||||
page = svc.list_records(db, 't1', user(), month='2026-10', page_size=5)
|
||||
assert [r.get('vehicleType') for r in page['list']] == ['carrier'] * 5
|
||||
assert len(statements) == 1
|
||||
assert 'v2' in statements[0][1] and 'v1' not in statements[0][1]
|
||||
statements.clear()
|
||||
assert svc.list_records(db, 't1', user(), month='2026-09')['list'] == []
|
||||
assert not statements
|
||||
finally:
|
||||
event.remove(db.get_bind(), 'before_cursor_execute', capture)
|
||||
|
||||
|
||||
def test_single_record_routes_return_vehicle_type(db):
|
||||
from app.api.pl import dispatch_calendar as api, site_dispatch_calendar as site_api
|
||||
_, Create, Update, _ = modules()
|
||||
created = api.create_record(Create(**payload()), db, user())
|
||||
assert created.get('vehicleType') == 'self'
|
||||
assert api.get_record(created['id'], db, user()).get('vehicleType') == 'self'
|
||||
updated = api.update_record(created['id'], Update(**payload(vehicleId='v2')), db, user())
|
||||
assert updated.get('vehicleType') == 'carrier'
|
||||
pending = api.create_record(Create(**payload(vehicleId='__pending_vehicle__', calendarKind='site')), db, user())
|
||||
assert pending.get('vehicleType') == 'pending'
|
||||
assert site_api.get_record(pending['id'], db, user()).get('vehicleType') == 'pending'
|
||||
with pytest.raises(ValidationError):
|
||||
Create(**payload(vehicleType='self'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action', ['create', 'update'])
|
||||
@pytest.mark.parametrize('vehicle_id,expected', [
|
||||
('v1', 'self'), ('v2', 'carrier'), ('__pending_vehicle__', 'pending'), ('legacy', 'unknown'),
|
||||
])
|
||||
def test_save_response_never_queries_after_commit(db, action, vehicle_id, expected):
|
||||
from sqlalchemy import event
|
||||
from app.api.pl import dispatch_calendar as api
|
||||
_, Create, Update, _ = modules()
|
||||
db.add(PlVehicle(id='legacy', license_plate='旧车辆', vehicle_type='legacy',
|
||||
tenant_no='t1', create_by='u1'))
|
||||
db.commit()
|
||||
# 与应用 SessionLocal 一致;提交后失联也必须返回已成功保存的数据。
|
||||
with sessionmaker(bind=db.get_bind(), expire_on_commit=False)() as session:
|
||||
existing = api.create_record(Create(**payload()), session, user()) if action == 'update' else None
|
||||
committed = False
|
||||
|
||||
def after_commit(session):
|
||||
nonlocal committed
|
||||
committed = True
|
||||
|
||||
def reject_after_commit(conn, cursor, statement, parameters, context, executemany):
|
||||
assert not committed, '保存成功后不得再查询数据库生成响应'
|
||||
|
||||
event.listen(session, 'after_commit', after_commit)
|
||||
event.listen(db.get_bind(), 'before_cursor_execute', reject_after_commit)
|
||||
try:
|
||||
body = payload(vehicleId=vehicle_id, origin='山峡')
|
||||
result = (api.create_record(Create(**body), session, user()) if action == 'create'
|
||||
else api.update_record(existing['id'], Update(**body), session, user()))
|
||||
assert committed
|
||||
assert result['vehicleType'] == expected
|
||||
assert result['vehicleId'] == vehicle_id
|
||||
assert result['id']
|
||||
finally:
|
||||
event.remove(session, 'after_commit', after_commit)
|
||||
event.remove(db.get_bind(), 'before_cursor_execute', reject_after_commit)
|
||||
@@ -13,6 +13,7 @@ from app.core.auth import get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.models.models import SysMenu, SysRole, SysRoleMenu, SysUserRole, SysRoleDataScopeDetail
|
||||
from app.models.pl_dispatch_calendar import PlDispatchCalendar
|
||||
from app.models.pl_models import PlVehicle
|
||||
|
||||
PATH = '/api/transport/site-dispatch-calendar'
|
||||
ORIGINAL = '/api/transport/dispatch-calendar'
|
||||
@@ -25,11 +26,13 @@ def setup():
|
||||
from app.api.pl.site_dispatch_calendar import router
|
||||
from app.api.pl.dispatch_calendar import router as original_router
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
for model in (PlDispatchCalendar, SysMenu, SysRole, SysRoleMenu, SysUserRole, SysRoleDataScopeDetail):
|
||||
for model in (PlDispatchCalendar, PlVehicle, SysMenu, SysRole, SysRoleMenu, SysUserRole, SysRoleDataScopeDetail):
|
||||
model.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
with factory() as db:
|
||||
db.add_all([
|
||||
PlVehicle(id='v1', vehicle_type='self', license_plate='鄂E12345', tenant_no='t1', create_by='u1'),
|
||||
PlVehicle(id='v2', vehicle_type='carrier', license_plate='鄂E67890', tenant_no='t1', create_by='u2'),
|
||||
SysRole(id='r1', role_name='基础角色', tenant_no='t1', data_scope='4'),
|
||||
SysRole(id='r2', role_name='现场人员', tenant_no='t1', data_scope='4'),
|
||||
SysUserRole(id='ur1', user_id='u1', role_id='r1', tenant_no='t1'),
|
||||
@@ -97,7 +100,9 @@ def test_fixed_kind_tenant_scope_and_details(setup):
|
||||
'tenant_no': 't2', 'vehicleId': 'v1'})
|
||||
assert response.status_code == 200
|
||||
assert [r['id'] for r in response.json()['list']] == ['site-own']
|
||||
assert response.json()['list'][0]['vehicleType'] == 'self'
|
||||
assert client.get(PATH + '/site-own').json()['calendarKind'] == 'site'
|
||||
assert client.get(PATH + '/site-own').json()['vehicleType'] == 'self'
|
||||
for rid in ('acid', 'alkali', 'other', 'site-t2', 'site-other', 'site-deleted', 'missing', 'vehicles', 'products'):
|
||||
assert client.get(PATH + '/' + rid).status_code == 404
|
||||
assert client.get(PATH, params={'month': '2026-10', 'vehicleId': 'v2'}).json()['total'] == 0
|
||||
@@ -107,6 +112,7 @@ def test_fixed_kind_tenant_scope_and_details(setup):
|
||||
# 菜单授权来自第二角色,全部数据范围来自第一角色,均按并集生效。
|
||||
assert client.get(PATH, params={'month': '2026-10'}).json()['total'] == 2
|
||||
assert client.get(PATH + '/site-other').status_code == 200
|
||||
assert client.get(PATH + '/site-other').json()['vehicleType'] == 'carrier'
|
||||
assert client.get(PATH + '/site-t2').status_code == 404
|
||||
page1 = client.get(PATH, params={'month': '2026-10', 'pageSize': 1, 'page': 1}).json()
|
||||
page2 = client.get(PATH, params={'month': '2026-10', 'pageSize': 1, 'page': 2}).json()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 派车日历车辆配色 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 实现工单 #5 已确认的两端日卡车辆类型配色。
|
||||
|
||||
**Architecture:** 后端批量解析只读车辆类型,前端共用日卡按该类型着色。保留租户和数据范围、现有写入及日历交互;不新增数据库列。
|
||||
|
||||
**Tech Stack:** FastAPI / SQLAlchemy / Vue 3 / TypeScript / SCSS。
|
||||
|
||||
## Task 1:类型返回与共用日卡
|
||||
|
||||
修改 `backend/app/services/pl_dispatch_calendar_service.py`、`backend/app/api/pl/dispatch_calendar.py`、`backend/app/api/pl/site_dispatch_calendar.py`、`web-admin/src/api/pl/dispatch-calendar.ts`、`web-admin/src/views/pl/dispatch-calendar/manual-model.ts`、`ManualDayContent.vue`;测试沿用 `backend/tests/test_pl_dispatch_calendar.py` 和 `web-admin/scripts/manual-dispatch-calendar.test.ts`。
|
||||
|
||||
- [x] 先添加 SQLite 回归:自有/三方/待派车列表返回 `vehicleType`,无权限、删除和跨租户车辆返回 unknown;断言分页多行只执行一次车辆查询。运行并确认新增用例因缺字段失败。
|
||||
- [x] `_row_to_dict(row, vehicle_type=None)` 返回规范枚举;保存时复用提交前类型,列表/详情通过批量序列化显式传类型。使用 `_query(db, tenant_no, user, PlVehicle).filter(PlVehicle.id.in_(ids))` 解析当前页 ID。列表与详情复用批量解析;创建、编辑响应使用非持久化属性,提交后不查询。
|
||||
- [x] 前端增加可选只读 `vehicleType?: 'self' | 'carrier' | 'pending' | 'unknown'`。模型函数先判断 `vehicleId === '__pending_vehicle__'`,再白名单校验车辆类型,其他为 unknown。
|
||||
- [x] 单车卡及多趟分组绑定 `vehicle-${类型}`;背景使用设计中的四组色值,移动自有车辆覆盖为 `#e9f9f2`。保留产品汇总配色。
|
||||
- [x] 运行后端日历三个专项文件与前端日历模型测试;规格审查通过,代码质量审查另见下方交付记录。
|
||||
|
||||
## Task 2:验证与交付记录
|
||||
|
||||
- [x] 浏览器渲染实际组件,用合成数据检查 Web、375px、414px 的三类单车/多趟卡、未知类型与点击行为,保存本地截图。
|
||||
- [x] 执行目标文件 ESLint、Stylelint 和 `pnpm build:typecheck`,记录基线问题,不改无关代码。
|
||||
- [x] 复核工单最新正文与评论、运行 `git diff --check`,记录最终验证及限制;仅在本地任务分支保存实现,不推送、合并或关闭工单。
|
||||
|
||||
|
||||
## 2026-11-01 验证与交付记录
|
||||
|
||||
- 用户已明确确认 Figma 18:2 配色并要求实施。分支 `codex/gitea-issue-5`,基线 `cb0344498abc4c796bc12ee20f25e9097a4076f1`。
|
||||
- 工单正文/全部评论在交付前重新读取,正文未变,0评论,无图片,远端仍 open。
|
||||
- 后端:使用主工作目录现有 Python 3.9 venv,工作目录为本任务 backend;`DATABASE_URL` 指向本机不可连接的测试地址,测试fixture仅使用SQLite。运行 `python -m pytest tests/test_pl_dispatch_calendar.py tests/test_site_dispatch_calendar.py tests/test_pl_dispatch_calendar_site_sync.py -q`:126 passed,12条既有依赖警告。
|
||||
- 前端:`pnpm exec tsx --test scripts/manual-dispatch-calendar.test.ts scripts/mobile-dispatch-calendar.test.ts scripts/pl-dispatch-calendar.test.ts scripts/calendar-copy.test.ts`:38 passed。
|
||||
- 对本次四个前端修改文件执行 ESLint;对 ManualDayContent.vue 执行 Stylelint,均通过。Prettier目标检查通过,`git diff --check`通过。
|
||||
- `pnpm build` 最终通过(39.22秒),存在既有组件重名与产物体积提示。
|
||||
- `pnpm build:typecheck` 未通过:首次隔离目录缺自动导入声明;生成声明后单独执行 `pnpm exec vue-tsc --noEmit`,基线与修改后均505条错误,输出1351行逐行一致,本次无新增。不能称全量类型检查通过。
|
||||
- pnpm复用同仓库现有node_modules软链,命令附 `pnpm_config_verify_deps_before_run=false`,防止新版pnpm自动重装共享依赖;未安装/更新依赖。
|
||||
- 浏览器:Vite在内存编译实际 ManualDayContent.vue,以合成数据在Chrome验证Web 1440px、移动375px/414px × 单车/多趟,共6组通过。核对三种类型及未知回退背景、选择事件、移动触达高度>=44、无横向溢出;实际截图已查看,与已批准配色一致。
|
||||
- 本地证据位于 `.local/agent/issue5/`:`verify-colors.mjs`、六张截图、`backend-final.log`、`vite-final.log`、`typecheck-baseline.log`、`typecheck-final.log`;这些临时证据被Git忽略。
|
||||
- 保存回归修正:先以8组commit后禁SQL用例确认6失败/2通过,再复用提交前车辆类型,修正后全部通过。列表仍实时查询类型,保存不新增提交后数据库依赖。
|
||||
- 独立规格审查和最终代码质量审查均通过,无剩余阻塞;审查者独立执行git diff --check通过。
|
||||
- 验证限制:未连接真实业务库,未执行微信真机或完整业务环境端到端验收。未修改数据库schema、未推送、未合并、未关闭工单。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 工单 #5:派车日历车辆类型配色
|
||||
|
||||
来源:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/5 。2026-11-01 已重新读取正文及全部评论(0 条,无图片)。
|
||||
用户已确认按 Figma v1 实施:https://www.figma.com/design/ImDcikQnCX5GiJQLwUHwDC/shanxia2.0?node-id=18-2 。
|
||||
|
||||
## 范围与视觉依据
|
||||
|
||||
复用 P08/P27 现有派车日历和 `web-admin/src/views/pl/dispatch-calendar/ManualDayContent.vue`;小程序入口实际使用移动 H5,复用同一日卡组件。酸、碱、其他、现场手工日历沿用现有结构、汇总、复制、打印和点击行为;实际派车信息页不在本次手工日卡范围内。
|
||||
|
||||
- 自有车辆:Web 沿用日卡浅绿 `#e8f3ec`,移动端采用三墙浅绿 `#e9f9f2`。
|
||||
- 三方车辆:沿用日历浅蓝 `#e7f0f8`。
|
||||
- 待派车:采用 Fresh Mint 浅红 `#fee2e2`。
|
||||
- 同车多趟的整个分组采用相同车辆类型背景;产品汇总保留产品配色。
|
||||
- 无法识别类型的历史车辆使用中性 `#f1f5f9`,不推断为三方车辆。
|
||||
|
||||
## 数据与实现
|
||||
|
||||
后端从当前租户、未删除且满足数据范围的车辆档案批量读取 `vehicle_type`,作为只读 `vehicleType` 返回。取值 `self` / `carrier` / `pending` / `unknown`;待派车以 `__pending_vehicle__` 专用 ID 判断,不依赖显示车牌名称。列表分页不增加逐行查询,详情和保存响应保持同一字段语义。不增加数据库字段,不改变写入、同步或权限逻辑。
|
||||
|
||||
前端只读字段不进入保存表单。日卡及同车多趟分组按车辆类型选择背景,不再按产品名选择卡片背景。旧响应缺字段时保留待派车 ID 识别,其余中性回退。
|
||||
|
||||
## 验收
|
||||
|
||||
覆盖三种类型、未知/已删除/跨租户/无权限车辆、同车多趟、待派车优先级;检查移动端 375/414px 和 Web 日卡的背景、内容及点击。后端用 SQLite 隔离测试,禁止连接业务库。执行受影响前端测试、检查和构建;独立审查后记录实际结果。未授权推送、合并或关单。
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
loadCalendarPages,
|
||||
manualCalendarDays,
|
||||
manualCopyText,
|
||||
manualVehicleType,
|
||||
validateManualDraft
|
||||
} from '../src/views/pl/dispatch-calendar/manual-model'
|
||||
|
||||
@@ -257,3 +258,20 @@ test('现场同车重复记录逐条计车,同名不同产品ID保持独立且
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
test('车辆分类以待派车ID优先,其余仅接受类型白名单,不从车牌或产品推断', () => {
|
||||
for (const vehicleType of ['self', 'carrier', 'pending', 'unknown'] as const) {
|
||||
assert.equal(manualVehicleType({ ...row('1'), vehicleType }), vehicleType)
|
||||
assert.equal(
|
||||
manualVehicleType({ ...row('1'), vehicleId: '__pending_vehicle__', vehicleType }),
|
||||
'pending'
|
||||
)
|
||||
}
|
||||
for (const vehicleType of [undefined, null, '', 'legacy', 'SELF']) {
|
||||
assert.equal(manualVehicleType({ vehicleId: 'v1', vehicleType }), 'unknown')
|
||||
}
|
||||
const misleadingPlate = { ...row('1'), licensePlate: '待派车' }
|
||||
const otherProduct = { ...row('1'), productName: '盐酸' }
|
||||
assert.equal(manualVehicleType(misleadingPlate), 'unknown')
|
||||
assert.equal(manualVehicleType(otherProduct), 'unknown')
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import request, { pickBxData } from '@/utils/http/bx'
|
||||
|
||||
export type DispatchCalendarKind = 'acid' | 'alkali' | 'other' | 'site'
|
||||
|
||||
export type DispatchCalendarVehicleType = 'self' | 'carrier' | 'pending' | 'unknown'
|
||||
|
||||
export interface DispatchCalendarBody {
|
||||
dispatchDate: string
|
||||
vehicleId: string
|
||||
@@ -16,6 +18,7 @@ export interface DispatchCalendarBody {
|
||||
export interface DispatchCalendarRecord extends DispatchCalendarBody {
|
||||
id: string
|
||||
sourceRecordId?: string | null
|
||||
readonly vehicleType?: DispatchCalendarVehicleType
|
||||
licensePlate: string
|
||||
productName: string
|
||||
createTime: string | null
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
v-if="group.rows.length === 1"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="`tone-${productTone(group.rows[0].productName)}`"
|
||||
:class="`vehicle-${manualVehicleType(group.rows[0])}`"
|
||||
data-testid="manual-record"
|
||||
@click="emit('select', group.rows[0])"
|
||||
>
|
||||
@@ -38,7 +38,7 @@
|
||||
<span class="route">{{ group.rows[0].origin }} → {{ group.rows[0].destination }}</span>
|
||||
<span v-if="group.rows[0].remark" class="remark">{{ group.rows[0].remark }}</span>
|
||||
</component>
|
||||
<div v-else class="pair">
|
||||
<div v-else class="pair" :class="`vehicle-${manualVehicleType(group.rows[0])}`">
|
||||
<div
|
||||
><span class="plate">{{ group.plate }}</span> {{ group.rows.length }}车</div
|
||||
>
|
||||
@@ -70,7 +70,7 @@
|
||||
import { Cell as VanCell } from 'vant'
|
||||
import 'vant/es/cell/style'
|
||||
import type { DispatchCalendarRecord } from '@/api/pl/dispatch-calendar'
|
||||
import { manualQuantityLabel, type manualCalendarDays } from './manual-model'
|
||||
import { manualQuantityLabel, manualVehicleType, type manualCalendarDays } from './manual-model'
|
||||
import { productTone } from './model'
|
||||
|
||||
defineProps<{ day: ReturnType<typeof manualCalendarDays>[number]; mobile?: boolean }>()
|
||||
@@ -158,23 +158,22 @@
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
background: #e8f3ec;
|
||||
}
|
||||
|
||||
.item.tone-sand {
|
||||
background: #ece8e0;
|
||||
.vehicle-self {
|
||||
background: var(--manual-vehicle-self-bg, #e8f3ec);
|
||||
}
|
||||
|
||||
.item.tone-rose {
|
||||
background: #f3e4e8;
|
||||
.vehicle-carrier {
|
||||
background: #e7f0f8;
|
||||
}
|
||||
|
||||
.item.tone-amber {
|
||||
background: #efe6d0;
|
||||
.vehicle-pending {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.item.tone-unknown {
|
||||
background: #fff4d6;
|
||||
.vehicle-unknown {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.plate {
|
||||
@@ -205,7 +204,6 @@
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding-bottom: 6px;
|
||||
background: var(--manual-day-primary-bg, #e7f0f8);
|
||||
}
|
||||
|
||||
.pair-trip {
|
||||
@@ -224,7 +222,7 @@
|
||||
.is-mobile {
|
||||
--manual-day-primary: #0a9f6d;
|
||||
--manual-day-primary-text: #087f57;
|
||||
--manual-day-primary-bg: #e9f9f2;
|
||||
--manual-vehicle-self-bg: #e9f9f2;
|
||||
}
|
||||
|
||||
.is-mobile :deep(.van-cell__value) {
|
||||
|
||||
@@ -2,10 +2,27 @@ import type {
|
||||
CalendarPage,
|
||||
CalendarPageQuery,
|
||||
DispatchCalendarBody,
|
||||
DispatchCalendarRecord
|
||||
DispatchCalendarRecord,
|
||||
DispatchCalendarVehicleType
|
||||
} from '@/api/pl/dispatch-calendar'
|
||||
import { dateKey, monthCells } from './model'
|
||||
|
||||
/** 待派车使用专用ID;旧响应和未知类型保持中性色,不从车牌快照推断。 */
|
||||
export function manualVehicleType(row: {
|
||||
vehicleId: string
|
||||
vehicleType?: string | null
|
||||
}): DispatchCalendarVehicleType {
|
||||
if (row.vehicleId === '__pending_vehicle__') return 'pending'
|
||||
switch (row.vehicleType) {
|
||||
case 'self':
|
||||
case 'carrier':
|
||||
case 'pending':
|
||||
return row.vehicleType
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/** 完整分页:中途缺页或重复页报错,不能把部分月份当作完整数据。 */
|
||||
export async function loadCalendarPages<T extends { id: string }>(
|
||||
fetchPage: (query: CalendarPageQuery) => Promise<CalendarPage<T>>,
|
||||
|
||||
Reference in new issue
Block a user