Compare commits

...

29 Commits

Author SHA1 Message Date
f056cc8625 fix: 图片URL双重/api/v1前缀导致404
根因: 后端返回 /api/v1/upload/files/xxx.jpg
      前端imageUrl又拼VITE_API_BASE_URL=/api/v1
      → /api/v1/api/v1/upload/files/xxx.jpg → 404
修复: /api/开头的URL直接返回, 不再拼接前缀
2026-08-13 09:07:10 +08:00
b8ade13b61 fix: TaskRecordResponse改用field_validator — 不再污染ORM对象导致图片列表写回VARCHAR报错
根因: model_validate直接obj.images=json.loads()把ORM对象的字符串改成列表
     SQLAlchemy autoflush时把列表写回VARCHAR列 → DataError
修复: field_validator(mode=before)在序列化层转换, 不修改ORM源对象
2026-08-13 09:03:17 +08:00
abc8999358 hotfix: get_product_by_serial补全func导入 — 修复500错误 2026-08-13 09:01:34 +08:00
997e16d9ba fix: 移除manifest.json中残留的push SDK配置 2026-08-12 18:05:52 +08:00
d8d42e9613 feat: 上游显示真实发送人 — 从task_logs追溯created_by 2026-08-12 17:52:35 +08:00
2170e2e9cf fix: 转交通知中product_sn始终为空 — product查询移到通知循环之前
根因: transfer_task中 product_result 查询在通知创建循环之后
     通知内容 product_sn 引用未初始化的 product 变量 → 始终为空字符串
修复: 将 product 查询提前到 flush() 之后、通知循环之前
2026-08-12 17:33:32 +08:00
badb470ea9 Revert "fix: 表格grid 16→14列对齐 — 身份证列宽更充足"
This reverts commit f0503f8800.
2026-08-12 17:31:22 +08:00
f0503f8800 fix: 表格grid 16→14列对齐 — 身份证列宽更充足 2026-08-12 17:29:15 +08:00
5062745c14 Revert "fix: 序列号列兜底显示 — external_serial为空时展示身份证后6位"
This reverts commit 8ac4c425a5.
2026-08-12 17:28:04 +08:00
8ac4c425a5 fix: 序列号列兜底显示 — external_serial为空时展示身份证后6位 2026-08-12 17:24:37 +08:00
d9c875924c feat: 移动端扫码页手动输入框增加一键清除×按钮 2026-08-12 17:23:20 +08:00
eba0c3be98 fix: 端点层operator_id兜底current_user.username — 管理员代办检测生效
根因: 移动端不传?operator_id=参数, 后端收到None
      _admin_proxy_note(None, assignee) → 不触发标记

修复: 6个端点(receive/reject/transfer/end/recall/spawn)
      operator_id or current_user.get(username) 兜底
2026-08-12 17:19:56 +08:00
828aee5161 feat: 任务全景搜索框增加一键清除(×)按钮 2026-08-12 17:14:09 +08:00
e7bfdc261b fix: TaskRecord也加入管理员代办审计标记 — 看板最新动态可见代理操作
新增 _admin_proxy_note() 辅助函数
receive/transfer 的 TaskRecord.remark 自动拼接 [管理员xx代办]
这样看板\最新动态\列就能显示管理员代操作记录
2026-08-12 17:11:07 +08:00
74cb77f547 refactor: 扫码页简化为单页模式 — 拍照按钮+手动输入 2026-08-12 17:06:40 +08:00
f6ba1d1740 fix: 扫码页优化 — 修复黑屏+右下角相册+Webview透明+扫描框位置调整 2026-08-12 17:05:42 +08:00
a09d4481a3 feat: 扫码页双模式 — 主页+微信级全屏扫码(plus.barcode原生摄像头) 2026-08-12 16:59:33 +08:00
2ca382bb2c refactor: 删除扫码页相册选择功能,仅保留拍照扫码+手动输入 2026-08-12 16:55:43 +08:00
9f3deb5b6c revert: 恢复扫码页为双按钮版本(拍照+相册) 2026-08-12 16:53:31 +08:00
8303c8b93f fix: 滑动卡片指示器联动 — _cardIdx从computed迁移到data独立管理
根因: _cardIdx存在computed lane对象中, computed重新计算时重置为0
修复: cardIndices{}独立data管理, keyed by lane._key
      新增watch product重置 + getCardIdx/setCardIdx方法
2026-08-12 16:11:22 +08:00
a5e256330a fix: parseImages支持数组输入 — Pydantic序列化后images已是数组非字符串 2026-08-12 16:08:37 +08:00
a38be2c568 fix: TaskRecordResponse类型匹配后端schema — 补全images字段 2026-08-12 16:05:05 +08:00
e511952ea0 feat: 最新动态列支持图片展示 — 纯文字/纯图片/图文混合三种模式
后端: ProductResponse 新增 latest_record_has_images
前端: 📷图标 + 纯图片时显示"图片记录" + Tooltip标注[含图片]
2026-08-12 16:01:24 +08:00
92c388190c fix: grid-cols-[16]改为inline style避免Tailwind任意值不生效导致表头竖排 2026-08-12 16:00:03 +08:00
b21b9ed0e2 fix: 表格布局恢复原始列宽 — 规格型号/创建时间各2列, 总16列 2026-08-12 15:56:48 +08:00
6016f92dd0 hotfix: func→sa_func 解决局部变量遮蔽 2026-08-12 15:52:06 +08:00
b21df33e0a hotfix: 补充 product_service.py 顶部缺失的 func 导入 2026-08-12 15:50:01 +08:00
163d9c20bb feat: 任务全景表格新增"最新动态"列
后端:
  - ProductResponse 新增 latest_record_time/content 字段
  - get_all_products 批量查询活跃任务(WIP/PENDING)的最新 task_records
  - 使用窗口函数 ROW_NUMBER() PARTITION BY product_id

前端:
  - AdminTasksPage: grid-cols-12→grid-cols-[14]
  - 表头+数据行新增"最新动态"列
  - Tooltip悬浮显示完整内容, 截断+灰字二级排版
2026-08-12 15:43:33 +08:00
1fdc607c07 feat: 管理员代工模式 — 前端权限放开 + 后端审计留痕
前端:
  - WorkspaceArea 新增 currentUserRole prop
  - isAssignee/canRecall: SUPER_ADMIN/SUPERVISOR 上帝视角直接放行
  - 管理员代操作时显示黄色提示条: 正在以管理员身份代[原负责人]操作
  - detail.vue: loadCurrentUser 提取并保存 user.role

后端:
  - _create_task_log 新增 task_assignee_id 参数
  - operator_id!=task_assignee_id 时自动拼接 [管理员xx代办操作]
  - 12处调用点全部传入 task_assignee_id
  - receive_task 位置赋值确认为 task.assignee_id(非 operator_id)
