Compare commits
29 Commits
49cbf22854
...
f056cc8625
| Author | SHA1 | Date | |
|---|---|---|---|
| f056cc8625 | |||
| b8ade13b61 | |||
| abc8999358 | |||
| 997e16d9ba | |||
| d8d42e9613 | |||
| 2170e2e9cf | |||
| badb470ea9 | |||
| f0503f8800 | |||
| 5062745c14 | |||
| 8ac4c425a5 | |||
| d9c875924c | |||
| eba0c3be98 | |||
| 828aee5161 | |||
| e7bfdc261b | |||
| 74cb77f547 | |||
| f6ba1d1740 | |||
| a09d4481a3 | |||
| 2ca382bb2c | |||
| 9f3deb5b6c | |||
| 8303c8b93f | |||
| a5e256330a | |||
| a38be2c568 | |||
| e511952ea0 | |||
| 92c388190c | |||
| b21b9ed0e2 | |||
| 6016f92dd0 | |||
| b21df33e0a | |||
| 163d9c20bb | |||
| 1fdc607c07 |
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 物料选项 */
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// ---- 操作响应 ----
|
||||
|
||||
@ -43,7 +43,6 @@
|
||||
}
|
||||
},
|
||||
"sdkConfigs" : {
|
||||
"push" : {},
|
||||
"speech" : {}
|
||||
},
|
||||
"ios" : {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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})`; } },
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user