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(
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"),
)
@ -146,7 +147,8 @@ async def recall_task_endpoint(
适用场景:转交后发现选错人,在对方接收前撤回。
"""
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"),
)
@ -173,7 +175,9 @@ async def spawn_subtask_endpoint(
**派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。**
用于 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,同步产品宏观状态。
"""
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"),
)
@ -222,7 +228,8 @@ async def reject_task_endpoint(
3. 为该负责人新建返工任务(is_rework=True, status=PENDING)。
"""
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"),
)
@ -256,7 +263,8 @@ async def transfer_task_endpoint(
- 否则 → 顶层同级转交。
"""
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"),
)

View File

@ -55,6 +55,10 @@ class ProductResponse(BaseModel):
overall_status: str | None = None
status: str
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}

View File

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

View File

@ -42,6 +42,7 @@ def _task_to_response(task: Task) -> TaskResponse:
created_at=task.created_at,
child_tasks=[_task_to_response(c) for c in task.child_tasks],
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_ids: set[str] = set()
all_task_ids: list[uuid.UUID] = []
def _collect_ids(tasks):
for t in tasks:
all_task_ids.append(t.id)
if t.assignee_id: assignee_ids.add(t.assignee_id)
if t.child_tasks: _collect_ids(t.child_tasks)
for t in top_tasks:
all_task_ids.append(t.id)
if t.assignee_id: assignee_ids.add(t.assignee_id)
_collect_ids(task_tree)
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(
id=product.id,
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_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 [
ProductResponse(
id=p.id,
@ -474,10 +542,8 @@ async def get_all_products(
category=p.category,
material_type=p.material_type,
parent_product_id=p.parent_product_id,
# 🔧 当前位置:动态主干assignee优先 → 静态兜底
current_location_id=(
main_assignees.get(p.id) # 动态主干
or p.current_location_id # 静态兜底
main_assignees.get(p.id) or p.current_location_id
),
current_location_name=(
"仓库" 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,
status=p.status,
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
]

View File

@ -217,19 +217,28 @@ async def _check_all_critical_children_completed(
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(
db: AsyncSession,
task_id: uuid.UUID,
action_type: str,
operator_id: str | None = None,
remark: str | None = None,
task_assignee_id: str | None = None,
) -> TaskLog:
"""创建任务操作日志"""
"""创建任务操作日志。自动检测管理员代办并拼接审计标记。"""
final_remark = (remark or "") + _admin_proxy_note(operator_id, task_assignee_id)
log = TaskLog(
task_id=task_id,
operator_id=operator_id,
action_type=action_type,
remark=remark,
remark=final_remark.strip() or None,
)
db.add(log)
return log
@ -349,7 +358,8 @@ async def end_task(
task.completed_at = now
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)
@ -414,7 +424,8 @@ async def recall_task(
task.status = TASK_STATUS_CANCELED
task.completed_at = now
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="[]"))
# 2. 生成接力新任务(以撤回节点为父,还给操作人)
@ -432,7 +443,8 @@ async def recall_task(
db.add(recovery)
await db.flush()
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="[]"))
# 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="[]"))
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.refresh(child)
return _to_response(child)
@ -546,8 +559,11 @@ async def receive_task(
action_type="receive",
operator_id=operator_id,
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))
@ -609,6 +625,7 @@ async def reject_task(
action_type="reject",
operator_id=operator_id,
remark=f"品质驳回: {request.reason}",
task_assignee_id=task.assignee_id,
)
# --- 2. 确定返工任务的负责人(追溯上一道工序的转交人) ---
@ -657,6 +674,7 @@ async def reject_task(
action_type="create",
operator_id=operator_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",
operator_id=operator_id,
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:解析下家 & 裂变 ---
# 兼容新旧格式
@ -795,21 +816,23 @@ async def transfer_task(
# 批量 flush 以生成 ID
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:
await _create_task_log(
db, nt.id,
action_type="create",
operator_id=operator_id,
remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}",
task_assignee_id=nt.assignee_id,
)
# 🔔 通知:新任务派发
if nt.assignee_id:
product_sn = ""
try:
if product:
product_sn = product.serial_number or ""
except Exception:
pass
db.add(Notification(
user_id=nt.assignee_id,
title=f"🟢 新任务派发",
@ -819,10 +842,6 @@ async def transfer_task(
))
# --- 更新 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 has_warehouse and not real_branches:
product.current_location_id = VIRTUAL_WAREHOUSE
@ -910,6 +929,7 @@ async def complete_task(
action_type="complete",
operator_id=request.operator_id,
remark=request.remark or f"完成任务: {task.task_name}",
task_assignee_id=task.assignee_id,
)
# --- 4. 可选:创建下一步任务(转交) ---
@ -941,6 +961,7 @@ async def complete_task(
action_type="create",
operator_id=request.operator_id,
remark=f"由任务「{task.task_name}」完成后转交创建",
task_assignee_id=next_task.assignee_id,
)
# 🔧 位置回溯:老接口也触发(父任务优先)
@ -1000,6 +1021,7 @@ async def create_subtask(
action_type="create",
operator_id=data.assignee_id,
remark=f"创建子任务「{data.task_name}」,父任务: 「{parent.task_name}」",
task_assignee_id=subtask.assignee_id,
)
await db.commit()

View File

@ -22,8 +22,18 @@ function ArrowRight({ 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>;
}
function parseImages(s: string | null | undefined): string[] { if (!s) return []; try { return JSON.parse(s); } catch { return []; } }
function imageUrl(u: string) { if (!u) return ""; return u.startsWith("http") ? u : import.meta.env.VITE_API_BASE_URL + (u.startsWith("/") ? u : "/" + u); }
function parseImages(s: any): string[] {
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>();
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="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>}
{(() => { 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>}

View File

@ -5,6 +5,7 @@ import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X,
} from "lucide-react";
import { Tooltip } from "antd";
import api from "../../services/api";
import { scanProduct } from "../../services/productApi";
import {
@ -263,8 +264,17 @@ export default function AdminTasksPage() {
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
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>
<button
type="submit"
@ -368,13 +378,14 @@ export default function AdminTasksPage() {
{isOpen && (
<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-1">序列号</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-2">最新动态</div>
<div className="col-span-2">创建时间</div>
<div className="col-span-2">操作</div>
</div>
@ -391,13 +402,27 @@ export default function AdminTasksPage() {
return (
<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-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-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 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">
<button

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@
<view class="ss-title-group">
<text class="ss-title">{{ lanes[currentLane].label }}</text>
<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>
</view>
@ -20,7 +20,7 @@
:style="{ height: swiperHeight + 'px' }" duration="250">
<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' }">
<swiper-item v-for="(card, ci) in lane.cards" :key="card._key">
<view class="ss-card-wrapper">
@ -85,7 +85,7 @@
</view>
<view class="ss-dots">
<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>
</view>
</view>
@ -104,9 +104,17 @@ export default {
data() {
return {
currentLane: 0,
cardIndices: {}, // { laneKey: currentCardIndex } — 独立管理避免computed重置
swiperHeight: 600,
};
},
watch: {
// product 变化时重置所有滑动位置
product: {
immediate: true,
handler() { this.cardIndices = {}; },
},
},
computed: {
branchLabelMap() {
const map = {};
@ -133,7 +141,7 @@ export default {
const 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) => {
if (!tasks) return [];
@ -160,7 +168,7 @@ export default {
const spawns = t.child_tasks.filter(c => c.task_type === 'SPAWN');
for (const sc of spawns) {
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); // 先占坑:父分支排在前面
followMain([sc], branchLane); // 再递归:孙子分支自然排在后面
}
@ -181,10 +189,13 @@ export default {
},
methods: {
formatUserName,
getCardIdx(laneKey) { return this.cardIndices[laneKey] || 0; },
setCardIdx(laneKey, idx) { this.$set(this.cardIndices, laneKey, idx); },
onLaneSwipe(e) { this.currentLane = e.detail.current; },
onCardSwipe(e, laneIdx) {
if (this.lanes[laneIdx]) {
this.$set(this.lanes[laneIdx], '_cardIdx', e.detail.current);
const lane = this.lanes[laneIdx];
if (lane) {
this.setCardIdx(lane._key, e.detail.current);
}
},
taskCardClass(t) {

View File

@ -61,7 +61,7 @@
<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-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 v-if="lockedTask.child_tasks && lockedTask.child_tasks.length" class="fc-link fc-down">
<text class="fc-link-label">⬇ 下游分支 ({{ lockedTask.child_tasks.length }})</text>
@ -78,6 +78,9 @@
<view class="fc-time">{{ formatTaskTime(lockedTask) }}</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">
<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>
@ -111,6 +114,7 @@ export default {
product: { type: Object, default: null },
currentUserId: { type: String, default: "" },
currentUsername: { type: String, default: "" },
currentUserRole: { type: String, default: "" },
initialLockTaskId: { type: String, default: "" },
},
emits: ["action", "viewRecords"],
@ -162,9 +166,22 @@ export default {
return result;
},
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() {
if (!this.lockedTask || this.lockedTask.status !== 'PENDING') return false;
if (this.isAdmin) return true; // 管理员可撤回任何转交
if (this.isAssignee) return false;
if (!this.lockedTask.parent_task_id) return false;
const parent = this.taskMap[this.lockedTask.parent_task_id];
@ -281,6 +298,8 @@ export default {
.btn-icon { font-size: 36rpx; margin-bottom: 6rpx; }
.btn-txt { font-size: 24rpx; font-weight: 700; }
.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; }
.footer-transfer { background: #dcfce7; color: #16a34a; }
.footer-record { background: #eff6ff; color: #2563eb; }

View File

@ -46,6 +46,7 @@
<!-- 工作区视图 -->
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
:currentUserId="currentUserId" :currentUsername="currentUsername"
:currentUserRole="currentUserRole"
:initialLockTaskId="autoLockTaskId"
:key="'wa-' + dictVersion"
@action="handleTaskAction" @viewRecords="handleViewRecords" />
@ -218,7 +219,7 @@ export default {
users: [], TASK_NAME_OPTIONS,
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,
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "workspace", autoLockTaskId: "",
currentUser: null, currentUserId: "", currentUsername: "", currentUserRole: "", currentMode: "workspace", autoLockTaskId: "",
processOptions: [], userOptions: [],
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
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 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]; },
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>
<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-text">点击扫码</text>
<text class="scan-hint">扫描二维码 / 条码查询产品</text>
<text class="scan-text">拍照扫码</text>
<text class="scan-hint">全屏扫描二维码 / 条码</text>
</view>
<!-- 手动输入 -->
<view class="manual-input">
<input v-model="serialNumber" class="input" type="text" maxlength="16"
placeholder="手动输入16位身份证" @confirm="handleSearch" />
<button class="search-btn" @tap="handleSearch" :disabled="loading">
{{ loading ? '查询中' : '查询' }}
</button>
<view class="manual-section">
<text class="section-label">或手动输入</text>
<view class="manual-input">
<view class="input-wrap">
<input v-model="serialNumber" class="input" type="text" maxlength="16"
placeholder="输入16位身份证" @confirm="handleSearch" />
<text v-if="serialNumber" class="input-clear" @tap="serialNumber=''">✕</text>
</view>
<button class="search-btn" @tap="handleSearch" :disabled="loading">
{{ loading ? '查询中' : '查询' }}
</button>
</view>
</view>
<!-- 最近扫描 -->
<view v-if="lastScanned" class="last-scan">最近扫描: <text class="sn-text">{{ lastScanned }}</text></view>
<view v-if="loading" class="loading">查询中...</view>
<view v-if="error" class="error-box">{{ error }}</view>
<view v-if="lastScanned" class="last-scan">
<text class="last-label">最近扫描</text>
<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-text">扫码或手动输入身份证查询产品进度</text>
<text class="empty-text">扫码或手动输入身份证</text>
<text class="empty-sub">查询产品流转进度</text>
</view>
</view>
</template>
@ -58,9 +74,12 @@ export default {
}
},
handleSearch() { this.doQuery(this.serialNumber.trim()); },
handleScanCode() {
// 📷 全屏相机扫码
handleScanCamera() {
uni.scanCode({
onlyFromCamera: true, scanType: ["qrCode", "barCode"],
onlyFromCamera: true,
scanType: ["qrCode", "barCode"],
success: (res) => {
const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
this.doQuery(sn);
@ -77,22 +96,44 @@ export default {
</script>
<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;
height: 180px; background: linear-gradient(135deg, #2563EB, #3B82F6);
border-radius: 16px; color: #fff; box-shadow: 0 4px 16px rgba(37,99,235,0.3); }
.scan-icon { font-size: 52px; margin-bottom: 8px; }
.scan-text { font-size: 20px; font-weight: 700; }
.scan-hint { font-size: 13px; opacity: 0.8; margin-top: 4px; }
.manual-input { display: flex; gap: 8px; margin-top: 16px; }
.input { flex: 1; height: 44px; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 14px; background: #fff; }
.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; }
.sn-text { font-family: monospace; color: #4b5563; }
.loading { text-align: center; padding: 24px 0; color: #6b7280; }
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; margin-top: 12px; }
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 40px; color: #9ca3af; }
.empty-icon { font-size: 48px; margin-bottom: 8px; }
.empty-text { font-size: 14px; }
height: 180px; border-radius: 20px; color: #fff; box-shadow: 0 4px 20px rgba(0,0,0,0.12);
transition: transform 0.15s; margin-bottom: 24px; }
.scan-btn:active { transform: scale(0.96); }
.camera-btn { background: linear-gradient(135deg, #2563EB, #4F46E5); }
.scan-icon { font-size: 44px; margin-bottom: 6px; }
.scan-text { font-size: 17px; font-weight: 700; }
.scan-hint { font-size: 11px; opacity: 0.75; margin-top: 4px; }
/* 手动输入 */
.manual-section { margin-top: 24px; }
.section-label { font-size: 12px; color: #9ca3af; margin-bottom: 8px; display: block; }
.manual-input { display: flex; gap: 8px; }
.input-wrap { flex: 1; position: relative; }
.input { width: 100%; height: 48px; padding: 0 36px 0 14px; border: 1px solid #e5e7eb; border-radius: 12px;
font-size: 15px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04); box-sizing: border-box; }
.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>