2026-08-12 15:24:50 +08:00
14 changed files with 307 additions and 93 deletions

View File

@ -125,7 +125,8 @@ async def end_task_endpoint(
用于工人认为工序已完结、无需转交下一人的场景。 用于工人认为工序已完结、无需转交下一人的场景。
""" """
return await task_service.end_task( return await task_service.end_task(
db, uuid.UUID(task_id), operator_id, db, uuid.UUID(task_id),
operator_id or current_user.get("username", "") or None,
operator_role=current_user.get("role"), operator_role=current_user.get("role"),
) )
@ -146,7 +147,8 @@ async def recall_task_endpoint(
适用场景:转交后发现选错人,在对方接收前撤回。 适用场景:转交后发现选错人,在对方接收前撤回。
""" """
return await task_service.recall_task( return await task_service.recall_task(
db, uuid.UUID(task_id), operator_id, db, uuid.UUID(task_id),
operator_id or current_user.get("username", "") or None,
operator_role=current_user.get("role"), operator_role=current_user.get("role"),
) )
@ -173,7 +175,9 @@ async def spawn_subtask_endpoint(
**派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。** **派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。**
用于 WIP 期间工人需要其他人协助协同的场景。 用于 WIP 期间工人需要其他人协助协同的场景。
""" """
return await task_service.spawn_subtask(db, uuid.UUID(task_id), data, operator_id) return await task_service.spawn_subtask(
db, uuid.UUID(task_id), data,
operator_id or current_user.get("username", "") or None)
# ============================================================ # ============================================================
@ -196,7 +200,9 @@ async def receive_task_endpoint(
动作:状态改为 WIP,记录 received_at,更新 task_name,同步产品宏观状态。 动作:状态改为 WIP,记录 received_at,更新 task_name,同步产品宏观状态。
""" """
return await task_service.receive_task( return await task_service.receive_task(
db, uuid.UUID(task_id), operator_id, remark, task_name, db, uuid.UUID(task_id),
operator_id or current_user.get("username", "") or None,
remark, task_name,
operator_role=current_user.get("role"), operator_role=current_user.get("role"),
) )
@ -222,7 +228,8 @@ async def reject_task_endpoint(
3. 为该负责人新建返工任务(is_rework=True, status=PENDING)。 3. 为该负责人新建返工任务(is_rework=True, status=PENDING)。
""" """
return await task_service.reject_task( return await task_service.reject_task(
db, uuid.UUID(task_id), request, operator_id, db, uuid.UUID(task_id), request,
operator_id or current_user.get("username", "") or None,
operator_role=current_user.get("role"), operator_role=current_user.get("role"),
) )
@ -256,7 +263,8 @@ async def transfer_task_endpoint(
- 否则 → 顶层同级转交。 - 否则 → 顶层同级转交。
""" """
return await task_service.transfer_task( return await task_service.transfer_task(
db, uuid.UUID(task_id), request, operator_id, db, uuid.UUID(task_id), request,
operator_id or current_user.get("username", "") or None,
operator_role=current_user.get("role"), operator_role=current_user.get("role"),
) )

View File

@ -55,6 +55,10 @@ class ProductResponse(BaseModel):
overall_status: str | None = None overall_status: str | None = None
status: str status: str
created_at: datetime created_at: datetime
# 🔧 最新动态 — 该产品活跃任务的最新记录
latest_record_time: datetime | None = None
latest_record_content: str | None = None
latest_record_has_images: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
# ============================================================ # ============================================================
@ -82,18 +82,19 @@ class TaskRecordResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@field_validator("images", mode="before")
@classmethod @classmethod
def model_validate(cls, obj, **kwargs): def _parse_images(cls, v):
"""处理 DB 中 images 的 JSON 字符串 → list 反序列化""" """处理 DB 中 images 的 JSON 字符串 → list 反序列化(不污染 ORM 对象)"""
import json import json
if hasattr(obj, "images") and isinstance(obj.images, str): if isinstance(v, str):
try: try:
obj.images = json.loads(obj.images) return json.loads(v)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
obj.images = [] return []
elif hasattr(obj, "images") and obj.images is None: if v is None:
obj.images = [] return []
return super().model_validate(obj, **kwargs) return v
# ============================================================ # ============================================================
@ -116,6 +117,7 @@ class TaskSummaryResponse(BaseModel):
received_at: datetime | None = None received_at: datetime | None = None
completed_at: datetime | None = None completed_at: datetime | None = None
created_at: datetime created_at: datetime
created_by: str | None = None # 谁创建的(从task_logs追溯)
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@ -140,6 +142,7 @@ class TaskResponse(BaseModel):
created_at: datetime created_at: datetime
child_tasks: list[TaskResponse] = [] child_tasks: list[TaskResponse] = []
records: list[TaskRecordResponse] = [] records: list[TaskRecordResponse] = []
created_by: str | None = None # 谁创建的(从task_logs追溯)
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@ -42,6 +42,7 @@ def _task_to_response(task: Task) -> TaskResponse:
created_at=task.created_at, created_at=task.created_at,
child_tasks=[_task_to_response(c) for c in task.child_tasks], child_tasks=[_task_to_response(c) for c in task.child_tasks],
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
created_by=getattr(task, "created_by", None),
) )
@ -85,15 +86,55 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
# 🔧 收集任务树中所有 assignee_id → 查中文姓名映射 # 🔧 收集任务树中所有 assignee_id → 查中文姓名映射
assignee_ids: set[str] = set() assignee_ids: set[str] = set()
all_task_ids: list[uuid.UUID] = []
def _collect_ids(tasks): def _collect_ids(tasks):
for t in tasks: for t in tasks:
all_task_ids.append(t.id)
if t.assignee_id: assignee_ids.add(t.assignee_id) if t.assignee_id: assignee_ids.add(t.assignee_id)
if t.child_tasks: _collect_ids(t.child_tasks) if t.child_tasks: _collect_ids(t.child_tasks)
for t in top_tasks: for t in top_tasks:
all_task_ids.append(t.id)
if t.assignee_id: assignee_ids.add(t.assignee_id) if t.assignee_id: assignee_ids.add(t.assignee_id)
_collect_ids(task_tree) _collect_ids(task_tree)
assignee_names = _lookup_display_names(list(assignee_ids)) assignee_names = _lookup_display_names(list(assignee_ids))
# 🔧 批量查 task_logs: 谁创建了每个任务
creator_map: dict[uuid.UUID, str] = {}
if all_task_ids:
from app.models.task_log import TaskLog
from sqlalchemy import func
# 每个 task 取最早的 create 日志的 operator_id
sub = (
select(
TaskLog.task_id,
TaskLog.operator_id,
func.row_number().over(
partition_by=TaskLog.task_id,
order_by=TaskLog.created_at.asc()
).label("rn")
)
.where(
TaskLog.task_id.in_(all_task_ids),
TaskLog.action_type == "create"
)
).subquery()
log_result = await db.execute(
select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1)
)
for row in log_result:
if row[1]:
creator_map[row[0]] = row[1]
# 注入 created_by 到 task_tree 和 top_tasks
def _inject_creator(tasks):
for t in tasks:
t.created_by = creator_map.get(t.id)
if t.child_tasks:
_inject_creator(t.child_tasks)
_inject_creator(task_tree)
for t in top_tasks:
t.created_by = creator_map.get(t.id)
return ProductScanResponse( return ProductScanResponse(
id=product.id, id=product.id,
serial_number=product.serial_number, serial_number=product.serial_number,
@ -461,6 +502,33 @@ async def get_all_products(
merged_location_ids = list(set(static_location_ids + dynamic_location_ids)) merged_location_ids = list(set(static_location_ids + dynamic_location_ids))
merged_name_map = _lookup_display_names(merged_location_ids) merged_name_map = _lookup_display_names(merged_location_ids)
# 🔧 批量查询每个产品活跃任务的最新记录
latest_record_map: dict[uuid.UUID, tuple] = {}
if product_ids:
from app.models.task import TaskRecord as TR
wip_pending_ids = select(Task.id).where(
and_(
Task.product_id.in_(product_ids),
Task.status.in_(["WIP", "PENDING"]),
)
).subquery()
ranked = (
select(TR.task_id, TR.remark, TR.images, TR.created_at, Task.product_id,
sa_func.row_number().over(
partition_by=Task.product_id,
order_by=TR.created_at.desc()
).label("rn"))
.join(Task, TR.task_id == Task.id)
.where(Task.id.in_(select(wip_pending_ids.c.id)))
).subquery()
rec_result = await db.execute(
select(ranked.c.product_id, ranked.c.created_at, ranked.c.remark, ranked.c.images)
.where(ranked.c.rn == 1)
)
for row in rec_result:
has_img = bool(row[3] and row[3] != "[]" and row[3] != "null")
latest_record_map[row[0]] = (row[1], row[2], has_img)
return [ return [
ProductResponse( ProductResponse(
id=p.id, id=p.id,
@ -474,10 +542,8 @@ async def get_all_products(
category=p.category, category=p.category,
material_type=p.material_type, material_type=p.material_type,
parent_product_id=p.parent_product_id, parent_product_id=p.parent_product_id,
# 🔧 当前位置:动态主干assignee优先 → 静态兜底
current_location_id=( current_location_id=(
main_assignees.get(p.id) # 动态主干 main_assignees.get(p.id) or p.current_location_id
or p.current_location_id # 静态兜底
), ),
current_location_name=( current_location_name=(
"仓库" if (main_assignees.get(p.id) or p.current_location_id) == "virtual_warehouse" "仓库" if (main_assignees.get(p.id) or p.current_location_id) == "virtual_warehouse"
@ -489,6 +555,9 @@ async def get_all_products(
overall_status=overall_names.get(p.id) or p.overall_status, overall_status=overall_names.get(p.id) or p.overall_status,
status=p.status, status=p.status,
created_at=p.created_at, created_at=p.created_at,
latest_record_time=latest_record_map.get(p.id, (None, None, False))[0],
latest_record_content=latest_record_map.get(p.id, (None, None, False))[1],
latest_record_has_images=latest_record_map.get(p.id, (None, None, False))[2],
) )
for p in products for p in products
] ]

View File

@ -217,19 +217,28 @@ async def _check_all_critical_children_completed(
return len(incomplete) == 0, incomplete return len(incomplete) == 0, incomplete
def _admin_proxy_note(operator_id: str | None, task_assignee_id: str | None) -> str:
"""检测管理员代办 → 返回审计标记后缀"""
if operator_id and task_assignee_id and operator_id != task_assignee_id:
return f" [管理员 {operator_id} 代办]"
return ""
async def _create_task_log( async def _create_task_log(
db: AsyncSession, db: AsyncSession,
task_id: uuid.UUID, task_id: uuid.UUID,
action_type: str, action_type: str,
operator_id: str | None = None, operator_id: str | None = None,
remark: str | None = None, remark: str | None = None,
task_assignee_id: str | None = None,
) -> TaskLog: ) -> TaskLog:
"""创建任务操作日志""" """创建任务操作日志。自动检测管理员代办并拼接审计标记。"""
final_remark = (remark or "") + _admin_proxy_note(operator_id, task_assignee_id)
log = TaskLog( log = TaskLog(
task_id=task_id, task_id=task_id,
operator_id=operator_id, operator_id=operator_id,
action_type=action_type, action_type=action_type,
remark=remark, remark=final_remark.strip() or None,
) )
db.add(log) db.add(log)
return log return log
@ -349,7 +358,8 @@ async def end_task(
task.completed_at = now task.completed_at = now
await _create_task_log(db, task_id, action_type="end", await _create_task_log(db, task_id, action_type="end",
operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)") operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)",
task_assignee_id=task.assignee_id)
# 🔧 位置回溯:分支结束后优先回溯到父任务负责人 # 🔧 位置回溯:分支结束后优先回溯到父任务负责人
await _recalc_product_location(db, task.product_id, task.id) await _recalc_product_location(db, task.product_id, task.id)
@ -414,7 +424,8 @@ async def recall_task(
task.status = TASK_STATUS_CANCELED task.status = TASK_STATUS_CANCELED
task.completed_at = now task.completed_at = now
await _create_task_log(db, task_id, action_type="recall", operator_id=operator_id, await _create_task_log(db, task_id, action_type="recall", operator_id=operator_id,
remark=f"撤回转交「{task.task_name}」→ {task.assignee_id}") remark=f"撤回转交「{task.task_name}」→ {task.assignee_id}",
task_assignee_id=task.assignee_id)
db.add(TaskRecord(task_id=task.id, remark=f"[撤回] 转交至 {task.assignee_id} 已撤回", images="[]")) db.add(TaskRecord(task_id=task.id, remark=f"[撤回] 转交至 {task.assignee_id} 已撤回", images="[]"))
# 2. 生成接力新任务(以撤回节点为父,还给操作人) # 2. 生成接力新任务(以撤回节点为父,还给操作人)
@ -432,7 +443,8 @@ async def recall_task(
db.add(recovery) db.add(recovery)
await db.flush() await db.flush()
await _create_task_log(db, recovery.id, action_type="create", operator_id=operator_id, await _create_task_log(db, recovery.id, action_type="create", operator_id=operator_id,
remark=f"撤回接力:撤回「{task.task_name}」→ {task.assignee_id} 后重新指派给 {operator_id}") remark=f"撤回接力:撤回「{task.task_name}」→ {task.assignee_id} 后重新指派给 {operator_id}",
task_assignee_id=recovery.assignee_id)
db.add(TaskRecord(task_id=recovery.id, remark=f"[重新接手] 撤回转交后系统自动生成接力节点", images="[]")) db.add(TaskRecord(task_id=recovery.id, remark=f"[重新接手] 撤回转交后系统自动生成接力节点", images="[]"))
# 3. 更新产品位置 # 3. 更新产品位置
@ -479,7 +491,8 @@ async def spawn_subtask(
db.add(TaskRecord(task_id=task.id, remark=data.remark or f"[派发协助] 分配给 {data.assignee_id}", images="[]")) db.add(TaskRecord(task_id=task.id, remark=data.remark or f"[派发协助] 分配给 {data.assignee_id}", images="[]"))
await _create_task_log(db, child.id, action_type="create", operator_id=operator_id, await _create_task_log(db, child.id, action_type="create", operator_id=operator_id,
remark=f"协助分支(由「{task.task_name}」派发,分配给 {data.assignee_id})") remark=f"协助分支(由「{task.task_name}」派发,分配给 {data.assignee_id})",
task_assignee_id=child.assignee_id)
await db.commit() await db.commit()
await db.refresh(child) await db.refresh(child)
return _to_response(child) return _to_response(child)
@ -546,8 +559,11 @@ async def receive_task(
action_type="receive", action_type="receive",
operator_id=operator_id, operator_id=operator_id,
remark=remark or f"操作员确认接收任务「{task.task_name}」", remark=remark or f"操作员确认接收任务「{task.task_name}」",
task_assignee_id=task.assignee_id,
) )
db.add(TaskRecord(task_id=task.id, remark=remark or f"[接收] 操作员已确认接收", images="[]")) db.add(TaskRecord(task_id=task.id,
remark=(remark or f"[接收] 操作员已确认接收") + _admin_proxy_note(operator_id, task.assignee_id),
images="[]"))
# 接收时同步产品位置到接收人 + 宏观状态同步 # 接收时同步产品位置到接收人 + 宏观状态同步
product_result = await db.execute(select(Product).where(Product.id == task.product_id)) product_result = await db.execute(select(Product).where(Product.id == task.product_id))
@ -609,6 +625,7 @@ async def reject_task(
action_type="reject", action_type="reject",
operator_id=operator_id, operator_id=operator_id,
remark=f"品质驳回: {request.reason}", remark=f"品质驳回: {request.reason}",
task_assignee_id=task.assignee_id,
) )
# --- 2. 确定返工任务的负责人(追溯上一道工序的转交人) --- # --- 2. 确定返工任务的负责人(追溯上一道工序的转交人) ---
@ -657,6 +674,7 @@ async def reject_task(
action_type="create", action_type="create",
operator_id=operator_id, operator_id=operator_id,
remark=f"返工任务(驳回自「{task.task_name}」,原因: {request.reason}),分配给 {rework_assignee_id}", remark=f"返工任务(驳回自「{task.task_name}」,原因: {request.reason}),分配给 {rework_assignee_id}",
task_assignee_id=rework_task.assignee_id,
) )
# 🔔 通知:品质驳回 # 🔔 通知:品质驳回
@ -745,8 +763,11 @@ async def transfer_task(
action_type="complete", action_type="complete",
operator_id=operator_id, operator_id=operator_id,
remark=request.note or f"完成任务「{task.task_name}」,转交至下一道工序", remark=request.note or f"完成任务「{task.task_name}」,转交至下一道工序",
task_assignee_id=task.assignee_id,
) )
db.add(TaskRecord(task_id=task.id, remark=request.note or f"[完工转交] 移交下一工序", images="[]")) db.add(TaskRecord(task_id=task.id,
remark=(request.note or f"[完工转交] 移交下一工序") + _admin_proxy_note(operator_id, task.assignee_id),
images="[]"))
# --- 动作 2:解析下家 & 裂变 --- # --- 动作 2:解析下家 & 裂变 ---
# 兼容新旧格式 # 兼容新旧格式
@ -795,21 +816,23 @@ async def transfer_task(
# 批量 flush 以生成 ID # 批量 flush 以生成 ID
await db.flush() await db.flush()
# 提前查询产品(通知需要 product_sn)
product_result = await db.execute(
select(Product).where(Product.id == task.product_id)
)
product = product_result.scalar_one_or_none()
product_sn = product.serial_number if product else ""
for nt in created_tasks: for nt in created_tasks:
await _create_task_log( await _create_task_log(
db, nt.id, db, nt.id,
action_type="create", action_type="create",
operator_id=operator_id, operator_id=operator_id,
remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}", remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}",
task_assignee_id=nt.assignee_id,
) )
# 🔔 通知:新任务派发 # 🔔 通知:新任务派发
if nt.assignee_id: if nt.assignee_id:
product_sn = ""
try:
if product:
product_sn = product.serial_number or ""
except Exception:
pass
db.add(Notification( db.add(Notification(
user_id=nt.assignee_id, user_id=nt.assignee_id,
title=f"🟢 新任务派发", title=f"🟢 新任务派发",
@ -819,10 +842,6 @@ async def transfer_task(
)) ))
# --- 更新 Product 的 current_location_id --- # --- 更新 Product 的 current_location_id ---
product_result = await db.execute(
select(Product).where(Product.id == task.product_id)
)
product = product_result.scalar_one_or_none()
if product: if product:
if has_warehouse and not real_branches: if has_warehouse and not real_branches:
product.current_location_id = VIRTUAL_WAREHOUSE product.current_location_id = VIRTUAL_WAREHOUSE
@ -910,6 +929,7 @@ async def complete_task(
action_type="complete", action_type="complete",
operator_id=request.operator_id, operator_id=request.operator_id,
remark=request.remark or f"完成任务: {task.task_name}", remark=request.remark or f"完成任务: {task.task_name}",
task_assignee_id=task.assignee_id,
) )
# --- 4. 可选:创建下一步任务(转交) --- # --- 4. 可选:创建下一步任务(转交) ---
@ -941,6 +961,7 @@ async def complete_task(
action_type="create", action_type="create",
operator_id=request.operator_id, operator_id=request.operator_id,
remark=f"由任务「{task.task_name}」完成后转交创建", remark=f"由任务「{task.task_name}」完成后转交创建",
task_assignee_id=next_task.assignee_id,
) )
# 🔧 位置回溯:老接口也触发(父任务优先) # 🔧 位置回溯:老接口也触发(父任务优先)
@ -1000,6 +1021,7 @@ async def create_subtask(
action_type="create", action_type="create",
operator_id=data.assignee_id, operator_id=data.assignee_id,
remark=f"创建子任务「{data.task_name}」,父任务: 「{parent.task_name}」", remark=f"创建子任务「{data.task_name}」,父任务: 「{parent.task_name}」",
task_assignee_id=subtask.assignee_id,
) )
await db.commit() await db.commit()

View File

@ -22,8 +22,18 @@ function ArrowRight({ color = "#9ca3af" }: { color?: string }) {
function ArrowLeft({ color = "#9ca3af" }: { color?: string }) { function ArrowLeft({ color = "#9ca3af" }: { color?: string }) {
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>; return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
} }
function parseImages(s: string | null | undefined): string[] { if (!s) return []; try { return JSON.parse(s); } catch { return []; } } function parseImages(s: any): string[] {
function imageUrl(u: string) { if (!u) return ""; return u.startsWith("http") ? u : import.meta.env.VITE_API_BASE_URL + (u.startsWith("/") ? u : "/" + u); } if (!s) return [];
if (Array.isArray(s)) return s; // Pydantic 序列化后的数组
if (typeof s === "string") { try { return JSON.parse(s); } catch { return []; } }
return [];
}
function imageUrl(u: string) {
if (!u) return "";
if (u.startsWith("http")) return u; // 绝对地址直接用
if (u.startsWith("/api/")) return u; // 已是完整API路径,避免双重/api/v1前缀
return import.meta.env.VITE_API_BASE_URL + (u.startsWith("/") ? u : "/" + u);
}
const ALL_TASKS = new Set<TaskResponse>(); const ALL_TASKS = new Set<TaskResponse>();
function collectAll(tasks: TaskResponse[]) { tasks.forEach(t => { ALL_TASKS.add(t); if (t.child_tasks) collectAll(t.child_tasks); }); } function collectAll(tasks: TaskResponse[]) { tasks.forEach(t => { ALL_TASKS.add(t); if (t.child_tasks) collectAll(t.child_tasks); }); }
@ -218,7 +228,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
<div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${i === 0 ? "bg-blue-500" : "bg-gray-300"}`} /> <div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${i === 0 ? "bg-blue-500" : "bg-gray-300"}`} />
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className="text-[10px] text-gray-400">{fmtTime(r.created_at)}</p> <div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className="text-[10px] text-gray-400">{fmtTime(r.created_at)}</p>
{(r.note || r.remark) && <p className="mt-0.5 text-xs text-gray-700">{r.note || r.remark}</p>} {(r.note || r.remark) && <p className="mt-0.5 text-xs text-gray-700">{r.note || r.remark}</p>}
{(() => { const imgs = parseImages((r as any).images); if (!imgs.length) return null; return <div className="mt-1 flex gap-1">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} className="h-12 w-12 rounded border object-cover cursor-pointer" onClick={() => window.open(imageUrl(img))} />)}</div>; })()} {(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return <div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} className="h-14 w-14 rounded border object-cover cursor-pointer hover:opacity-80 transition-opacity" onClick={() => window.open(imageUrl(img))} />)}</div>; })()}
</div> </div>
</div> </div>
))}</div>} ))}</div>}

View File

@ -5,6 +5,7 @@ import {
Search, Loader2, Package, ChevronDown, ChevronRight, Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X, Warehouse, GitBranch, X,
} from "lucide-react"; } from "lucide-react";
import { Tooltip } from "antd";
import api from "../../services/api"; import api from "../../services/api";
import { scanProduct } from "../../services/productApi"; import { scanProduct } from "../../services/productApi";
import { import {
@ -263,8 +264,17 @@ export default function AdminTasksPage() {
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索产品身份证、订单号、规格型号..." placeholder="搜索产品身份证、订单号、规格型号..."
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-8 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/> />
{keyword && (
<button
type="button"
onClick={() => { setKeyword(""); loadProducts(""); }}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<X className="h-4 w-4" />
</button>
)}
</div> </div>
<button <button
type="submit" type="submit"
@ -368,13 +378,14 @@ export default function AdminTasksPage() {
{isOpen && ( {isOpen && (
<div className="border-t border-gray-100"> <div className="border-t border-gray-100">
{/* 表头 */} {/* 表头 */}
<div className="grid grid-cols-12 gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500"> <div className="grid gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500" style={{ gridTemplateColumns: "repeat(16, minmax(0, 1fr))" }}>
<div className="col-span-2">产品身份证</div> <div className="col-span-2">产品身份证</div>
<div className="col-span-1">序列号</div> <div className="col-span-1">序列号</div>
<div className="col-span-2">规格型号</div> <div className="col-span-2">规格型号</div>
<div className="col-span-1">宏观状态</div> <div className="col-span-1">宏观状态</div>
<div className="col-span-1">任务状态</div> <div className="col-span-1">任务状态</div>
<div className="col-span-1">当前位置</div> <div className="col-span-1">当前位置</div>
<div className="col-span-2">最新动态</div>
<div className="col-span-2">创建时间</div> <div className="col-span-2">创建时间</div>
<div className="col-span-2">操作</div> <div className="col-span-2">操作</div>
</div> </div>
@ -391,13 +402,27 @@ export default function AdminTasksPage() {
return ( return (
<div key={p.id}> <div key={p.id}>
<div className="grid grid-cols-12 gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50"> <div className="grid gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50" style={{ gridTemplateColumns: "repeat(16, minmax(0, 1fr))" }}>
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div> <div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
<div className="col-span-1 font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div> <div className="col-span-1 font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>
<div className="col-span-2 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div> <div className="col-span-2 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>
<div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div> <div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div>
<div className="col-span-1"><span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span></div> <div className="col-span-1"><span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span></div>
<div className="col-span-1 text-xs text-gray-500 truncate">{p.current_location_id === "virtual_warehouse" ? (<span className="inline-flex items-center gap-1 text-purple-600">🏭 仓库</span>) : (p.current_location_name || p.current_location_id || "—")}</div> <div className="col-span-1 text-xs text-gray-500 truncate">{p.current_location_id === "virtual_warehouse" ? (<span className="inline-flex items-center gap-1 text-purple-600">🏭 仓库</span>) : (p.current_location_name || p.current_location_id || "—")}</div>
{/* 最新动态 */}
<div className="col-span-2 text-xs">
{p.latest_record_time ? (
<Tooltip title={(p.latest_record_content || "") + (p.latest_record_has_images ? " [含图片]" : "")}>
<div className="cursor-default">
<div className="text-[10px] text-gray-400">{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</div>
<div className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-gray-600">
{p.latest_record_has_images && <span className="shrink-0">📷</span>}
<span className="truncate">{p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")}</span>
</div>
</div>
</Tooltip>
) : <span className="text-gray-300">—</span>}
</div>
<div className="col-span-2 text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div> <div className="col-span-2 text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>
<div className="col-span-2"> <div className="col-span-2">
<button <button

View File

@ -18,6 +18,9 @@ export interface ProductResponse {
overall_status: string | null; overall_status: string | null;
status: string; status: string;
created_at: string; created_at: string;
latest_record_time: string | null;
latest_record_content: string | null;
latest_record_has_images: boolean;
} }
/** MOM 物料选项 */ /** MOM 物料选项 */

View File

@ -43,12 +43,11 @@ export interface TaskResponse extends TaskSummary {
} }
export interface TaskRecordResponse { export interface TaskRecordResponse {
id: string; id: number;
task_id: string; task_id: string;
action: string; remark: string | null;
operator_id: string | null; images: string[];
note: string | null; created_at: string | null;
created_at: string;
} }
// ---- 操作响应 ---- // ---- 操作响应 ----

View File

@ -43,7 +43,6 @@
} }
}, },
"sdkConfigs" : { "sdkConfigs" : {
"push" : {},
"speech" : {} "speech" : {}
}, },
"ios" : { "ios" : {

View File

@ -6,7 +6,7 @@
<view class="ss-title-group"> <view class="ss-title-group">
<text class="ss-title">{{ lanes[currentLane].label }}</text> <text class="ss-title">{{ lanes[currentLane].label }}</text>
<text class="ss-step-hint"> <text class="ss-step-hint">
步骤 {{ lanes[currentLane]._cardIdx + 1 }}/{{ lanes[currentLane].cards.length }} 步骤 {{ getCardIdx(lanes[currentLane]._key) + 1 }}/{{ lanes[currentLane].cards.length }}
<text v-if="lanes.length > 1"> · ← 左右滑切换分支 →</text> <text v-if="lanes.length > 1"> · ← 左右滑切换分支 →</text>
</text> </text>
</view> </view>
@ -20,7 +20,7 @@
:style="{ height: swiperHeight + 'px' }" duration="250"> :style="{ height: swiperHeight + 'px' }" duration="250">
<swiper-item v-for="(lane, li) in lanes" :key="lane._key"> <swiper-item v-for="(lane, li) in lanes" :key="lane._key">
<!-- 🚀 垂直滑动:当前分支的时间线 --> <!-- 🚀 垂直滑动:当前分支的时间线 -->
<swiper class="ss-swiper-v" :current="lane._cardIdx" @change="onCardSwipe($event, li)" <swiper class="ss-swiper-v" :current="getCardIdx(lane._key)" @change="onCardSwipe($event, li)"
duration="200" vertical :style="{ height: swiperHeight + 'px' }"> duration="200" vertical :style="{ height: swiperHeight + 'px' }">
<swiper-item v-for="(card, ci) in lane.cards" :key="card._key"> <swiper-item v-for="(card, ci) in lane.cards" :key="card._key">
<view class="ss-card-wrapper"> <view class="ss-card-wrapper">
@ -85,7 +85,7 @@
</view> </view>
<view class="ss-dots"> <view class="ss-dots">
<view v-for="(c, i) in lanes[currentLane].cards" :key="'d'+i" <view v-for="(c, i) in lanes[currentLane].cards" :key="'d'+i"
:class="['ss-dot', i === lanes[currentLane]._cardIdx ? 'ss-dot-active' : '', c._isMain ? 'ss-dot-main' : '']"> :class="['ss-dot', i === getCardIdx(lanes[currentLane]._key) ? 'ss-dot-active' : '', c._isMain ? 'ss-dot-main' : '']">
<text class="ss-dot-label">{{ i + 1 }}</text> <text class="ss-dot-label">{{ i + 1 }}</text>
</view> </view>
</view> </view>
@ -104,9 +104,17 @@ export default {
data() { data() {
return { return {
currentLane: 0, currentLane: 0,
cardIndices: {}, // { laneKey: currentCardIndex } — 独立管理避免computed重置
swiperHeight: 600, swiperHeight: 600,
}; };
}, },
watch: {
// product 变化时重置所有滑动位置
product: {
immediate: true,
handler() { this.cardIndices = {}; },
},
},
computed: { computed: {
branchLabelMap() { branchLabelMap() {
const map = {}; const map = {};
@ -133,7 +141,7 @@ export default {
const result = []; const result = [];
if (!this.product || !this.product.task_tree) return result; if (!this.product || !this.product.task_tree) return result;
const mainLane = { _key: 'main', label: '主分支', cards: [], _cardIdx: 0 }; const mainLane = { _key: 'main', label: '主分支', cards: [] };
const sortTasks = (tasks) => { const sortTasks = (tasks) => {
if (!tasks) return []; if (!tasks) return [];
@ -160,7 +168,7 @@ export default {
const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN'); const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN');
for (const sc of spawns) { for (const sc of spawns) {
const blabel = (this.branchLabelMap && this.branchLabelMap[sc.id]) || '协助分支'; const blabel = (this.branchLabelMap && this.branchLabelMap[sc.id]) || '协助分支';
const branchLane = { _key: 'branch_' + sc.id, label: blabel, cards: [], _cardIdx: 0 }; const branchLane = { _key: 'branch_' + sc.id, label: blabel, cards: [] };
result.push(branchLane); // 先占坑:父分支排在前面 result.push(branchLane); // 先占坑:父分支排在前面
followMain([sc], branchLane); // 再递归:孙子分支自然排在后面 followMain([sc], branchLane); // 再递归:孙子分支自然排在后面
} }
@ -181,10 +189,13 @@ export default {
}, },
methods: { methods: {
formatUserName, formatUserName,
getCardIdx(laneKey) { return this.cardIndices[laneKey] || 0; },
setCardIdx(laneKey, idx) { this.$set(this.cardIndices, laneKey, idx); },
onLaneSwipe(e) { this.currentLane = e.detail.current; }, onLaneSwipe(e) { this.currentLane = e.detail.current; },
onCardSwipe(e, laneIdx) { onCardSwipe(e, laneIdx) {
if (this.lanes[laneIdx]) { const lane = this.lanes[laneIdx];
this.$set(this.lanes[laneIdx], '_cardIdx', e.detail.current); if (lane) {
this.setCardIdx(lane._key, e.detail.current);
} }
}, },
taskCardClass(t) { taskCardClass(t) {

View File

@ -61,7 +61,7 @@
<view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up"> <view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up">
<text class="fc-link-label">{{ isNestedSpawn ? '⬆ 上游协助 (嵌套)' : '⬆ 上游工序' }}</text> <text class="fc-link-label">{{ isNestedSpawn ? '⬆ 上游协助 (嵌套)' : '⬆ 上游工序' }}</text>
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} → {{ formatUserName(parentTaskOf(lockedTask).assignee_id) || '—' }}</text> <text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} → {{ formatUserName(lockedTask.created_by) || formatUserName(parentTaskOf(lockedTask).assignee_id) || '—' }}</text>
</view> </view>
<view v-if="lockedTask.child_tasks && lockedTask.child_tasks.length" class="fc-link fc-down"> <view v-if="lockedTask.child_tasks && lockedTask.child_tasks.length" class="fc-link fc-down">
<text class="fc-link-label">⬇ 下游分支 ({{ lockedTask.child_tasks.length }})</text> <text class="fc-link-label">⬇ 下游分支 ({{ lockedTask.child_tasks.length }})</text>
@ -78,6 +78,9 @@
<view class="fc-time">{{ formatTaskTime(lockedTask) }}</view> <view class="fc-time">{{ formatTaskTime(lockedTask) }}</view>
</view> </view>
<view v-if="isAdminProxy" class="admin-proxy-notice">
<text class="proxy-text">⚠️ 正在以管理员身份代 [{{ formatUserName(lockedTask.assignee_id) || lockedTask.assignee_id }}] 操作</text>
</view>
<view v-if="isAssignee" class="footer-actions"> <view v-if="isAssignee" class="footer-actions">
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer" <button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer"
@tap="$emit('action', { task: lockedTask, type: 'transfer' })"><text class="btn-icon">🔄</text><text class="btn-txt">完工转交</text></button> @tap="$emit('action', { task: lockedTask, type: 'transfer' })"><text class="btn-icon">🔄</text><text class="btn-txt">完工转交</text></button>
@ -111,6 +114,7 @@ export default {
product: { type: Object, default: null }, product: { type: Object, default: null },
currentUserId: { type: String, default: "" }, currentUserId: { type: String, default: "" },
currentUsername: { type: String, default: "" }, currentUsername: { type: String, default: "" },
currentUserRole: { type: String, default: "" },
initialLockTaskId: { type: String, default: "" }, initialLockTaskId: { type: String, default: "" },
}, },
emits: ["action", "viewRecords"], emits: ["action", "viewRecords"],
@ -162,9 +166,22 @@ export default {
return result; return result;
}, },
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; }, lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
isAssignee() { if (!this.lockedTask) return false; return this.lockedTask.assignee_id == this.currentUserId || this.lockedTask.assignee_id == this.currentUsername; }, isAdmin() { return this.currentUserRole === 'SUPER_ADMIN' || this.currentUserRole === 'SUPERVISOR'; },
isAssignee() {
if (!this.lockedTask) return false;
if (this.isAdmin) return true; // 管理员上帝视角
return this.lockedTask.assignee_id == this.currentUserId
|| this.lockedTask.assignee_id == this.currentUsername;
},
isAdminProxy() {
// 管理员正在代操作非本人任务
return this.isAdmin && this.lockedTask
&& this.lockedTask.assignee_id != this.currentUserId
&& this.lockedTask.assignee_id != this.currentUsername;
},
canRecall() { canRecall() {
if (!this.lockedTask || this.lockedTask.status !== 'PENDING') return false; if (!this.lockedTask || this.lockedTask.status !== 'PENDING') return false;
if (this.isAdmin) return true; // 管理员可撤回任何转交
if (this.isAssignee) return false; if (this.isAssignee) return false;
if (!this.lockedTask.parent_task_id) return false; if (!this.lockedTask.parent_task_id) return false;
const parent = this.taskMap[this.lockedTask.parent_task_id]; const parent = this.taskMap[this.lockedTask.parent_task_id];
@ -281,6 +298,8 @@ export default {
.btn-icon { font-size: 36rpx; margin-bottom: 6rpx; } .btn-icon { font-size: 36rpx; margin-bottom: 6rpx; }
.btn-txt { font-size: 24rpx; font-weight: 700; } .btn-txt { font-size: 24rpx; font-weight: 700; }
.footer-readonly { justify-content: center; background: #fef2f2; } .footer-readonly { justify-content: center; background: #fef2f2; }
.admin-proxy-notice { padding: 10rpx 20rpx; background: #fef9e7; border-top: 2rpx solid #fde68a; flex-shrink: 0; }
.proxy-text { font-size: 22rpx; color: #b45309; font-weight: 600; }
.readonly-hint { font-size: 24rpx; color: #dc2626; font-weight: 600; } .readonly-hint { font-size: 24rpx; color: #dc2626; font-weight: 600; }
.footer-transfer { background: #dcfce7; color: #16a34a; } .footer-transfer { background: #dcfce7; color: #16a34a; }
.footer-record { background: #eff6ff; color: #2563eb; } .footer-record { background: #eff6ff; color: #2563eb; }

View File

@ -46,6 +46,7 @@
<!-- 工作区视图 --> <!-- 工作区视图 -->
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product" <WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
:currentUserId="currentUserId" :currentUsername="currentUsername" :currentUserId="currentUserId" :currentUsername="currentUsername"
:currentUserRole="currentUserRole"
:initialLockTaskId="autoLockTaskId" :initialLockTaskId="autoLockTaskId"
:key="'wa-' + dictVersion" :key="'wa-' + dictVersion"
@action="handleTaskAction" @viewRecords="handleViewRecords" /> @action="handleTaskAction" @viewRecords="handleViewRecords" />
@ -218,7 +219,7 @@ export default {
users: [], TASK_NAME_OPTIONS, users: [], TASK_NAME_OPTIONS,
createFirstVisible: false, isWarehouseTransfer: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "" }, firstSaving: false, createFirstVisible: false, isWarehouseTransfer: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "" }, firstSaving: false,
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false, recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false,
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "workspace", autoLockTaskId: "", currentUser: null, currentUserId: "", currentUsername: "", currentUserRole: "", currentMode: "workspace", autoLockTaskId: "",
processOptions: [], userOptions: [], processOptions: [], userOptions: [],
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "", actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
transferForm: { selectedUserId: "", isWarehouse: false, note: "" }, transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
@ -302,7 +303,7 @@ export default {
async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } }, async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } },
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } }, async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } },
loadCurrentUser() { try { let user = uni.getStorageSync("user"); if (typeof user === "string" && user) { try { user = JSON.parse(user); } catch (e) { user = null; } } if (user && typeof user === "object") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; } } catch {} }, loadCurrentUser() { try { let user = uni.getStorageSync("user"); if (typeof user === "string" && user) { try { user = JSON.parse(user); } catch (e) { user = null; } } if (user && typeof user === "object") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; this.currentUserRole = user.role || ""; } } catch {} },
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; }, onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
onAssigneeChange(e) { const idx = e.detail.value; const u = this.users[idx]; if (u) { this.firstForm.assigneeIdx = idx; this.firstForm.assignee_id = u.username; this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`; } }, onAssigneeChange(e) { const idx = e.detail.value; const u = this.users[idx]; if (u) { this.firstForm.assigneeIdx = idx; this.firstForm.assignee_id = u.username; this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`; } },

View File

@ -1,30 +1,46 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 扫码大按钮 --> <!-- 🚀 全屏扫码大按钮 -->
<view class="scan-btn" @tap="handleScanCode"> <view class="scan-btn camera-btn" @tap="handleScanCamera">
<text class="scan-icon">📷</text> <text class="scan-icon">📷</text>
<text class="scan-text">点击扫码</text> <text class="scan-text">拍照扫码</text>
<text class="scan-hint">扫描二维码 / 条码查询产品</text> <text class="scan-hint">全屏扫描二维码 / 条码</text>
</view> </view>
<!-- 手动输入 --> <!-- 手动输入 -->
<view class="manual-section">
<text class="section-label">或手动输入</text>
<view class="manual-input"> <view class="manual-input">
<view class="input-wrap">
<input v-model="serialNumber" class="input" type="text" maxlength="16" <input v-model="serialNumber" class="input" type="text" maxlength="16"
placeholder="手动输入16位身份证" @confirm="handleSearch" /> placeholder="输入16位身份证" @confirm="handleSearch" />
<text v-if="serialNumber" class="input-clear" @tap="serialNumber=''">✕</text>
</view>
<button class="search-btn" @tap="handleSearch" :disabled="loading"> <button class="search-btn" @tap="handleSearch" :disabled="loading">
{{ loading ? '查询中' : '查询' }} {{ loading ? '查询中' : '查询' }}
</button> </button>
</view> </view>
</view>
<!-- 最近扫描 --> <!-- 最近扫描 -->
<view v-if="lastScanned" class="last-scan">最近扫描: <text class="sn-text">{{ lastScanned }}</text></view> <view v-if="lastScanned" class="last-scan">
<view v-if="loading" class="loading">查询中...</view> <text class="last-label">最近扫描</text>
<view v-if="error" class="error-box">{{ error }}</view> <text class="sn-text" @tap="handleSearch">{{ lastScanned }}</text>
</view>
<view v-if="loading" class="loading">
<text class="loading-icon">⏳</text>
<text>查询中...</text>
</view>
<view v-if="error" class="error-box">
<text class="error-icon">⚠️</text>
<text>{{ error }}</text>
</view>
<!-- 空状态 --> <!-- 空状态 -->
<view v-if="!loading && !error" class="empty"> <view v-if="!loading && !error && !lastScanned" class="empty">
<text class="empty-icon">📱</text> <text class="empty-icon">📱</text>
<text class="empty-text">扫码或手动输入身份证查询产品进度</text> <text class="empty-text">扫码或手动输入身份证</text>
<text class="empty-sub">查询产品流转进度</text>
</view> </view>
</view> </view>
</template> </template>
@ -58,9 +74,12 @@ export default {
} }
}, },
handleSearch() { this.doQuery(this.serialNumber.trim()); }, handleSearch() { this.doQuery(this.serialNumber.trim()); },
handleScanCode() {
// 📷 全屏相机扫码
handleScanCamera() {
uni.scanCode({ uni.scanCode({
onlyFromCamera: true, scanType: ["qrCode", "barCode"], onlyFromCamera: true,
scanType: ["qrCode", "barCode"],
success: (res) => { success: (res) => {
const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16); const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
this.doQuery(sn); this.doQuery(sn);
@ -77,22 +96,44 @@ export default {
</script> </script>
<style scoped> <style scoped>
.page { min-height: 100vh; padding: 40px 16px 16px; } .page { min-height: 100vh; padding: 24px 16px 16px; background: #f3f4f6; }
/* 扫码区域 */
.scan-btn { display: flex; flex-direction: column; align-items: center; justify-content: center; .scan-btn { display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 180px; background: linear-gradient(135deg, #2563EB, #3B82F6); height: 180px; border-radius: 20px; color: #fff; box-shadow: 0 4px 20px rgba(0,0,0,0.12);
border-radius: 16px; color: #fff; box-shadow: 0 4px 16px rgba(37,99,235,0.3); } transition: transform 0.15s; margin-bottom: 24px; }
.scan-icon { font-size: 52px; margin-bottom: 8px; } .scan-btn:active { transform: scale(0.96); }
.scan-text { font-size: 20px; font-weight: 700; } .camera-btn { background: linear-gradient(135deg, #2563EB, #4F46E5); }
.scan-hint { font-size: 13px; opacity: 0.8; margin-top: 4px; } .scan-icon { font-size: 44px; margin-bottom: 6px; }
.manual-input { display: flex; gap: 8px; margin-top: 16px; } .scan-text { font-size: 17px; font-weight: 700; }
.input { flex: 1; height: 44px; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 14px; background: #fff; } .scan-hint { font-size: 11px; opacity: 0.75; margin-top: 4px; }
.search-btn { height: 44px; padding: 0 20px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; line-height: 44px; }
.search-btn[disabled] { opacity: 0.6; } /* 手动输入 */
.last-scan { font-size: 12px; color: #9ca3af; margin-top: 16px; } .manual-section { margin-top: 24px; }
.sn-text { font-family: monospace; color: #4b5563; } .section-label { font-size: 12px; color: #9ca3af; margin-bottom: 8px; display: block; }
.loading { text-align: center; padding: 24px 0; color: #6b7280; } .manual-input { display: flex; gap: 8px; }
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; margin-top: 12px; } .input-wrap { flex: 1; position: relative; }
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 40px; color: #9ca3af; } .input { width: 100%; height: 48px; padding: 0 36px 0 14px; border: 1px solid #e5e7eb; border-radius: 12px;
.empty-icon { font-size: 48px; margin-bottom: 8px; } font-size: 15px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04); box-sizing: border-box; }
.empty-text { font-size: 14px; } .input-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
font-size: 16px; color: #9ca3af; padding: 4px; z-index: 2; }
.search-btn { height: 48px; padding: 0 22px; background: #2563EB; color: #fff; border: none;
border-radius: 12px; font-size: 15px; font-weight: 600; line-height: 48px; }
.search-btn[disabled] { opacity: 0.5; }
/* 状态 */
.last-scan { display: flex; align-items: center; gap: 8px; margin-top: 20px; padding: 12px 16px;
background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
.last-label { font-size: 12px; color: #9ca3af; }
.sn-text { font-family: monospace; font-size: 14px; color: #2563EB; font-weight: 600; flex: 1; }
.loading { display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 24px 0; color: #6b7280; font-size: 14px; }
.loading-icon { font-size: 20px; }
.error-box { display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 14px;
border-radius: 12px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
.error-icon { font-size: 16px; }
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 60px; color: #9ca3af; }
.empty-icon { font-size: 56px; margin-bottom: 12px; }
.empty-text { font-size: 15px; font-weight: 500; }
.empty-sub { font-size: 12px; margin-top: 4px; }
</style> </style>