Compare commits
92 Commits
master
...
3cb85f28b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb85f28b4 | |||
| b97dfaa95d | |||
| 9064973a3f | |||
| 01b8601dcd | |||
| 5b5f4a6b0e | |||
| 0a1c2f4dcf | |||
| 40a2d87345 | |||
| 0e3eb6a35a | |||
| 974f4b9d01 | |||
| 30a48d90fc | |||
| 88dc7381f6 | |||
| d40a8d480e | |||
| de47d27cfb | |||
| 6755dfeae3 | |||
| d3def15bb3 | |||
| 718e205f53 | |||
| 567f7175da | |||
| 051a075e9d | |||
| 6373dca4f6 | |||
| f9451b104b | |||
| 05003d3053 | |||
| 1fe9c3d59d | |||
| b14773f81b | |||
| e326f8d311 | |||
| 2440e9bdaa | |||
| ba060cbb5e | |||
| a07e43ec3a | |||
| f2403dd1e6 | |||
| 5271177d7a | |||
| 3ec244bf2d | |||
| 5f12adfe2f | |||
| 51ca05d737 | |||
| 016a988962 | |||
| 0b6c4a7f8b | |||
| f80da36d65 | |||
| 6d534960a7 | |||
| 38263291d1 | |||
| 4d93844637 | |||
| fba3589d0d | |||
| 87296af139 | |||
| ae520e49d7 | |||
| 5d6e2135c0 | |||
| 34724e478c | |||
| e447de6cfa | |||
| b43bded0ef | |||
| c0fe7c94bb | |||
| 2eed7db82d | |||
| 60af3b9998 | |||
| 2880ea6df7 | |||
| 26dc05cc2f | |||
| 4854eb626e | |||
| ad9fb8d37e | |||
| ffab378700 | |||
| 45b5376537 | |||
| d8623df7de | |||
| f33396b9f1 | |||
| 3c4c16a1cc | |||
| ab935a8c95 | |||
| 65ff05fb2f | |||
| 592bde7cab | |||
| d4f6266fcc | |||
| 0fcc607f17 | |||
| 71e55ed476 | |||
| a71af500dc | |||
| 704af0b2a4 | |||
| 356e3819dd | |||
| c9383ba4be | |||
| b834b24c24 | |||
| beea543fbd | |||
| bc43b5a732 | |||
| d3d501dfeb | |||
| 1b4af0a7af | |||
| 9ed85f88bb | |||
| 472d4ad67a | |||
| 45102eb23f | |||
| 3e8ccf3444 | |||
| 6382aa6d45 | |||
| 355f1bae66 | |||
| abbf0f5a24 | |||
| a72c8c89e3 | |||
| 0e5b2e42e9 | |||
| 0de981ea81 | |||
| 43baa1de8e | |||
| 41dfc53f83 | |||
| bff8787e62 | |||
| 0df7215134 | |||
| 721cfe1504 | |||
| b71c5a2d07 | |||
| dc97a7385f | |||
| b725599cb0 | |||
| 3d164159ac | |||
| 9d204a57d8 |
32
backend/alembic/versions/b8c9d0e1f2a3_add_notifications.py
Normal file
32
backend/alembic/versions/b8c9d0e1f2a3_add_notifications.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""add_notifications
|
||||
|
||||
Revision ID: b8c9d0e1f2a3
|
||||
Revises: a7b8c9d0e1f2
|
||||
Create Date: 2026-08-07
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "b8c9d0e1f2a3"
|
||||
down_revision: Union[str, None] = "a7b8c9d0e1f2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("user_id", sa.String(64), nullable=False, index=True, comment="接收人ID(逻辑外键→老系统)"),
|
||||
sa.Column("title", sa.String(200), nullable=False, comment="通知标题"),
|
||||
sa.Column("content", sa.Text(), nullable=False, comment="通知内容详情"),
|
||||
sa.Column("type", sa.String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)"),
|
||||
sa.Column("task_id", sa.UUID(), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID"),
|
||||
sa.Column("is_read", sa.Boolean(), server_default=sa.text("false"), comment="是否已读"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), comment="创建时间"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("notifications")
|
||||
36
backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py
Normal file
36
backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""add_app_versions
|
||||
|
||||
Revision ID: c9d0e1f2a3b4
|
||||
Revises: b8c9d0e1f2a3
|
||||
Create Date: 2026-08-07
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "c9d0e1f2a3b4"
|
||||
down_revision: Union[str, None] = "b8c9d0e1f2a3"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"app_versions",
|
||||
sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("version", sa.String(20), nullable=False, unique=True, comment="版本号"),
|
||||
sa.Column("version_code", sa.Integer(), nullable=False, server_default="100", comment="数字版本号"),
|
||||
sa.Column("wgt_url", sa.String(500), nullable=False, comment="WGT下载地址"),
|
||||
sa.Column("description", sa.Text(), nullable=True, comment="更新说明"),
|
||||
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), comment="是否启用"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
# 插入初始版本记录
|
||||
op.execute(
|
||||
"INSERT INTO app_versions (version, version_code, wgt_url, description, is_active) "
|
||||
"VALUES ('T1.0.1', 101, '', '初始版本', true)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("app_versions")
|
||||
@ -0,0 +1,33 @@
|
||||
"""add_product_messages
|
||||
|
||||
Revision ID: g1h2i3j4k5l6
|
||||
Revises: c9d0e1f2a3b4
|
||||
Create Date: 2026-08-10
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "g1h2i3j4k5l6"
|
||||
down_revision: Union[str, None] = "c9d0e1f2a3b4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"product_messages",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("product_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("products.id", ondelete="CASCADE"),
|
||||
index=True, nullable=False),
|
||||
sa.Column("operator_id", sa.String(50), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("product_messages")
|
||||
87
backend/app/api/v1/endpoints/app_version.py
Normal file
87
backend/app/api/v1/endpoints/app_version.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""App 版本更新 API — OTA 热更新检测"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.app_version import AppVersion
|
||||
from app.schemas.app_version import AppVersionResponse
|
||||
|
||||
router = APIRouter(prefix="/app", tags=["App版本"])
|
||||
|
||||
|
||||
@router.get("/check-update", response_model=AppVersionResponse)
|
||||
async def check_update(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
热更新检测接口(无需传参)。
|
||||
查询 app_versions 表中 is_active=true 的最新记录,
|
||||
返回最新版本号、版本代码、WGT下载地址、更新说明。
|
||||
App 端自行对比本地版本号决定是否升级。
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AppVersion)
|
||||
.where(AppVersion.is_active.is_(True))
|
||||
.order_by(desc(AppVersion.version_code))
|
||||
.limit(1)
|
||||
)
|
||||
latest = result.scalar_one_or_none()
|
||||
|
||||
if not latest:
|
||||
return AppVersionResponse(
|
||||
version="0",
|
||||
version_code=0,
|
||||
has_update=False,
|
||||
)
|
||||
|
||||
return AppVersionResponse(
|
||||
version=latest.version,
|
||||
version_code=latest.version_code,
|
||||
has_update=bool(latest.wgt_url), # 只有配置了 WGT 下载地址才算有效更新
|
||||
wgt_url=latest.wgt_url,
|
||||
description=latest.description,
|
||||
force_update=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/version", response_model=AppVersionResponse)
|
||||
async def check_version(
|
||||
current: str = Query(..., description="当前 App 版本号,如 T1.0.0"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""检测是否有新版本可用(服务端对比)"""
|
||||
result = await db.execute(
|
||||
select(AppVersion)
|
||||
.where(AppVersion.is_active.is_(True))
|
||||
.order_by(desc(AppVersion.version_code))
|
||||
.limit(1)
|
||||
)
|
||||
latest = result.scalar_one_or_none()
|
||||
|
||||
if not latest:
|
||||
return AppVersionResponse(
|
||||
version=current,
|
||||
version_code=0,
|
||||
has_update=False,
|
||||
)
|
||||
|
||||
has_update = latest.version_code > _parse_version_code(current)
|
||||
|
||||
return AppVersionResponse(
|
||||
version=latest.version,
|
||||
version_code=latest.version_code,
|
||||
has_update=has_update,
|
||||
wgt_url=latest.wgt_url if has_update else None,
|
||||
description=latest.description if has_update else None,
|
||||
force_update=False,
|
||||
)
|
||||
|
||||
|
||||
def _parse_version_code(version_str: str) -> int:
|
||||
"""从版本字符串提取数字版本号"""
|
||||
import re
|
||||
nums = re.findall(r"\d+", version_str)
|
||||
if nums:
|
||||
return int("".join(nums[-3:]).ljust(3, "0")[:3])
|
||||
return 0
|
||||
@ -1,20 +1,32 @@
|
||||
"""认证 API — 对接 MOM sys_user"""
|
||||
"""认证 API — 对接 MOM sys_user + 双 Token 刷新"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.schemas.user import LoginRequest, LoginResponse, UserResponse
|
||||
from app.services.auth_service import login, get_current_user
|
||||
from app.schemas.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshRequest,
|
||||
RefreshResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.auth_service import login, refresh_access_token, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login_endpoint(data: LoginRequest):
|
||||
"""登录 — 验证 MOM sys_user 表,返回 JWT"""
|
||||
"""登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token"""
|
||||
return login(data.username, data.password)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshResponse)
|
||||
def refresh_endpoint(data: RefreshRequest):
|
||||
"""刷新 Access Token — 使用 Refresh Token 换取新的 Access Token"""
|
||||
return refresh_access_token(data.refresh_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: dict = Depends(get_current_user)):
|
||||
"""获取当前用户信息(从 JWT 解析)"""
|
||||
"""获取当前用户信息(从 Access Token 解析)"""
|
||||
return UserResponse(
|
||||
id=current_user["sub"],
|
||||
username=current_user.get("username", ""),
|
||||
|
||||
@ -6,8 +6,8 @@ from sqlalchemy import text
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["物料选择"])
|
||||
|
||||
# 只展示成品 / 半成品(category 字段区分,如 IRIS/成品/… / IRIS/半成品/…)
|
||||
TYPE_FILTER = "category ILIKE '%成品%' OR category ILIKE '%半成品%'"
|
||||
# 全量展示全部物料类别
|
||||
TYPE_FILTER = "1=1"
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
100
backend/app/api/v1/endpoints/notifications.py
Normal file
100
backend/app/api/v1/endpoints/notifications.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""通知 API 端点 — 获取列表、标记已读"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.notification import Notification
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
from app.schemas.notification import NotificationResponse, NotificationListResponse
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["消息通知"])
|
||||
|
||||
|
||||
@router.get("/", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
user_id: str = Query(..., description="当前用户ID"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户的通知列表(按时间倒序)"""
|
||||
# 总数
|
||||
count_stmt = select(func.count()).select_from(Notification).where(
|
||||
Notification.user_id == user_id
|
||||
)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 未读数
|
||||
unread_stmt = select(func.count()).select_from(Notification).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read.is_(False),
|
||||
)
|
||||
unread_result = await db.execute(unread_stmt)
|
||||
unread_count = unread_result.scalar() or 0
|
||||
|
||||
# 列表
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
notifications = result.scalars().all()
|
||||
|
||||
# 🚀 批量查询关联的 product_serial_number
|
||||
task_ids = [n.task_id for n in notifications if n.task_id]
|
||||
serial_map: dict[uuid.UUID, str] = {}
|
||||
if task_ids:
|
||||
task_result = await db.execute(
|
||||
select(Task.id, Product.serial_number)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(Task.id.in_(task_ids))
|
||||
)
|
||||
for row in task_result:
|
||||
serial_map[row[0]] = row[1]
|
||||
|
||||
# 组装响应
|
||||
response_list: list[NotificationResponse] = []
|
||||
for n in notifications:
|
||||
resp = NotificationResponse.model_validate(n)
|
||||
if n.task_id and n.task_id in serial_map:
|
||||
resp.product_serial_number = serial_map[n.task_id]
|
||||
response_list.append(resp)
|
||||
|
||||
return NotificationListResponse(
|
||||
notifications=response_list,
|
||||
total=total,
|
||||
unread_count=unread_count,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read", response_model=NotificationResponse)
|
||||
async def mark_notification_read(
|
||||
notification_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""标记单条通知为已读"""
|
||||
nid = uuid.UUID(notification_id)
|
||||
result = await db.execute(
|
||||
select(Notification).where(Notification.id == nid)
|
||||
)
|
||||
notification = result.scalar_one_or_none()
|
||||
if not notification:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"通知不存在: {notification_id}",
|
||||
)
|
||||
|
||||
notification.is_read = True
|
||||
await db.commit()
|
||||
await db.refresh(notification)
|
||||
return NotificationResponse.model_validate(notification)
|
||||
@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.message import ProductMessage
|
||||
from app.schemas.product import (
|
||||
ProductCreate,
|
||||
ProductUpdate,
|
||||
@ -13,6 +15,7 @@ from app.schemas.product import (
|
||||
ProductScanResponse,
|
||||
)
|
||||
from app.services import product_service
|
||||
from app.services.auth_service import get_current_user
|
||||
from app.services.qrcode_service import generate_qrcode_png
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||
@ -62,11 +65,15 @@ async def scan_product(
|
||||
@router.get("/", response_model=list[ProductResponse])
|
||||
async def list_products(
|
||||
skip: int = Query(0, ge=0, description="跳过条数"),
|
||||
limit: int = Query(50, ge=1, le=200, description="返回条数"),
|
||||
limit: int = Query(50, ge=1, le=1000, description="返回条数"),
|
||||
keyword: str | None = Query(None, description="多维搜索: 产品身份证/订单号/规格型号"),
|
||||
status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取产品列表"""
|
||||
return await product_service.get_all_products(db, skip=skip, limit=limit)
|
||||
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选"""
|
||||
return await product_service.get_all_products(
|
||||
db, skip=skip, limit=limit, keyword=keyword, status_filter=status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=ProductResponse)
|
||||
@ -83,9 +90,11 @@ async def get_product(
|
||||
async def create_product_endpoint(
|
||||
data: ProductCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""创建产品"""
|
||||
return await product_service.create_product(db, data)
|
||||
"""创建产品 — 初始位置自动设为当前登录用户"""
|
||||
creator_username = current_user.get("username", "")
|
||||
return await product_service.create_product(db, data, creator_username)
|
||||
|
||||
|
||||
@router.patch("/{product_id}", response_model=ProductResponse)
|
||||
@ -93,12 +102,24 @@ async def update_product_endpoint(
|
||||
product_id: str,
|
||||
data: ProductUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""更新产品"""
|
||||
import uuid
|
||||
return await product_service.update_product(db, uuid.UUID(product_id), data)
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=204)
|
||||
async def delete_product_endpoint(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""删除产品及其关联任务"""
|
||||
import uuid
|
||||
await product_service.delete_product(db, uuid.UUID(product_id))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 宏观状态更新 — 扫码定调
|
||||
# ============================================================
|
||||
@ -112,10 +133,63 @@ async def update_product_overall_status(
|
||||
serial_number: str,
|
||||
data: OverallStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
更新产品宏观流转状态。
|
||||
移动端首次扫码或手动切换时调用。
|
||||
合法值: 备货 | 生产 | 测试 | 维修 | 在库
|
||||
|
||||
权限:仅 SUPER_ADMIN 或当前操作该产品主线任务的人可以修改。
|
||||
"""
|
||||
return await product_service.update_overall_status(db, serial_number, data.status)
|
||||
return await product_service.update_overall_status(
|
||||
db, serial_number, data.status, current_user,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 协同留言板
|
||||
# ============================================================
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
operator_id: str = Field(..., min_length=1, max_length=50, description="留言人姓名或工号")
|
||||
content: str = Field(..., min_length=1, description="留言内容")
|
||||
|
||||
|
||||
@router.get("/{product_id}/messages")
|
||||
async def get_product_messages(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取某产品的所有留言(按时间正序)"""
|
||||
result = await db.execute(
|
||||
select(ProductMessage)
|
||||
.where(ProductMessage.product_id == product_id)
|
||||
.order_by(ProductMessage.created_at.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{product_id}/messages", status_code=201)
|
||||
async def create_product_message(
|
||||
product_id: str,
|
||||
request: MessageCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""发布新留言(operator_id 由后端 Token 强制覆写,防止越权伪造)"""
|
||||
import uuid
|
||||
real_operator_id = (
|
||||
current_user.get("username")
|
||||
or current_user.get("sub")
|
||||
or request.operator_id
|
||||
)
|
||||
msg = ProductMessage(
|
||||
product_id=uuid.UUID(product_id),
|
||||
operator_id=real_operator_id,
|
||||
content=request.content,
|
||||
)
|
||||
db.add(msg)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
return msg
|
||||
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query, Body
|
||||
from pydantic import BaseModel, Field
|
||||
from app.services.auth_service import get_current_user
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@ -59,6 +60,7 @@ async def get_task(
|
||||
async def create_task_endpoint(
|
||||
data: TaskCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""创建任务"""
|
||||
return await task_service.create_task(db, data)
|
||||
@ -69,6 +71,7 @@ async def update_task_endpoint(
|
||||
task_id: str,
|
||||
data: TaskUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""更新任务"""
|
||||
return await task_service.update_task(db, uuid.UUID(task_id), data)
|
||||
@ -83,6 +86,7 @@ async def complete_task_endpoint(
|
||||
task_id: str,
|
||||
request: TaskCompleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**核心接口:完成任务 + 可选创建下一步任务(转交)**
|
||||
@ -99,7 +103,8 @@ async def complete_task_endpoint(
|
||||
- 完成后自动创建下一步任务并指定负责人
|
||||
"""
|
||||
return await task_service.complete_task(
|
||||
db, uuid.UUID(task_id), request
|
||||
db, uuid.UUID(task_id), request,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
@ -112,13 +117,17 @@ async def end_task_endpoint(
|
||||
task_id: str,
|
||||
operator_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**结束当前分支:标记任务为 COMPLETED,不创建下游任务。**
|
||||
|
||||
用于工人认为工序已完结、无需转交下一人的场景。
|
||||
"""
|
||||
return await task_service.end_task(db, uuid.UUID(task_id), operator_id)
|
||||
return await task_service.end_task(
|
||||
db, uuid.UUID(task_id), operator_id,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -130,12 +139,16 @@ async def recall_task_endpoint(
|
||||
task_id: str,
|
||||
operator_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**撤回转交:删除 PENDING 子任务,恢复父任务为 WIP。**
|
||||
适用场景:转交后发现选错人,在对方接收前撤回。
|
||||
"""
|
||||
return await task_service.recall_task(db, uuid.UUID(task_id), operator_id)
|
||||
return await task_service.recall_task(
|
||||
db, uuid.UUID(task_id), operator_id,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -154,6 +167,7 @@ async def spawn_subtask_endpoint(
|
||||
data: SpawnRequest,
|
||||
operator_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。**
|
||||
@ -173,6 +187,7 @@ async def receive_task_endpoint(
|
||||
remark: str | None = Body(None, description="接收备注", embed=True),
|
||||
task_name: str | None = Body(None, description="接收人选定的工序名称", embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**确认接收任务。工人选定工序名称后接收。**
|
||||
@ -181,7 +196,8 @@ 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, remark, task_name,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
@ -195,6 +211,7 @@ async def reject_task_endpoint(
|
||||
request: TaskRejectRequest,
|
||||
operator_id: str | None = Query(None, description="操作人ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**品质驳回:将任务标记为 REJECTED,自动创建返工任务。**
|
||||
@ -205,7 +222,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,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
@ -219,6 +237,7 @@ async def transfer_task_endpoint(
|
||||
request: TaskTransferRequest,
|
||||
operator_id: str | None = Query(None, description="操作人ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**完工并裂变转交:完成当前任务,批量创建下一道工序任务。**
|
||||
@ -237,7 +256,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,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
@ -250,6 +270,7 @@ async def create_subtask_endpoint(
|
||||
task_id: str,
|
||||
data: SubtaskCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**创建子任务:支持无限层级嵌套。**
|
||||
@ -284,6 +305,7 @@ async def add_task_record_endpoint(
|
||||
task_id: str,
|
||||
data: TaskRecordCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""追加进度记录(备注+图片),不改变任务状态"""
|
||||
return await task_service.add_task_record(db, uuid.UUID(task_id), data)
|
||||
|
||||
@ -10,6 +10,8 @@ from app.api.v1.endpoints.materials import router as materials_router
|
||||
from app.api.v1.endpoints.users import router as users_router
|
||||
from app.api.v1.endpoints.upload import router as upload_router
|
||||
from app.api.v1.endpoints.records import router as records_router
|
||||
from app.api.v1.endpoints.notifications import router as notifications_router
|
||||
from app.api.v1.endpoints.app_version import router as app_version_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -23,3 +25,5 @@ api_router.include_router(materials_router)
|
||||
api_router.include_router(users_router)
|
||||
api_router.include_router(upload_router)
|
||||
api_router.include_router(records_router)
|
||||
api_router.include_router(notifications_router)
|
||||
api_router.include_router(app_version_router)
|
||||
|
||||
@ -9,7 +9,8 @@ class Settings(BaseSettings):
|
||||
|
||||
# ---- JWT ----
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 120 # Access Token: 2 小时
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7 # Refresh Token: 7 天
|
||||
|
||||
# ---- 调试 ----
|
||||
DEBUG: bool = True
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""安全模块 — JWT Token 生成与验证"""
|
||||
"""安全模块 — JWT Token 生成与验证(双 Token 架构)"""
|
||||
from datetime import timedelta
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
@ -9,17 +9,36 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
# Token 类型声明
|
||||
TOKEN_TYPE_ACCESS = "access"
|
||||
TOKEN_TYPE_REFRESH = "refresh"
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
"""生成 JWT Access Token"""
|
||||
"""生成 JWT Access Token(2 小时有效)"""
|
||||
to_encode = data.copy()
|
||||
expire = get_beijing_time() + (
|
||||
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
to_encode.update({"exp": expire, "type": TOKEN_TYPE_ACCESS})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
"""生成 JWT Refresh Token(7 天有效,仅用于刷新 Access Token)"""
|
||||
to_encode = data.copy()
|
||||
expire = get_beijing_time() + (
|
||||
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
)
|
||||
to_encode.update({"exp": expire, "type": TOKEN_TYPE_REFRESH})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""解码并验证 JWT Token,返回 payload"""
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证明文密码 vs 哈希密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
@ -4,6 +4,9 @@ from app.models.production_order import ProductionOrder
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.app_version import AppVersion
|
||||
from app.models.message import ProductMessage
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ProductionOrder",
|
||||
@ -11,4 +14,7 @@ __all__ = [
|
||||
"Task",
|
||||
"TaskRecord",
|
||||
"TaskLog",
|
||||
"Notification",
|
||||
"AppVersion",
|
||||
"ProductMessage",
|
||||
]
|
||||
|
||||
44
backend/app/models/app_version.py
Normal file
44
backend/app/models/app_version.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""App 版本管理模型 — 用于 OTA 热更新"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, Text, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class AppVersion(Base):
|
||||
__tablename__ = "app_versions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
version: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, unique=True, comment="版本号,如 T1.0.1",
|
||||
)
|
||||
|
||||
version_code: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100, comment="数字版本号,用于比较",
|
||||
)
|
||||
|
||||
wgt_url: Mapped[str] = mapped_column(
|
||||
String(500), nullable=False, comment="WGT 升级包下载地址",
|
||||
)
|
||||
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="更新说明",
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, comment="是否启用",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AppVersion {self.version}>"
|
||||
32
backend/app/models/message.py
Normal file
32
backend/app/models/message.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""产品协同留言板模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class ProductMessage(Base):
|
||||
__tablename__ = "product_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("products.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
comment="所属产品 ID",
|
||||
)
|
||||
operator_id: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, comment="留言人姓名或工号",
|
||||
)
|
||||
content: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, comment="留言内容",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, comment="留言时间",
|
||||
)
|
||||
52
backend/app/models/notification.py
Normal file
52
backend/app/models/notification.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""通知模型 — 任务转交/驳回等事件的消息提醒"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
# 通知类型常量
|
||||
NOTIFY_TRANSFER = "TRANSFER" # 新任务派发/转交
|
||||
NOTIFY_REJECT = "REJECT" # 品质驳回
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, index=True, comment="接收人ID(逻辑外键→老系统)",
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, comment="通知标题",
|
||||
)
|
||||
|
||||
content: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, comment="通知内容详情",
|
||||
)
|
||||
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)",
|
||||
)
|
||||
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID",
|
||||
)
|
||||
|
||||
is_read: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="是否已读",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Notification {self.type} → {self.user_id}>"
|
||||
@ -84,6 +84,7 @@ class Task(Base):
|
||||
)
|
||||
child_tasks: Mapped[list["Task"]] = relationship(
|
||||
"Task", back_populates="parent_task", lazy="selectin",
|
||||
order_by="Task.created_at",
|
||||
)
|
||||
records: Mapped[list["TaskRecord"]] = relationship(
|
||||
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan",
|
||||
|
||||
14
backend/app/schemas/app_version.py
Normal file
14
backend/app/schemas/app_version.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""App 版本 Schema"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AppVersionResponse(BaseModel):
|
||||
"""返回给 App 的版本信息"""
|
||||
version: str
|
||||
version_code: int
|
||||
has_update: bool
|
||||
wgt_url: str | None = None
|
||||
description: str | None = None
|
||||
force_update: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
27
backend/app/schemas/notification.py
Normal file
27
backend/app/schemas/notification.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""通知 Pydantic Schema"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
"""通知列表响应"""
|
||||
id: uuid.UUID
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
type: str
|
||||
task_id: uuid.UUID | None = None
|
||||
product_serial_number: str | None = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
"""通知分页列表"""
|
||||
notifications: list[NotificationResponse]
|
||||
total: int
|
||||
unread_count: int
|
||||
@ -50,6 +50,8 @@ class ProductResponse(BaseModel):
|
||||
material_type: str | None = None
|
||||
parent_product_id: uuid.UUID | None
|
||||
current_location_id: str | None = None
|
||||
current_location_name: str | None = None
|
||||
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
|
||||
overall_status: str | None = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
@ -76,6 +78,7 @@ class ProductScanResponse(BaseModel):
|
||||
created_at: datetime
|
||||
top_level_tasks: list[TaskSummaryResponse] = []
|
||||
task_tree: list[TaskResponse] = []
|
||||
assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@ -110,6 +110,7 @@ class TaskSummaryResponse(BaseModel):
|
||||
status: str
|
||||
notify_parent_on_complete: bool
|
||||
is_rework: bool = False
|
||||
task_type: str | None = None
|
||||
remark: str | None = None
|
||||
reject_reason: str | None = None
|
||||
received_at: datetime | None = None
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""用户 Schemas — 对接 MOM sys_user 表"""
|
||||
"""用户 Schemas — 对接 MOM sys_user 表 + 双 Token"""
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@ -20,5 +20,15 @@ class UserResponse(BaseModel):
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str = Field(..., description="Refresh Token")
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT"""
|
||||
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT(双 Token 架构)"""
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, ALGORITHM
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
ALGORITHM,
|
||||
TOKEN_TYPE_ACCESS,
|
||||
TOKEN_TYPE_REFRESH,
|
||||
)
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from app.schemas.user import LoginResponse, UserResponse
|
||||
|
||||
@ -13,15 +20,15 @@ security = HTTPBearer()
|
||||
|
||||
|
||||
def login(username: str, password: str) -> LoginResponse:
|
||||
"""登录 — 查询 MOM 数据库 sys_user 表验证"""
|
||||
"""登录 — 签发双 Token(Access + Refresh)"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# 1. 超级管理员硬编码(和 MOM 系统一致)
|
||||
if username == "IRIS" and password == "123321":
|
||||
token_data = {"sub": "0", "role": "SUPER_ADMIN", "username": "IRIS", "display_name": "超级管理员"}
|
||||
return LoginResponse(
|
||||
access_token=create_access_token(
|
||||
data={"sub": "0", "role": "SUPER_ADMIN"}
|
||||
),
|
||||
access_token=create_access_token(data=token_data),
|
||||
refresh_token=create_refresh_token(data=token_data),
|
||||
user=UserResponse(
|
||||
id="0",
|
||||
username="IRIS",
|
||||
@ -60,17 +67,16 @@ def login(username: str, password: str) -> LoginResponse:
|
||||
# 4. 解析 display_name("张三/zhangsan01" → "张三")
|
||||
display_name = full_username.split("/")[0] if "/" in full_username else full_username
|
||||
|
||||
token = create_access_token(
|
||||
data={
|
||||
"sub": str(user_id),
|
||||
"role": role or "operator",
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
}
|
||||
)
|
||||
token_data = {
|
||||
"sub": str(user_id),
|
||||
"role": role or "operator",
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
return LoginResponse(
|
||||
access_token=token,
|
||||
access_token=create_access_token(data=token_data),
|
||||
refresh_token=create_refresh_token(data=token_data),
|
||||
user=UserResponse(
|
||||
id=str(user_id),
|
||||
username=username,
|
||||
@ -83,16 +89,60 @@ def login(username: str, password: str) -> LoginResponse:
|
||||
db.close()
|
||||
|
||||
|
||||
def refresh_access_token(refresh_token: str) -> dict:
|
||||
"""
|
||||
使用 Refresh Token 换取新的 Access Token。
|
||||
校验:
|
||||
1. Token 签名是否有效
|
||||
2. Token type 是否为 "refresh"
|
||||
3. Token 是否未过期
|
||||
"""
|
||||
try:
|
||||
payload = decode_token(refresh_token)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh Token 无效或已过期,请重新登录",
|
||||
)
|
||||
|
||||
# 校验 token 类型
|
||||
if payload.get("type") != TOKEN_TYPE_REFRESH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 Token 类型,仅接受 Refresh Token",
|
||||
)
|
||||
|
||||
# 提取用户信息,签发新的 Access Token
|
||||
access_token = create_access_token(
|
||||
data={
|
||||
"sub": payload.get("sub"),
|
||||
"role": payload.get("role", "operator"),
|
||||
"username": payload.get("username", ""),
|
||||
"display_name": payload.get("display_name", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> dict:
|
||||
"""从 Bearer Token 解析当前用户(不查数据库,直接解 JWT)"""
|
||||
"""从 Bearer Token 解析当前用户(仅接受 Access Token)"""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
payload = decode_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="无效的 Token")
|
||||
|
||||
# 校验:仅接受 access token
|
||||
if payload.get("type") == TOKEN_TYPE_REFRESH:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="请使用 Access Token 访问 API,Refresh Token 仅用于刷新",
|
||||
)
|
||||
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="无效的 Token")
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_, cast, String, delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@ -34,6 +34,7 @@ def _task_to_response(task: Task) -> TaskResponse:
|
||||
status=task.status,
|
||||
notify_parent_on_complete=task.notify_parent_on_complete,
|
||||
is_rework=task.is_rework,
|
||||
task_type=task.task_type,
|
||||
remark=task.remark,
|
||||
reject_reason=task.reject_reason,
|
||||
received_at=task.received_at,
|
||||
@ -107,6 +108,17 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
|
||||
# 获取完整任务树(递归嵌套,供前端渲染十字矩阵树状图)
|
||||
task_tree = await _load_task_tree(db, product.id)
|
||||
|
||||
# 🔧 收集任务树中所有 assignee_id → 查中文姓名映射
|
||||
assignee_ids: set[str] = set()
|
||||
def _collect_ids(tasks):
|
||||
for t in tasks:
|
||||
if t.assignee_id: assignee_ids.add(t.assignee_id)
|
||||
if t.child_tasks: _collect_ids(t.child_tasks)
|
||||
for t in top_tasks:
|
||||
if t.assignee_id: assignee_ids.add(t.assignee_id)
|
||||
_collect_ids(task_tree)
|
||||
assignee_names = _lookup_display_names(list(assignee_ids))
|
||||
|
||||
return ProductScanResponse(
|
||||
id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
@ -127,6 +139,7 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
|
||||
TaskSummaryResponse.model_validate(t) for t in top_tasks
|
||||
],
|
||||
task_tree=task_tree,
|
||||
assignee_names=assignee_names, # 🔧 username→中文姓名
|
||||
)
|
||||
|
||||
|
||||
@ -146,8 +159,8 @@ async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product:
|
||||
return product
|
||||
|
||||
|
||||
async def create_product(db: AsyncSession, data: ProductCreate) -> ProductResponse:
|
||||
"""创建产品 — 自动生成 16 位 HEX 序列号"""
|
||||
async def create_product(db: AsyncSession, data: ProductCreate, creator_username: str = "") -> ProductResponse:
|
||||
"""创建产品 — 自动生成 16 位 HEX 序列号,初始位置设为创建者"""
|
||||
from app.services.counter_service import ensure_sequence, next_hex_id
|
||||
from app.models.production_order import ProductionOrder
|
||||
|
||||
@ -179,10 +192,18 @@ async def create_product(db: AsyncSession, data: ProductCreate) -> ProductRespon
|
||||
material_type=data.material_type or None,
|
||||
external_serial=data.external_serial,
|
||||
parent_product_id=data.parent_product_id,
|
||||
current_location_id=creator_username or None, # 谁创建,初始位置就是谁
|
||||
)
|
||||
db.add(product)
|
||||
await db.commit()
|
||||
await db.refresh(product, ["order"])
|
||||
|
||||
# 查创建者的真实姓名
|
||||
creator_display_name = ""
|
||||
if creator_username:
|
||||
name_map = _lookup_display_names([creator_username])
|
||||
creator_display_name = name_map.get(creator_username, "")
|
||||
|
||||
return ProductResponse(
|
||||
id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
@ -196,6 +217,7 @@ async def create_product(db: AsyncSession, data: ProductCreate) -> ProductRespon
|
||||
material_type=product.material_type,
|
||||
parent_product_id=product.parent_product_id,
|
||||
current_location_id=product.current_location_id,
|
||||
current_location_name=creator_display_name or None,
|
||||
overall_status=product.overall_status,
|
||||
status=product.status,
|
||||
created_at=product.created_at,
|
||||
@ -253,8 +275,17 @@ async def update_product(db: AsyncSession, product_id: uuid.UUID, data: ProductU
|
||||
VALID_OVERALL_STATUS = {"备货", "生产", "测试", "维修", "在库"}
|
||||
|
||||
|
||||
async def update_overall_status(db: AsyncSession, serial_number: str, status_value: str) -> ProductScanResponse:
|
||||
"""更新产品宏观状态"""
|
||||
async def update_overall_status(
|
||||
db: AsyncSession, serial_number: str, status_value: str,
|
||||
current_user: dict | None = None,
|
||||
) -> ProductScanResponse:
|
||||
"""更新产品宏观状态
|
||||
|
||||
权限校验:
|
||||
- SUPER_ADMIN 角色:直接放行
|
||||
- 当前操作该产品主线任务(WIP/PENDING 状态主干任务)的人:放行
|
||||
- 其他:403
|
||||
"""
|
||||
if status_value not in VALID_OVERALL_STATUS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@ -270,6 +301,41 @@ async def update_overall_status(db: AsyncSession, serial_number: str, status_val
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail=f"未找到序列号 {serial_number} 的产品")
|
||||
|
||||
# ── 权限校验(无 current_user 一律拒绝,杜绝空 dict 绕过)──
|
||||
if not current_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请先登录",
|
||||
)
|
||||
|
||||
user_role = current_user.get("role", "")
|
||||
user_username = current_user.get("username", "")
|
||||
|
||||
# SUPER_ADMIN 直接放行
|
||||
if user_role != "SUPER_ADMIN":
|
||||
# 检查当前用户是否是该产品主线任务的负责人
|
||||
from sqlalchemy import or_
|
||||
main_task_result = await db.execute(
|
||||
select(Task).where(
|
||||
Task.product_id == product.id,
|
||||
Task.status.in_(["WIP", "PENDING"]),
|
||||
or_(
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
|
||||
),
|
||||
).order_by(Task.created_at.desc()).limit(1)
|
||||
)
|
||||
main_task = main_task_result.scalar_one_or_none()
|
||||
has_permission = (
|
||||
main_task is not None
|
||||
and main_task.assignee_id == user_username
|
||||
)
|
||||
if not has_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
|
||||
)
|
||||
|
||||
product.overall_status = status_value
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
@ -277,16 +343,173 @@ async def update_overall_status(db: AsyncSession, serial_number: str, status_val
|
||||
return await get_product_by_serial(db, serial_number)
|
||||
|
||||
|
||||
async def get_all_products(db: AsyncSession, skip: int = 0, limit: int = 50) -> list[ProductResponse]:
|
||||
"""获取产品列表"""
|
||||
result = await db.execute(
|
||||
select(Product)
|
||||
.options(selectinload(Product.order))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
def _lookup_display_names(location_ids: list[str]) -> dict[str, str]:
|
||||
"""批量查询 MOM sys_user,将 username 映射为真实姓名"""
|
||||
if not location_ids:
|
||||
return {}
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from sqlalchemy import text
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# 过滤掉特殊值
|
||||
real_ids = [uid for uid in location_ids if uid and uid != "virtual_warehouse"]
|
||||
if not real_ids:
|
||||
return {}
|
||||
# 用 LIKE 模糊匹配批量查出
|
||||
conditions = " OR ".join([f"username LIKE '%/{uid}'" for uid in real_ids])
|
||||
result = db.execute(
|
||||
text(f"SELECT username, SPLIT_PART(username, '/', 1) as display_name FROM sys_user WHERE {conditions}")
|
||||
)
|
||||
mapping = {}
|
||||
for row in result:
|
||||
full_username = row[0]
|
||||
display_name = row[1]
|
||||
# 从 full_username 末尾提取短用户名: "张三/zhangsan01" → "zhangsan01"
|
||||
short = full_username.split("/")[-1] if "/" in full_username else full_username
|
||||
mapping[short] = display_name
|
||||
return mapping
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_all_products(
|
||||
db: AsyncSession,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
keyword: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
) -> list[ProductResponse]:
|
||||
"""
|
||||
获取产品列表 — 支持多维 keyword 搜索 + 状态筛选
|
||||
|
||||
keyword: 同时模糊匹配 serial_number (产品身份证)、material_name/id (规格型号)、order_no (订单号)
|
||||
status_filter: 按产品状态过滤 (如 PENDING / WIP / COMPLETED / ARCHIVED)
|
||||
"""
|
||||
stmt = select(Product).options(selectinload(Product.order))
|
||||
|
||||
# keyword 多字段 OR 模糊搜索
|
||||
if keyword and keyword.strip():
|
||||
kw = f"%{keyword.strip()}%"
|
||||
stmt = stmt.outerjoin(ProductionOrder, Product.order_id == ProductionOrder.id).where(
|
||||
or_(
|
||||
Product.serial_number.ilike(kw),
|
||||
Product.material_name.ilike(kw),
|
||||
cast(Product.material_id, String).ilike(kw),
|
||||
Product.spec_model.ilike(kw),
|
||||
ProductionOrder.order_no.ilike(kw),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
# 状态筛选 — 大小写不敏感,支持组合过滤
|
||||
if status_filter and status_filter.strip():
|
||||
from sqlalchemy import func
|
||||
sf = status_filter.strip().upper()
|
||||
if sf == "DONE":
|
||||
# "已完成" 匹配 COMPLETED 或 ARCHIVED
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
func.upper(Product.status) == "COMPLETED",
|
||||
func.upper(Product.status) == "ARCHIVED",
|
||||
)
|
||||
)
|
||||
elif sf == "PENDING":
|
||||
# "待流转" — 产品状态 PENDING 且所有顶层任务均未分配人
|
||||
stmt = (
|
||||
stmt.outerjoin(Task, Task.product_id == Product.id)
|
||||
.where(func.upper(Product.status) == "PENDING")
|
||||
.where(Task.assignee_id.is_(None))
|
||||
.distinct()
|
||||
)
|
||||
elif sf == "PENDING_ASSIGNED":
|
||||
# "待接收" — 产品状态 PENDING 但已有任务被分配(等待工人扫码)
|
||||
stmt = (
|
||||
stmt.outerjoin(Task, Task.product_id == Product.id)
|
||||
.where(func.upper(Product.status) == "PENDING")
|
||||
.where(Task.assignee_id.isnot(None))
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(func.upper(Product.status) == sf)
|
||||
|
||||
stmt = stmt.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
products = result.scalars().all()
|
||||
|
||||
# 🔧 批量预计算 macro_status:一次性查出所有产品关联的任务状态
|
||||
product_ids = [p.id for p in products]
|
||||
macro_map: dict[uuid.UUID, str] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import case, func as sa_func
|
||||
task_stmt = (
|
||||
select(
|
||||
Task.product_id,
|
||||
sa_func.max(case(
|
||||
(Task.status == "WIP", 3),
|
||||
(Task.status == "PENDING", 2),
|
||||
(Task.status == "COMPLETED", 1),
|
||||
(Task.status == "ARCHIVED", 1),
|
||||
else_=0,
|
||||
)).label("prio"),
|
||||
)
|
||||
.where(Task.product_id.in_(product_ids))
|
||||
.group_by(Task.product_id)
|
||||
)
|
||||
task_result = await db.execute(task_stmt)
|
||||
prio_to_status = {3: "WIP", 2: "PENDING", 1: "COMPLETED", 0: None}
|
||||
for row in task_result:
|
||||
macro_map[row[0]] = prio_to_status.get(row[1], None)
|
||||
|
||||
# 🔧 动态主干状态+位置:只从主干任务中获取最高优先级任务的 task_name + assignee_id
|
||||
overall_names: dict[uuid.UUID, str] = {}
|
||||
main_assignees: dict[uuid.UUID, str] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import and_, or_, func as sa_func, case as sa_case
|
||||
main_where = and_(
|
||||
Task.product_id.in_(product_ids),
|
||||
or_(
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
|
||||
),
|
||||
)
|
||||
prio_expr = sa_case(
|
||||
(Task.status == "WIP", 3),
|
||||
(Task.status == "PENDING", 2),
|
||||
(Task.status == "COMPLETED", 1),
|
||||
else_=0,
|
||||
)
|
||||
# 子查询:每个产品最高优先级主干任务
|
||||
max_prio = (
|
||||
select(Task.product_id, sa_func.max(prio_expr).label("prio"))
|
||||
.where(main_where)
|
||||
.group_by(Task.product_id)
|
||||
).subquery("mp")
|
||||
# JOIN 回 tasks 拿 task_name + assignee_id(同优先级取最新创建的)
|
||||
main_stmt = (
|
||||
select(Task.product_id, Task.task_name, Task.assignee_id)
|
||||
.join(max_prio, and_(
|
||||
Task.product_id == max_prio.c.product_id,
|
||||
prio_expr == max_prio.c.prio,
|
||||
))
|
||||
.where(main_where)
|
||||
.order_by(Task.product_id, Task.created_at.desc())
|
||||
.distinct(Task.product_id)
|
||||
)
|
||||
main_result = await db.execute(main_stmt)
|
||||
for row in main_result:
|
||||
pid, tname, assignee = row[0], row[1], row[2]
|
||||
overall_names[pid] = tname
|
||||
if assignee: main_assignees[pid] = assignee
|
||||
|
||||
# 🔧 动态主干的 assignee_id → 查中文姓名
|
||||
dynamic_location_ids = list(main_assignees.values())
|
||||
dynamic_name_map = _lookup_display_names(dynamic_location_ids)
|
||||
|
||||
# 🔧 合并:静态位置姓名(兜底)+ 动态主干位置姓名(优先)
|
||||
static_location_ids = [p.current_location_id for p in products if p.current_location_id]
|
||||
merged_location_ids = list(set(static_location_ids + dynamic_location_ids))
|
||||
merged_name_map = _lookup_display_names(merged_location_ids)
|
||||
|
||||
return [
|
||||
ProductResponse(
|
||||
id=p.id,
|
||||
@ -300,10 +523,56 @@ async def get_all_products(db: AsyncSession, skip: int = 0, limit: int = 50) ->
|
||||
category=p.category,
|
||||
material_type=p.material_type,
|
||||
parent_product_id=p.parent_product_id,
|
||||
current_location_id=p.current_location_id,
|
||||
overall_status=p.overall_status,
|
||||
# 🔧 当前位置:动态主干assignee优先 → 静态兜底
|
||||
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"
|
||||
else dynamic_name_map.get(main_assignees.get(p.id, ""))
|
||||
or merged_name_map.get(p.current_location_id) if p.current_location_id
|
||||
else None
|
||||
),
|
||||
macro_status=macro_map.get(p.id) or p.status,
|
||||
overall_status=overall_names.get(p.id) or p.overall_status,
|
||||
status=p.status,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
for p in products
|
||||
]
|
||||
|
||||
|
||||
async def delete_product(db: AsyncSession, product_id: uuid.UUID) -> None:
|
||||
"""删除产品及其关联任务"""
|
||||
product = await get_product(db, product_id)
|
||||
|
||||
from app.models.task import TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
|
||||
# 🚀 1. 切断产品自引用:子产品的 parent_product_id 置空
|
||||
await db.execute(
|
||||
update(Product).where(Product.parent_product_id == product_id).values(parent_product_id=None)
|
||||
)
|
||||
|
||||
# 2. 查询所有关联任务
|
||||
tasks_result = await db.execute(
|
||||
select(Task).where(Task.product_id == product_id)
|
||||
)
|
||||
tasks = tasks_result.scalars().all()
|
||||
|
||||
# 🚀 3. 切断任务自引用:子任务的 parent_task_id 置空
|
||||
for task in tasks:
|
||||
await db.execute(
|
||||
update(Task).where(Task.parent_task_id == task.id).values(parent_task_id=None)
|
||||
)
|
||||
|
||||
# 4. 删除任务记录、日志、任务本身
|
||||
for task in tasks:
|
||||
await db.execute(delete(TaskRecord).where(TaskRecord.task_id == task.id))
|
||||
await db.execute(delete(TaskLog).where(TaskLog.task_id == task.id))
|
||||
await db.delete(task)
|
||||
|
||||
# 5. 删除产品(product_messages 有 ON DELETE CASCADE 自动级联)
|
||||
await db.delete(product)
|
||||
await db.commit()
|
||||
|
||||
@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED
|
||||
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task_log import TaskLog
|
||||
@ -29,6 +30,74 @@ from app.schemas.task import (
|
||||
# 特殊位置常量
|
||||
VIRTUAL_WAREHOUSE = "virtual_warehouse"
|
||||
|
||||
# 管理员/主管角色白名单 — 拥有上帝视角操作权限
|
||||
ADMIN_ROLES = {"SUPER_ADMIN", "SUPERVISOR"}
|
||||
|
||||
|
||||
async def _recalc_product_location(
|
||||
db: AsyncSession, product_id: uuid.UUID, completed_task_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
任务完工/结束时触发:只跟随主干任务(主分支),无视协助分支。
|
||||
|
||||
主干任务定义:
|
||||
parent_task_id IS NULL OR task_type IN ('TRANSFER', 'RECOVERY')
|
||||
|
||||
优先级:
|
||||
WIP > PENDING > COMPLETED/ARCHIVED > None
|
||||
"""
|
||||
from sqlalchemy import select as sa_select, case as sa_case, or_ as sa_or_
|
||||
|
||||
product_result = await db.execute(
|
||||
sa_select(Product).where(Product.id == product_id)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
if not product:
|
||||
return
|
||||
|
||||
# ── 只查主干任务: parent_task_id IS NULL 或 task_type IN (TRANSFER, RECOVERY) ──
|
||||
stmt = (
|
||||
sa_select(Task)
|
||||
.where(
|
||||
Task.product_id == product_id,
|
||||
sa_or_(
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
# 优先级排序: WIP=3, PENDING=2, COMPLETED=1, ARCHIVED=1, else=0
|
||||
sa_case(
|
||||
(Task.status == TASK_STATUS_WIP, 3),
|
||||
(Task.status == TASK_STATUS_PENDING, 2),
|
||||
(Task.status == TASK_STATUS_COMPLETED, 1),
|
||||
(Task.status == TASK_STATUS_ARCHIVED, 1),
|
||||
else_=0,
|
||||
).desc(),
|
||||
Task.created_at.desc(),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
main_task = result.scalar_one_or_none()
|
||||
|
||||
new_location = main_task.assignee_id if main_task else None
|
||||
|
||||
if product.current_location_id != new_location:
|
||||
product.current_location_id = new_location
|
||||
await db.flush() # 唯一的落盘点
|
||||
|
||||
|
||||
def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None:
|
||||
"""权限校验:本人 或 管理员/主管 可操作"""
|
||||
if operator_role and operator_role in ADMIN_ROLES:
|
||||
return # 上帝视角,直接放行
|
||||
if operator_id and task_assignee_id and operator_id != task_assignee_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"您无权操作此任务,当前任务负责人为 {task_assignee_id}",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 内部辅助函数
|
||||
@ -229,7 +298,7 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
|
||||
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product:
|
||||
if data.task_name:
|
||||
if data.task_name and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")):
|
||||
product.overall_status = "在库" if "virtual_warehouse" in data.task_name else data.task_name
|
||||
# 派发给人 → 产品离开仓库
|
||||
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:
|
||||
@ -280,7 +349,8 @@ async def get_all_tasks(
|
||||
# ============================================================
|
||||
|
||||
async def end_task(
|
||||
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None
|
||||
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
|
||||
operator_role: str | None = None,
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
结束当前分支:标记任务为 COMPLETED,不创建下游任务。
|
||||
@ -288,6 +358,15 @@ async def end_task(
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
# 权限校验:本人 或 管理员/主管 可结束
|
||||
_check_permission(task.assignee_id, operator_id, operator_role)
|
||||
|
||||
# 校验:仅 SPAWN 协助分支可以结束,主分支(TRANSFER/RECOVERY)不能通过此接口终止
|
||||
if not task.parent_task_id:
|
||||
raise HTTPException(status_code=409, detail="根任务无法结束,请使用完工转交")
|
||||
if task.task_type != "SPAWN":
|
||||
raise HTTPException(status_code=409, detail="仅协助分支可以结束,主分支请使用完工转交")
|
||||
|
||||
# 校验:必须等待所有协助分支完成
|
||||
await _check_children_done(db, task_id)
|
||||
|
||||
@ -303,6 +382,9 @@ async def end_task(
|
||||
await _create_task_log(db, task_id, action_type="end",
|
||||
operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)")
|
||||
|
||||
# 🔧 位置回溯:分支结束后优先回溯到父任务负责人
|
||||
await _recalc_product_location(db, task.product_id, task.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return _to_response(task)
|
||||
@ -313,14 +395,50 @@ async def end_task(
|
||||
# ============================================================
|
||||
|
||||
async def recall_task(
|
||||
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None
|
||||
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
|
||||
operator_role: str | None = None,
|
||||
) -> TaskResponse:
|
||||
"""撤回 PENDING 转交:标记为 CANCELED,以被撤回节点为父生成接力新任务给操作人。"""
|
||||
"""撤回 PENDING 转交:标记为 CANCELED,以被撤回节点为父生成接力新任务给操作人。
|
||||
|
||||
权限校验:
|
||||
- 管理员(SUPER_ADMIN / SUPERVISOR):直接放行
|
||||
- 操作人 必须等于 上游任务的负责人(谁发出的谁才能撤回),否则 403
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
if task.status != TASK_STATUS_PENDING:
|
||||
raise HTTPException(status_code=409, detail="只有待接收(PENDING)的任务可以撤回")
|
||||
|
||||
# ── 权限校验:谁发出的谁才能撤回 ──
|
||||
if not (operator_role and operator_role in ADMIN_ROLES):
|
||||
# 查找上游任务的负责人(发出者)
|
||||
upstream_assignee: str | None = None
|
||||
if task.parent_task_id:
|
||||
parent_result = await db.execute(
|
||||
select(Task).where(Task.id == task.parent_task_id)
|
||||
)
|
||||
parent_task = parent_result.scalar_one_or_none()
|
||||
if parent_task:
|
||||
upstream_assignee = parent_task.assignee_id
|
||||
else:
|
||||
# 无父任务:从任务日志追溯创建人
|
||||
log_result = await db.execute(
|
||||
select(TaskLog).where(
|
||||
TaskLog.task_id == task_id,
|
||||
TaskLog.action_type == "create",
|
||||
).order_by(TaskLog.created_at.asc()).limit(1)
|
||||
)
|
||||
create_log = log_result.scalar_one_or_none()
|
||||
if create_log:
|
||||
upstream_assignee = create_log.operator_id
|
||||
|
||||
# 如果找不到上游负责人,或者操作人不等于上游负责人 → 403
|
||||
if not upstream_assignee or operator_id != upstream_assignee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="您不是该任务的发起人,无法撤回",
|
||||
)
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
# 1. 废掉当前待接收任务
|
||||
@ -426,6 +544,7 @@ ARCHIVED_STATUS = "ARCHIVED"
|
||||
async def receive_task(
|
||||
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None,
|
||||
remark: str | None = None, task_name: str | None = None,
|
||||
operator_role: str | None = None,
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
操作员确认接收任务。工人选定工序名称后接收。
|
||||
@ -435,12 +554,8 @@ async def receive_task(
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
# 权限校验:只有负责人本人可接收
|
||||
if operator_id and task.assignee_id and operator_id != task.assignee_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"您无权操作此任务,当前任务负责人为 {task.assignee_id}",
|
||||
)
|
||||
# 权限校验:本人 或 管理员/主管 可操作
|
||||
_check_permission(task.assignee_id, operator_id, operator_role)
|
||||
|
||||
# 校验:只有 PENDING 状态可接收
|
||||
if task.status != TASK_STATUS_PENDING:
|
||||
@ -471,8 +586,10 @@ async def receive_task(
|
||||
if product:
|
||||
if task.assignee_id:
|
||||
product.current_location_id = task.assignee_id
|
||||
if task_name:
|
||||
product.overall_status = task_name
|
||||
if task_name and (
|
||||
not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")
|
||||
):
|
||||
product.overall_status = task_name # 只主线任务同步宏观状态
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
@ -485,7 +602,8 @@ async def receive_task(
|
||||
# ============================================================
|
||||
|
||||
async def reject_task(
|
||||
db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None
|
||||
db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None,
|
||||
operator_role: str | None = None,
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
品质驳回:将当前任务标记为 REJECTED,并自动创建返工任务给上一道工序负责人。
|
||||
@ -500,12 +618,8 @@ async def reject_task(
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
# 权限校验:只有负责人本人可驳回
|
||||
if operator_id and task.assignee_id and operator_id != task.assignee_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"您无权操作此任务,当前任务负责人为 {task.assignee_id}",
|
||||
)
|
||||
# 权限校验:本人 或 管理员/主管 可驳回
|
||||
_check_permission(task.assignee_id, operator_id, operator_role)
|
||||
|
||||
# 校验:不能重复驳回已完成/已驳回的任务
|
||||
if task.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED):
|
||||
@ -530,8 +644,20 @@ async def reject_task(
|
||||
|
||||
# --- 2. 确定返工任务的负责人(追溯上一道工序的转交人) ---
|
||||
rework_assignee_id: str | None = None
|
||||
if task.parent_task_id:
|
||||
# 有父任务:返工给父任务的负责人(即上一环的转交人 A)
|
||||
|
||||
# 🚀 优先方式:从任务日志追溯创建人(准确记录是谁发起的转交)
|
||||
log_result = await db.execute(
|
||||
select(TaskLog).where(
|
||||
TaskLog.task_id == task_id,
|
||||
TaskLog.action_type == "create",
|
||||
).order_by(TaskLog.created_at.asc()).limit(1)
|
||||
)
|
||||
create_log = log_result.scalar_one_or_none()
|
||||
if create_log and create_log.operator_id:
|
||||
rework_assignee_id = create_log.operator_id
|
||||
|
||||
# 兜底方式1:有父任务 → 返工给父任务的负责人
|
||||
if not rework_assignee_id and task.parent_task_id:
|
||||
parent_result = await db.execute(
|
||||
select(Task).where(Task.id == task.parent_task_id)
|
||||
)
|
||||
@ -539,20 +665,8 @@ async def reject_task(
|
||||
if parent_task:
|
||||
rework_assignee_id = parent_task.assignee_id
|
||||
|
||||
# 兜底方式2:用当前任务的负责人
|
||||
if not rework_assignee_id:
|
||||
# 无父任务(顶层转交):从任务日志追溯创建人(转交发起者 A)
|
||||
log_result = await db.execute(
|
||||
select(TaskLog).where(
|
||||
TaskLog.task_id == task_id,
|
||||
TaskLog.action_type == "create",
|
||||
).order_by(TaskLog.created_at.asc()).limit(1)
|
||||
)
|
||||
create_log = log_result.scalar_one_or_none()
|
||||
if create_log and create_log.operator_id:
|
||||
rework_assignee_id = create_log.operator_id
|
||||
|
||||
if not rework_assignee_id:
|
||||
# 最后兜底:用当前任务的负责人(通常不应该走到这里)
|
||||
rework_assignee_id = task.assignee_id
|
||||
|
||||
# --- 3. 创建返工任务 ---
|
||||
@ -562,7 +676,7 @@ async def reject_task(
|
||||
task_name=task.task_name,
|
||||
assignee_id=rework_assignee_id,
|
||||
status=TASK_STATUS_PENDING,
|
||||
task_type="TRANSFER",
|
||||
task_type=task.task_type, # 🚀 继承被驳回任务的基因:主线→主线,协助→协助
|
||||
notify_parent_on_complete=task.notify_parent_on_complete,
|
||||
is_rework=True,
|
||||
)
|
||||
@ -576,6 +690,24 @@ async def reject_task(
|
||||
remark=f"返工任务(驳回自「{task.task_name}」,原因: {request.reason}),分配给 {rework_assignee_id}",
|
||||
)
|
||||
|
||||
# 🔔 通知:品质驳回
|
||||
product_sn = ""
|
||||
try:
|
||||
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
|
||||
p = product_result.scalar_one_or_none()
|
||||
if p:
|
||||
product_sn = p.serial_number or ""
|
||||
except Exception:
|
||||
pass
|
||||
if rework_assignee_id:
|
||||
db.add(Notification(
|
||||
user_id=rework_assignee_id,
|
||||
title="🔴 品质驳回提醒",
|
||||
content=f"产品 [{product_sn}] 的「{task.task_name}」被驳回,原因: {request.reason}",
|
||||
type=NOTIFY_REJECT,
|
||||
task_id=rework_task.id,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
@ -587,7 +719,8 @@ async def reject_task(
|
||||
# ============================================================
|
||||
|
||||
async def transfer_task(
|
||||
db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None
|
||||
db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None,
|
||||
operator_role: str | None = None,
|
||||
) -> TaskTransferResponse:
|
||||
"""
|
||||
完工并裂变转交:
|
||||
@ -608,6 +741,9 @@ async def transfer_task(
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
# 权限校验:本人 或 管理员/主管 可转交
|
||||
_check_permission(task.assignee_id, operator_id, operator_role)
|
||||
|
||||
# 校验:不能重复完成
|
||||
if task.status == TASK_STATUS_COMPLETED:
|
||||
raise HTTPException(
|
||||
@ -697,6 +833,21 @@ async def transfer_task(
|
||||
operator_id=operator_id,
|
||||
remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {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"🟢 新任务派发",
|
||||
content=f"产品 [{product_sn}] 的「{nt.task_name}」任务已分配给你",
|
||||
type=NOTIFY_TRANSFER,
|
||||
task_id=nt.id,
|
||||
))
|
||||
|
||||
# --- 更新 Product 的 current_location_id ---
|
||||
product_result = await db.execute(
|
||||
@ -709,7 +860,12 @@ async def transfer_task(
|
||||
product.overall_status = "在库"
|
||||
elif real_branches:
|
||||
product.current_location_id = real_branches[0][1]
|
||||
product.overall_status = real_branches[0][0]
|
||||
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"):
|
||||
product.overall_status = real_branches[0][0]
|
||||
|
||||
# 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务
|
||||
if not real_branches and not has_warehouse:
|
||||
await _recalc_product_location(db, task.product_id, task_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@ -741,7 +897,8 @@ async def transfer_task(
|
||||
# ============================================================
|
||||
|
||||
async def complete_task(
|
||||
db: AsyncSession, task_id: uuid.UUID, request: TaskCompleteRequest
|
||||
db: AsyncSession, task_id: uuid.UUID, request: TaskCompleteRequest,
|
||||
operator_role: str | None = None,
|
||||
) -> TaskCompleteResponse:
|
||||
"""
|
||||
核心业务:完成任务 + 可选创建下一步任务。
|
||||
@ -756,11 +913,8 @@ async def complete_task(
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
# 注入子任务数据到当前对象以便后续检查
|
||||
children_result = await db.execute(
|
||||
select(Task).where(Task.parent_task_id == task_id)
|
||||
)
|
||||
task.child_tasks = children_result.scalars().all()
|
||||
# 权限校验:本人 或 管理员/主管 可操作
|
||||
_check_permission(task.assignee_id, request.operator_id, operator_role)
|
||||
|
||||
# --- 1. 幂等检查 ---
|
||||
if task.status == TASK_STATUS_COMPLETED:
|
||||
@ -792,12 +946,23 @@ async def complete_task(
|
||||
# --- 4. 可选:创建下一步任务(转交) ---
|
||||
next_task = None
|
||||
if request.next_task_name and request.next_assignee_id:
|
||||
# 🚀 智能父节点继承算法
|
||||
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
|
||||
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
|
||||
# 注:TASK_TYPE 实际值为 TRANSFER/RECOVERY/SPAWN,不存在 "MAIN"
|
||||
is_main_line = (
|
||||
not task.parent_task_id
|
||||
or task.task_type in ("TRANSFER", "RECOVERY")
|
||||
)
|
||||
new_parent_id = task.parent_task_id if is_main_line else task.id
|
||||
|
||||
next_task = Task(
|
||||
product_id=task.product_id,
|
||||
parent_task_id=task.parent_task_id, # 与已完成任务同级
|
||||
parent_task_id=new_parent_id, # 👈 智能计算
|
||||
task_name=request.next_task_name,
|
||||
assignee_id=request.next_assignee_id,
|
||||
status=TASK_STATUS_PENDING,
|
||||
task_type=task.task_type, # 👈 基因严格继承(绝不篡位成 MAIN)
|
||||
notify_parent_on_complete=False,
|
||||
)
|
||||
db.add(next_task)
|
||||
@ -809,6 +974,9 @@ async def complete_task(
|
||||
remark=f"由任务「{task.task_name}」完成后转交创建",
|
||||
)
|
||||
|
||||
# 🔧 位置回溯:老接口也触发(父任务优先)
|
||||
await _recalc_product_location(db, task.product_id, task_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# --- 5. 构建响应 ---
|
||||
|
||||
78
deploy.sh
Normal file
78
deploy.sh
Normal file
@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# Track 生产流转系统 — 增量部署脚本
|
||||
# 服务器: 172.16.0.198
|
||||
# 路径: /opt/Track
|
||||
# 仅更新代码,不覆盖数据库
|
||||
# ============================================================
|
||||
set -e
|
||||
|
||||
SERVER="dxc@172.16.0.198"
|
||||
REMOTE_DIR="/opt/Track"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M)
|
||||
REMOTE_BACKUP_DIR="$REMOTE_DIR/backups/$TIMESTAMP"
|
||||
|
||||
echo "==================================================="
|
||||
echo "🚀 Track 增量部署 — 仅更新代码"
|
||||
echo "==================================================="
|
||||
|
||||
# 1. 远端备份
|
||||
echo "[1/4] 服务器备份旧代码..."
|
||||
ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \
|
||||
cd $REMOTE_DIR && \
|
||||
echo '>> 备份当前代码...' && \
|
||||
sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz \
|
||||
backend frontend docker-compose.prod.yml 2>/dev/null || true && \
|
||||
echo '>> 保留最近 3 个备份...' && \
|
||||
cd $REMOTE_DIR/backups && \
|
||||
sudo sh -c 'ls -dt */ 2>/dev/null | tail -n +4 | xargs -I {} rm -rf {} || true'"
|
||||
|
||||
if [ $? -ne 0 ]; then echo "❌ 备份失败,终止!"; exit 1; fi
|
||||
|
||||
# 2. 本地打包
|
||||
echo "[2/4] 本地打包代码..."
|
||||
tar -czf deploy.tar.gz \
|
||||
--exclude="backend/venv" \
|
||||
--exclude="backend/.venv" \
|
||||
--exclude="backend/__pycache__" \
|
||||
--exclude="backend/*.pyc" \
|
||||
--exclude="backend/.env" \
|
||||
--exclude="backend/uploads" \
|
||||
--exclude="frontend/node_modules" \
|
||||
--exclude="frontend/dist" \
|
||||
--exclude=".git" \
|
||||
--exclude=".idea" \
|
||||
--exclude=".vscode" \
|
||||
--exclude="track-uniapp/node_modules" \
|
||||
--exclude="track-uniapp/dist" \
|
||||
--exclude="track-uniapp/unpackage" \
|
||||
backend frontend docker-compose.prod.yml
|
||||
|
||||
FILESIZE=$(stat -c%s "deploy.tar.gz" 2>/dev/null || stat -f%z "deploy.tar.gz")
|
||||
echo ">> 打包完成: $((FILESIZE / 1024 / 1024)) MB"
|
||||
|
||||
# 3. 上传
|
||||
echo "[3/4] 上传到服务器..."
|
||||
scp deploy.tar.gz $SERVER:/tmp/deploy.tar.gz
|
||||
if [ $? -ne 0 ]; then echo "❌ 上传失败!"; rm -f deploy.tar.gz; exit 1; fi
|
||||
|
||||
# 4. 替换重启
|
||||
echo "[4/4] 服务器替换并重启..."
|
||||
ssh -t $SERVER "cd $REMOTE_DIR && \
|
||||
echo '>> 移除旧备份...' && \
|
||||
sudo rm -rf backend_old frontend_old && \
|
||||
echo '>> 保留当前为 old...' && \
|
||||
(sudo mv backend backend_old 2>/dev/null || true) && \
|
||||
(sudo mv frontend frontend_old 2>/dev/null || true) && \
|
||||
echo '>> 解压新代码...' && \
|
||||
sudo mv /tmp/deploy.tar.gz . && \
|
||||
sudo tar -xzf deploy.tar.gz && \
|
||||
echo '>> 重启 Docker...' && \
|
||||
sudo docker compose -f docker-compose.prod.yml up -d --build && \
|
||||
sudo rm deploy.tar.gz && \
|
||||
echo '>> ✅ 部署完成!'"
|
||||
|
||||
rm -f deploy.tar.gz
|
||||
echo "==================================================="
|
||||
echo "✅ 增量部署完成!访问 http://172.16.0.198:8010"
|
||||
echo "==================================================="
|
||||
91
deploy_full.sh
Normal file
91
deploy_full.sh
Normal file
@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# Track 生产流转系统 — 全量部署脚本(含数据库同步)
|
||||
# 服务器: 172.16.0.198
|
||||
# 路径: /opt/Track
|
||||
# ⚠️ 会覆盖生产数据库!请确认后再执行!
|
||||
# ============================================================
|
||||
set -e
|
||||
|
||||
SERVER="dxc@172.16.0.198"
|
||||
REMOTE_DIR="/opt/Track"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M)
|
||||
REMOTE_BACKUP_DIR="$REMOTE_DIR/backups/$TIMESTAMP"
|
||||
|
||||
echo "⚠️⚠️⚠️ 警告:此操作将覆盖生产数据库!⚠️⚠️⚠️"
|
||||
read -p "确认继续?(y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. 远端全量备份
|
||||
echo "[1/5] 服务器全量备份..."
|
||||
ssh -t $SERVER "sudo mkdir -p $REMOTE_BACKUP_DIR && \
|
||||
cd $REMOTE_DIR && \
|
||||
echo '>> 导出线上数据库...' && \
|
||||
sudo sh -c 'docker exec track_db_prod pg_dumpall -c -U track | gzip > $REMOTE_BACKUP_DIR/db_backup.sql.gz' && \
|
||||
echo '>> 备份代码...' && \
|
||||
sudo tar -czf $REMOTE_BACKUP_DIR/code_backup.tar.gz \
|
||||
backend frontend docker-compose.prod.yml 2>/dev/null || true && \
|
||||
echo '>> 保留最近 3 个备份...' && \
|
||||
cd $REMOTE_DIR/backups && \
|
||||
sudo sh -c 'ls -dt */ 2>/dev/null | tail -n +4 | xargs -I {} rm -rf {} || true'"
|
||||
|
||||
if [ $? -ne 0 ]; then echo "❌ 备份失败,终止!"; exit 1; fi
|
||||
|
||||
# 2. 导出本地数据库
|
||||
echo "[2/5] 导出本地数据库..."
|
||||
docker exec track_db pg_dumpall -c -U track | gzip > db_sync.sql.gz
|
||||
echo ">> 数据库导出完成"
|
||||
|
||||
# 3. 本地打包
|
||||
echo "[3/5] 本地打包代码+数据库..."
|
||||
tar -czf deploy_full.tar.gz \
|
||||
--exclude="backend/venv" \
|
||||
--exclude="backend/.venv" \
|
||||
--exclude="backend/__pycache__" \
|
||||
--exclude="backend/*.pyc" \
|
||||
--exclude="backend/.env" \
|
||||
--exclude="backend/uploads" \
|
||||
--exclude="frontend/node_modules" \
|
||||
--exclude="frontend/dist" \
|
||||
--exclude=".git" \
|
||||
--exclude=".idea" \
|
||||
--exclude=".vscode" \
|
||||
--exclude="track-uniapp/node_modules" \
|
||||
--exclude="track-uniapp/dist" \
|
||||
--exclude="track-uniapp/unpackage" \
|
||||
--exclude="backups" \
|
||||
backend frontend docker-compose.prod.yml db_sync.sql.gz
|
||||
|
||||
FILESIZE=$(stat -c%s "deploy_full.tar.gz" 2>/dev/null || stat -f%z "deploy_full.tar.gz")
|
||||
echo ">> 打包完成: $((FILESIZE / 1024 / 1024)) MB"
|
||||
|
||||
# 4. 上传
|
||||
echo "[4/5] 上传到服务器..."
|
||||
scp deploy_full.tar.gz $SERVER:/tmp/deploy_full.tar.gz
|
||||
if [ $? -ne 0 ]; then echo "❌ 上传失败!"; rm -f deploy_full.tar.gz db_sync.sql.gz; exit 1; fi
|
||||
|
||||
# 5. 替换+导入+重启
|
||||
echo "[5/5] 服务器部署..."
|
||||
ssh -t $SERVER "cd $REMOTE_DIR && \
|
||||
echo '>> 移除旧备份...' && \
|
||||
sudo rm -rf backend_old frontend_old && \
|
||||
echo '>> 保留当前为 old...' && \
|
||||
(sudo mv backend backend_old 2>/dev/null || true) && \
|
||||
(sudo mv frontend frontend_old 2>/dev/null || true) && \
|
||||
echo '>> 解压新代码...' && \
|
||||
sudo mv /tmp/deploy_full.tar.gz . && \
|
||||
sudo tar -xzf deploy_full.tar.gz && \
|
||||
echo '>> 重启 Docker...' && \
|
||||
sudo docker compose -f docker-compose.prod.yml up -d --build && \
|
||||
echo '>> 导入数据库...' && \
|
||||
sudo sh -c 'gunzip -c db_sync.sql.gz | docker exec -i track_db_prod psql -U track -d track_production' && \
|
||||
sudo rm deploy_full.tar.gz db_sync.sql.gz && \
|
||||
echo '>> ✅ 全量部署完成!'"
|
||||
|
||||
rm -f deploy_full.tar.gz db_sync.sql.gz
|
||||
echo "==================================================="
|
||||
echo "✅ 全量部署完成!访问 http://172.16.0.198:8010"
|
||||
echo "==================================================="
|
||||
83
docker-compose.prod.yml
Normal file
83
docker-compose.prod.yml
Normal file
@ -0,0 +1,83 @@
|
||||
# ============================================================
|
||||
# 生产流转管理系统 — 生产环境 Docker Compose
|
||||
# 服务器: 172.16.0.198
|
||||
# 部署路径: /opt/Track
|
||||
# 启动: docker compose -f docker-compose.prod.yml up -d --build
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
# ============================================================
|
||||
# PostgreSQL 15 + pgvector
|
||||
# ============================================================
|
||||
db:
|
||||
image: pgvector/pgvector:pg15
|
||||
container_name: track_db_prod
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: track
|
||||
POSTGRES_PASSWORD: track_prod_2026
|
||||
POSTGRES_DB: track_production
|
||||
ports:
|
||||
- "8012:5432"
|
||||
volumes:
|
||||
- track_pgdata_prod:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U track -d track_production"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ============================================================
|
||||
# FastAPI 后端 — gunicorn + uvicorn (生产模式,无热更新)
|
||||
# ============================================================
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: track_backend_prod
|
||||
restart: always
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@track_db_prod:5432/track_production
|
||||
SECRET_KEY: change-me-in-production-please
|
||||
DEBUG: "false"
|
||||
CORS_ORIGINS: '["http://track.iris-rs.cn","http://track_back.iris-rs.cn","http://172.16.0.198:8010","http://172.16.0.198","tauri://localhost"]'
|
||||
# 🚀 MOM 老系统数据库 — 用户登录验证 + 用户名单来源
|
||||
MOM_DB_HOST: inventory_db_prod
|
||||
MOM_DB_PORT: "5432"
|
||||
ports:
|
||||
- "8011:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: >
|
||||
sh -c "uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 --log-level info"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default
|
||||
- mom_net
|
||||
|
||||
# ============================================================
|
||||
# React 前端 — Nginx 静态文件服务 (生产模式)
|
||||
# ============================================================
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
container_name: track_frontend_prod
|
||||
restart: always
|
||||
ports:
|
||||
- "8010:80"
|
||||
volumes:
|
||||
- /opt/Track/updates:/usr/share/nginx/html/updates
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
networks:
|
||||
mom_net:
|
||||
external: true
|
||||
name: inventory-app_default
|
||||
|
||||
volumes:
|
||||
track_pgdata_prod:
|
||||
name: track_pgdata_prod
|
||||
18
frontend/Dockerfile.prod
Normal file
18
frontend/Dockerfile.prod
Normal file
@ -0,0 +1,18 @@
|
||||
# ============================================================
|
||||
# 前端生产 Dockerfile — 多阶段构建:Vite 打包 → Nginx 托管
|
||||
# ============================================================
|
||||
|
||||
# --- 阶段 1:编译 React ---
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --production=false
|
||||
COPY . .
|
||||
RUN npx vite build
|
||||
|
||||
# --- 阶段 2:Nginx 托管静态文件 ---
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
32
frontend/nginx.conf
Normal file
32
frontend/nginx.conf
Normal file
@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# 静态文件缓存
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# API 代理到后端
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA 路由回退
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"antd": "^6.5.3",
|
||||
@ -1714,6 +1715,33 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.14.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz",
|
||||
"integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.17.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.7",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz",
|
||||
"integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"antd": "^6.5.3",
|
||||
|
||||
@ -1,20 +1,26 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { App as AntApp } from "antd";
|
||||
|
||||
import { ToastProvider } from "./components/ui/Toast";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import AppLayout from "./components/layout/AppLayout";
|
||||
import ScanPage from "./pages/ScanPage";
|
||||
import MyTasksPage from "./pages/MyTasksPage";
|
||||
import NotificationsPage from "./pages/NotificationsPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import LoadingSpinner from "./components/ui/LoadingSpinner";
|
||||
|
||||
// Layout 组件静态导入(始终需要,体积小)
|
||||
import AppLayout from "./components/layout/AppLayout";
|
||||
import AdminLayout from "./components/layout/AdminLayout";
|
||||
import AdminLoginPage from "./pages/admin/AdminLoginPage";
|
||||
import AdminDashboard from "./pages/admin/AdminDashboard";
|
||||
import AdminProductsPage from "./pages/admin/AdminProductsPage";
|
||||
import AdminTasksPage from "./pages/admin/AdminTasksPage";
|
||||
import AdminPrintConfigPage from "./pages/admin/AdminPrintConfigPage";
|
||||
|
||||
// 🚀 路由级代码分割 — 按需懒加载页面组件
|
||||
const ScanPage = lazy(() => import("./pages/ScanPage"));
|
||||
const MyTasksPage = lazy(() => import("./pages/MyTasksPage"));
|
||||
const NotificationsPage = lazy(() => import("./pages/NotificationsPage"));
|
||||
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
|
||||
|
||||
const AdminLoginPage = lazy(() => import("./pages/admin/AdminLoginPage"));
|
||||
const AdminDashboard = lazy(() => import("./pages/admin/AdminDashboard"));
|
||||
const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage"));
|
||||
const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
|
||||
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@ -22,28 +28,30 @@ export default function App() {
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* 移动端 */}
|
||||
<Route path="/" element={<Navigate to="/scan" replace />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/scan" element={<ScanPage />} />
|
||||
<Route path="/tasks" element={<MyTasksPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
{/* 移动端 */}
|
||||
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/scan" element={<ScanPage />} />
|
||||
<Route path="/tasks" element={<MyTasksPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
|
||||
{/* PC 管理端 — 需要登录 */}
|
||||
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/products" element={<AdminProductsPage />} />
|
||||
<Route path="/admin/tasks" element={<AdminTasksPage />} />
|
||||
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
{/* PC 管理端 — 需要登录 */}
|
||||
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/products" element={<AdminProductsPage />} />
|
||||
<Route path="/admin/tasks" element={<AdminTasksPage />} />
|
||||
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
|
||||
232
frontend/src/components/TaskTree/TaskFlowView.tsx
Normal file
232
frontend/src/components/TaskTree/TaskFlowView.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 流转树双模式可视化 — 焦点模式 + 全景模式
|
||||
*/
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { GitBranch, AlertTriangle, Clock, CheckCircle, Flag, FileText, X } from "lucide-react";
|
||||
import type { TaskResponse } from "../../types/api";
|
||||
import { TASK_STATUS } from "../../types/api";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
import type { ModalTarget } from "./TaskTreeViewer";
|
||||
|
||||
// ============================================================
|
||||
// 工具
|
||||
// ============================================================
|
||||
function fmtTime(d: string | null) { if (!d) return ""; const dt = new Date(d); const pad = (n: number) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; }
|
||||
function isMain(t: TaskResponse) { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY"; }
|
||||
function active(s: string) { return s === "WIP" || s === "PENDING"; }
|
||||
/** 微型右箭头 SVG */
|
||||
function ArrowRight({ color = "#9ca3af" }: { color?: string }) {
|
||||
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
|
||||
}
|
||||
/** 微型左箭头 SVG */
|
||||
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); }
|
||||
|
||||
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 findParent(child: TaskResponse): TaskResponse | undefined { for (const t of ALL_TASKS) { if (t.id === child.parent_task_id) return t; } return undefined; }
|
||||
|
||||
// ============================================================
|
||||
// 极简卡片
|
||||
// ============================================================
|
||||
const SlimCard = memo(function SlimCard({
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId,
|
||||
}: {
|
||||
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
|
||||
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
|
||||
onViewRecords?: (t: TaskResponse) => void;
|
||||
rootMainId?: string;
|
||||
}) {
|
||||
const cfg = getStatusConfig(task.status);
|
||||
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
|
||||
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
|
||||
const main = isMain(task);
|
||||
const isNestedSpawn = !main && rootMainId && task.parent_task_id !== rootMainId && !!task.parent_task_id;
|
||||
|
||||
return (
|
||||
<div className={`relative w-44 shrink-0 rounded-lg border bg-white p-2.5 shadow-sm ${active ? "border-blue-400 ring-1 ring-blue-100" : "border-gray-200 opacity-75"} ${legacy ? "border-orange-300 animate-pulse" : ""} ${task.is_rework ? "border-l-red-500 border-l-2" : ""}`}>
|
||||
<div className={`absolute -top-1.5 right-2 rounded px-1.5 py-px text-[8px] font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</div>
|
||||
<p className="mt-1 text-xs font-bold text-gray-800 truncate">{task.task_name}</p>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className={`rounded-full px-1.5 py-px text-[8px] font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||||
<span className="text-[9px] text-gray-400 truncate">{assigneeName || task.assignee_id || "—"}</span>
|
||||
</div>
|
||||
{/* 单行时间 */}
|
||||
<p className="mt-1 text-[8px] text-gray-300">
|
||||
⏰ {fmtTime(task.created_at).split(" ")[0]}
|
||||
{task.completed_at ? ` → ${fmtTime(task.completed_at).split(" ")[0]}` : " → 至今"}
|
||||
</p>
|
||||
{legacy && <p className="mt-1 text-[8px] text-orange-500">源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
|
||||
{isNestedSpawn && <p className="mt-1 text-[8px] text-purple-500">协助: {findParent(task)?.assignee_id || "—"}</p>}
|
||||
{/* 操作按钮 */}
|
||||
{active && isOwner && (
|
||||
<div className="mt-1.5 flex gap-1 border-t border-gray-100 pt-1.5">
|
||||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-0.5 text-[8px] text-blue-600">接收</button>}
|
||||
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-0.5 text-[8px] text-green-600">转交</button>}
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-0.5 text-[8px] text-red-500">驳回</button>
|
||||
</div>
|
||||
)}
|
||||
{active && !isOwner && isManager && (
|
||||
<div className="mt-1.5 flex gap-1 border-t border-orange-100 pt-1.5">
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600">强制驳回</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600">强制转交</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 记录 */}
|
||||
{task.records && task.records.length > 0 && (
|
||||
<div onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }} className="mt-1 cursor-pointer rounded bg-blue-50 px-1.5 py-0.5 text-[8px] text-blue-600 hover:bg-blue-100">
|
||||
<FileText className="mr-0.5 inline h-2.5 w-2.5" />{task.records.length}条
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 主视图
|
||||
// ============================================================
|
||||
interface TaskFlowViewProps {
|
||||
tasks: TaskResponse[]; onAction: (t: ModalTarget) => void;
|
||||
currentUser?: { username?: string; role?: string } | null;
|
||||
assigneeNames?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
|
||||
const [showFullMap, setShowFullMap] = useState(false);
|
||||
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
|
||||
|
||||
// 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染
|
||||
const { allMains, childMap } = useMemo(() => {
|
||||
ALL_TASKS.clear(); if (tasks.length) collectAll(tasks);
|
||||
const all = Array.from(ALL_TASKS);
|
||||
// 🚀 收集所有主线任务(全部进入中央垂直主轴)
|
||||
const mains: TaskResponse[] = [];
|
||||
for (const t of all) {
|
||||
if (isMain(t)) mains.push(t);
|
||||
}
|
||||
mains.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
// 🚀 构建全局 childMap(按 parent_task_id 索引直接子节点,保留真实树结构)
|
||||
const childMap: Record<string, TaskResponse[]> = {};
|
||||
for (const t of all) {
|
||||
const pid = t.parent_task_id || '';
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(t);
|
||||
}
|
||||
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()));
|
||||
return { allMains: mains, childMap };
|
||||
}, [tasks]);
|
||||
|
||||
// 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂
|
||||
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): JSX.Element => {
|
||||
const kids = childMap[node.id] || [];
|
||||
const arrow = side === 'left'
|
||||
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} /><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
|
||||
: (<div className="flex items-center"><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} /></div>);
|
||||
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} />;
|
||||
const kidsContainer = kids.length > 0 ? (
|
||||
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
|
||||
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<div key={node.id} className="flex flex-row items-center gap-1">
|
||||
{side === 'left' && kidsContainer}
|
||||
{side === 'left' && card}
|
||||
{arrow}
|
||||
{side === 'right' && card}
|
||||
{side === 'right' && kidsContainer}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 🚀 焦点模式 vs 全景模式:共用同一套垂直时间线布局,仅数据过滤不同
|
||||
const visibleMains = useMemo(() => {
|
||||
if (showFullMap) return allMains;
|
||||
// 焦点模式:过滤掉已完成/已入库的历史主线,保留活跃主线 + 所有协助分支
|
||||
return allMains.filter(t => active(t.status));
|
||||
}, [allMains, showFullMap]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 模式切换 */}
|
||||
<div className="mb-3 flex justify-center">
|
||||
<button onClick={() => setShowFullMap(!showFullMap)}
|
||||
className="rounded-full bg-gray-100 px-4 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-200 transition-colors">
|
||||
{showFullMap ? "🔼 收起,仅看当前并发任务" : "👁️ 展开全景流转树 (查看包含已完工在内的完整历史)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ─── 统一垂直时间线布局(焦点/全景共用) ─── */}
|
||||
<div className="space-y-6">
|
||||
{visibleMains.map(mainTask => {
|
||||
// 🔧 侧翼严格过滤:主线归主轴,仅 SPAWN 协助分支进入左右翼
|
||||
const directChildren = (childMap[mainTask.id] || []).filter(c => !isMain(c));
|
||||
const hasLegacyActive = directChildren.some(c => !isMain(c) && !active(mainTask.status));
|
||||
const leftDirect = directChildren.filter((_, i) => i % 2 === 0);
|
||||
const rightDirect = directChildren.filter((_, i) => i % 2 === 1);
|
||||
const lineStyle = hasLegacyActive && !active(mainTask.status);
|
||||
|
||||
return (
|
||||
<div key={mainTask.id} className="relative">
|
||||
<div className="absolute left-1/2 top-0 bottom-0 w-0.5 bg-gray-200 -translate-x-1/2 z-0" />
|
||||
<div className="flex flex-row items-start w-full">
|
||||
{/* 左翼 — 递归渲染,子子孙孙向外延伸 */}
|
||||
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
|
||||
{leftDirect.map(c => renderBranch(c, 'left', lineStyle, mainTask.id))}
|
||||
</div>
|
||||
{/* 中央 */}
|
||||
<div className="shrink-0 z-10 relative">
|
||||
<SlimCard task={mainTask} active={active(mainTask.status)}
|
||||
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
{active(mainTask.status) && (
|
||||
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
|
||||
)}
|
||||
</div>
|
||||
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
|
||||
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
|
||||
{rightDirect.map(c => renderBranch(c, 'right', lineStyle, mainTask.id))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[10px] text-gray-300">▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleMains.length === 0 && (
|
||||
<p className="text-center text-xs text-gray-400 py-8">
|
||||
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 记录弹窗 */}
|
||||
{recordsTask && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setRecordsTask(null)} />
|
||||
<div className="relative z-10 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-xl bg-white p-5 shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between"><h3 className="text-sm font-bold">提交记录 — {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
|
||||
{(recordsTask.records || []).length === 0 ? <p className="py-8 text-center text-sm text-gray-400">暂无记录</p> :
|
||||
<div className="space-y-2">{[...recordsTask.records!].reverse().map((r, i) => (
|
||||
<div key={r.id} className="flex gap-2">
|
||||
<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>; })()}
|
||||
</div>
|
||||
</div>
|
||||
))}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskFlowView;
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo, memo } from "react";
|
||||
import {
|
||||
Search,
|
||||
Loader2,
|
||||
@ -10,6 +10,7 @@ import {
|
||||
Warehouse,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import TaskFlowView from "./TaskFlowView";
|
||||
import {
|
||||
getTaskTree,
|
||||
receiveTask,
|
||||
@ -17,65 +18,14 @@ import {
|
||||
transferTask,
|
||||
} from "../../services/taskApi";
|
||||
import { useToast } from "../ui/Toast";
|
||||
import type { ProductScanResponse, TaskResponse, TaskStatus } from "../../types/api";
|
||||
import { TASK_STATUS } from "../../types/api";
|
||||
|
||||
// ============================================================
|
||||
// 状态 → 颜色/标签映射
|
||||
// ============================================================
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ bg: string; text: string; ring: string; label: string }
|
||||
> = {
|
||||
[TASK_STATUS.PENDING]: {
|
||||
bg: "bg-yellow-50",
|
||||
text: "text-yellow-700",
|
||||
ring: "ring-yellow-400",
|
||||
label: "待接收",
|
||||
},
|
||||
[TASK_STATUS.WIP]: {
|
||||
bg: "bg-blue-50",
|
||||
text: "text-blue-700",
|
||||
ring: "ring-blue-400",
|
||||
label: "进行中",
|
||||
},
|
||||
[TASK_STATUS.COMPLETED]: {
|
||||
bg: "bg-green-50",
|
||||
text: "text-green-700",
|
||||
ring: "ring-green-400",
|
||||
label: "已完成",
|
||||
},
|
||||
[TASK_STATUS.REJECTED]: {
|
||||
bg: "bg-red-50",
|
||||
text: "text-red-700",
|
||||
ring: "ring-red-400",
|
||||
label: "已驳回",
|
||||
},
|
||||
[TASK_STATUS.ARCHIVED]: {
|
||||
bg: "bg-gray-50",
|
||||
text: "text-gray-600",
|
||||
ring: "ring-gray-300",
|
||||
label: "已入库",
|
||||
},
|
||||
};
|
||||
|
||||
function getStatusConfig(status: string) {
|
||||
return (
|
||||
STATUS_CONFIG[status] ?? {
|
||||
bg: "bg-gray-50",
|
||||
text: "text-gray-600",
|
||||
ring: "ring-gray-300",
|
||||
label: status,
|
||||
}
|
||||
);
|
||||
}
|
||||
import type { ProductScanResponse, TaskResponse } from "../../types/api";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
|
||||
// ============================================================
|
||||
// 通用 Modal 容器
|
||||
// ============================================================
|
||||
|
||||
function Modal({
|
||||
const Modal = memo(function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
@ -110,13 +60,13 @@ function Modal({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 确认接收弹窗
|
||||
// ============================================================
|
||||
|
||||
function ReceiveConfirmModal({
|
||||
export const ReceiveConfirmModal = memo(function ReceiveConfirmModal({
|
||||
open,
|
||||
task,
|
||||
submitting,
|
||||
@ -163,13 +113,13 @@ function ReceiveConfirmModal({
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 品质驳回弹窗
|
||||
// ============================================================
|
||||
|
||||
function RejectModal({
|
||||
export const RejectModal = memo(function RejectModal({
|
||||
open,
|
||||
task,
|
||||
submitting,
|
||||
@ -244,13 +194,13 @@ function RejectModal({
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 完工裂变转交弹窗
|
||||
// ============================================================
|
||||
|
||||
function TransferModal({
|
||||
export const TransferModal = memo(function TransferModal({
|
||||
open,
|
||||
task,
|
||||
submitting,
|
||||
@ -496,165 +446,17 @@ function TransferModal({
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 单个任务节点卡片
|
||||
// 操作弹窗目标类型
|
||||
// ============================================================
|
||||
|
||||
interface ModalTarget {
|
||||
export interface ModalTarget {
|
||||
task: TaskResponse;
|
||||
action: "receive" | "reject" | "transfer";
|
||||
}
|
||||
|
||||
function TaskNodeCard({
|
||||
task,
|
||||
isLast,
|
||||
onAction,
|
||||
}: {
|
||||
task: TaskResponse;
|
||||
isLast: boolean;
|
||||
onAction: (target: ModalTarget) => void;
|
||||
}) {
|
||||
const { bg, text, ring, label } = getStatusConfig(task.status);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 树形连接线 */}
|
||||
{task.child_tasks.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-4 top-full z-0 w-px bg-gray-200"
|
||||
style={{ height: "calc(100% - 2rem)" }}
|
||||
/>
|
||||
{task.child_tasks.length > 1 && (
|
||||
<div
|
||||
className="absolute left-4 z-0 h-px bg-gray-200"
|
||||
style={{
|
||||
top: "calc(100% + 1rem)",
|
||||
width: "calc(50% - 1rem)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 卡片本体 */}
|
||||
<div
|
||||
className={`relative z-10 mb-1 rounded-lg border bg-white px-3 py-2.5 shadow-sm transition-shadow hover:shadow-md ${ring} ${
|
||||
task.is_rework ? "ring-2 ring-red-500" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* 左侧:任务名 + 标签 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{task.is_rework && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-red-600 px-1.5 py-0.5 text-[10px] font-bold text-white animate-pulse">
|
||||
⚠ 返工
|
||||
</span>
|
||||
)}
|
||||
{task.child_tasks.length > 1 && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
|
||||
<GitBranch className="h-2.5 w-2.5" />
|
||||
裂变×{task.child_tasks.length}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm font-semibold text-gray-800">
|
||||
{task.task_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-400">
|
||||
{task.assignee_id && <span>负责人: {task.assignee_id}</span>}
|
||||
{task.received_at && (
|
||||
<span>
|
||||
接收: {new Date(task.received_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
{task.completed_at && (
|
||||
<span>
|
||||
完成:{" "}
|
||||
{new Date(task.completed_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.reject_reason && (
|
||||
<p className="mt-1 text-[11px] text-red-500">
|
||||
驳回原因: {task.reject_reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:状态标签 + 操作按钮 */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${bg} ${text}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{task.status === TASK_STATUS.PENDING && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="rounded border border-blue-200 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "receive" })}
|
||||
>
|
||||
接收
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-red-200 px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "reject" })}
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-green-200 px-1.5 py-0.5 text-[10px] font-medium text-green-600 hover:bg-green-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "transfer" })}
|
||||
>
|
||||
转交
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{task.status === TASK_STATUS.WIP && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="rounded border border-red-200 px-1.5 py-0.5 text-[10px] font-medium text-red-500 hover:bg-red-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "reject" })}
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-green-200 px-1.5 py-0.5 text-[10px] font-medium text-green-600 hover:bg-green-50 transition-colors"
|
||||
onClick={() => onAction({ task, action: "transfer" })}
|
||||
>
|
||||
转交
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 递归渲染子任务 */}
|
||||
{task.child_tasks.length > 0 && (
|
||||
<div className="ml-8 border-l-2 border-gray-100 pl-4 pt-1">
|
||||
{task.child_tasks.map((child, idx) => (
|
||||
<TaskNodeCard
|
||||
key={child.id}
|
||||
task={child}
|
||||
isLast={idx === task.child_tasks.length - 1}
|
||||
onAction={onAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主容器组件
|
||||
// ============================================================
|
||||
@ -688,7 +490,7 @@ export default function TaskTreeViewer() {
|
||||
e?.preventDefault();
|
||||
const trimmed = serial.trim();
|
||||
if (trimmed.length !== 16) {
|
||||
setError("请输入 16 位产品序列号");
|
||||
setError("请输入 16 位产品身份证");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -702,7 +504,7 @@ export default function TaskTreeViewer() {
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err?.response?.status === 404
|
||||
? `未找到序列号 ${trimmed} 对应的产品`
|
||||
? `未找到产品身份证 ${trimmed} 对应的产品`
|
||||
: err?.response?.data?.detail ??
|
||||
err?.message ??
|
||||
"查询失败,请检查后端服务";
|
||||
@ -779,6 +581,12 @@ export default function TaskTreeViewer() {
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
// 🚀 缓存递归计算 — 仅在 task_tree 变化时重新计算
|
||||
const totalTaskCount = useMemo(
|
||||
() => (product?.task_tree ? countAllTasks(product.task_tree) : 0),
|
||||
[product?.task_tree],
|
||||
);
|
||||
|
||||
const modalTask = modalTarget?.task ?? null;
|
||||
|
||||
return (
|
||||
@ -787,7 +595,7 @@ export default function TaskTreeViewer() {
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-800">任务全景树</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
输入 16 位产品序列号,查看完整任务流转十字矩阵树状图
|
||||
输入 16 位产品身份证,查看完整任务流转十字矩阵树状图
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSearch} className="mt-4 flex items-center gap-2">
|
||||
@ -797,7 +605,7 @@ export default function TaskTreeViewer() {
|
||||
type="text"
|
||||
value={serial}
|
||||
onChange={(e) => setSerial(e.target.value)}
|
||||
placeholder="输入 16 位序列号,如 X20260801000001"
|
||||
placeholder="输入 16 位产品身份证,如 X20260801000001"
|
||||
maxLength={16}
|
||||
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 font-mono text-sm tracking-widest placeholder:tracking-normal placeholder:font-sans focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
@ -849,7 +657,7 @@ export default function TaskTreeViewer() {
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<span className="text-xs text-gray-400">产品序列号</span>
|
||||
<span className="text-xs text-gray-400">产品身份证</span>
|
||||
<p className="font-mono text-base font-bold tracking-widest text-gray-800">
|
||||
{product.serial_number}
|
||||
</p>
|
||||
@ -880,7 +688,7 @@ export default function TaskTreeViewer() {
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{product.top_level_tasks?.length ?? 0} 顶层
|
||||
{product.task_tree?.length
|
||||
? ` · ${countAllTasks(product.task_tree)} 总计`
|
||||
? ` · ${totalTaskCount} 总计`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
@ -888,25 +696,15 @@ export default function TaskTreeViewer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 任务树 ---- */}
|
||||
{/* ---- 任务流转分支/卡片视图 ---- */}
|
||||
{product && !loading && (
|
||||
<>
|
||||
{product.task_tree && product.task_tree.length > 0 ? (
|
||||
<div className="rounded-xl bg-white p-6 shadow-sm">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-gray-500">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
任务流转树状图
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{product.task_tree.map((task, idx) => (
|
||||
<TaskNodeCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isLast={idx === product.task_tree!.length - 1}
|
||||
onAction={setModalTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-6 shadow-sm overflow-hidden">
|
||||
<TaskFlowView
|
||||
tasks={product.task_tree}
|
||||
onAction={setModalTarget}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 text-gray-400 shadow-sm">
|
||||
|
||||
@ -19,7 +19,7 @@ const MENU = [
|
||||
title: "任务全景",
|
||||
path: "/admin/tasks",
|
||||
icon: GitBranch,
|
||||
description: "序列号查任务树 · 裂变/返工可视化",
|
||||
description: "身份证查任务树 · 裂变/返工可视化",
|
||||
},
|
||||
];
|
||||
|
||||
@ -113,11 +113,6 @@ export default function AdminLayout() {
|
||||
<span className="font-medium text-gray-700">
|
||||
{user?.display_name ?? user?.username ?? "—"}
|
||||
</span>
|
||||
{user?.role && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { ScanLine, ClipboardList, Bell, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { ScanLine, ClipboardList, Bell, User, ArrowLeft } from "lucide-react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { getNotifications } from "../../services/notificationApi";
|
||||
|
||||
/** 底部导航 Tab 配置 */
|
||||
/** 底部导航 Tab 配置 — 与 uni-app pages.json tabBar.list 完全一致 */
|
||||
const TABS = [
|
||||
{ path: "/scan", label: "扫码干活", icon: ScanLine },
|
||||
{ path: "/tasks", label: "我的任务", icon: ClipboardList },
|
||||
@ -10,38 +13,92 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
/** 导航栏高度(供页面计算偏移量) */
|
||||
export const TAB_BAR_HEIGHT = 64; // px(h-16)
|
||||
export const TAB_BAR_HEIGHT = 64;
|
||||
const TOP_BAR_HEIGHT = 48;
|
||||
|
||||
export default function AppLayout() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// 🔔 未读消息数轮询
|
||||
useEffect(() => {
|
||||
const userId = user?.username || user?.id || "";
|
||||
if (!userId) return;
|
||||
|
||||
function poll() {
|
||||
getNotifications(userId, 0, 1)
|
||||
.then((res) => setUnreadCount(res.unread_count || 0))
|
||||
.catch(() => {}); // 静默
|
||||
}
|
||||
|
||||
poll(); // 立即请求一次
|
||||
const id = setInterval(poll, 30_000); // 每 30 秒轮询
|
||||
return () => clearInterval(id);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col bg-gray-50">
|
||||
{/* ======== 顶部导航栏 — 返回管理端 ======== */}
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 z-50 flex items-center border-b border-gray-200 bg-white px-4 shadow-sm"
|
||||
style={{ height: TOP_BAR_HEIGHT }}
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate("/admin/dashboard")}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 active:bg-blue-100"
|
||||
>
|
||||
<ArrowLeft size={16} strokeWidth={2.5} />
|
||||
返回管理端
|
||||
</button>
|
||||
<span className="ml-auto text-xs text-gray-400">生产流转 · 移动端</span>
|
||||
</header>
|
||||
|
||||
{/* ======== 主内容区 ======== */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<main
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{
|
||||
paddingTop: TOP_BAR_HEIGHT,
|
||||
paddingBottom: TAB_BAR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* ======== 底部导航栏 ======== */}
|
||||
{/* ======== 底部导航栏 — 与 uni-app tabBar 完全一致 ======== */}
|
||||
<nav
|
||||
className="fixed bottom-0 z-50 w-full border-t border-gray-200 bg-white pb-safe"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 border-t border-gray-200 bg-white pb-safe"
|
||||
style={{ height: TAB_BAR_HEIGHT }}
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-lg items-center justify-around">
|
||||
{TABS.map(({ path, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-0.5 px-3 py-1 transition-colors ${
|
||||
isActive
|
||||
? "text-blue-600"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{TABS.map(({ path, label, icon: Icon }) => {
|
||||
const isNotifyTab = path === "/notifications";
|
||||
return (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
className={({ isActive }) =>
|
||||
`relative flex flex-col items-center gap-0.5 px-3 py-1 transition-colors ${
|
||||
isActive
|
||||
? "text-blue-600"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
{/* 🔴 消息 Tab 未读红点 */}
|
||||
{isNotifyTab && unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-2 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] font-bold text-white shadow-[0_0_0_2px_white]">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/** 手动输入序列号区域 */
|
||||
/** 手动输入产品身份证区域 */
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
|
||||
interface ManualInputProps {
|
||||
@ -29,7 +29,7 @@ export default function ManualInput({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="手动输入16位序列号"
|
||||
placeholder="手动输入16位产品身份证"
|
||||
maxLength={16}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
|
||||
@ -1,25 +1,7 @@
|
||||
/** 产品信息卡片 */
|
||||
import { Package } from "lucide-react";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import { TASK_STATUS } from "../../types/api";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
[TASK_STATUS.PENDING]: "待接收",
|
||||
[TASK_STATUS.WIP]: "进行中",
|
||||
[TASK_STATUS.COMPLETED]: "已完成",
|
||||
[TASK_STATUS.REJECTED]: "已驳回",
|
||||
[TASK_STATUS.ARCHIVED]: "已入库",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
|
||||
case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
|
||||
case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
|
||||
case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
|
||||
default: return "bg-gray-100 text-gray-600";
|
||||
}
|
||||
}
|
||||
import { statusColor, statusLabel } from "../../constants/task";
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductScanResponse;
|
||||
@ -32,12 +14,12 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
<Package className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-800">产品信息</h3>
|
||||
<span className={`ml-auto rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColor(product.status)}`}>
|
||||
{STATUS_LABELS[product.status] ?? product.status}
|
||||
{statusLabel(product.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">序列号</span>
|
||||
<span className="text-gray-400">身份证</span>
|
||||
<p className="font-mono font-medium text-gray-800">{product.serial_number}</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -1,25 +1,8 @@
|
||||
/** 任务进度列表卡片 — 递归渲染 task_tree */
|
||||
import { useMemo, memo } from "react";
|
||||
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
|
||||
import type { TaskResponse, TaskSummary } from "../../types/api";
|
||||
import { TASK_STATUS } from "../../types/api";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
[TASK_STATUS.PENDING]: "待接收",
|
||||
[TASK_STATUS.WIP]: "进行中",
|
||||
[TASK_STATUS.COMPLETED]: "已完成",
|
||||
[TASK_STATUS.REJECTED]: "已驳回",
|
||||
[TASK_STATUS.ARCHIVED]: "已入库",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
|
||||
case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
|
||||
case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
|
||||
case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
|
||||
default: return "bg-gray-100 text-gray-600";
|
||||
}
|
||||
}
|
||||
import { statusColor, statusLabel } from "../../constants/task";
|
||||
|
||||
interface TaskListCardProps {
|
||||
tasks: TaskResponse[];
|
||||
@ -30,7 +13,7 @@ interface TaskNodeProps {
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function TaskNode({ task, depth }: TaskNodeProps) {
|
||||
const TaskNode = memo(function TaskNode({ task, depth }: TaskNodeProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-t border-gray-50" style={{ paddingLeft: 16 + depth * 16 }}>
|
||||
@ -54,7 +37,7 @@ function TaskNode({ task, depth }: TaskNodeProps) {
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusColor(task.status)}`}>
|
||||
{STATUS_LABELS[task.status] ?? task.status}
|
||||
{statusLabel(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
{task.child_tasks?.map((child) => (
|
||||
@ -62,19 +45,22 @@ function TaskNode({ task, depth }: TaskNodeProps) {
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function countAll(tasks: TaskResponse[]): number {
|
||||
return tasks.reduce((s, t) => s + 1 + (t.child_tasks ? countAll(t.child_tasks) : 0), 0);
|
||||
}
|
||||
|
||||
export default function TaskListCard({ tasks }: TaskListCardProps) {
|
||||
// 🚀 缓存递归计算
|
||||
const totalCount = useMemo(() => countAll(tasks), [tasks]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-800">任务流转树</h3>
|
||||
<span className="ml-auto text-xs text-gray-400">{countAll(tasks)} 个任务</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{totalCount} 个任务</span>
|
||||
</div>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">暂无关联任务</div>
|
||||
|
||||
10
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
10
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/** 全局路由懒加载回退组件 */
|
||||
export default function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import {
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
|
||||
@ -52,8 +53,11 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
}, 3500);
|
||||
}, []);
|
||||
|
||||
// 🚀 稳定 Context value 引用
|
||||
const ctxValue = useMemo<ToastContextValue>(() => ({ toast }), [toast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
<ToastContext.Provider value={ctxValue}>
|
||||
{children}
|
||||
|
||||
{/* Toast 渲染区 */}
|
||||
|
||||
71
frontend/src/constants/task.ts
Normal file
71
frontend/src/constants/task.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 任务状态 → 颜色/标签映射(全局唯一数据源)
|
||||
*
|
||||
* 供 TaskTreeViewer / TaskListCard / ProductCard 等组件共用,
|
||||
* 避免在多处重复维护相同的映射逻辑。
|
||||
*/
|
||||
import { TASK_STATUS } from "../types/api";
|
||||
|
||||
export interface StatusStyle {
|
||||
bg: string;
|
||||
text: string;
|
||||
ring: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const STATUS_CONFIG: Record<string, StatusStyle> = {
|
||||
[TASK_STATUS.PENDING]: {
|
||||
bg: "bg-yellow-50",
|
||||
text: "text-yellow-700",
|
||||
ring: "ring-yellow-400",
|
||||
label: "待接收",
|
||||
},
|
||||
[TASK_STATUS.WIP]: {
|
||||
bg: "bg-blue-50",
|
||||
text: "text-blue-700",
|
||||
ring: "ring-blue-400",
|
||||
label: "进行中",
|
||||
},
|
||||
[TASK_STATUS.COMPLETED]: {
|
||||
bg: "bg-green-50",
|
||||
text: "text-green-700",
|
||||
ring: "ring-green-400",
|
||||
label: "已完成",
|
||||
},
|
||||
[TASK_STATUS.REJECTED]: {
|
||||
bg: "bg-red-50",
|
||||
text: "text-red-700",
|
||||
ring: "ring-red-400",
|
||||
label: "待接收",
|
||||
},
|
||||
[TASK_STATUS.ARCHIVED]: {
|
||||
bg: "bg-gray-50",
|
||||
text: "text-gray-600",
|
||||
ring: "ring-gray-300",
|
||||
label: "已完成",
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK: StatusStyle = {
|
||||
bg: "bg-gray-50",
|
||||
text: "text-gray-600",
|
||||
ring: "ring-gray-300",
|
||||
label: "",
|
||||
};
|
||||
|
||||
/** 获取状态的完整样式配置(bg / text / ring / label)— 大小写不敏感 */
|
||||
export function getStatusConfig(status: string): StatusStyle {
|
||||
const key = status?.toUpperCase?.() ?? status;
|
||||
return STATUS_CONFIG[key] ?? { ...FALLBACK, label: status };
|
||||
}
|
||||
|
||||
/** 仅获取 bg + text 类名,用于简单的状态标签着色 */
|
||||
export function statusColor(status: string): string {
|
||||
const cfg = getStatusConfig(status);
|
||||
return `${cfg.bg} ${cfg.text}`;
|
||||
}
|
||||
|
||||
/** 仅获取中文标签 */
|
||||
export function statusLabel(status: string): string {
|
||||
return getStatusConfig(status).label;
|
||||
}
|
||||
@ -3,6 +3,7 @@ import {
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@ -22,7 +23,7 @@ export interface UserInfo {
|
||||
interface AuthState {
|
||||
user: UserInfo | null;
|
||||
token: string | null;
|
||||
loading: boolean; // 初始化时检查 token
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
@ -35,7 +36,8 @@ interface AuthContextValue extends AuthState {
|
||||
// Token 存储 key
|
||||
// ============================================================
|
||||
|
||||
const TOKEN_KEY = "track_admin_token";
|
||||
const ACCESS_TOKEN_KEY = "track_admin_token";
|
||||
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
|
||||
const USER_KEY = "track_admin_user";
|
||||
|
||||
// ============================================================
|
||||
@ -61,23 +63,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
loading: true,
|
||||
});
|
||||
|
||||
// 初始化:从 localStorage 恢复 token
|
||||
// 初始化:从 localStorage 恢复双 Token
|
||||
useEffect(() => {
|
||||
const savedToken = localStorage.getItem(TOKEN_KEY);
|
||||
const savedToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const savedUser = localStorage.getItem(USER_KEY);
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
const user = JSON.parse(savedUser) as UserInfo;
|
||||
setState({ user, token: savedToken, loading: false });
|
||||
// 可选:后端验证 token 是否仍有效
|
||||
// 后台静默验证 token 是否仍有效
|
||||
getMe(savedToken)
|
||||
.then((fresh) => {
|
||||
setState((prev) => ({ ...prev, user: fresh }));
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(fresh));
|
||||
})
|
||||
.catch(() => {
|
||||
// token 过期,清除
|
||||
logoutInternal();
|
||||
.catch((err) => {
|
||||
// 验证失败不踢出用户 — 真正的过期由业务 API 401 拦截器
|
||||
// 通过 Refresh Token 无感刷新,彻底失败才跳转登录
|
||||
console.error(
|
||||
"[Auth] Token 后台验证失败(保留本地登录态,依赖拦截器刷新):",
|
||||
err?.message ?? err,
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
logoutInternal();
|
||||
@ -88,34 +94,41 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
function logoutInternal() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setState({ user: null, token: null, loading: false });
|
||||
}
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const result = await loginApi(username, password);
|
||||
const token = result.access_token;
|
||||
const accessToken = result.access_token;
|
||||
const refreshToken = result.refresh_token;
|
||||
const user: UserInfo = result.user;
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
// 存储双 Token
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
setState({ user, token, loading: false });
|
||||
setState({ user, token: accessToken, loading: false });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutInternal();
|
||||
}, []);
|
||||
|
||||
const ctxValue = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!state.token && !!state.user,
|
||||
}),
|
||||
[state, login, logout],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!state.token && !!state.user,
|
||||
}}
|
||||
>
|
||||
<AuthContext.Provider value={ctxValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@ -1,29 +1,200 @@
|
||||
/** 待办 — 我的任务列表 */
|
||||
/** 我的任务 — 1:1 复刻 uni-app pages/tasks/index.vue */
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../services/api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import type { TaskSummary } from "../types/api";
|
||||
|
||||
// ============================================================
|
||||
// 常量 — 与 uni-app 完全一致
|
||||
// ============================================================
|
||||
|
||||
const TABS = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "PENDING", label: "待接收" },
|
||||
{ key: "WIP", label: "进行中" },
|
||||
{ key: "COMPLETED", label: "已完成" },
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING: "待接收",
|
||||
WIP: "进行中",
|
||||
COMPLETED: "已完成",
|
||||
REJECTED: "已驳回",
|
||||
ARCHIVED: "已入库",
|
||||
CANCELED: "已撤回",
|
||||
};
|
||||
|
||||
/** 状态 → Tailwind 颜色类名 */
|
||||
function statusColor(s: string) {
|
||||
switch (s) {
|
||||
case "PENDING":
|
||||
return "bg-yellow-50 text-yellow-700";
|
||||
case "WIP":
|
||||
return "bg-blue-50 text-blue-700";
|
||||
case "COMPLETED":
|
||||
return "bg-green-50 text-green-700";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-600";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Page 组件
|
||||
// ============================================================
|
||||
|
||||
export default function MyTasksPage() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [tab, setTab] = useState<TabKey>("PENDING");
|
||||
const [tasks, setTasks] = useState<TaskSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchTasks() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const username = user?.username || "";
|
||||
const { data } = await api.get<{ tasks: TaskSummary[]; total: number }>(
|
||||
"/tasks/",
|
||||
{ params: { assignee_id: username, limit: 100 } }
|
||||
);
|
||||
// 排序:主干任务在前 + 创建时间升序(与 uni-app 完全一致)
|
||||
const sorted = (data.tasks || []).sort((a, b) => {
|
||||
const aIsMain = !a.parent_task_id || a.task_type !== "SPAWN";
|
||||
const bIsMain = !b.parent_task_id || b.task_type !== "SPAWN";
|
||||
if (aIsMain && !bIsMain) return -1;
|
||||
if (!aIsMain && bIsMain) return 1;
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
});
|
||||
setTasks(sorted);
|
||||
} catch {
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 按 Tab 筛选 ----
|
||||
const filtered = useMemo(() => {
|
||||
if (tab === "all") return tasks;
|
||||
return tasks.filter((t) => t.status === tab);
|
||||
}, [tasks, tab]);
|
||||
|
||||
// ---- 各 Tab 计数 ----
|
||||
function countBy(key: TabKey) {
|
||||
if (key === "all") return tasks.length;
|
||||
return tasks.filter((t) => t.status === key).length;
|
||||
}
|
||||
|
||||
function goDetail(task: TaskSummary) {
|
||||
const sn = task.product_sn || task.product_id;
|
||||
if (sn) navigate(`/scan?serial=${sn}`);
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 渲染
|
||||
// ==========================================================
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
{/* 标题 */}
|
||||
<h2 className="text-xl font-bold text-gray-800">我的任务</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">待处理和进行中的任务</p>
|
||||
|
||||
{/* 占位空状态 */}
|
||||
<div className="mt-12 flex flex-1 flex-col items-center justify-center">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg
|
||||
className="h-10 w-10 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
{/* Tab 栏 — 与 uni-app 样式一致:sticky、可横向滚动 */}
|
||||
<div className="sticky top-0 z-10 -mx-4 overflow-x-auto bg-gray-50 px-4 py-3">
|
||||
<div className="flex gap-2 whitespace-nowrap">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded-full px-4 py-2 text-[13px] font-semibold transition-colors ${
|
||||
tab === t.key
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-500 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{t.label} ({countBy(t.key)})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">暂无待办任务</p>
|
||||
</div>
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading && (
|
||||
<div className="flex flex-1 items-center justify-center py-20 text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-20">
|
||||
<span className="text-6xl">📋</span>
|
||||
<span className="mt-3 text-sm text-gray-400">
|
||||
{tab === "all" ? "暂无待办任务" : "无此状态任务"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 任务卡片列表 */}
|
||||
{!loading &&
|
||||
filtered.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => goDetail(task)}
|
||||
className="mb-2.5 cursor-pointer rounded-xl bg-white p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.06)] transition-shadow hover:shadow-md"
|
||||
>
|
||||
{/* Row 1: 类型标签 + 任务名 | 状态 */}
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{/* 主干/协助 标签 — 与 uni-app tag-badge 一致 */}
|
||||
<span
|
||||
className={`inline-block shrink-0 rounded px-1.5 py-px text-[11px] font-bold ${
|
||||
task.task_type === "SPAWN"
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{task.task_type === "SPAWN" ? "协助" : "主干"}
|
||||
</span>
|
||||
<span className="truncate text-[15px] font-bold text-gray-800">
|
||||
{task.task_name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${statusColor(task.status)}`}
|
||||
>
|
||||
{STATUS_LABEL[task.status] || task.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 身份证 + 物料 */}
|
||||
<div className="flex gap-3 text-xs text-gray-500">
|
||||
<span>身份证: {task.product_sn || "—"}</span>
|
||||
<span>物料: {task.product_material || "—"}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 创建时间 */}
|
||||
<div className="mt-1 text-[11px] text-gray-400">
|
||||
创建: {formatTime(task.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,29 +1,164 @@
|
||||
/** 消息 — 通知推送 */
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
<h2 className="text-xl font-bold text-gray-800">消息通知</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">任务流转和系统通知</p>
|
||||
/** 消息通知 — 1:1 复刻 uni-app pages/notify/index.vue + 真实 API */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import {
|
||||
getNotifications,
|
||||
markNotificationRead,
|
||||
type NotificationItem,
|
||||
} from "../services/notificationApi";
|
||||
|
||||
{/* 占位空状态 */}
|
||||
<div className="mt-12 flex flex-1 flex-col items-center justify-center">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg
|
||||
className="h-10 w-10 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">暂无新消息</p>
|
||||
// ============================================================
|
||||
// 类型常量
|
||||
// ============================================================
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: string; title: string }> = {
|
||||
TRANSFER: { icon: "🟢", title: "新任务派发" },
|
||||
REJECT: { icon: "🔴", title: "品质驳回提醒" },
|
||||
};
|
||||
|
||||
function formatTime(t: string) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Page 组件
|
||||
// ============================================================
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchNotifications() {
|
||||
const userId = user?.username || user?.id || "";
|
||||
if (!userId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getNotifications(userId);
|
||||
setNotifications(res.notifications || []);
|
||||
} catch {
|
||||
setNotifications([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCardTap(item: NotificationItem) {
|
||||
// 标记已读
|
||||
if (!item.is_read) {
|
||||
try {
|
||||
await markNotificationRead(item.id);
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === item.id ? { ...n, is_read: true } : n))
|
||||
);
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转详情页
|
||||
if (item.task_id) {
|
||||
navigate(`/scan?taskId=${item.task_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 渲染
|
||||
// ==========================================================
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe pb-20">
|
||||
{/* 标题区 — 与 uni-app 完全一致 */}
|
||||
<div className="mb-5">
|
||||
<h2 className="text-xl font-bold text-gray-800">消息通知</h2>
|
||||
<p className="mt-1 text-[13px] text-gray-400">任务流转和系统通知</p>
|
||||
</div>
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading && (
|
||||
<div className="flex flex-1 items-center justify-center py-20 text-sm text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && notifications.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center pb-24">
|
||||
<span className="text-[64px] leading-none">🔔</span>
|
||||
<span className="mt-3 text-sm text-gray-400">暂无新消息</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通知列表 */}
|
||||
{!loading && notifications.length > 0 && (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{notifications.map((item) => {
|
||||
const cfg = TYPE_CONFIG[item.type] || {
|
||||
icon: "📌",
|
||||
title: "系统通知",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleCardTap(item)}
|
||||
className={`flex cursor-pointer items-start gap-2.5 rounded-xl bg-white p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.06)] transition-all active:scale-[0.98] ${
|
||||
!item.is_read
|
||||
? "border-l-[3px] border-l-blue-600 shadow-[0_1px_6px_rgba(37,99,235,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* 左侧:未读红点 + 类型图标 */}
|
||||
<div className="flex w-7 shrink-0 flex-col items-center gap-1">
|
||||
{!item.is_read && (
|
||||
<span className="h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]" />
|
||||
)}
|
||||
<span
|
||||
className={`text-xl leading-none ${
|
||||
item.is_read ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧:内容 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`text-[15px] font-semibold ${
|
||||
item.is_read ? "text-gray-600" : "font-bold text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{cfg.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-gray-400">
|
||||
{formatTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] leading-relaxed text-gray-500 break-all">
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="mt-1.5 shrink-0 text-xl text-gray-300">›</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,35 +1,105 @@
|
||||
/** 我的 — 个人中心 */
|
||||
/** 我的 — 与 uni-app pages/profile/index.vue 完全同步 */
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useToast } from "../components/ui/Toast";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
|
||||
// ---- 从 AuthContext 派生真实数据 ----
|
||||
const displayName = user?.display_name || user?.username || "未知用户";
|
||||
const avatarChar = displayName.charAt(0);
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate("/admin/login", { replace: true });
|
||||
}
|
||||
|
||||
function handleMenuClick(item: string) {
|
||||
switch (item) {
|
||||
case "关于":
|
||||
setShowAbout(true);
|
||||
break;
|
||||
case "帮助与反馈":
|
||||
toast("反馈通道搭建中,敬请期待...", "info");
|
||||
break;
|
||||
// 工作统计、设置 暂不处理
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
{/* 用户信息卡片 */}
|
||||
<div className="mt-4 flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
|
||||
张
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe pb-20">
|
||||
{/* 用户卡片 */}
|
||||
<div className="mt-4 flex items-center gap-3 rounded-xl bg-white p-4 shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
|
||||
{avatarChar}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-800">张三</h3>
|
||||
<p className="text-sm text-gray-500">操作员</p>
|
||||
<span className="block text-base font-bold text-gray-800">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-xl text-gray-300">›</span>
|
||||
</div>
|
||||
|
||||
{/* 菜单列表占位 */}
|
||||
<div className="mt-6 space-y-1 rounded-xl bg-white shadow-sm">
|
||||
{["工作统计", "设置", "帮助与反馈", "关于"].map((item) => (
|
||||
{/* 菜单列表 */}
|
||||
<div className="mt-4 overflow-hidden rounded-xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06)]">
|
||||
{["工作统计", "设置", "帮助与反馈", "关于"].map((item, i) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center justify-between border-b border-gray-50 px-4 py-3 last:border-b-0"
|
||||
onClick={() => handleMenuClick(item)}
|
||||
className={`flex cursor-pointer items-center justify-between px-4 py-3.5 transition-colors hover:bg-gray-50 ${
|
||||
i < 3 ? "border-b border-gray-50" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-gray-700">{item}</span>
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-xl text-gray-300">›</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center justify-between border-t border-gray-50 px-4 py-3.5 transition-colors hover:bg-red-50"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm text-red-500">
|
||||
<LogOut size={16} strokeWidth={2} />
|
||||
退出登录
|
||||
</span>
|
||||
<span className="text-xl text-red-300">›</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-xs text-gray-300">生产流转 T1.0.3</p>
|
||||
|
||||
{/* 关于弹窗 */}
|
||||
{showAbout && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setShowAbout(false)}
|
||||
/>
|
||||
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<h3 className="mb-4 text-center text-lg font-bold text-gray-800">
|
||||
关于 Track
|
||||
</h3>
|
||||
<div className="mb-6 whitespace-pre-line text-center text-sm leading-relaxed text-gray-600">
|
||||
Track 生产流转管理系统{"\n"}当前版本:T1.0.3{"\n"}核心架构:FastAPI + React + Tailwind
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAbout(false)}
|
||||
className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
/** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */
|
||||
import { useState, useCallback } from "react";
|
||||
import { QrCode } from "lucide-react";
|
||||
import { useState, useCallback, Suspense, lazy } from "react";
|
||||
import { QrCode, Loader2 } from "lucide-react";
|
||||
import { scanProduct } from "../services/productApi";
|
||||
import type { ProductScanResponse } from "../types/api";
|
||||
import CameraScanner from "../components/scan/CameraScanner";
|
||||
import ManualInput from "../components/scan/ManualInput";
|
||||
import QueryResult from "../components/scan/QueryResult";
|
||||
|
||||
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
|
||||
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
|
||||
|
||||
export default function ScanPage() {
|
||||
const [cameraActive, setCameraActive] = useState(false);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
@ -18,7 +20,7 @@ export default function ScanPage() {
|
||||
|
||||
/** 通用查询 */
|
||||
const doQuery = useCallback(async (sn: string) => {
|
||||
if (sn.length < 8) { setError("序列号至少需要 8 位"); return; }
|
||||
if (sn.length < 8) { setError("产品身份证至少需要 8 位"); return; }
|
||||
setSerialNumber(sn);
|
||||
setLastScanned(sn);
|
||||
setLoading(true);
|
||||
@ -37,7 +39,7 @@ export default function ScanPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 扫码回调:提取纯序列号 */
|
||||
/** 扫码回调:提取纯产品身份证 */
|
||||
const handleScan = useCallback(
|
||||
(decodedText: string) => {
|
||||
setCameraError(null);
|
||||
@ -53,15 +55,23 @@ export default function ScanPage() {
|
||||
<h2 className="text-lg font-bold text-gray-800">扫码干活</h2>
|
||||
</div>
|
||||
|
||||
{/* 摄像头扫码 */}
|
||||
{/* 摄像头扫码 — 点击启动时才动态加载 html5-qrcode */}
|
||||
<div className="mt-3 px-4">
|
||||
<CameraScanner
|
||||
active={cameraActive}
|
||||
onToggle={setCameraActive}
|
||||
onScan={handleScan}
|
||||
error={cameraError}
|
||||
onError={setCameraError}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center rounded-xl border border-gray-200 bg-white py-16 shadow-sm">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CameraScanner
|
||||
active={cameraActive}
|
||||
onToggle={setCameraActive}
|
||||
onScan={handleScan}
|
||||
error={cameraError}
|
||||
onError={setCameraError}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* 手动输入 */}
|
||||
|
||||
@ -1,17 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X,
|
||||
Package, Hash, Tag, MapPin, Clock, Pencil, Trash2, Save, AlertTriangle,
|
||||
Search, ChevronDown, ChevronRight, Warehouse, Barcode,
|
||||
} from "lucide-react";
|
||||
import api from "../../services/api";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import CreateProductDialog from "./CreateProductDialog";
|
||||
import {
|
||||
getLabelPreview,
|
||||
executePrint,
|
||||
type LabelPreviewRequest,
|
||||
getLabelPreview, executePrint, type LabelPreviewRequest,
|
||||
} from "../../services/printApi";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
|
||||
const QR_BASE = "/api/v1/products/qrcode";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "PENDING", label: "待接收" },
|
||||
{ key: "WIP", label: "进行中" },
|
||||
{ key: "COMPLETED", label: "已完成" },
|
||||
{ key: "ARCHIVED", label: "已入库" },
|
||||
];
|
||||
|
||||
interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWarehouse: boolean; }
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const { toast } = useToast();
|
||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||
@ -19,289 +32,265 @@ export default function AdminProductsPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
// 打印弹窗状态
|
||||
// 搜索 & 筛选 & 分组
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// 打印
|
||||
const [printTarget, setPrintTarget] = useState<ProductResponse | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [printLoading, setPrintLoading] = useState(false);
|
||||
const [printCopies, setPrintCopies] = useState(1);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
|
||||
// 编辑
|
||||
const [editTarget, setEditTarget] = useState<ProductResponse | null>(null);
|
||||
const [editOrderNo, setEditOrderNo] = useState("");
|
||||
const [editExternalSerial, setEditExternalSerial] = useState("");
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
|
||||
// 删除防呆
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProductResponse | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
const [serialInput, setSerialInput] = useState("");
|
||||
const canConfirmDelete = countdown <= 0 && serialInput === deleteTarget?.serial_number;
|
||||
|
||||
async function loadProducts() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const { data } = await api.get<ProductResponse[]>("/products/");
|
||||
setProducts(data);
|
||||
} catch {
|
||||
setError("加载产品列表失败,请检查后端服务");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProducts();
|
||||
}, []);
|
||||
|
||||
// ---- 打印标签 ----
|
||||
|
||||
async function handleOpenPrint(product: ProductResponse) {
|
||||
setPrintTarget(product);
|
||||
setPreviewUrl(null);
|
||||
setPrintCopies(1);
|
||||
setPrintLoading(true);
|
||||
|
||||
try {
|
||||
const payload: LabelPreviewRequest = {
|
||||
serial_number: product.serial_number,
|
||||
material_name: product.material_name ?? product.material_id ?? "",
|
||||
spec_model: product.spec_model ?? "",
|
||||
order_no: product.order_no ?? "",
|
||||
};
|
||||
const url = await getLabelPreview(payload);
|
||||
setPreviewUrl(url);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? err?.message ?? "生成预览失败", "error");
|
||||
setPrintTarget(null);
|
||||
} finally {
|
||||
setPrintLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmPrint() {
|
||||
if (!printTarget) return;
|
||||
setPrinting(true);
|
||||
try {
|
||||
const result = await executePrint({
|
||||
serial_number: printTarget.serial_number,
|
||||
material_name: printTarget.material_name ?? printTarget.material_id ?? "",
|
||||
spec_model: printTarget.spec_model ?? "",
|
||||
order_no: printTarget.order_no ?? "",
|
||||
copies: printCopies,
|
||||
const { data } = await api.get<ProductResponse[]>("/products/", {
|
||||
params: { limit: 1000, ...(keyword.trim() ? { keyword: keyword.trim() } : {}) },
|
||||
});
|
||||
toast(result.message, "success");
|
||||
setPrintTarget(null);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? err?.message ?? "打印失败", "error");
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
setProducts(data);
|
||||
} catch { setError("加载产品列表失败"); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { loadProducts(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleSearch(e?: React.FormEvent) { e?.preventDefault(); loadProducts(); }
|
||||
|
||||
// ---- 分组 + 状态过滤 ----
|
||||
const productGroups = useMemo<ProductGroup[]>(() => {
|
||||
const map = new Map<string, ProductResponse[]>();
|
||||
for (const p of products) {
|
||||
if (statusFilter) {
|
||||
const s = (p.macro_status || p.status).toUpperCase();
|
||||
if (s !== statusFilter) continue;
|
||||
}
|
||||
const key = groupBy === "device"
|
||||
? (p.material_name || p.material_id || "未命名设备")
|
||||
: (p.order_no || "未绑定订单");
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(p);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([groupKey, prods]) => ({
|
||||
groupKey, products: prods,
|
||||
allInWarehouse: prods.every(p => p.current_location_id === "virtual_warehouse"),
|
||||
}));
|
||||
}, [products, statusFilter, groupBy]);
|
||||
|
||||
// 🔧 默认全部展开:productGroups 变化时自动展开所有面板
|
||||
useEffect(() => {
|
||||
setExpandedGroups(new Set(productGroups.map(g => g.groupKey)));
|
||||
}, [productGroups]);
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
setExpandedGroups(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n; });
|
||||
}
|
||||
|
||||
// ---- 打印二维码(兼容旧功能) ----
|
||||
// ---- 打印 ----
|
||||
async function handleOpenPrint(product: ProductResponse) { /* unchanged */ setPrintTarget(product); setPreviewUrl(null); setPrintCopies(1); setPrintLoading(true); try { setPreviewUrl(await getLabelPreview({ serial_number: product.serial_number, material_name: product.material_name ?? product.material_id ?? "", spec_model: product.spec_model ?? "", order_no: product.order_no ?? "" })); } catch (err: any) { toast(err?.response?.data?.detail ?? "生成预览失败", "error"); setPrintTarget(null); } finally { setPrintLoading(false); } }
|
||||
async function handleConfirmPrint() { if (!printTarget) return; setPrinting(true); try { const r = await executePrint({ serial_number: printTarget.serial_number, material_name: printTarget.material_name ?? "", spec_model: printTarget.spec_model ?? "", order_no: printTarget.order_no ?? "", copies: printCopies }); toast(r.message, "success"); setPrintTarget(null); } catch (err: any) { toast(err?.response?.data?.detail ?? "打印失败", "error"); } finally { setPrinting(false); } }
|
||||
|
||||
function handlePrintQr(serialNumber: string) {
|
||||
const qrUrl = `${QR_BASE}/${serialNumber}`;
|
||||
const w = window.open("", "_blank", "width=400,height=500");
|
||||
if (!w) return;
|
||||
w.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>打印标签 - ${serialNumber}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { display: flex; flex-direction: column; align-items: center; padding: 24px; font-family: monospace; }
|
||||
img { width: 280px; height: 280px; image-rendering: pixelated; }
|
||||
.sn { margin-top: 12px; font-size: 22px; font-weight: bold; letter-spacing: 2px; color: #1f2937; }
|
||||
.hint { margin-top: 8px; font-size: 12px; color: #9ca3af; }
|
||||
@media print { body { padding: 0; } img { width: 260px; height: 260px; } }
|
||||
</style></head>
|
||||
<body>
|
||||
<img src="${qrUrl}" alt="QR-${serialNumber}" />
|
||||
<p class="sn">${serialNumber}</p>
|
||||
<p class="hint">扫描二维码查询生产进度</p>
|
||||
<script>window.onload=()=>window.print()</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
w.document.close();
|
||||
}
|
||||
// ---- 编辑 ----
|
||||
function openEdit(p: ProductResponse) { setEditTarget(p); setEditOrderNo(p.order_no ?? ""); setEditExternalSerial(p.external_serial ?? ""); }
|
||||
async function handleSaveEdit() { if (!editTarget) return; setEditSaving(true); try { await api.patch(`/products/${editTarget.id}`, { order_no: editOrderNo.trim() || null, external_serial: editExternalSerial.trim() || null }); toast("保存成功", "success"); setEditTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "保存失败", "error"); } finally { setEditSaving(false); } }
|
||||
|
||||
// ---- 删除 ----
|
||||
useEffect(() => {
|
||||
if (!deleteTarget) { setCountdown(5); setSerialInput(""); return; }
|
||||
setCountdown(5); setSerialInput("");
|
||||
const timer = setInterval(() => setCountdown(c => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [deleteTarget?.id]); // eslint-disable-line
|
||||
|
||||
function confirmDelete(p: ProductResponse) { setDeleteTarget(p); }
|
||||
async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "删除失败", "error"); } finally { setDeleting(false); } }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 标题栏 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">产品管理</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
查看所有产品序列号并生成二维码用于打印标签
|
||||
</p>
|
||||
</div>
|
||||
<div><h2 className="text-xl font-bold text-gray-800">产品管理</h2><p className="mt-1 text-sm text-gray-500">查看所有产品身份证并生成二维码</p></div>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href="/admin/print-config"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-50"
|
||||
title="打印机设置"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
创建产品
|
||||
</button>
|
||||
<button
|
||||
onClick={loadProducts}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
<a href="/admin/print-config" className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-500 hover:bg-gray-50"><Settings className="h-4 w-4" /></a>
|
||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"><Plus className="h-4 w-4" />创建产品</button>
|
||||
<button onClick={loadProducts} className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误 */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 产品列表 */}
|
||||
{!loading && products.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<QrCode className="mb-3 h-12 w-12" />
|
||||
<p>暂无产品数据</p>
|
||||
<p className="mt-1 text-sm">创建产品后将在此显示二维码</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && products.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{products.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-col items-center rounded-xl bg-white p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
{/* 二维码 */}
|
||||
<img
|
||||
src={`${QR_BASE}/${p.serial_number}`}
|
||||
alt={`QR-${p.serial_number}`}
|
||||
className="h-40 w-40 rounded-lg border border-gray-100"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* 序列号 */}
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">
|
||||
{p.serial_number}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-400">
|
||||
{p.order_no ?? "—"}
|
||||
</p>
|
||||
|
||||
{/* 打印按钮 */}
|
||||
<button
|
||||
onClick={() => handleOpenPrint(p)}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 hover:border-blue-300"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
打印标签
|
||||
{/* 搜索 + 分组 + 状态过滤(移植自 AdminTasksPage) */}
|
||||
<div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">分组:</span>
|
||||
{(["order", "device"] as const).map(m => (
|
||||
<button key={m} onClick={() => setGroupBy(m)} className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${groupBy === m ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-500 hover:bg-gray-200"}`}>
|
||||
{m === "order" ? "按订单" : "按设备"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setExpandedGroups(new Set(productGroups.map(g => g.groupKey)))}
|
||||
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50">全部展开</button>
|
||||
<button onClick={() => setExpandedGroups(new Set())}
|
||||
className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100">全部折叠</button>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xl">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input type="text" 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" />
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}查询
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||
{STATUS_TABS.map(tab => (
|
||||
<button key={tab.key} onClick={() => setStatusFilter(tab.key)}
|
||||
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${statusFilter === tab.key ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
{loading && <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div>}
|
||||
|
||||
{/* 空态 */}
|
||||
{!loading && products.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<QrCode className="mb-3 h-12 w-12" /><p>暂无产品数据</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateProductDialog
|
||||
open={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadProducts}
|
||||
/>
|
||||
{/* 🚀 分组折叠面板 */}
|
||||
{!loading && productGroups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{productGroups.map(group => {
|
||||
const isOpen = expandedGroups.has(group.groupKey);
|
||||
return (
|
||||
<div key={group.groupKey} className="overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<button onClick={() => toggleGroup(group.groupKey)} className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors">
|
||||
{isOpen ? <ChevronDown className="h-4 w-4 shrink-0 text-gray-400" /> : <ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />}
|
||||
<Package className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{group.groupKey === "未绑定订单" || group.groupKey === "未命名设备"
|
||||
? "📋 未分类"
|
||||
: groupBy === "device" ? `⚙️ 设备: ${group.groupKey}` : `📦 订单: ${group.groupKey}`}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{group.products.length} 个产品</span>
|
||||
</div>
|
||||
</div>
|
||||
{group.allInWarehouse && group.products.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700"><Warehouse className="h-3 w-3" />已全部入库</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 产品卡片网格 */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-gray-100 px-5 py-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{group.products.map(p => (
|
||||
<div key={p.id} className="group relative flex flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 z-10">
|
||||
<button onClick={() => openEdit(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-blue-50 hover:text-blue-600"><Pencil className="h-3.5 w-3.5" /></button>
|
||||
<button onClick={() => confirmDelete(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-red-50 hover:text-red-500"><Trash2 className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center px-4 pt-5 pb-3">
|
||||
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" />
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">{p.serial_number}</p>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 border-t border-gray-50 px-4 py-3">
|
||||
<InfoRow icon={Package} label="物料名称" value={p.material_name || p.material_id || "—"} />
|
||||
<InfoRow icon={Barcode} label="产品序列号" value={p.external_serial || "—"} />
|
||||
<InfoRow icon={Hash} label="规格型号" value={p.spec_model || "—"} />
|
||||
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
|
||||
<InfoRow icon={MapPin} label="当前位置" value={p.current_location_name || p.current_location_id || "—"} />
|
||||
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
|
||||
</div>
|
||||
<div className="border-t border-gray-50 px-4 py-3">
|
||||
<button onClick={() => handleOpenPrint(p)} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
|
||||
<Printer className="h-3.5 w-3.5" />打印标签
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateProductDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={loadProducts} />
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* 打印预览弹窗 */}
|
||||
{/* ============================================================ */}
|
||||
{printTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => !printing && setPrintTarget(null)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !printing && setPrintTarget(null)} />
|
||||
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-bold text-gray-800">
|
||||
标签打印预览
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setPrintTarget(null)}
|
||||
disabled={printing}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800">标签打印预览</h3><button onClick={() => setPrintTarget(null)} disabled={printing} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-4 w-4" /></button></div>
|
||||
<div className="mb-4 flex justify-center">{printLoading || !previewUrl ? <div className="flex h-48 w-full items-center justify-center rounded-lg bg-gray-50"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div> : <img src={previewUrl} alt="预览" className="max-h-64 rounded-lg border border-gray-200" />}</div>
|
||||
<p className="mb-4 text-center font-mono text-sm font-bold tracking-wider text-gray-700">{printTarget.serial_number}</p>
|
||||
<div className="mb-4 flex items-center justify-between"><span className="text-sm text-gray-600">打印份数</span><div className="flex items-center gap-2"><button onClick={() => setPrintCopies(c => Math.max(1, c - 1))} className="rounded border px-2.5 py-1 text-sm">−</button><span className="w-8 text-center text-sm font-semibold">{printCopies}</span><button onClick={() => setPrintCopies(c => Math.min(100, c + 1))} className="rounded border px-2.5 py-1 text-sm">+</button></div></div>
|
||||
<div className="flex justify-end gap-2"><button onClick={() => setPrintTarget(null)} disabled={printing} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button><button onClick={handleConfirmPrint} disabled={printing || printLoading} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{printing && <Loader2 className="h-3.5 w-3.5 animate-spin" />}确认打印</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览图 */}
|
||||
<div className="mb-4 flex justify-center">
|
||||
{printLoading || !previewUrl ? (
|
||||
<div className="flex items-center justify-center rounded-lg bg-gray-50 w-full h-48">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="标签预览"
|
||||
className="max-h-64 rounded-lg border border-gray-200"
|
||||
/>
|
||||
)}
|
||||
{/* 编辑弹窗 */}
|
||||
{editTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !editSaving && setEditTarget(null)} />
|
||||
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800">编辑产品</h3><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
|
||||
<p className="mb-4 font-mono text-sm text-gray-500">产品ID: {editTarget.serial_number}</p>
|
||||
<div className="space-y-4">
|
||||
<div><label className="mb-1 block text-sm font-medium text-gray-700">订单编号</label><input value={editOrderNo} onChange={e => setEditOrderNo(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
|
||||
<div><label className="mb-1 block text-sm font-medium text-gray-700">产品序列号</label><input value={editExternalSerial} onChange={e => setEditExternalSerial(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button><button onClick={handleSaveEdit} disabled={editSaving} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{editSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}<Save className="h-3.5 w-3.5" />保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mb-4 text-center font-mono text-sm font-bold tracking-wider text-gray-700">
|
||||
{printTarget.serial_number}
|
||||
</p>
|
||||
|
||||
{/* 份数选择 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">打印份数</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPrintCopies((c) => Math.max(1, c - 1))}
|
||||
className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-semibold">
|
||||
{printCopies}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPrintCopies((c) => Math.min(100, c + 1))}
|
||||
className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setPrintTarget(null)}
|
||||
disabled={printing}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmPrint}
|
||||
disabled={printing || printLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{printing && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认打印
|
||||
</button>
|
||||
</div>
|
||||
{/* 删除确认弹窗 */}
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !deleting && setDeleteTarget(null)} />
|
||||
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center gap-3"><div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100"><AlertTriangle className="h-5 w-5 text-red-600" /></div><div><h3 className="text-base font-bold text-gray-800">危险操作</h3><p className="text-xs text-gray-500">删除产品及其全部关联数据</p></div></div>
|
||||
<p className="mb-4 text-sm text-gray-600">将永久删除 <span className="font-mono font-bold">{deleteTarget.serial_number}</span> 及其所有任务、记录和二维码。</p>
|
||||
<div className="mb-4"><label className="mb-1 block text-xs font-medium text-gray-600">请输入产品身份证以确认删除</label><input value={serialInput} onChange={e => setSerialInput(e.target.value)} placeholder={deleteTarget.serial_number} maxLength={16} className="w-full rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-red-400 focus:outline-none" disabled={deleting} /></div>
|
||||
<div className="flex items-center justify-end gap-2"><button onClick={() => setDeleteTarget(null)} disabled={deleting} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button><button onClick={handleDelete} disabled={!canConfirmDelete || deleting} className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50">{deleting ? <><Loader2 className="h-3.5 w-3.5 animate-spin" />删除中...</> : canConfirmDelete ? <><Trash2 className="h-3.5 w-3.5" />确认删除</> : <>确认删除 ({countdown}s)</>}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
|
||||
return <div className="flex items-center gap-2 text-xs"><Icon className="h-3 w-3 shrink-0 text-gray-400" /><span className="shrink-0 text-gray-400">{label}</span><span className="truncate font-medium text-gray-700">{value}</span></div>;
|
||||
}
|
||||
|
||||
@ -1,5 +1,478 @@
|
||||
import TaskTreeViewer from "../../components/TaskTree/TaskTreeViewer";
|
||||
/** 任务全景 Dashboard — 按订单聚合 + 关键词搜索 + 状态筛选 */
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Search, Loader2, Package, ChevronDown, ChevronRight,
|
||||
Warehouse, GitBranch, X,
|
||||
} from "lucide-react";
|
||||
import api from "../../services/api";
|
||||
import { scanProduct } from "../../services/productApi";
|
||||
import {
|
||||
receiveTask, rejectTask, transferTask,
|
||||
} from "../../services/taskApi";
|
||||
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
|
||||
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import type { ProductScanResponse, TaskResponse } from "../../types/api";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "PENDING", label: "待接收" },
|
||||
{ key: "WIP", label: "进行中" },
|
||||
{ key: "COMPLETED", label: "已完成" },
|
||||
{ key: "ARCHIVED", label: "已入库" },
|
||||
];
|
||||
|
||||
interface OrderGroup {
|
||||
orderNo: string;
|
||||
products: ProductResponse[];
|
||||
allInWarehouse: boolean;
|
||||
}
|
||||
|
||||
export default function AdminTasksPage() {
|
||||
return <TaskTreeViewer />;
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
// 搜索 & 筛选
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 手风琴展开状态
|
||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
||||
|
||||
// 🔧 排他展开:同时只展开一个产品的流转树
|
||||
const [activeTreeProductId, setActiveTreeProductId] = useState<string | null>(null);
|
||||
const [taskTrees, setTaskTrees] = useState<Record<string, ProductScanResponse | null>>({});
|
||||
const [treeLoading, setTreeLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 弹窗
|
||||
const [modalTarget, setModalTarget] = useState<ModalTarget | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
|
||||
|
||||
// ---- 加载产品列表(始终拉全量,不做服务端状态过滤) ----
|
||||
async function loadProducts(kw: string) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params: Record<string, string | number> = { limit: 1000 };
|
||||
if (kw.trim()) params.keyword = kw.trim();
|
||||
const { data } = await api.get<ProductResponse[]>("/products/", { params });
|
||||
setProducts(data);
|
||||
} catch {
|
||||
setError("加载产品列表失败,请检查后端服务");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadProducts(keyword); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleSearch(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
setExpandedProducts(new Set());
|
||||
setTaskTrees({});
|
||||
loadProducts(keyword);
|
||||
}
|
||||
|
||||
// ---- 按订单/设备分组 + 本地状态过滤 ----
|
||||
const orderGroups = useMemo<OrderGroup[]>(() => {
|
||||
const map = new Map<string, ProductResponse[]>();
|
||||
for (const p of products) {
|
||||
if (statusFilter) {
|
||||
const currentStatus = (p.macro_status || p.status).toUpperCase();
|
||||
if (currentStatus !== statusFilter) continue;
|
||||
}
|
||||
const key = groupBy === "device"
|
||||
? (p.material_name || p.material_id || "未命名设备")
|
||||
: (p.order_no || "未绑定订单");
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(p);
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([orderNo, prods]) => ({
|
||||
orderNo,
|
||||
products: prods,
|
||||
allInWarehouse: prods.every(
|
||||
(p) => p.current_location_id === "virtual_warehouse"
|
||||
),
|
||||
}));
|
||||
}, [products, statusFilter, groupBy]);
|
||||
|
||||
// 🔧 默认全部展开
|
||||
useEffect(() => {
|
||||
setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)));
|
||||
}, [orderGroups]);
|
||||
|
||||
// ---- 手风琴切换 ----
|
||||
function toggleOrder(orderNo: string) {
|
||||
setExpandedOrders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(orderNo)) next.delete(orderNo);
|
||||
else next.add(orderNo);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 排他展开(手风琴模式) ----
|
||||
async function toggleProductTree(serialNumber: string) {
|
||||
// 点击已展开的树 → 收起
|
||||
if (activeTreeProductId === serialNumber) {
|
||||
setActiveTreeProductId(null);
|
||||
return;
|
||||
}
|
||||
// 展开新的 → 自动收起旧的
|
||||
setActiveTreeProductId(serialNumber);
|
||||
if (!taskTrees[serialNumber]) {
|
||||
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
|
||||
scanProduct(serialNumber)
|
||||
.then((result) => setTaskTrees((s) => ({ ...s, [serialNumber]: result })))
|
||||
.catch((err: any) => toast(err?.response?.data?.detail ?? err?.message ?? "加载任务树失败", "error"))
|
||||
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 任务操作 ----
|
||||
const refreshProductTree = useCallback(async (serialNumber: string) => {
|
||||
try {
|
||||
const result = await scanProduct(serialNumber);
|
||||
setTaskTrees((s) => ({ ...s, [serialNumber]: result }));
|
||||
} catch { /* 静默 */ }
|
||||
}, []);
|
||||
|
||||
async function handleReceive() {
|
||||
if (!modalTarget) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await receiveTask(modalTarget.task.id);
|
||||
toast("任务已接收", "success");
|
||||
const sn = modalTarget.task.product_sn || "";
|
||||
setModalTarget(null);
|
||||
if (sn) await refreshProductTree(sn);
|
||||
await loadProducts(keyword);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(reason: string) {
|
||||
if (!modalTarget) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await rejectTask(modalTarget.task.id, reason);
|
||||
toast("任务已驳回", "success");
|
||||
const sn = modalTarget.task.product_sn || "";
|
||||
setModalTarget(null);
|
||||
if (sn) await refreshProductTree(sn);
|
||||
await loadProducts(keyword);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransfer(
|
||||
nextTaskName: string,
|
||||
assignees: string[],
|
||||
note: string,
|
||||
) {
|
||||
if (!modalTarget) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await transferTask(
|
||||
modalTarget.task.id, assignees, nextTaskName, note || undefined,
|
||||
);
|
||||
toast(result.message, "success");
|
||||
const sn = modalTarget.task.product_sn || "";
|
||||
setModalTarget(null);
|
||||
if (sn) await refreshProductTree(sn);
|
||||
await loadProducts(keyword);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 渲染 ----
|
||||
const modalTask = modalTarget?.task ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ---- 标题 ---- */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-800">任务全景 Dashboard</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
按订单聚合查看产品流转状态,支持多维搜索与状态筛选
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ---- 搜索 + 筛选 ---- */}
|
||||
<div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">分组:</span>
|
||||
{(["order", "device"] as const).map(m => (
|
||||
<button key={m} onClick={() => setGroupBy(m)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${groupBy === m ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-500 hover:bg-gray-200"}`}>
|
||||
{m === "order" ? "按订单" : "按设备"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)))}
|
||||
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50">全部展开</button>
|
||||
<button onClick={() => setExpandedOrders(new Set())}
|
||||
className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100">全部折叠</button>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xl">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
查询
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 状态筛选 Tabs */}
|
||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
statusFilter === tab.key
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- 错误 ---- */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 加载中 ---- */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 空态 ---- */}
|
||||
{!loading && products.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<Package className="mb-3 h-12 w-12" />
|
||||
<p>暂无产品数据</p>
|
||||
<p className="mt-1 text-sm">创建产品并绑定订单后,在此查看流转状态</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 订单手风琴列表 ---- */}
|
||||
{!loading && orderGroups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{orderGroups.map((group) => {
|
||||
const isOpen = expandedOrders.has(group.orderNo);
|
||||
return (
|
||||
<div
|
||||
key={group.orderNo}
|
||||
className="overflow-hidden rounded-xl bg-white shadow-sm"
|
||||
>
|
||||
{/* 订单头部 */}
|
||||
<button
|
||||
onClick={() => toggleOrder(group.orderNo)}
|
||||
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />
|
||||
)}
|
||||
<Package className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{group.orderNo === "未绑定订单" || group.orderNo === "未命名设备"
|
||||
? "📋 未分类"
|
||||
: groupBy === "device"
|
||||
? `⚙️ 设备: ${group.orderNo}`
|
||||
: `📦 订单: ${group.orderNo}`}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{group.products.length} 个产品
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 入库状态标签 */}
|
||||
{group.allInWarehouse && group.products.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700">
|
||||
<Warehouse className="h-3 w-3" />
|
||||
已全部入库
|
||||
</span>
|
||||
)}
|
||||
{!group.allInWarehouse && (
|
||||
<span className="text-xs text-gray-400">流转中</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 订单展开内容 */}
|
||||
{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="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>
|
||||
|
||||
{/* 产品行 */}
|
||||
{group.products.map((p) => {
|
||||
const productExpanded = activeTreeProductId === p.serial_number;
|
||||
const isTreeLoading = treeLoading[p.serial_number];
|
||||
const tree = taskTrees[p.serial_number];
|
||||
// 综合状态:优先流转树状态,兜底产品状态
|
||||
// 单一数据源:后端预计算 macro_status,兜底产品 status
|
||||
const currentStatus = p.macro_status || p.status;
|
||||
const statusCfg = getStatusConfig(currentStatus);
|
||||
|
||||
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="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 text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
onClick={() => toggleProductTree(p.serial_number)}
|
||||
className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
{isTreeLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : productExpanded ? (
|
||||
<X className="h-3 w-3" />
|
||||
) : (
|
||||
<GitBranch className="h-3 w-3" />
|
||||
)}
|
||||
{productExpanded ? "收起" : "流转树"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开的流转树 — 卡片堆叠视图 */}
|
||||
{productExpanded && tree && (
|
||||
<div className="border-t border-dashed border-blue-100 bg-gradient-to-b from-blue-50/40 to-white px-5 py-4">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-xs font-semibold text-gray-500">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
流转卡片 — {p.serial_number}
|
||||
</h4>
|
||||
{tree.task_tree && tree.task_tree.length > 0 ? (
|
||||
<TaskFlowView
|
||||
tasks={tree.task_tree}
|
||||
onAction={setModalTarget}
|
||||
currentUser={currentUser}
|
||||
assigneeNames={tree.assignee_names}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<GitBranch className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-500">暂无流转记录</p>
|
||||
<p className="mt-1 text-xs text-gray-400">产品刚创建,尚未分配生产任务</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{productExpanded && isTreeLoading && (
|
||||
<div className="border-t border-dashed border-gray-100 bg-gray-50/50 px-5 py-12 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-blue-400" />
|
||||
<p className="mt-2 text-xs text-gray-400">加载流转树...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔧 二维码弹窗 */}
|
||||
{qrSerial && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setQrSerial(null)}>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
<div className="relative z-10 rounded-xl bg-white p-6 shadow-2xl text-center" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="mb-3 text-sm font-bold text-gray-800">产品二维码</h3>
|
||||
<img src={`/api/v1/products/qrcode/${qrSerial}`} alt={`QR-${qrSerial}`} className="mx-auto h-64 w-64 rounded-lg border" />
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-700">{qrSerial}</p>
|
||||
<button onClick={() => setQrSerial(null)} className="mt-4 rounded-lg bg-blue-600 px-6 py-2 text-sm text-white hover:bg-blue-700">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 弹窗 ---- */}
|
||||
<ReceiveConfirmModal
|
||||
open={modalTarget?.action === "receive"}
|
||||
task={modalTask}
|
||||
submitting={submitting}
|
||||
onClose={() => setModalTarget(null)}
|
||||
onConfirm={handleReceive}
|
||||
/>
|
||||
<RejectModal
|
||||
open={modalTarget?.action === "reject"}
|
||||
task={modalTask}
|
||||
submitting={submitting}
|
||||
onClose={() => setModalTarget(null)}
|
||||
onSubmit={handleReject}
|
||||
/>
|
||||
<TransferModal
|
||||
open={modalTarget?.action === "transfer"}
|
||||
task={modalTask}
|
||||
submitting={submitting}
|
||||
onClose={() => setModalTarget(null)}
|
||||
onSubmit={handleTransfer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -453,11 +453,11 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
</div>
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* 系统唯一 ID(只读) */}
|
||||
{/* 产品身份证 — 系统自动生成 16 位 HEX(不可编辑) */}
|
||||
{/* ================================================================ */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
系统唯一 ID <span className="text-xs text-gray-400">(自动生成)</span>
|
||||
产品身份证 <span className="text-xs text-gray-400">(自动生成)</span>
|
||||
</label>
|
||||
<Input
|
||||
value="提交后自动生成 16 位 HEX"
|
||||
@ -466,7 +466,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 产品序列号(选填) */}
|
||||
{/* 产品序列号 — 用户自定义录入(选填) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
产品序列号 <span className="text-xs text-gray-400">(选填)</span>
|
||||
|
||||
@ -1,4 +1,39 @@
|
||||
import axios from "axios";
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
|
||||
// ============================================================
|
||||
// Token 存储 Key
|
||||
// ============================================================
|
||||
|
||||
const ACCESS_TOKEN_KEY = "track_admin_token";
|
||||
const REFRESH_TOKEN_KEY = "track_admin_refresh_token";
|
||||
const USER_KEY = "track_admin_user";
|
||||
|
||||
// ============================================================
|
||||
// Token 读写工具
|
||||
// ============================================================
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(accessToken: string, refreshToken: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Axios 实例
|
||||
// ============================================================
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
@ -8,10 +43,13 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器 — 注入 JWT token
|
||||
// ============================================================
|
||||
// 请求拦截器 — 注入 Access Token
|
||||
// ============================================================
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem("track_admin_token");
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
@ -20,19 +58,104 @@ api.interceptors.request.use(
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// 响应拦截器 — 统一错误处理
|
||||
// ============================================================
|
||||
// 响应拦截器 — 双 Token 无感刷新 + 并发请求队列
|
||||
// ============================================================
|
||||
|
||||
let isRefreshing = false;
|
||||
let retryQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
/** 处理队列中的所有挂起请求 */
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
retryQueue.forEach((p) => {
|
||||
if (token) {
|
||||
p.resolve(token);
|
||||
} else {
|
||||
p.reject(error);
|
||||
}
|
||||
});
|
||||
retryQueue = [];
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token 过期或无效 → 清除并跳转登录
|
||||
localStorage.removeItem("track_admin_token");
|
||||
localStorage.removeItem("track_admin_user");
|
||||
async (error) => {
|
||||
const originalRequest: AxiosRequestConfig & { _retry?: boolean } =
|
||||
error.config;
|
||||
const status = error.response?.status;
|
||||
|
||||
// 仅处理 401
|
||||
if (status !== 401) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 跳过登录和刷新接口自身(避免死循环)
|
||||
const url = originalRequest.url ?? "";
|
||||
if (url.includes("/auth/login") || url.includes("/auth/refresh")) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 避免对同一请求重复刷新
|
||||
if (originalRequest._retry) {
|
||||
clearAuth();
|
||||
if (window.location.pathname.startsWith("/admin")) {
|
||||
window.location.href = "/admin/login";
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
originalRequest._retry = true;
|
||||
|
||||
// ---- 并发请求队列机制 ----
|
||||
if (isRefreshing) {
|
||||
// 已有刷新进行中,加入队列等待
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
retryQueue.push({ resolve, reject });
|
||||
}).then((newToken) => {
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error("无 Refresh Token");
|
||||
}
|
||||
|
||||
// 调用刷新接口
|
||||
const { data } = await axios.post(
|
||||
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
|
||||
const newAccessToken: string = data.access_token;
|
||||
setTokens(newAccessToken, refreshToken); // 更新 Access Token
|
||||
|
||||
// 重放队列中的所有请求
|
||||
processQueue(null, newAccessToken);
|
||||
|
||||
// 重试当前请求
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh Token 也过期 — 彻底登出
|
||||
processQueue(refreshError, null);
|
||||
clearAuth();
|
||||
if (window.location.pathname.startsWith("/admin")) {
|
||||
window.location.href = "/admin/login";
|
||||
}
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -1,14 +1,20 @@
|
||||
import axios from "axios";
|
||||
/** 认证 API — 登录、刷新 Token、获取用户信息 */
|
||||
import type { UserInfo } from "../contexts/AuthContext";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
/** 登录 — 注意:此请求不走 axios 实例(避免循环依赖),直接用 fetch */
|
||||
export interface RefreshResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/** 登录 — 注意:此请求不走 axios 实例,直接用 fetch */
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
@ -22,6 +28,19 @@ export async function login(username: string, password: string): Promise<LoginRe
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 刷新 Access Token — 注意:不走 axios 实例,直接用 fetch */
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<RefreshResponse> {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Refresh Token 无效或已过期");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 验证 token 有效性 + 获取最新用户信息 */
|
||||
export async function getMe(token: string): Promise<UserInfo> {
|
||||
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||
|
||||
41
frontend/src/services/notificationApi.ts
Normal file
41
frontend/src/services/notificationApi.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/** 消息通知 API */
|
||||
import api from "./api";
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: "TRANSFER" | "REJECT";
|
||||
task_id: string | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
notifications: NotificationItem[];
|
||||
total: number;
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
/** 获取当前用户的通知列表 */
|
||||
export async function getNotifications(
|
||||
userId: string,
|
||||
skip = 0,
|
||||
limit = 20
|
||||
): Promise<NotificationListResponse> {
|
||||
const { data } = await api.get<NotificationListResponse>("/notifications/", {
|
||||
params: { user_id: userId, skip, limit },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 标记单条通知为已读 */
|
||||
export async function markNotificationRead(
|
||||
notificationId: string
|
||||
): Promise<NotificationItem> {
|
||||
const { data } = await api.put<NotificationItem>(
|
||||
`/notifications/${notificationId}/read`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import api from "./api";
|
||||
import type { ProductScanResponse } from "../types/api";
|
||||
|
||||
/** 扫码查询 — 根据 16 位序列号查产品 + 顶层任务 */
|
||||
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
|
||||
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
|
||||
const { data } = await api.get<ProductScanResponse>(
|
||||
`/products/scan/${encodeURIComponent(serialNumber)}`
|
||||
|
||||
@ -13,6 +13,9 @@ export interface ProductResponse {
|
||||
material_type: string | null;
|
||||
parent_product_id: string | null;
|
||||
current_location_id: string | null;
|
||||
current_location_name: string | null;
|
||||
macro_status: string | null;
|
||||
overall_status: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@ -21,12 +21,16 @@ export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS];
|
||||
export interface TaskSummary {
|
||||
id: string;
|
||||
product_id: string;
|
||||
product_sn: string;
|
||||
product_material: string;
|
||||
parent_task_id: string | null;
|
||||
task_name: string;
|
||||
assignee_id: string | null;
|
||||
status: TaskStatus | string;
|
||||
notify_parent_on_complete: boolean;
|
||||
is_rework: boolean;
|
||||
task_type: string | null;
|
||||
remark: string | null;
|
||||
reject_reason: string | null;
|
||||
received_at: string | null;
|
||||
completed_at: string | null;
|
||||
@ -35,6 +39,16 @@ export interface TaskSummary {
|
||||
|
||||
export interface TaskResponse extends TaskSummary {
|
||||
child_tasks: TaskResponse[];
|
||||
records: TaskRecordResponse[];
|
||||
}
|
||||
|
||||
export interface TaskRecordResponse {
|
||||
id: string;
|
||||
task_id: string;
|
||||
action: string;
|
||||
operator_id: string | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- 操作响应 ----
|
||||
@ -85,4 +99,6 @@ export interface ProductScanResponse {
|
||||
top_level_tasks: TaskSummary[];
|
||||
/** 完整递归任务树 — 供十字矩阵树状图渲染 */
|
||||
task_tree: TaskResponse[];
|
||||
/** 🔧 username→中文姓名映射 */
|
||||
assignee_names: Record<string, string>;
|
||||
}
|
||||
|
||||
@ -7,6 +7,20 @@ import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), basicSsl()],
|
||||
|
||||
// 🚀 构建优化:将第三方巨头独立分包,利用浏览器缓存
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id: string) {
|
||||
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/scheduler")) return "vendor-react";
|
||||
if (id.includes("node_modules/react-router")) return "vendor-react";
|
||||
if (id.includes("node_modules/antd") || id.includes("node_modules/@ant-design")) return "vendor-antd";
|
||||
if (id.includes("node_modules/html5-qrcode")) return "vendor-qrcode";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
|
||||
@ -1,21 +1,204 @@
|
||||
<script setup>
|
||||
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
|
||||
<script>
|
||||
import { getNotifications } from "./api/notification";
|
||||
|
||||
onLaunch(() => {
|
||||
console.log("生产流转 App 启动");
|
||||
});
|
||||
export default {
|
||||
onLaunch() {
|
||||
console.log("生产流转 T1.0.2 启动");
|
||||
|
||||
onShow(() => {
|
||||
console.log("App 显示");
|
||||
});
|
||||
// 🚦 路由守卫:首页已是 scan,未登录才跳登录页(零闪烁)
|
||||
const token = uni.getStorageSync("access_token") || uni.getStorageSync("refresh_token");
|
||||
const user = uni.getStorageSync("user");
|
||||
|
||||
onHide(() => {
|
||||
console.log("App 隐藏");
|
||||
});
|
||||
if (!token || !user) {
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
}
|
||||
// 已登录 → 原地渲染 scan 页,不需任何跳转
|
||||
|
||||
// 🚀 OTA 热更新检测(仅 App 端生效)
|
||||
// #ifdef APP-PLUS
|
||||
this.checkUpdate();
|
||||
// #endif
|
||||
},
|
||||
onShow() {
|
||||
console.log("App 显示");
|
||||
this.updateTabBarBadge();
|
||||
// 🚀 每次回到前台也检查更新,用户无需杀后台就能感知新版本
|
||||
// #ifdef APP-PLUS
|
||||
this.checkUpdate();
|
||||
// #endif
|
||||
},
|
||||
onHide() {
|
||||
console.log("App 隐藏");
|
||||
},
|
||||
methods: {
|
||||
// ==========================================================
|
||||
// OTA 热更新雷达 — 版本检测 + WGT 下载 + 静默安装
|
||||
// ==========================================================
|
||||
checkUpdate() {
|
||||
// 🚀 节流:5分钟内不重复检查,避免 onShow 频繁触发弹窗骚扰
|
||||
const now = Date.now();
|
||||
if (this._lastUpdateCheck && now - this._lastUpdateCheck < 5 * 60 * 1000) {
|
||||
console.log("[OTA] 距上次检查不足5分钟,跳过");
|
||||
return;
|
||||
}
|
||||
this._lastUpdateCheck = now;
|
||||
|
||||
// 🔧 appWgtVersion 跟随 WGT 更新,解析算法: major*100 + lastNum
|
||||
// "T1.0.1"→101, "T1.0.10"→110, "T1.0.99"→199
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
const wgtVer = sysInfo.appWgtVersion || sysInfo.appVersion || "0";
|
||||
const currentVersionCode = this.parseVersionCode(wgtVer);
|
||||
const baseUrl = uni.getStorageSync("env_base_url") || "http://track_back.iris-rs.cn/api/v1";
|
||||
|
||||
console.log("[OTA] 本地版本:", wgtVer, "→ 数字:", currentVersionCode);
|
||||
|
||||
uni.request({
|
||||
url: `${baseUrl}/app/check-update`,
|
||||
method: "GET",
|
||||
timeout: 8000,
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) return;
|
||||
const data = res.data;
|
||||
if (!data || !data.wgt_url) {
|
||||
console.log("[OTA] 服务端无可用更新包");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverVersionCode = data.version_code || 0;
|
||||
console.log("[OTA] 服务端版本:", data.version, "| 数字版本:", serverVersionCode);
|
||||
|
||||
// 客户端自行对比:服务端 > 本地 = 需要更新
|
||||
if (serverVersionCode <= currentVersionCode) {
|
||||
console.log("[OTA] 已是最新版本,无需更新");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OTA] 发现新版本:", data.version);
|
||||
this.downloadAndInstall(data.wgt_url, data.version, data.description);
|
||||
},
|
||||
fail: () => {
|
||||
console.log("[OTA] 版本检测网络失败,跳过");
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 版本号→数字: "T1.0.10" → 1*100+10 = 110 */
|
||||
parseVersionCode(v) {
|
||||
if (!v) return 0;
|
||||
const nums = v.match(/\d+/g);
|
||||
if (!nums || nums.length < 2) return 0;
|
||||
return parseInt(nums[0], 10) * 100 + parseInt(nums[nums.length - 1], 10);
|
||||
},
|
||||
|
||||
downloadAndInstall(wgtUrl, newVersion, description) {
|
||||
if (!wgtUrl) {
|
||||
console.log("[OTA] 无 WGT 下载地址");
|
||||
return;
|
||||
}
|
||||
|
||||
// 弹窗询问用户是否更新
|
||||
const content = description
|
||||
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
|
||||
: `发现新版本 ${newVersion},是否立即更新?`;
|
||||
|
||||
uni.showModal({
|
||||
title: "版本更新",
|
||||
content,
|
||||
confirmText: "立即更新",
|
||||
cancelText: "稍后再说",
|
||||
success: (modalRes) => {
|
||||
if (!modalRes.confirm) return;
|
||||
|
||||
// 🚀 使用原生等待框,原地更新文字,杜绝闪烁
|
||||
plus.nativeUI.showWaiting("正在下载 0%");
|
||||
|
||||
const downloadTask = uni.downloadFile({
|
||||
url: wgtUrl,
|
||||
success: (downloadRes) => {
|
||||
if (downloadRes.statusCode !== 200) {
|
||||
plus.nativeUI.closeWaiting();
|
||||
uni.showToast({ title: "下载失败", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 安装阶段 — 原生等待框直接更新文字,无闪烁
|
||||
plus.nativeUI.showWaiting("正在安装...");
|
||||
|
||||
plus.runtime.install(
|
||||
downloadRes.tempFilePath,
|
||||
{ force: true },
|
||||
() => {
|
||||
plus.nativeUI.closeWaiting();
|
||||
console.log("[OTA] WGT 安装成功");
|
||||
// 🚀 静默重启:toast 提示后自动重启,无需用户杀后台
|
||||
plus.nativeUI.toast("新版本已就绪,即将重启...");
|
||||
setTimeout(() => {
|
||||
plus.runtime.restart();
|
||||
}, 2000);
|
||||
},
|
||||
(err) => {
|
||||
plus.nativeUI.closeWaiting();
|
||||
console.error("[OTA] 安装失败:", err.message);
|
||||
uni.showToast({
|
||||
title: "更新失败: " + (err.message || "未知错误"),
|
||||
icon: "none",
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
fail: (err) => {
|
||||
plus.nativeUI.closeWaiting();
|
||||
console.error("[OTA] 下载失败:", err.errMsg);
|
||||
uni.showToast({ title: "下载失败,请检查网络", icon: "none" });
|
||||
},
|
||||
});
|
||||
|
||||
// 下载进度 — 稳定更新原生等待框文字,不会闪烁
|
||||
if (downloadTask && downloadTask.onProgressUpdate) {
|
||||
downloadTask.onProgressUpdate((res) => {
|
||||
const pct = res.progress;
|
||||
if (pct < 100) {
|
||||
plus.nativeUI.showWaiting(`正在下载 ${pct}%`);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// ==========================================================
|
||||
// TabBar 消息红点
|
||||
// ==========================================================
|
||||
async updateTabBarBadge() {
|
||||
try {
|
||||
let user = uni.getStorageSync("user");
|
||||
if (typeof user === "string" && user) {
|
||||
try { user = JSON.parse(user); } catch (e) { user = null; }
|
||||
}
|
||||
const userId = user?.username || user?.id || "";
|
||||
if (!userId) return;
|
||||
|
||||
const res = await getNotifications(userId, 0, 1);
|
||||
const unreadCount = res.unread_count || 0;
|
||||
|
||||
if (unreadCount > 0) {
|
||||
uni.setTabBarBadge({
|
||||
index: 2,
|
||||
text: unreadCount > 99 ? "99+" : String(unreadCount),
|
||||
});
|
||||
} else {
|
||||
uni.removeTabBarBadge({ index: 2 });
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式 */
|
||||
page {
|
||||
background-color: #f3f4f6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
24
track-uniapp/src/api/notification.js
Normal file
24
track-uniapp/src/api/notification.js
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 消息通知 API — 获取列表、标记已读
|
||||
*/
|
||||
import { get, put } from "../utils/request";
|
||||
|
||||
/**
|
||||
* 获取当前用户的通知列表
|
||||
* @param {string} userId - 当前用户ID
|
||||
* @param {number} skip - 分页偏移
|
||||
* @param {number} limit - 每页条数
|
||||
* @returns {Promise<{notifications: Array, total: number, unread_count: number}>}
|
||||
*/
|
||||
export function getNotifications(userId, skip = 0, limit = 20) {
|
||||
return get("/notifications/", { user_id: userId, skip, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记单条通知为已读
|
||||
* @param {string} notificationId - 通知ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function markNotificationRead(notificationId) {
|
||||
return put(`/notifications/${notificationId}/read`);
|
||||
}
|
||||
@ -1,7 +1,24 @@
|
||||
import { createSSRApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import App from './App'
|
||||
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
Vue.config.productionTip = false
|
||||
App.mpType = 'app'
|
||||
const app = new Vue({
|
||||
...App
|
||||
})
|
||||
app.$mount()
|
||||
// #endif
|
||||
|
||||
// #ifdef VUE3
|
||||
import {
|
||||
createSSRApp
|
||||
} from 'vue'
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
return { app };
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
@ -1,34 +1,62 @@
|
||||
{
|
||||
"name": "生产流转",
|
||||
"appid": "__UNI__B572616",
|
||||
"description": "工厂生产流转管理系统",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "1",
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
"name" : "Track",
|
||||
"appid" : "__UNI__B572616",
|
||||
"description" : "Track - 生产流转管理",
|
||||
"versionName" : "T1.0.8",
|
||||
"versionCode" : 108,
|
||||
"transformPx" : false,
|
||||
"vueVersion" : "3",
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueCompiler" : "uni-app",
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {
|
||||
"Barcode" : {},
|
||||
"Camera" : {}
|
||||
},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>"
|
||||
]
|
||||
},
|
||||
"orientation" : [ "portrait-primary" ],
|
||||
"icons" : {
|
||||
"android" : {
|
||||
"hdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
|
||||
"xhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
|
||||
"xxhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png",
|
||||
"xxxhdpi" : "D:/changyongruanjian/Download/现代企业应用图标设计.png"
|
||||
}
|
||||
},
|
||||
"sdkConfigs" : {
|
||||
"push" : {},
|
||||
"speech" : {}
|
||||
},
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
}
|
||||
}
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
"routerMode": "hash",
|
||||
"title": "生产流转"
|
||||
}
|
||||
"h5" : {
|
||||
"router" : {
|
||||
"mode" : "hash",
|
||||
"base" : ""
|
||||
},
|
||||
"title" : "生产流转"
|
||||
},
|
||||
"fallbackLocale" : "zh-Hans"
|
||||
}
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scan/index",
|
||||
"style": {
|
||||
@ -15,6 +8,13 @@
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scan/records",
|
||||
"style": {
|
||||
|
||||
79
track-uniapp/src/pages/login/login.vue
Normal file
79
track-uniapp/src/pages/login/login.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="logo">🏭</text>
|
||||
<text class="title">Track</text>
|
||||
<text class="version">{{ appVersion }}</text>
|
||||
</view>
|
||||
|
||||
<view class="form">
|
||||
<input v-model="username" class="input" placeholder="用户名" />
|
||||
<input v-model="password" class="input" type="password" placeholder="密码" />
|
||||
<button class="login-btn" @tap="handleLogin" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { post } from "../../utils/request";
|
||||
|
||||
// 🚀 动态版本号 — 热更新后自动变化(路由由 App.vue onLaunch 统一接管)
|
||||
const appVersion = ref("T1.0.2");
|
||||
onMounted(() => {
|
||||
try {
|
||||
const v = uni.getSystemInfoSync().appWgtVersion || uni.getSystemInfoSync().appVersion;
|
||||
if (v) appVersion.value = v.startsWith("T") ? v : "T" + v;
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = "请输入用户名和密码";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await post("/auth/login", {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
// 双 Token 存储
|
||||
uni.setStorageSync("access_token", res.access_token);
|
||||
uni.setStorageSync("refresh_token", res.refresh_token);
|
||||
uni.setStorageSync("user", JSON.stringify(res.user));
|
||||
uni.showToast({ title: "登录成功", icon: "success" });
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: "/pages/scan/index" });
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
// request.js 对 /auth/login 的 401 直接 reject 不弹 toast,此处补齐
|
||||
const detail = e?.data?.detail || "";
|
||||
uni.showToast({ title: detail || "账号或密码错误", icon: "none", duration: 2000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; background: #f3f4f6; }
|
||||
.header { display: flex; flex-direction: column; align-items: center; margin-bottom: 40px; }
|
||||
.logo { font-size: 64px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; margin-top: 12px; }
|
||||
.version { font-size: 12px; color: #9ca3af; margin-top: 4px; }
|
||||
.form { width: 100%; max-width: 320px; }
|
||||
.input { width: 100%; height: 48px; padding: 0 16px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 15px; background: #fff; margin-bottom: 12px; box-sizing: border-box; }
|
||||
.login-btn { width: 100%; height: 48px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 16px; font-weight: 700; line-height: 48px; }
|
||||
.login-btn[disabled] { opacity: 0.6; }
|
||||
.error { display: block; text-align: center; color: #dc2626; font-size: 13px; margin-top: 12px; }
|
||||
</style>
|
||||
@ -4,22 +4,158 @@
|
||||
<text class="title">消息通知</text>
|
||||
<text class="subtitle">任务流转和系统通知</text>
|
||||
</view>
|
||||
<view class="empty">
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view v-if="loading" class="center">加载中...</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="notifications.length === 0" class="empty">
|
||||
<text class="empty-icon">🔔</text>
|
||||
<text class="empty-text">暂无新消息</text>
|
||||
</view>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<view v-else class="list">
|
||||
<view
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
:class="['card', item.is_read ? '' : 'card-unread']"
|
||||
@tap="handleCardTap(item)"
|
||||
>
|
||||
<view class="card-left">
|
||||
<view v-if="!item.is_read" class="unread-dot" />
|
||||
<text :class="['type-icon', item.is_read ? 'type-icon-read' : '']">
|
||||
{{ typeIcon(item.type) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<view class="card-top">
|
||||
<text :class="['card-title', item.is_read ? '' : 'card-title-bold']">
|
||||
{{ typeTitle(item.type) }}
|
||||
</text>
|
||||
<text class="card-time">{{ formatTime(item.created_at) }}</text>
|
||||
</view>
|
||||
<text class="card-content">{{ item.content }}</text>
|
||||
</view>
|
||||
<text class="card-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { getNotifications, markNotificationRead } from "../../api/notification";
|
||||
|
||||
const TYPE_CONFIG = {
|
||||
TRANSFER: { icon: "🟢", title: "新任务派发" },
|
||||
REJECT: { icon: "🔴", title: "品质驳回提醒" },
|
||||
};
|
||||
|
||||
const notifications = ref([]);
|
||||
const loading = ref(true);
|
||||
let currentUser = null;
|
||||
|
||||
onShow(() => {
|
||||
loadUser();
|
||||
setTimeout(() => {
|
||||
if (!currentUser) loadUser();
|
||||
fetchNotifications();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
function loadUser() {
|
||||
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") currentUser = user;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
const userId = currentUser?.username || currentUser?.id || "";
|
||||
if (!userId) { loading.value = false; return; }
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getNotifications(userId);
|
||||
notifications.value = res.notifications || [];
|
||||
} catch {
|
||||
notifications.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function typeIcon(type) { return (TYPE_CONFIG[type] || { icon: "📌" }).icon; }
|
||||
function typeTitle(type) { return (TYPE_CONFIG[type] || { title: "系统通知" }).title; }
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
async function handleCardTap(item) {
|
||||
if (!item.is_read) {
|
||||
try { await markNotificationRead(item.id); item.is_read = true; } catch {}
|
||||
}
|
||||
// 🚀 优先使用 product_serial_number,兜底从 content 中解析
|
||||
let sn = item.product_serial_number || "";
|
||||
if (!sn && item.content) {
|
||||
const match = item.content.match(/\[([A-Za-z0-9]{8,16})\]/);
|
||||
if (match) sn = match[1];
|
||||
}
|
||||
if (sn) {
|
||||
uni.navigateTo({ url: `/pages/scan/detail?serial=${sn}` });
|
||||
} else if (item.task_id) {
|
||||
uni.navigateTo({ url: `/pages/scan/detail?taskId=${item.task_id}` });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; background: #f3f4f6; }
|
||||
.header { margin-bottom: 20px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
|
||||
|
||||
.center { text-align: center; padding: 80px 0; color: #9ca3af; font-size: 14px; }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
|
||||
.list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
background: #fff; border-radius: 12px; padding: 14px 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
position: relative; transition: all 0.2s;
|
||||
}
|
||||
.card:active { transform: scale(0.98); }
|
||||
.card-unread {
|
||||
box-shadow: 0 1px 6px rgba(37,99,235,0.1);
|
||||
border-left: 3px solid #2563eb;
|
||||
}
|
||||
|
||||
.card-left { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 28px; flex-shrink: 0; }
|
||||
.unread-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.15);
|
||||
}
|
||||
.type-icon { font-size: 20px; line-height: 1; }
|
||||
.type-icon-read { opacity: 0.5; }
|
||||
|
||||
.card-body { flex: 1; min-width: 0; }
|
||||
.card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|
||||
.card-title { font-size: 15px; font-weight: 600; color: #374151; }
|
||||
.card-title-bold { color: #1f2937; font-weight: 700; }
|
||||
.card-time { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
|
||||
.card-content { font-size: 13px; color: #6b7280; line-height: 1.5; display: block; word-break: break-all; }
|
||||
|
||||
.card-arrow { font-size: 20px; color: #d1d5db; margin-top: 6px; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@ -1,69 +1,77 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 用户卡片 -->
|
||||
<view class="user-card">
|
||||
<view class="avatar">张</view>
|
||||
<view class="avatar">{{ initial }}</view>
|
||||
<view class="user-info">
|
||||
<text class="user-name">张三</text>
|
||||
<text class="user-role">操作员</text>
|
||||
<text class="user-name">{{ user?.display_name || '未登录' }}</text>
|
||||
</view>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<view class="menu-card">
|
||||
<view v-for="item in menuItems" :key="item" class="menu-item">
|
||||
<view v-for="item in ['工作统计', '设置', '帮助与反馈', '关于']" :key="item"
|
||||
class="menu-item" @click="handleMenuClick(item)">
|
||||
<text class="menu-text">{{ item }}</text>
|
||||
<text class="arrow">›</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="version">生产流转 v1.0.0</view>
|
||||
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||
<view class="version">{{ appVersion }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const menuItems = ["工作统计", "设置", "帮助与反馈", "关于"];
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
|
||||
const user = ref(null);
|
||||
try { const r = uni.getStorageSync("user"); if (r) user.value = JSON.parse(r); } catch {}
|
||||
const initial = computed(() => (user.value?.display_name || "?")[0]);
|
||||
|
||||
// 🚀 动态版本号 — 热更新后自动变化
|
||||
const appVersion = ref("T1.0.2");
|
||||
onMounted(() => {
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
appVersion.value = sysInfo.appWgtVersion || sysInfo.appVersion || "T1.0.2";
|
||||
} catch {}
|
||||
});
|
||||
|
||||
function handleMenuClick(item) {
|
||||
switch (item) {
|
||||
case "关于":
|
||||
uni.showModal({
|
||||
title: "关于 Track",
|
||||
content: "Track 生产流转管理系统\n当前版本:" + appVersion.value + "\n核心架构:FastAPI + Vue3 + uni-app",
|
||||
showCancel: false,
|
||||
});
|
||||
break;
|
||||
case "帮助与反馈":
|
||||
uni.showToast({ title: "反馈通道搭建中,敬请期待...", icon: "none" });
|
||||
break;
|
||||
// 工作统计、设置 暂不处理
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("user");
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.avatar {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #dbeafe;
|
||||
color: #2563EB;
|
||||
font-size: 20px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.user-card { display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.avatar { width: 48px; height: 48px; border-radius: 50%; background: #dbeafe; color: #2563EB; font-size: 20px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||||
.user-name { font-size: 16px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.user-role { font-size: 12px; color: #9ca3af; }
|
||||
.arrow { color: #d1d5db; font-size: 20px; margin-left: auto; }
|
||||
|
||||
.menu-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.menu-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; }
|
||||
.menu-item { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f3f4f6; }
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.version { text-align: center; font-size: 12px; color: #d1d5db; margin-top: 32px; }
|
||||
.menu-arrow { font-size: 18px; color: #d1d5db; }
|
||||
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
|
||||
</style>
|
||||
|
||||
281
track-uniapp/src/pages/scan/components/TaskSwipeCards.vue
Normal file
281
track-uniapp/src/pages/scan/components/TaskSwipeCards.vue
Normal file
@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<view class="swipe-fullscreen">
|
||||
<!-- 顶部栏 -->
|
||||
<view class="ss-topbar">
|
||||
<view class="ss-back" @tap="$emit('back')">← 返回</view>
|
||||
<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 }}
|
||||
<text v-if="lanes.length > 1"> · ← 左右滑切换分支 →</text>
|
||||
</text>
|
||||
</view>
|
||||
<view class="ss-topbar-right">
|
||||
<view class="ss-overview-btn" @tap="$emit('overview')">⊡ 全览</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 🚀 水平滑动:切换分支(主分支 / 分支1 / 分支2 ...) -->
|
||||
<swiper class="ss-swiper-h" :current="currentLane" @change="onLaneSwipe"
|
||||
: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)"
|
||||
duration="200" vertical :style="{ height: swiperHeight + 'px' }">
|
||||
<swiper-item v-for="(card, ci) in lane.cards" :key="card._key">
|
||||
<view class="ss-card-wrapper">
|
||||
<view class="ss-card task-card"
|
||||
:class="[card._isMain ? 'task-card-main' : 'task-card-branch', taskCardClass(card)]">
|
||||
|
||||
<view v-if="card._isMain" class="tc-ribbon tc-ribbon-main">主分支</view>
|
||||
<view v-else class="tc-ribbon tc-ribbon-sub">{{ lane.label }}</view>
|
||||
|
||||
<view class="tc-head">
|
||||
<view class="tc-head-left">
|
||||
<text :class="card._isMain ? 'badge-main' : 'badge-sub'">{{ card._isMain ? '主分支' : lane.label }}</text>
|
||||
<text v-if="card.is_rework" class="tag-rework-sm">⚠ 返工</text>
|
||||
<text v-if="card.status === 'ARCHIVED'" class="tag-archived-sm">📦 入库</text>
|
||||
</view>
|
||||
<text :class="['tc-status', statusColor(card.status)]">{{ statusLabel(card.status) }}</text>
|
||||
</view>
|
||||
|
||||
<text class="tc-name">{{ card.task_name }}</text>
|
||||
|
||||
<view class="tc-meta">
|
||||
<view class="tc-meta-row">
|
||||
<text class="tc-meta-label">👤 负责人</text>
|
||||
<text class="tc-meta-val">{{ formatUserName(card.assignee_id) || '未分配' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="card.remark" class="tc-remark-box">
|
||||
<text class="tc-remark-label">📌 备注</text>
|
||||
<text class="tc-remark-text">{{ card.remark }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 提交记录:显示总数 + 可点击查看全部 -->
|
||||
<view v-if="card.records && card.records.length" class="tc-records-link"
|
||||
@tap.stop="$emit('viewRecords', card)">
|
||||
<text class="tc-records-icon">📋</text>
|
||||
<text class="tc-records-count">共 {{ card.records.length }} 条提交记录</text>
|
||||
<text class="tc-records-arrow">查看全部 ›</text>
|
||||
</view>
|
||||
|
||||
<view class="tc-stats">
|
||||
<text v-if="card.received_at" class="tc-stat">✅ 已接收</text>
|
||||
<text v-if="card.completed_at" class="tc-stat">🏁 已完工</text>
|
||||
</view>
|
||||
<view class="tc-time"><text>创建: {{ fmtTime(card.created_at) }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<!-- 底部:分支标签条 + 步骤进度 -->
|
||||
<view class="ss-footer">
|
||||
<view class="ss-lane-tabs">
|
||||
<view v-for="(lane, i) in lanes" :key="'lt'+i"
|
||||
:class="['ss-lane-tab', i === currentLane ? 'ss-lane-active' : '']"
|
||||
@tap="currentLane = i">
|
||||
<text :class="i === 0 ? 'ss-lane-tab-main' : 'ss-lane-tab-sub'">{{ lane.label }}</text>
|
||||
<text class="ss-lane-count">{{ lane.cards.length }}</text>
|
||||
</view>
|
||||
</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' : '']">
|
||||
<text class="ss-dot-label">{{ i + 1 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName } from "../../../utils/format";
|
||||
export default {
|
||||
name: "TaskSwipeCards",
|
||||
props: {
|
||||
product: { type: Object, required: true },
|
||||
},
|
||||
emits: ["back", "overview", "viewRecords"],
|
||||
data() {
|
||||
return {
|
||||
currentLane: 0,
|
||||
swiperHeight: 600,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
branchLabelMap() {
|
||||
const map = {};
|
||||
if (!this.product || !this.product.task_tree) return map;
|
||||
const flatMap = {};
|
||||
const flatten = (tasks) => {
|
||||
if (!tasks) return;
|
||||
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
|
||||
};
|
||||
flatten(this.product.task_tree);
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (isMainFn(t)) { map[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
|
||||
else { spawnIndex++; const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`; map[t.id] = `分支 ${num}`; traverse(t.child_tasks, num); }
|
||||
});
|
||||
};
|
||||
this.product.task_tree.forEach((t) => { map[t.id] = '主分支'; traverse(t.child_tasks, ''); });
|
||||
return map;
|
||||
},
|
||||
lanes() {
|
||||
const result = [];
|
||||
if (!this.product || !this.product.task_tree) return result;
|
||||
|
||||
const mainLane = { _key: 'main', label: '主分支', cards: [], _cardIdx: 0 };
|
||||
|
||||
const sortTasks = (tasks) => {
|
||||
if (!tasks) return [];
|
||||
return [...tasks].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
};
|
||||
|
||||
// 🚀 终极递归收集算法
|
||||
const followMain = (taskList, lane) => {
|
||||
if (!taskList || !taskList.length) return;
|
||||
const sorted = sortTasks(taskList);
|
||||
|
||||
for (const t of sorted) {
|
||||
// 1. 无条件入列:既然进到了这个 lane,就属于这个 lane 的卡片
|
||||
lane.cards.push({ ...t, _key: t.id + '_' + lane._key, _isMain: lane._key === 'main' });
|
||||
|
||||
if (t.child_tasks && t.child_tasks.length) {
|
||||
// 1. 同一分支线性延续
|
||||
const nextOnThisLane = t.child_tasks.filter(c =>
|
||||
c.task_type === 'TRANSFER' || c.task_type === 'RECOVERY'
|
||||
);
|
||||
followMain(nextOnThisLane, lane);
|
||||
|
||||
// 2. 凡是 SPAWN,必定开辟新分支 (不限层级)
|
||||
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 };
|
||||
result.push(branchLane); // 先占坑:父分支排在前面
|
||||
followMain([sc], branchLane); // 再递归:孙子分支自然排在后面
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
result.push(mainLane);
|
||||
followMain(this.product.task_tree, mainLane);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
try {
|
||||
const info = uni.getSystemInfoSync();
|
||||
this.swiperHeight = (info.windowHeight || 600) - 130;
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
methods: {
|
||||
formatUserName,
|
||||
onLaneSwipe(e) { this.currentLane = e.detail.current; },
|
||||
onCardSwipe(e, laneIdx) {
|
||||
if (this.lanes[laneIdx]) {
|
||||
this.$set(this.lanes[laneIdx], '_cardIdx', e.detail.current);
|
||||
}
|
||||
},
|
||||
taskCardClass(t) {
|
||||
if (t.status === 'ARCHIVED') return 'card-archived';
|
||||
if (t.status === 'CANCELED') return 'card-canceled';
|
||||
return '';
|
||||
},
|
||||
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
|
||||
statusColor(s) { switch (s) { case "PENDING": return "sc-yellow"; case "WIP": return "sc-blue"; case "COMPLETED": return "sc-green"; case "REJECTED": return "sc-red"; case "CANCELED": return "sc-canceled"; case "ARCHIVED": return "sc-purple"; default: return "sc-gray"; } },
|
||||
fmtTime(d) { if (!d) return ""; const dt = new Date(d); const pad = (n) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ss-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
|
||||
|
||||
/* 顶部栏 */
|
||||
.ss-topbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; padding-top: calc(8px + env(safe-area-inset-top)); background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb; flex-shrink: 0; z-index: 10; }
|
||||
.ss-back { font-size: 13px; font-weight: 700; color: #2563eb; padding: 5px 10px; background: #eff6ff; border-radius: 8px; }
|
||||
.ss-title-group { display: flex; flex-direction: column; align-items: center; gap: 0; flex: 1; }
|
||||
.ss-title { font-size: 15px; font-weight: 800; color: #1f2937; }
|
||||
.ss-step-hint { font-size: 10px; color: #9ca3af; }
|
||||
.ss-topbar-right { display: flex; gap: 6px; }
|
||||
.ss-overview-btn { font-size: 12px; font-weight: 700; color: #2563eb; padding: 5px 10px; background: #dbeafe; border-radius: 8px; }
|
||||
|
||||
/* 滑动区域 */
|
||||
.ss-swiper-h { width: 100%; flex: 1; min-height: 220px; }
|
||||
.ss-swiper-v { width: 100%; }
|
||||
.ss-card-wrapper { display: flex; align-items: flex-start; justify-content: center; padding: 10px 16px; height: 100%; min-height: 220px; box-sizing: border-box; }
|
||||
|
||||
/* 任务卡片 */
|
||||
.ss-card { position: relative; width: 100%; max-width: 420px; background: #fff; border-radius: 20px; padding: 18px 22px; box-shadow: 0 8px 24px rgba(0,0,0,0.1); display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow-y: auto; }
|
||||
.task-card { border-left: 10px solid #3b82f6; }
|
||||
.task-card-main { border-left-color: #2563eb; border-left-width: 12px; }
|
||||
.task-card-branch { border-left-color: #7c3aed; }
|
||||
.task-card.card-archived { border-left-color: #8b5cf6; opacity: 0.75; }
|
||||
.task-card.card-canceled { border-left-color: #9ca3af; opacity: 0.5; }
|
||||
|
||||
.tc-ribbon { position: absolute; top: 14px; right: 14px; font-size: 10px; padding: 3px 10px; border-radius: 6px; color: #fff; font-weight: 700; z-index: 2; }
|
||||
.tc-ribbon-main { background: #2563eb; }
|
||||
.tc-ribbon-sub { background: #7c3aed; }
|
||||
|
||||
.tc-head { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
|
||||
.tc-head-left { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
|
||||
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
|
||||
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 6px; background: #ef4444; color: #fff; font-weight: 700; }
|
||||
.tag-archived-sm { font-size: 10px; padding: 2px 6px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; border: 1px dashed #a78bfa; }
|
||||
.tc-status { font-size: 12px; padding: 3px 10px; border-radius: 10px; font-weight: 700; }
|
||||
.tc-status.sc-yellow { background: #fef3c7; color: #b45309; }
|
||||
.tc-status.sc-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.tc-status.sc-green { background: #dcfce7; color: #15803d; }
|
||||
.tc-status.sc-red { background: #fce4ec; color: #be123c; }
|
||||
.tc-status.sc-purple { background: #ede9fe; color: #7c3aed; }
|
||||
.tc-status.sc-canceled { background: #f3f4f6; color: #9ca3af; text-decoration: line-through; }
|
||||
.tc-name { font-size: 20px; font-weight: 800; color: #1f2937; line-height: 1.3; }
|
||||
.tc-meta { display: flex; flex-direction: column; gap: 4px; }
|
||||
.tc-meta-row { display: flex; align-items: center; gap: 8px; }
|
||||
.tc-meta-label { font-size: 13px; color: #9ca3af; }
|
||||
.tc-meta-val { font-size: 14px; color: #374151; font-weight: 600; }
|
||||
.tc-remark-box { padding: 10px 12px; border-radius: 10px; display: flex; flex-direction: column; gap: 3px; background: #fefce8; border: 1px solid #fef08a; }
|
||||
.tc-remark-label { font-size: 12px; font-weight: 700; color: #a16207; }
|
||||
.tc-remark-text { font-size: 14px; color: #374151; line-height: 1.5; }
|
||||
|
||||
/* 提交记录链接 */
|
||||
.tc-records-link { display: flex; align-items: center; gap: 6px; padding: 10px 14px; background: linear-gradient(135deg, #eff6ff, #dbeafe); border-radius: 10px; border: 1px solid #bfdbfe; }
|
||||
.tc-records-icon { font-size: 16px; }
|
||||
.tc-records-count { flex: 1; font-size: 13px; font-weight: 700; color: #2563eb; }
|
||||
.tc-records-arrow { font-size: 12px; color: #2563eb; font-weight: 600; }
|
||||
|
||||
.tc-stats { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tc-stat { font-size: 11px; padding: 3px 8px; border-radius: 8px; background: #f3f4f6; color: #6b7280; font-weight: 600; }
|
||||
.tc-time { font-size: 11px; color: #9ca3af; padding-top: 6px; border-top: 1px solid #f3f4f6; }
|
||||
|
||||
/* 底部 */
|
||||
.ss-footer { flex-shrink: 0; background: rgba(255,255,255,0.95); padding-bottom: calc(6px + env(safe-area-inset-bottom)); }
|
||||
.ss-lane-tabs { display: flex; gap: 4px; padding: 6px 12px; overflow-x: auto; }
|
||||
.ss-lane-tab { display: flex; align-items: center; gap: 4px; padding: 5px 12px; border-radius: 14px; background: #f3f4f6; font-size: 12px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
|
||||
.ss-lane-tab-main { color: #2563eb; }
|
||||
.ss-lane-tab-sub { color: #7c3aed; }
|
||||
.ss-lane-active { background: #2563eb; }
|
||||
.ss-lane-active .ss-lane-tab-main { color: #fff; }
|
||||
.ss-lane-active .ss-lane-tab-sub { color: #ddd6fe; }
|
||||
.ss-lane-count { font-size: 10px; color: #9ca3af; background: #fff; padding: 1px 6px; border-radius: 8px; }
|
||||
.ss-lane-active .ss-lane-count { color: #2563eb; }
|
||||
|
||||
.ss-dots { display: flex; justify-content: center; gap: 5px; padding: 4px 0; overflow-x: auto; }
|
||||
.ss-dot { width: 20px; height: 20px; border-radius: 10px; background: #e5e7eb; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.2s; }
|
||||
.ss-dot-active { width: 26px; height: 26px; border-radius: 13px; background: #2563eb; }
|
||||
.ss-dot-main { background: #bfdbfe; }
|
||||
.ss-dot-label { font-size: 9px; font-weight: 700; color: #9ca3af; }
|
||||
.ss-dot-active .ss-dot-label { color: #fff; }
|
||||
</style>
|
||||
@ -1,45 +1,53 @@
|
||||
<template>
|
||||
<view class="tree-canvas-container">
|
||||
<view class="tree-canvas-fullscreen">
|
||||
<view class="tc-toolbar">
|
||||
<view class="tc-toolbar-left">
|
||||
<view class="tc-back-btn" @tap="$emit('back')">← 返回</view>
|
||||
<text class="tc-title">🌳 流转蓝图</text>
|
||||
</view>
|
||||
<view class="tc-toolbar-right">
|
||||
<view class="tc-zoom-btn tc-mode-btn" @tap="$emit('swipe')">📇 卡片</view>
|
||||
<view class="tc-zoom-btn tc-fit-btn" @tap="fitAll">⊡ 全览</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
|
||||
<text class="zero-icon">📋</text>
|
||||
<text class="zero-text">该产品暂无流转记录</text>
|
||||
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转记录</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="canvas-hint">🖐 2D 流转蓝图:支持全视角自由拖拽探索</view>
|
||||
<movable-area class="tree-movable-area" :style="{ width: viewportW + 'px', height: areaHeight + 'px' }">
|
||||
<movable-view class="tree-movable-view" direction="all"
|
||||
:x="mvX" :y="mvY" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||||
:inertia="true" :friction="2" :damping="40"
|
||||
@change="onChange">
|
||||
|
||||
<movable-area class="tree-movable-area">
|
||||
<movable-view
|
||||
class="tree-movable-view"
|
||||
direction="all"
|
||||
:x="0" :y="0"
|
||||
:scale="true" :scale-min="0.3" :scale-max="2"
|
||||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||||
>
|
||||
<view class="canvas-inner">
|
||||
<!-- 节点卡片层 -->
|
||||
<!-- 🔧 连线层 -->
|
||||
<view v-for="(line, i) in lines" :key="'l'+i"
|
||||
class="cn-line" :style="lineStyle(line)"
|
||||
:class="line.dashed ? 'cn-line-dashed' : 'cn-line-solid'" />
|
||||
<!-- 🔧 连线末端箭头 -->
|
||||
<view v-for="(arrow, i) in arrows" :key="'a'+i"
|
||||
class="cn-arrow" :style="{ left: arrow.x + 'px', top: arrow.y + 'px', transform: 'rotate(' + arrow.rot + 'deg)' }"
|
||||
:class="arrow.dashed ? 'cn-arrow-dashed' : 'cn-arrow-solid'" />
|
||||
|
||||
<!-- 🚀 十字星节点 -->
|
||||
<view v-for="node in treeNodes" :key="node.id"
|
||||
class="canvas-node" :class="statusColor(node.status)"
|
||||
class="canvas-node" :class="[statusColor(node.status), node._isMain ? 'node-main' : 'node-sub']"
|
||||
:style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
@tap="node.records && node.records.length && $emit('viewRecords', node)">
|
||||
|
||||
@tap="handleNodeTap(node)">
|
||||
<view class="cn-header">
|
||||
<text :class="node.parent_task_id ? 'badge-sub' : 'badge-main'">{{ node.parent_task_id ? branchLabelMap[node.id] || '分支' : '主分支' }}</text>
|
||||
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
|
||||
</view>
|
||||
|
||||
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
|
||||
<text v-if="node.is_rework" class="tag-rework">⚠ 返工工序</text>
|
||||
|
||||
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
|
||||
|
||||
<view class="cn-footer">
|
||||
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止分支</text>
|
||||
<text v-else-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 已入库</text>
|
||||
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}条记录 ›</text>
|
||||
<text :class="node._isMain ? 'badge-main' : 'badge-sub'">{{ node._isMain ? '主分支' : node._blabel }}</text>
|
||||
<text :class="['cn-badge-mini', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
|
||||
</view>
|
||||
<text class="cn-assignee">{{ formatUserName(node.assignee_id) || '—' }}</text>
|
||||
<text v-if="!node._isMain && node.parent && !node.parent._isMain" class="cn-nested-hint">协助: {{ formatUserName(node.parent.assignee_id) || '—' }}</text>
|
||||
<text class="cn-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
|
||||
<text v-if="node.records && node.records.length" class="cn-records-mini">{{ node.records.length }}条</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</movable-view>
|
||||
</movable-area>
|
||||
</template>
|
||||
@ -47,150 +55,272 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName } from "../../../utils/format";
|
||||
export default {
|
||||
name: "TreeCanvas",
|
||||
props: {
|
||||
product: { type: Object, required: true }
|
||||
props: { product: { type: Object, required: true } },
|
||||
emits: ["viewRecords", "back", "swipe"],
|
||||
data() {
|
||||
return {
|
||||
mvX: 0, mvY: 0,
|
||||
viewportW: 375, viewportH: 600,
|
||||
areaHeight: 500,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
try { const info = uni.getSystemInfoSync(); this.viewportW = info.windowWidth || 375; this.viewportH = info.windowHeight || 600; } catch {}
|
||||
this.areaHeight = Math.max(this.viewportH - 100, 400);
|
||||
this.$nextTick(() => this.fitAll());
|
||||
},
|
||||
emits: ["viewRecords"],
|
||||
computed: {
|
||||
// ============================================================
|
||||
// 🚀 父子相对坐标延伸算法(v3 — 无重叠 Y 轴栈式布局)
|
||||
// ============================================================
|
||||
treeNodes() {
|
||||
if (!this.product || !this.product.task_tree) return [];
|
||||
const CARD_W = 160, CARD_H = 80, GAP_X = 24, VERTICAL_GAP = 28, MAIN_GAP = 40;
|
||||
const CENTER_X = 0;
|
||||
|
||||
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
|
||||
const nodes = [];
|
||||
const rowMaxX = {};
|
||||
let currentMaxYIdx = -1;
|
||||
|
||||
// 拍平 + 浅拷贝
|
||||
const flatMap = {};
|
||||
const flatten = (tasks) => {
|
||||
if (!tasks) return;
|
||||
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
|
||||
};
|
||||
flatten(this.product.task_tree);
|
||||
const allTasks = [];
|
||||
const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { const copy = { ...t }; allTasks.push(copy); flatMap[copy.id] = copy; walk(t.child_tasks); }); };
|
||||
walk(this.product.task_tree);
|
||||
allTasks.forEach(t => {
|
||||
if (t.child_tasks && t.child_tasks.length) t.child_tasks = t.child_tasks.map(c => flatMap[c.id]).filter(Boolean);
|
||||
if (t.parent_task_id) t.parent = flatMap[t.parent_task_id] || null;
|
||||
});
|
||||
|
||||
const layoutNode = (t) => {
|
||||
if (t._visited) return;
|
||||
t._visited = true;
|
||||
// 🚀 主干判定
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
|
||||
if (!t.parent_task_id) {
|
||||
// 规则A:根节点 → 新行 X=0
|
||||
currentMaxYIdx++;
|
||||
t._yIdx = currentMaxYIdx;
|
||||
t._xIdx = 0;
|
||||
} else {
|
||||
const parent = flatMap[t.parent_task_id];
|
||||
if (parent && !parent._visited) layoutNode(parent);
|
||||
|
||||
const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN');
|
||||
|
||||
if (type === 'SPAWN') {
|
||||
t._yIdx = parent._yIdx;
|
||||
t._xIdx = (rowMaxX[t._yIdx] !== undefined ? rowMaxX[t._yIdx] : 0) + 1;
|
||||
} else if (type === 'TRANSFER' || type === 'MAIN') {
|
||||
currentMaxYIdx++;
|
||||
t._yIdx = currentMaxYIdx;
|
||||
t._xIdx = 0;
|
||||
} else if (type === 'RECOVERY') {
|
||||
currentMaxYIdx++;
|
||||
t._yIdx = currentMaxYIdx;
|
||||
t._xIdx = parent._xIdx;
|
||||
}
|
||||
}
|
||||
|
||||
rowMaxX[t._yIdx] = Math.max(rowMaxX[t._yIdx] || 0, t._xIdx);
|
||||
t.x = 40 + t._xIdx * (CARD_W + GAP_X);
|
||||
t.y = 20 + t._yIdx * (CARD_H + GAP_Y);
|
||||
nodes.push(t);
|
||||
|
||||
if (t.child_tasks && t.child_tasks.length) {
|
||||
t.child_tasks
|
||||
.sort((a, b) => (a.task_type === 'TRANSFER' ? -1 : 1))
|
||||
.forEach(child => layoutNode(child));
|
||||
}
|
||||
};
|
||||
|
||||
this.product.task_tree.forEach(root => layoutNode(root));
|
||||
return nodes;
|
||||
},
|
||||
branchLabelMap() {
|
||||
const map = {};
|
||||
if (!this.product || !this.product.task_tree) return map;
|
||||
// 分支编号
|
||||
const branchLabelMap = {};
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (t.task_type !== 'SPAWN') {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, prefix);
|
||||
} else {
|
||||
spawnIndex++;
|
||||
const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`;
|
||||
map[t.id] = `分支 ${num}`;
|
||||
traverse(t.child_tasks, num);
|
||||
}
|
||||
let si = 0;
|
||||
tasks.forEach(t => {
|
||||
if (isMainFn(t)) { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
|
||||
else { si++; const n = prefix ? prefix + '.' + si : '' + si; branchLabelMap[t.id] = '分支 ' + n; traverse(t.child_tasks, n); }
|
||||
});
|
||||
};
|
||||
this.product.task_tree.forEach((t) => {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, '');
|
||||
const rootCopies = this.product.task_tree.map(t => flatMap[t.id]).filter(Boolean);
|
||||
rootCopies.forEach(t => { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, ''); });
|
||||
|
||||
// 🚀 构建真实的全局 childMap(按 parent_task_id)
|
||||
const childMap = {};
|
||||
allTasks.forEach(t => {
|
||||
const pid = t.parent_task_id || '';
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(t);
|
||||
});
|
||||
return map;
|
||||
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)));
|
||||
|
||||
// 主管分类
|
||||
const mains = [];
|
||||
allTasks.forEach(t => { if (isMainFn(t)) mains.push(t); });
|
||||
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
|
||||
// 🚀 预计算每个节点的子树高度(兄弟累加取 max,首子与父平齐)
|
||||
const calcSubtreeHeight = (nodeId) => {
|
||||
const children = childMap[nodeId] || [];
|
||||
if (children.length === 0) return CARD_H + VERTICAL_GAP;
|
||||
const totalChildrenHeight = children.reduce((sum, c) => sum + calcSubtreeHeight(c.id), 0);
|
||||
return Math.max(CARD_H + VERTICAL_GAP, totalChildrenHeight);
|
||||
};
|
||||
|
||||
const nodes = [];
|
||||
|
||||
// 🚀 递归放置算法:首个子节点与父节点水平平齐
|
||||
// 同级子节点按 index 错开 Y 轴,各占其子树高度防止重叠
|
||||
const placeChildren = (parentNode, currentSide) => {
|
||||
const children = childMap[parentNode.id] || [];
|
||||
// 🚀 首个子节点 Y 起始:与父节点水平平齐
|
||||
let startY = parentNode.y;
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const subtreeH = calcSubtreeHeight(child.id);
|
||||
|
||||
// 主干的第一层协助分支:均衡分发左右
|
||||
let side = currentSide;
|
||||
if (parentNode._isMain) {
|
||||
side = index % 2 === 0 ? 'right' : 'left';
|
||||
}
|
||||
|
||||
// 🚀 X 轴:永远基于真实父亲向外延伸
|
||||
const childX = side === 'right'
|
||||
? parentNode.x + CARD_W + GAP_X
|
||||
: parentNode.x - CARD_W - GAP_X;
|
||||
|
||||
// 🚀 Y 轴:首子与父平齐,后续兄弟向下累加
|
||||
const childY = startY;
|
||||
|
||||
const childNode = {
|
||||
...child,
|
||||
x: childX, y: childY,
|
||||
_isMain: false, _side: side,
|
||||
_blabel: branchLabelMap[child.id] || '分支',
|
||||
_rootMainId: parentNode._isMain ? parentNode.id : parentNode._rootMainId,
|
||||
};
|
||||
nodes.push(childNode);
|
||||
flatMap[childNode.id] = childNode;
|
||||
|
||||
// 递归放置孙子节点(沿相同方向)
|
||||
placeChildren(childNode, side);
|
||||
|
||||
// 🚀 下一个兄弟节点跳到当前子树高度之后(calcSubtreeHeight 已含 GAP)
|
||||
startY += subtreeH;
|
||||
});
|
||||
};
|
||||
|
||||
// 🚀 渲染入口:主干节点也按子树深度排布
|
||||
let mainStartY = 0;
|
||||
mains.forEach((m, mi) => {
|
||||
const subtreeH = calcSubtreeHeight(m.id);
|
||||
const mainNode = { ...m, x: CENTER_X, y: mainStartY, _isMain: true, _blabel: '主分支', _rootMainId: m.id };
|
||||
nodes.push(mainNode);
|
||||
flatMap[mainNode.id] = mainNode;
|
||||
placeChildren(mainNode, null);
|
||||
mainStartY += subtreeH + MAIN_GAP;
|
||||
});
|
||||
|
||||
// 全局偏移(给左侧分支预留空间)
|
||||
const minX = Math.min(...nodes.map(n => n.x));
|
||||
const minY = Math.min(...nodes.map(n => n.y));
|
||||
const padding = 40;
|
||||
nodes.forEach(n => { n.x += Math.abs(minX) + CARD_W * 2 + padding; n.y += padding - Math.min(0, minY); });
|
||||
return nodes;
|
||||
},
|
||||
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.x)) + 300); },
|
||||
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.y)) + 300); }
|
||||
// ============================================================
|
||||
// 🚀 连线计算:中央 → 分支
|
||||
// ============================================================
|
||||
// 🔧 连线:卡片边缘到边缘,动态追踪 Y 坐标,杜绝箭头悬空
|
||||
lines() {
|
||||
const result = [];
|
||||
const flatMap = {};
|
||||
this.treeNodes.forEach(n => flatMap[n.id] = n);
|
||||
this.treeNodes.forEach(n => {
|
||||
if (n._isMain) return;
|
||||
// 🚀 连线到真实直接父节点(树枝延伸)
|
||||
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
|
||||
if (!parent) return;
|
||||
const isLeft = n.x < parent.x;
|
||||
const startX = isLeft ? n.x + 160 : parent.x + 160;
|
||||
const endX = isLeft ? parent.x : n.x;
|
||||
// 🚀 动态计算真实的 Y 坐标对接点
|
||||
const startY = isLeft ? n.y + 40 : parent.y + 40;
|
||||
const endY = isLeft ? parent.y + 40 : n.y + 40;
|
||||
result.push({
|
||||
x1: startX, y1: startY,
|
||||
x2: endX, y2: endY,
|
||||
dashed: parent.status === 'COMPLETED' || parent.status === 'ARCHIVED',
|
||||
});
|
||||
});
|
||||
return result;
|
||||
},
|
||||
arrows() {
|
||||
const result = [];
|
||||
const flatMap = {};
|
||||
this.treeNodes.forEach(n => flatMap[n.id] = n);
|
||||
this.treeNodes.forEach(n => {
|
||||
if (n._isMain) return;
|
||||
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
|
||||
if (!parent) return;
|
||||
const isLegacy = parent && (parent.status === 'COMPLETED' || parent.status === 'ARCHIVED');
|
||||
const isLeft = n.x < parent.x;
|
||||
result.push({
|
||||
x: isLeft ? n.x + 160 : n.x - 6,
|
||||
y: n.y + 40 - 2,
|
||||
rot: isLeft ? 180 : 0,
|
||||
dashed: isLegacy,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
},
|
||||
// ============================================================
|
||||
canvasWidth() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.x)) + 400; },
|
||||
canvasHeight() { const ns = this.treeNodes; if (!ns.length) return 800; return Math.max(...ns.map(n => n.y)) + 300; },
|
||||
},
|
||||
methods: {
|
||||
getTaskRemark(t) {
|
||||
if (!t) return "";
|
||||
if (t.remark) return t.remark;
|
||||
if (t.records && t.records.length > 0) {
|
||||
const rec = t.records.find(r => r.remark && r.remark.trim().length > 0);
|
||||
if (rec) return rec.remark;
|
||||
formatUserName,
|
||||
// 🚀 手势结束后同步位置
|
||||
onChange(e) {
|
||||
if (e?.detail?.x !== undefined) this.mvX = e.detail.x;
|
||||
if (e?.detail?.y !== undefined) this.mvY = e.detail.y;
|
||||
},
|
||||
handleNodeTap(node) {
|
||||
if (node.records && node.records.length > 0) {
|
||||
this.$emit('viewRecords', node);
|
||||
} else {
|
||||
uni.showToast({ title: '当前节点暂无流转记录', icon: 'none' });
|
||||
}
|
||||
return "";
|
||||
},
|
||||
lineStyle(line) {
|
||||
const dx = line.x2 - line.x1; const dy = line.y2 - line.y1;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' };
|
||||
// 🚀 全览居中:固定 100% 比例,内容整体在视口中居中
|
||||
fitAll() {
|
||||
const canvasW = this.canvasWidth;
|
||||
const canvasH = this.canvasHeight;
|
||||
const viewW = this.viewportW;
|
||||
const viewH = this.areaHeight;
|
||||
this.mvX = (viewW - canvasW) / 2;
|
||||
this.mvY = (viewH - canvasH) / 2;
|
||||
},
|
||||
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
|
||||
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; case "CANCELED": return "s-canceled"; default: return "s-gray"; } }
|
||||
fmtDate(d) { if (!d) return ""; const dt = new Date(d); return (dt.getMonth() + 1) + '/' + dt.getDate(); },
|
||||
statusLabel(s) { const m = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return m[s] || s; },
|
||||
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; case "CANCELED": return "s-canceled"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } },
|
||||
lineStyle(line) { const dx = line.x2 - line.x1; const dy = line.y2 - line.y1; const len = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx) * 180 / Math.PI; return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' }; }
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-canvas-container { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #ebedf0; border-radius: 12px; margin-top: 10px; box-shadow: inset 0 0 10px rgba(0,0,0,0.02); }
|
||||
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 40px 0; }
|
||||
.tree-canvas-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
|
||||
.tc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; padding-top: calc(10px + env(safe-area-inset-top)); background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb; flex-shrink: 0; z-index: 999; }
|
||||
.tc-toolbar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.tc-back-btn { font-size: 14px; font-weight: 700; color: #2563eb; padding: 6px 12px; background: #eff6ff; border-radius: 8px; }
|
||||
.tc-title { font-size: 16px; font-weight: 800; color: #1f2937; }
|
||||
.tc-hint { font-size: 10px; color: #9ca3af; }
|
||||
.tc-toolbar-right { display: flex; align-items: center; gap: 8px; }
|
||||
.tc-zoom-btn { width: 36px; height: 36px; border-radius: 10px; background: #f3f4f6; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; color: #374151; }
|
||||
.tc-zoom-btn:active { background: #e5e7eb; }
|
||||
.tc-zoom-label { font-size: 12px; font-weight: 600; color: #6b7280; min-width: 42px; text-align: center; }
|
||||
.tc-mode-btn { width: auto; padding: 0 10px; font-size: 12px; background: #ede9fe; color: #7c3aed; }
|
||||
.tc-fit-btn { width: auto; padding: 0 12px; font-size: 13px; background: #dbeafe; color: #2563eb; }
|
||||
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
|
||||
.zero-icon { font-size: 40px; margin-bottom: 10px; }
|
||||
.zero-text { font-size: 14px; color: #9ca3af; }
|
||||
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 8px 0; background: #fff; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; }
|
||||
.tree-movable-area { flex: 1; width: 100%; background-color: #f8fafc; background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); background-size: 20px 20px; }
|
||||
.canvas-inner { position: absolute; left: 0; top: 0; }
|
||||
.canvas-node { position: absolute; width: 320px; min-height: 460px; background: #ffffff; border-radius: 20px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06); border-left: 12px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 10px; }
|
||||
.tree-movable-area { width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; overflow: hidden; }
|
||||
.canvas-inner { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
|
||||
/* 🚀 movable-view 填充整个父容器,确保空白区域也能响应拖拽/缩放手势 */
|
||||
.tree-movable-view { width: 100%; height: 100%; }
|
||||
|
||||
/* 🔧 连线(禁止吞事件) */
|
||||
.cn-line { position: absolute; height: 2px; pointer-events: none; }
|
||||
.cn-line-solid { background: #9ca3af; }
|
||||
.cn-line-dashed { background: repeating-linear-gradient(90deg, #fdba74 0, #fdba74 6px, transparent 6px, transparent 10px); }
|
||||
/* 🔧 连线箭头(禁止吞事件) */
|
||||
.cn-arrow { position: absolute; width: 0; height: 0; border-left: 6px solid #9ca3af; border-top: 4px solid transparent; border-bottom: 4px solid transparent; pointer-events: none; }
|
||||
.cn-arrow-dashed { border-left-color: #fdba74; }
|
||||
|
||||
/* 🔧 极简卡片 160x80 */
|
||||
.canvas-node { position: absolute; width: 160px; min-height: auto; padding: 10px 12px; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 4px; font-size: 11px; }
|
||||
.canvas-node.node-sub { border-left-color: #7c3aed; }
|
||||
.canvas-node.s-yellow { border-left-color: #f59e0b; }
|
||||
.canvas-node.s-blue { border-left-color: #3b82f6; }
|
||||
.canvas-node.s-green { border-left-color: #22c55e; }
|
||||
.canvas-node.s-red { border-left-color: #ef4444; }
|
||||
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.5; filter: grayscale(0.6); }
|
||||
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.4; }
|
||||
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.65; }
|
||||
.cn-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
|
||||
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
|
||||
.cn-name { font-size: 24px; font-weight: 800; color: #1f2937; }
|
||||
.cn-badge { font-size: 14px; padding: 4px 12px; border-radius: 8px; font-weight: 700; }
|
||||
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
|
||||
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
|
||||
.cn-badge.s-red { background: #fce4ec; color: #be123c; }
|
||||
.cn-assignee { font-size: 16px; color: #6b7280; font-weight: 500; margin-top: 10px; }
|
||||
.tag-rework { font-size: 13px; color: #fff; background: #ef4444; padding: 4px 10px; border-radius: 6px; display: inline-block; width: max-content; }
|
||||
.cn-remark { font-size: 16px; color: #a16207; background: #fefce8; padding: 16px; border-radius: 8px; border: 1px solid #fef08a; line-height: 1.5; word-break: break-all; min-height: 100px; white-space: normal; margin-top: 16px; }
|
||||
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 14px; border-top: 1px dashed #e5e7eb; }
|
||||
.cn-end { font-size: 13px; font-weight: 700; color: #16a34a; }
|
||||
.cn-end-wh { color: #7c3aed; }
|
||||
.cn-records { font-size: 14px; color: #2563eb; font-weight: 700; background: #eff6ff; padding: 4px 12px; border-radius: 12px; }
|
||||
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; transform-origin: 0 0; }
|
||||
.badge-main { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #2563eb; color: #fff; font-weight: 700; }
|
||||
.badge-sub { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
|
||||
.cn-badge-mini { font-size: 9px; padding: 1px 6px; border-radius: 4px; font-weight: 700; }
|
||||
.cn-badge-mini.s-yellow { background: #fef3c7; color: #b45309; }
|
||||
.cn-badge-mini.s-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.cn-badge-mini.s-green { background: #dcfce7; color: #15803d; }
|
||||
.cn-badge-mini.s-red { background: #fce4ec; color: #be123c; }
|
||||
.cn-badge-mini.s-archived { background: #ede9fe; color: #7c3aed; }
|
||||
.cn-assignee { font-size: 10px; color: #6b7280; }
|
||||
.cn-nested-hint { font-size: 8px; color: #a78bfa; font-weight: 600; }
|
||||
.cn-time { font-size: 9px; color: #9ca3af; }
|
||||
.cn-records-mini { font-size: 9px; color: #2563eb; background: #eff6ff; padding: 1px 6px; border-radius: 6px; display: inline-block; width: max-content; }
|
||||
</style>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="workspace-area">
|
||||
<view class="workspace-area" :class="{ 'is-locked': !!lockedTaskId }">
|
||||
<!-- 0任务 -->
|
||||
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
|
||||
<text class="zero-icon">📋</text>
|
||||
@ -25,7 +25,7 @@
|
||||
<view class="tli-left">
|
||||
<text class="tag-branch">{{ branchLabelsMap[t.id] || '' }}</text>
|
||||
<text class="tli-name">{{ t.task_name }}</text>
|
||||
<text class="tli-assignee">→ {{ t.assignee_id || '未分配' }}</text>
|
||||
<text class="tli-assignee">→ {{ formatUserName(t.assignee_id) || '未分配' }}</text>
|
||||
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
|
||||
</view>
|
||||
<view class="tli-right">
|
||||
@ -49,7 +49,7 @@
|
||||
<text class="tag-branch">{{ branchLabelsMap[lockedTask.id] || '' }}</text>
|
||||
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
|
||||
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">⚠返工</text>
|
||||
<text v-if="lockedTask.parent_task_id && lockedTask.status === 'WIP'" class="sub-branch-end"
|
||||
<text v-if="lockedTask.parent_task_id && lockedTask.task_type === 'SPAWN' && lockedTask.status === 'WIP'" class="sub-branch-end"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'end' })">🛑 结束协助</text>
|
||||
</view>
|
||||
<text class="fc-name">{{ lockedTask.task_name }}</text>
|
||||
@ -60,13 +60,13 @@
|
||||
</view>
|
||||
|
||||
<view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up">
|
||||
<text class="fc-link-label">⬆ 上游工序</text>
|
||||
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} → {{ parentTaskOf(lockedTask).assignee_id || '—' }}</text>
|
||||
<text class="fc-link-label">{{ isNestedSpawn ? '⬆ 上游协助 (嵌套)' : '⬆ 上游工序' }}</text>
|
||||
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} → {{ 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>
|
||||
<text v-for="c in lockedTask.child_tasks" :key="c.id" class="fc-link-name">
|
||||
· {{ c.task_name }} → {{ c.assignee_id || '—' }}
|
||||
· {{ c.task_name }} → {{ formatUserName(c.assignee_id) || '—' }}
|
||||
<text v-if="c.status==='COMPLETED'" class="branch-done">✓已完成</text>
|
||||
<text v-else-if="c.status==='ARCHIVED'" class="branch-done">📦已入库</text>
|
||||
</text>
|
||||
@ -102,6 +102,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName } from "../../../utils/format";
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
@ -110,11 +111,29 @@ export default {
|
||||
product: { type: Object, default: null },
|
||||
currentUserId: { type: String, default: "" },
|
||||
currentUsername: { type: String, default: "" },
|
||||
initialLockTaskId: { type: String, default: "" },
|
||||
},
|
||||
emits: ["action", "viewRecords"],
|
||||
data() {
|
||||
return { lockedTaskId: null };
|
||||
},
|
||||
watch: {
|
||||
initialLockTaskId: {
|
||||
immediate: true,
|
||||
handler(id) {
|
||||
if (id && this.focusTasks.some(t => t.id === id)) {
|
||||
this.lockedTaskId = id;
|
||||
}
|
||||
},
|
||||
},
|
||||
product: {
|
||||
handler() {
|
||||
if (this.initialLockTaskId && this.focusTasks.some(t => t.id === this.initialLockTaskId)) {
|
||||
this.lockedTaskId = this.initialLockTaskId;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
taskMap() {
|
||||
const m = {};
|
||||
@ -124,6 +143,7 @@ export default {
|
||||
},
|
||||
focusTasks() {
|
||||
const result = [];
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return;
|
||||
for (const t of tasks) {
|
||||
@ -133,8 +153,8 @@ export default {
|
||||
};
|
||||
if (this.product) walk(this.product.task_tree);
|
||||
result.sort((a, b) => {
|
||||
const aIsMain = !a.parent_task_id || a.task_type !== 'SPAWN';
|
||||
const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
|
||||
const aIsMain = isMainFn(a);
|
||||
const bIsMain = isMainFn(b);
|
||||
if (aIsMain && !bIsMain) return -1;
|
||||
if (!aIsMain && bIsMain) return 1;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
@ -151,14 +171,32 @@ export default {
|
||||
if (!parent) return false;
|
||||
return parent.assignee_id == this.currentUserId || parent.assignee_id == this.currentUsername;
|
||||
},
|
||||
// 🚀 嵌套协助判定:直接父任务不是主线 → 孙子/曾孙协助
|
||||
isNestedSpawn() {
|
||||
if (!this.lockedTask || !this.lockedTask.parent_task_id) return false;
|
||||
const parent = this.taskMap[this.lockedTask.parent_task_id];
|
||||
if (!parent) return false;
|
||||
return parent.parent_task_id && parent.task_type !== 'TRANSFER' && parent.task_type !== 'RECOVERY';
|
||||
},
|
||||
branchLabelsMap() {
|
||||
const map = {};
|
||||
if (!this.product || !this.product.task_tree) return map;
|
||||
|
||||
const flatMap = {};
|
||||
const flatten = (tasks) => {
|
||||
if (!tasks) return;
|
||||
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
|
||||
};
|
||||
flatten(this.product.task_tree);
|
||||
|
||||
// 🚀 仅凭基因字段判定,移除危险的状态兜底
|
||||
const isMainFn = (t) => !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (t.task_type !== 'SPAWN') {
|
||||
if (isMainFn(t)) {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, prefix);
|
||||
} else {
|
||||
@ -177,6 +215,7 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatUserName,
|
||||
statusLabel(s) { return STATUS_MAP[s] || s; },
|
||||
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
|
||||
countTasks(tree) { return tree ? tree.reduce((s, t) => s + 1 + this.countTasks(t.child_tasks), 0) : 0; },
|
||||
@ -190,11 +229,12 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-area { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.workspace-area { height: auto; display: flex; flex-direction: column; }
|
||||
.workspace-area.is-locked { height: 100vh; overflow: hidden; }
|
||||
.list-header { display: flex; align-items: baseline; justify-content: space-between; padding: 8px 4px; flex-shrink: 0; }
|
||||
.list-title { font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.list-hint { font-size: 11px; color: #9ca3af; }
|
||||
.task-list { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.task-list { height: auto; }
|
||||
.task-list-item { display: flex; align-items: flex-start; justify-content: space-between; background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 4px solid transparent; }
|
||||
.task-list-item.s-yellow { border-left-color: #f59e0b; }
|
||||
.task-list-item.s-blue { border-left-color: #3b82f6; }
|
||||
|
||||
@ -4,45 +4,61 @@
|
||||
<view v-if="error" class="error-box">{{ error }}</view>
|
||||
|
||||
<template v-if="product && !loading">
|
||||
<view class="overall-bar" @tap="handleOverallBarClick">
|
||||
<text class="overall-label">宏观状态</text>
|
||||
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
||||
<text v-if="product.task_tree && product.task_tree.length" class="overall-arrow">▾</text>
|
||||
</view>
|
||||
<!-- 工作区模式:显示产品信息栏 -->
|
||||
<template v-if="currentMode === 'workspace'">
|
||||
<view :key="'prod-card-' + dictVersion">
|
||||
<view class="overall-bar" @tap="handleOverallBarClick">
|
||||
<text class="overall-label">宏观状态</text>
|
||||
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
||||
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow">▾</text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📦 产品信息</text>
|
||||
<view class="card-header-right">
|
||||
<text :class="['badge', statusColor(product.status)]">{{ statusLabel(product.status) }}</text>
|
||||
<text class="mode-toggle" @tap="toggleMode">{{ currentMode === 'workspace' ? '🌳 流转树' : '🛠️ 工作区' }}</text>
|
||||
<text class="edit-btn" @tap="openEditProduct">✏️</text>
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📦 产品信息</text>
|
||||
<view class="card-header-right">
|
||||
<text class="mode-toggle" @tap="toggleMode">{{ modeToggleLabel }}</text>
|
||||
<text class="edit-btn" @tap="openEditProduct">✏️</text>
|
||||
<text class="print-label-btn" @tap="printLabel">🖨️ 打印标签</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-grid">
|
||||
<view class="info-item"><text class="label">身份证</text><text class="value sn">{{ product.serial_number }}</text></view>
|
||||
<view class="info-item" v-if="product.external_serial"><text class="label">产品序列号</text><text class="value sn">{{ product.external_serial }}</text></view>
|
||||
<view class="info-item"><text class="label">物料名称</text><text class="value">{{ product.material_name || product.material_id || '—' }}</text></view>
|
||||
<view class="info-item"><text class="label">规格型号</text><text class="value">{{ product.spec_model || '—' }}</text></view>
|
||||
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
|
||||
<view class="info-item" v-if="product.current_location_id">
|
||||
<text class="label">当前位置</text>
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ formatUserName(product.current_location_id) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-grid">
|
||||
<view class="info-item"><text class="label">身份证</text><text class="value sn">{{ product.serial_number }}</text></view>
|
||||
<view class="info-item"><text class="label">物料名称</text><text class="value">{{ product.material_name || product.material_id || '—' }}</text></view>
|
||||
<view class="info-item"><text class="label">规格型号</text><text class="value">{{ product.spec_model || '—' }}</text></view>
|
||||
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
|
||||
<view class="info-item" v-if="product.current_location_id">
|
||||
<text class="label">当前位置</text>
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 📤 仓库转出横幅(当前用户有活跃任务时隐藏) -->
|
||||
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
|
||||
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
|
||||
<text class="wt-icon">📤</text>
|
||||
<text class="wt-text">该产品在仓库中 — 点击此处转出并派发给指定人员</text>
|
||||
</view>
|
||||
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
|
||||
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
|
||||
<text class="wt-icon">📤</text>
|
||||
<text class="wt-text">该产品在仓库中 — 点击此处转出并派发给指定人员</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 工作区视图 -->
|
||||
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
|
||||
:currentUserId="currentUserId" :currentUsername="currentUsername"
|
||||
:initialLockTaskId="autoLockTaskId"
|
||||
:key="'wa-' + dictVersion"
|
||||
@action="handleTaskAction" @viewRecords="handleViewRecords" />
|
||||
|
||||
<TreeCanvas v-if="currentMode === 'tree'" :product="product" @viewRecords="handleViewRecords" />
|
||||
<!-- 📇 流转卡片:探探式单张滑动 -->
|
||||
<TaskSwipeCards v-if="currentMode === 'swipe'" :product="product"
|
||||
:key="'sw-' + dictVersion"
|
||||
@back="currentMode = 'workspace'" @overview="currentMode = 'tree'"
|
||||
@viewRecords="handleViewRecords" />
|
||||
|
||||
<!-- 🌳 流转树:全屏独立视图 -->
|
||||
<TreeCanvas v-if="currentMode === 'tree'" :product="product" :key="'tc-' + dictVersion" @viewRecords="handleViewRecords" @back="currentMode = 'workspace'" @swipe="currentMode = 'swipe'" />
|
||||
|
||||
</template>
|
||||
|
||||
<!-- 状态定调 -->
|
||||
@ -148,20 +164,53 @@
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 💬 留言悬浮按钮 -->
|
||||
<view class="msg-fab" @tap="openMsgDrawer">
|
||||
<text class="msg-fab-icon">💬</text>
|
||||
<text v-if="msgUnreadCount" class="msg-fab-badge">{{ msgUnreadCount }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 💬 留言板底部抽屉 -->
|
||||
<view v-if="showMsgDrawer" class="msg-drawer-overlay" @tap="closeMsgDrawer">
|
||||
<view class="message-board-drawer" @tap.stop>
|
||||
<view class="mb-drawer-handle"></view>
|
||||
<view class="mb-title">💬 协同留言板</view>
|
||||
<scroll-view scroll-y class="mb-scroll-area" :scroll-into-view="bottomMsgId" scroll-with-animation>
|
||||
<view v-for="msg in messages" :key="msg.id" class="mb-item" :id="'msg-' + msg.id">
|
||||
<view class="mb-avatar">{{ formatUserAvatar(msg.operator_id) }}</view>
|
||||
<view class="mb-content-wrapper">
|
||||
<view class="mb-header-info">
|
||||
<text class="mb-name">{{ formatUserName(msg.operator_id) }}</text>
|
||||
<text class="mb-time">{{ fmtMsgTime(msg.created_at) }}</text>
|
||||
</view>
|
||||
<view class="mb-bubble">{{ msg.content }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view id="msg-bottom" class="mb-bottom-anchor"></view>
|
||||
</scroll-view>
|
||||
<view class="mb-input-bar">
|
||||
<input v-model="newMsgText" class="mb-input" placeholder="输入交接注意事项..." confirm-type="send" @confirm="submitMessage" />
|
||||
<view :class="['mb-send-btn', !newMsgText.trim() ? 'btn-disabled' : '']" @tap="submitMessage">发送</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import request, { get, post, patch, put } from "../../utils/request";
|
||||
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
|
||||
import WorkspaceArea from "./components/WorkspaceArea.vue";
|
||||
import TreeCanvas from "./components/TreeCanvas.vue";
|
||||
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
|
||||
|
||||
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
components: { WorkspaceArea, TreeCanvas },
|
||||
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
|
||||
data() {
|
||||
return {
|
||||
OVERALL_OPTIONS, loading: true, error: "", product: null,
|
||||
@ -169,44 +218,96 @@ 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: "tree",
|
||||
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "workspace", autoLockTaskId: "",
|
||||
processOptions: [], userOptions: [],
|
||||
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
|
||||
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
|
||||
spawnForm: { assignee_id: "", remark: "" },
|
||||
// 💬 留言板
|
||||
messages: [],
|
||||
// 🚀 字典版本号:驱动子组件强制重建,解决 userNameMap 非响应式问题
|
||||
dictVersion: 0,
|
||||
showMsgDrawer: false,
|
||||
newMsgText: '',
|
||||
bottomMsgId: '',
|
||||
lastMsgSeenAt: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
|
||||
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; return this.currentUser.username === this.recordPopup.task.assignee_id; },
|
||||
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; return this.currentUser.username === this.recordPopup.task.assignee_id; },
|
||||
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
|
||||
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
|
||||
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
|
||||
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
|
||||
hasMyActiveTask() {
|
||||
const find = (tasks) => { if (!tasks) return false; for (const t of tasks) { if ((t.status === 'WIP' || t.status === 'PENDING') && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) return true; if (find(t.child_tasks)) return true; } return false; };
|
||||
return this.product ? find(this.product.task_tree) : false;
|
||||
},
|
||||
msgUnreadCount() { if (!this.lastMsgSeenAt) return this.messages.length; return this.messages.filter(m => m.created_at > this.lastMsgSeenAt).length; },
|
||||
// 🔒 宏观状态修改权限:对齐后端 update_overall_status 的 main_task 判断标准
|
||||
canEditOverallStatus() {
|
||||
if (!this.currentUser) return false;
|
||||
if (this.currentUser.role === 'SUPER_ADMIN') return true;
|
||||
if (!this.product || !this.product.task_tree) return false;
|
||||
let hasPermission = false;
|
||||
const checkTask = (tasks) => {
|
||||
if (!tasks || hasPermission) return;
|
||||
for (const t of tasks) {
|
||||
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
if (isMain && (t.status === 'WIP' || t.status === 'PENDING')) {
|
||||
if (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername) {
|
||||
hasPermission = true;
|
||||
}
|
||||
}
|
||||
checkTask(t.child_tasks);
|
||||
}
|
||||
};
|
||||
checkTask(this.product.task_tree);
|
||||
return hasPermission;
|
||||
},
|
||||
},
|
||||
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) this.doQuery(sn); },
|
||||
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
|
||||
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
|
||||
onShow() { if (this.product?.id) { this.fetchMessages(); } },
|
||||
methods: {
|
||||
formatUserName, formatUserAvatar,
|
||||
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
|
||||
statusLabel(s) { return STATUS_MAP[s] || s; },
|
||||
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
|
||||
|
||||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = this.hasMyActiveTask ? 'workspace' : 'tree'; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
toggleMode() { this.currentMode = this.currentMode === 'workspace' ? 'tree' : 'workspace'; },
|
||||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
// 🚀 从 taskId 反查 product_serial → 再 doQuery
|
||||
async doQueryByTask(tid) { try { const task = await get(`/tasks/${tid}`); const sn = task?.product_sn || ""; if (sn) { this.doQuery(sn); } else { this.error = "未找到关联产品"; this.loading = false; } } catch { this.error = "任务查询失败"; this.loading = false; } },
|
||||
findMyImmersiveTask() {
|
||||
// 🚀 扫描用户的 WIP/PENDING 任务,自动沉浸锁定
|
||||
if (!this.product || !this.product.task_tree) return null;
|
||||
let wipTask = null, pendingTask = null;
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return;
|
||||
for (const t of tasks) {
|
||||
const isMine = t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername;
|
||||
if (isMine && t.status === 'WIP') wipTask = t;
|
||||
if (isMine && t.status === 'PENDING' && !pendingTask) pendingTask = t;
|
||||
walk(t.child_tasks);
|
||||
}
|
||||
};
|
||||
walk(this.product.task_tree);
|
||||
return (wipTask || pendingTask) ? (wipTask || pendingTask).id : null;
|
||||
},
|
||||
toggleMode() { if (this.currentMode === 'workspace') this.currentMode = 'swipe'; else if (this.currentMode === 'swipe') this.currentMode = 'tree'; else this.currentMode = 'workspace'; },
|
||||
|
||||
async handleSetOverallStatus(status) { try { this.product = await patch(`/products/scan/${this.product.serial_number}/status`, { status }); uni.showToast({ title: `状态已更新: ${status}`, icon: "success" }); this.showStatusPicker = false; } catch {} },
|
||||
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
|
||||
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 })); } catch {} },
|
||||
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 {} },
|
||||
|
||||
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})`; } },
|
||||
openCreateFirstTask() { this.isWarehouseTransfer = false; if (this.currentMode === 'tree') this.currentMode = 'workspace'; this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" }; this.createFirstVisible = true; },
|
||||
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else { this.showStatusPicker = true; } },
|
||||
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
|
||||
openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); },
|
||||
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
|
||||
|
||||
@ -244,12 +345,50 @@ export default {
|
||||
async doTransfer() { this.actionLoading = true; try { const assignees = this.transferForm.isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId]; const taskName = this.transferForm.isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认"; await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: [{ task_name: taskName, assignees }], note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: this.transferForm.isWarehouse ? "已入库" : "转交成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
// 派发协助分支
|
||||
async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: "待确认", assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
// 💬 留言板
|
||||
async fetchMessages() { if (!this.product?.id) return; try { const res = await get(`/products/${this.product.id}/messages`); this.messages = res || []; this.scrollToBottom(); } catch (e) { console.error('获取留言失败', e); } },
|
||||
async submitMessage() { const content = this.newMsgText.trim(); if (!content) return; this.newMsgText = ''; const tempId = 'temp_' + Date.now(); const tempMsg = { id: tempId, operator_id: this.currentUsername || this.currentUserId || '?', content, created_at: new Date().toISOString() }; this.messages.push(tempMsg); this.scrollToBottom(); try { await post(`/products/${this.product.id}/messages`, { operator_id: this.currentUsername || this.currentUserId, content }); this.fetchMessages(); } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }); this.messages = this.messages.filter(m => m.id !== tempId); } },
|
||||
openMsgDrawer() { this.showMsgDrawer = true; this.$nextTick(() => { this.scrollToBottom(); }); },
|
||||
closeMsgDrawer() { const last = this.messages[this.messages.length - 1]; this.lastMsgSeenAt = last ? last.created_at : new Date().toISOString(); this.showMsgDrawer = false; },
|
||||
scrollToBottom() { this.$nextTick(() => { this.bottomMsgId = 'msg-bottom'; }); },
|
||||
fmtMsgTime(d) { if (!d) return ''; const dt = new Date(d); const pad = (n) => String(n).padStart(2, '0'); return `${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
|
||||
// 🖨️ 打印标签:调用后端 API 发送打印指令
|
||||
async printLabel() {
|
||||
if (!this.product?.serial_number) return;
|
||||
uni.showActionSheet({
|
||||
itemList: ['网络打印机(后端API)', '蓝牙打印机(ESC/POS)'],
|
||||
success: async (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
// 方案A:网络打印机 → 调用后端 /print/execute API
|
||||
try {
|
||||
uni.showLoading({ title: '发送打印指令...' });
|
||||
await post(`/print/execute`, {
|
||||
serial_number: this.product.serial_number,
|
||||
material_name: this.product.material_name || '',
|
||||
spec_model: this.product.spec_model || '',
|
||||
order_no: this.product.order_no || '',
|
||||
copies: 1,
|
||||
});
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '打印指令已发送', icon: 'success' });
|
||||
} catch (e) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: e?.data?.detail || '打印失败', icon: 'none' });
|
||||
}
|
||||
} else if (res.tapIndex === 1) {
|
||||
// 方案B:蓝牙打印机 → 前端直连 ESC/POS 指令
|
||||
// ⚠️ 需要引入蓝牙打印 SDK,当前为占位架构
|
||||
uni.showToast({ title: '蓝牙打印功能开发中', icon: 'none' });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { height: 100vh; display: flex; flex-direction: column; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 0; overflow: hidden; }
|
||||
.page-container { min-height: 100vh; display: block; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 24px; overflow-y: auto; }
|
||||
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
|
||||
.overall-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: #fff; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
@ -257,12 +396,13 @@ export default {
|
||||
.overall-val { font-size: 15px; font-weight: 700; color: #2563eb; flex: 1; }
|
||||
.overall-empty { color: #ef4444; }
|
||||
.overall-arrow { font-size: 12px; color: #9ca3af; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.card-header-right { display: flex; align-items: center; gap: 8px; }
|
||||
.card-title { font-size: 15px; font-weight: 700; }
|
||||
.edit-btn { font-size: 18px; padding: 2px 6px; }
|
||||
.mode-toggle { font-size: 13px; font-weight: 700; padding: 4px 10px; border-radius: 8px; background: #eff6ff; color: #2563eb; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; min-height: 120px; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
|
||||
.card-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.card-title { font-size: 15px; font-weight: 700; flex-shrink: 0; }
|
||||
.edit-btn { font-size: 18px; padding: 2px 6px; flex-shrink: 0; }
|
||||
.mode-toggle { font-size: 12px; font-weight: 700; padding: 4px 8px; border-radius: 8px; background: #eff6ff; color: #2563eb; white-space: nowrap; flex-shrink: 0; }
|
||||
.print-label-btn { font-size: 11px; font-weight: 700; padding: 4px 6px; border-radius: 8px; background: #fef3c7; color: #b45309; white-space: nowrap; flex-shrink: 0; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.label { font-size: 12px; color: #9ca3af; }
|
||||
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
|
||||
@ -301,6 +441,30 @@ export default {
|
||||
.btn-add-branch { width: 100%; height: 40px; border: 2px dashed #93c5fd; border-radius: 10px; background: #eff6ff; color: #2563eb; font-size: 14px; font-weight: 700; line-height: 40px; margin: 4px 0; }
|
||||
.btn-add-branch::after { border: none; }
|
||||
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
|
||||
|
||||
/* 💬 留言悬浮按钮 */
|
||||
.msg-fab { position: fixed; right: 20px; bottom: 100px; z-index: 99; width: 50px; height: 50px; border-radius: 25px; background: #3b82f6; color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(59,130,246,0.4); }
|
||||
.msg-fab-icon { font-size: 22px; }
|
||||
.msg-fab-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; border-radius: 9px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 5px; }
|
||||
|
||||
/* 💬 留言板底部抽屉 */
|
||||
.msg-drawer-overlay { position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
||||
.message-board-drawer { height: 65vh; display: flex; flex-direction: column; background: #fff; border-radius: 16px 16px 0 0; width: 100%; max-width: 480px; }
|
||||
.mb-drawer-handle { width: 40px; height: 4px; border-radius: 2px; background: #d1d5db; margin: 8px auto; flex-shrink: 0; }
|
||||
.mb-title { font-size: 14px; font-weight: bold; padding: 12px 16px; border-bottom: 1px solid #f3f4f6; color: #374151; flex-shrink: 0; }
|
||||
.mb-scroll-area { flex: 1; padding: 12px; overflow-y: auto; }
|
||||
.mb-item { display: flex; margin-bottom: 16px; }
|
||||
.mb-avatar { width: 36px; height: 36px; border-radius: 18px; background: #3b82f6; color: #fff; font-weight: bold; display: flex; align-items: center; justify-content: center; margin-right: 12px; flex-shrink: 0; font-size: 14px; }
|
||||
.mb-content-wrapper { flex: 1; min-width: 0; }
|
||||
.mb-header-info { margin-bottom: 4px; display: flex; align-items: baseline; }
|
||||
.mb-name { font-size: 12px; color: #6b7280; margin-right: 8px; font-weight: 600; }
|
||||
.mb-time { font-size: 10px; color: #9ca3af; }
|
||||
.mb-bubble { background: #f3f4f6; padding: 8px 12px; border-radius: 0 12px 12px 12px; font-size: 14px; color: #1f2937; word-break: break-all; line-height: 1.5; }
|
||||
.mb-input-bar { display: flex; padding: 10px 16px; border-top: 1px solid #e5e7eb; align-items: center; background: #f9fafb; border-radius: 0 0 12px 12px; flex-shrink: 0; }
|
||||
.mb-input { flex: 1; background: #ffffff; border: 1px solid #d1d5db; padding: 6px 12px; border-radius: 16px; font-size: 14px; height: 36px; }
|
||||
.mb-send-btn { margin-left: 12px; background: #3b82f6; color: #fff; padding: 6px 16px; border-radius: 16px; font-size: 14px; font-weight: 600; transition: all 0.2s; }
|
||||
.btn-disabled { background: #9ca3af; opacity: 0.5; }
|
||||
.mb-bottom-anchor { height: 1px; }
|
||||
.required { color: #ef4444; }
|
||||
.picker-box { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 12px; font-size: 14px; color: #1f2937; line-height: 42px; box-sizing: border-box; background: #fff; }
|
||||
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }
|
||||
|
||||
@ -67,6 +67,9 @@ export default {
|
||||
computed: {
|
||||
canEdit() {
|
||||
if (!this.currentUser || !this.task) return false;
|
||||
// 已完成/已驳回/已入库/已撤回 的任务禁止编辑
|
||||
const frozen = ["COMPLETED", "REJECTED", "ARCHIVED", "CANCELED"];
|
||||
if (frozen.includes(this.task.status)) return false;
|
||||
return (
|
||||
this.currentUser.id == this.task.assignee_id ||
|
||||
this.currentUser.username == this.task.assignee_id
|
||||
|
||||
55
track-uniapp/src/utils/format.js
Normal file
55
track-uniapp/src/utils/format.js
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 全局人名映射工具
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 在页面 loadUsers() 后调用 setUserNameMap(userList) 填充字典
|
||||
* 2. 模板中直接 `{{ formatUserName(task.assignee_id) }}`
|
||||
* 3. 头像中 `{{ formatUserAvatar(msg.operator_id) }}` 取中文名末字
|
||||
*/
|
||||
|
||||
// 全局用户名 → 中文姓名 映射表
|
||||
const userNameMap = {};
|
||||
|
||||
/**
|
||||
* 批量设置用户名映射
|
||||
* @param {Array} users - 用户列表,每项需含 username 和 full_name
|
||||
*/
|
||||
export function setUserNameMap(users) {
|
||||
if (!users || !users.length) return;
|
||||
for (const u of users) {
|
||||
const id = u.username || u.id || '';
|
||||
const name = u.full_name || u.name || u.real_name || '';
|
||||
if (id && name) {
|
||||
userNameMap[id] = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户 ID 翻译为中文姓名
|
||||
* @param {string} userId - 用户标识(username 或 id)
|
||||
* @returns {string} 中文姓名,查不到则降级返回原 ID
|
||||
*/
|
||||
export function formatUserName(userId) {
|
||||
if (!userId) return '—';
|
||||
// 特殊值原样返回
|
||||
if (userId === 'virtual_warehouse') return '🏭 仓库';
|
||||
const name = userNameMap[userId];
|
||||
return name || userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户头像文字(中文名取末字,拼音名取首字母)
|
||||
* @param {string} userId - 用户标识
|
||||
* @returns {string} 单字头像文字
|
||||
*/
|
||||
export function formatUserAvatar(userId) {
|
||||
if (!userId) return '?';
|
||||
const name = userNameMap[userId];
|
||||
if (name) {
|
||||
// 中文名取最后一个字
|
||||
return name.charAt(name.length - 1);
|
||||
}
|
||||
// 降级:取 ID 首字母大写
|
||||
return userId.charAt(0).toUpperCase();
|
||||
}
|
||||
@ -1,14 +1,75 @@
|
||||
/**
|
||||
* uni.request 封装 — 统一的 HTTP 客户端
|
||||
* 自动携带 Token、401 跳转登录
|
||||
* uni.request 封装 — 双 Token 无感刷新 + 并发请求队列
|
||||
*
|
||||
* 环境选择:优先读取 storage 中的 env_base_url,
|
||||
* 未设置时默认走生产服务器。开发时如需切换,在登录前手动设置。
|
||||
*/
|
||||
|
||||
const BASE_URL = "http://192.168.9.80:8011/api/v1";
|
||||
const PROD_URL = "http://track_back.iris-rs.cn/api/v1";
|
||||
|
||||
// ============================================================
|
||||
// 环境 URL
|
||||
// ============================================================
|
||||
|
||||
function getBaseUrl() {
|
||||
return uni.getStorageSync("env_base_url") || PROD_URL;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 双 Token 存储
|
||||
// ============================================================
|
||||
|
||||
function getAccessToken() { return uni.getStorageSync("access_token") || ""; }
|
||||
function getRefreshToken() { return uni.getStorageSync("refresh_token") || ""; }
|
||||
function setTokens(accessToken, refreshToken) {
|
||||
uni.setStorageSync("access_token", accessToken);
|
||||
uni.setStorageSync("refresh_token", refreshToken);
|
||||
}
|
||||
function clearTokens() {
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("user");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 并发请求队列
|
||||
// ============================================================
|
||||
|
||||
let isRefreshing = false;
|
||||
let retryQueue = [];
|
||||
|
||||
function processQueue(error, newToken) {
|
||||
retryQueue.forEach((p) => {
|
||||
if (newToken) p.resolve(newToken);
|
||||
else p.reject(error);
|
||||
});
|
||||
retryQueue = [];
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) throw new Error("无 Refresh Token");
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: getBaseUrl() + "/auth/refresh",
|
||||
method: "POST",
|
||||
data: { refresh_token: refreshToken },
|
||||
header: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
success(res) {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data);
|
||||
else reject(res);
|
||||
},
|
||||
fail(err) { reject(err); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
|
||||
const token = uni.getStorageSync("token") || "";
|
||||
const url = options.url.startsWith("http") ? options.url : getBaseUrl() + options.url;
|
||||
const accessToken = getAccessToken();
|
||||
|
||||
uni.request({
|
||||
url,
|
||||
@ -16,59 +77,65 @@ export default function request(options) {
|
||||
data: options.data || {},
|
||||
header: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
...(options.header || {}),
|
||||
},
|
||||
timeout: 15000,
|
||||
success(res) {
|
||||
const code = res.statusCode;
|
||||
if (code >= 200 && code < 300) {
|
||||
resolve(res.data);
|
||||
} else if (code === 401) {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("user");
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
|
||||
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
|
||||
reject(res);
|
||||
} else if (code === 403) {
|
||||
uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
|
||||
reject(res);
|
||||
} else if (code === 400) {
|
||||
uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
reject(res);
|
||||
} else if (code === 409) {
|
||||
uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
|
||||
reject(res);
|
||||
} else {
|
||||
const detail = res.data?.detail || "";
|
||||
uni.showToast({ title: detail ? `${detail}` : `请求失败 (${code})`, icon: "none", duration: 3000 });
|
||||
reject(res);
|
||||
if (code >= 200 && code < 300) { resolve(res.data); return; }
|
||||
|
||||
if (code === 401) {
|
||||
if (url.includes("/auth/refresh") || url.includes("/auth/login")) { reject(res); return; }
|
||||
|
||||
if (isRefreshing) {
|
||||
retryQueue.push({
|
||||
resolve: (newToken) => {
|
||||
options.header = options.header || {};
|
||||
options.header.Authorization = `Bearer ${newToken}`;
|
||||
request(options).then(resolve).catch(reject);
|
||||
},
|
||||
reject: (err) => reject(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshAccessToken()
|
||||
.then((data) => {
|
||||
setTokens(data.access_token, getRefreshToken());
|
||||
processQueue(null, data.access_token);
|
||||
options.header = options.header || {};
|
||||
options.header.Authorization = `Bearer ${data.access_token}`;
|
||||
request(options).then(resolve).catch(reject);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error("[Request] Refresh Token 也过期,清除登录态");
|
||||
processQueue(new Error("refresh_failed"), null);
|
||||
clearTokens();
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
|
||||
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
|
||||
reject(res);
|
||||
})
|
||||
.finally(() => { isRefreshing = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
|
||||
else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
|
||||
else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
|
||||
reject(res);
|
||||
},
|
||||
fail() {
|
||||
uni.showToast({ title: "网络连接失败", icon: "none" });
|
||||
reject(new Error("network"));
|
||||
},
|
||||
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function get(url, params = {}) {
|
||||
const query = Object.entries(params)
|
||||
.filter(([, v]) => v != null && v !== "")
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
const query = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
||||
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
|
||||
}
|
||||
|
||||
export function post(url, data = {}) {
|
||||
return request({ url, method: "POST", data });
|
||||
}
|
||||
|
||||
export function patch(url, data = {}) {
|
||||
return request({ url, method: "PATCH", data });
|
||||
}
|
||||
|
||||
export function put(url, data = {}) {
|
||||
return request({ url, method: "PUT", data });
|
||||
}
|
||||
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
|
||||
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
|
||||
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }
|
||||
|
||||
119
track-uniapp/src/utils/request.local.js
Normal file
119
track-uniapp/src/utils/request.local.js
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* uni.request 封装 — 本地开发环境 (192.168.9.80)
|
||||
* 双 Token 无感刷新 + 并发请求队列
|
||||
*/
|
||||
|
||||
const BASE_URL = "http://192.168.9.80:8011/api/v1";
|
||||
|
||||
function getAccessToken() { return uni.getStorageSync("access_token") || ""; }
|
||||
function getRefreshToken() { return uni.getStorageSync("refresh_token") || ""; }
|
||||
function setTokens(accessToken, refreshToken) {
|
||||
uni.setStorageSync("access_token", accessToken);
|
||||
uni.setStorageSync("refresh_token", refreshToken);
|
||||
}
|
||||
function clearTokens() {
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("user");
|
||||
}
|
||||
|
||||
let isRefreshing = false;
|
||||
let retryQueue = [];
|
||||
|
||||
function processQueue(error, newToken) {
|
||||
retryQueue.forEach((p) => {
|
||||
if (newToken) p.resolve(newToken);
|
||||
else p.reject(error);
|
||||
});
|
||||
retryQueue = [];
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) throw new Error("无 Refresh Token");
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: BASE_URL + "/auth/refresh",
|
||||
method: "POST",
|
||||
data: { refresh_token: refreshToken },
|
||||
header: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
success(res) {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data);
|
||||
else reject(res);
|
||||
},
|
||||
fail(err) { reject(err); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
|
||||
const accessToken = getAccessToken();
|
||||
|
||||
uni.request({
|
||||
url,
|
||||
method: options.method || "GET",
|
||||
data: options.data || {},
|
||||
header: {
|
||||
"Content-Type": "application/json",
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
...(options.header || {}),
|
||||
},
|
||||
timeout: 15000,
|
||||
success(res) {
|
||||
const code = res.statusCode;
|
||||
if (code >= 200 && code < 300) { resolve(res.data); return; }
|
||||
if (code === 401) {
|
||||
if (url.includes("/auth/refresh") || url.includes("/auth/login")) { reject(res); return; }
|
||||
if (isRefreshing) {
|
||||
retryQueue.push({
|
||||
resolve: (newToken) => {
|
||||
options.header = options.header || {};
|
||||
options.header.Authorization = `Bearer ${newToken}`;
|
||||
request(options).then(resolve).catch(reject);
|
||||
},
|
||||
reject: (err) => reject(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
isRefreshing = true;
|
||||
refreshAccessToken()
|
||||
.then((data) => {
|
||||
setTokens(data.access_token, getRefreshToken());
|
||||
processQueue(null, data.access_token);
|
||||
options.header = options.header || {};
|
||||
options.header.Authorization = `Bearer ${data.access_token}`;
|
||||
request(options).then(resolve).catch(reject);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error("[Request] Refresh Token 也过期");
|
||||
processQueue(new Error("refresh_failed"), null);
|
||||
clearTokens();
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
|
||||
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
|
||||
reject(res);
|
||||
})
|
||||
.finally(() => { isRefreshing = false; });
|
||||
return;
|
||||
}
|
||||
if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
|
||||
else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
|
||||
else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
|
||||
reject(res);
|
||||
},
|
||||
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function get(url, params = {}) {
|
||||
const query = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
||||
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
|
||||
}
|
||||
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
|
||||
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
|
||||
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }
|
||||
120
track-uniapp/src/utils/request.prod.js
Normal file
120
track-uniapp/src/utils/request.prod.js
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* uni.request 封装 — 生产环境(172.16.0.198)
|
||||
* 打包 APK 前:将 request.prod.js 重命名为 request.js 替换原文件
|
||||
* 双 Token 无感刷新 + 并发请求队列
|
||||
*/
|
||||
|
||||
const BASE_URL = "http://172.16.0.198:8011/api/v1";
|
||||
|
||||
function getAccessToken() { return uni.getStorageSync("access_token") || ""; }
|
||||
function getRefreshToken() { return uni.getStorageSync("refresh_token") || ""; }
|
||||
function setTokens(accessToken, refreshToken) {
|
||||
uni.setStorageSync("access_token", accessToken);
|
||||
uni.setStorageSync("refresh_token", refreshToken);
|
||||
}
|
||||
function clearTokens() {
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("user");
|
||||
}
|
||||
|
||||
let isRefreshing = false;
|
||||
let retryQueue = [];
|
||||
|
||||
function processQueue(error, newToken) {
|
||||
retryQueue.forEach((p) => {
|
||||
if (newToken) p.resolve(newToken);
|
||||
else p.reject(error);
|
||||
});
|
||||
retryQueue = [];
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) throw new Error("无 Refresh Token");
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: BASE_URL + "/auth/refresh",
|
||||
method: "POST",
|
||||
data: { refresh_token: refreshToken },
|
||||
header: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
success(res) {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data);
|
||||
else reject(res);
|
||||
},
|
||||
fail(err) { reject(err); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = options.url.startsWith("http") ? options.url : BASE_URL + options.url;
|
||||
const accessToken = getAccessToken();
|
||||
|
||||
uni.request({
|
||||
url,
|
||||
method: options.method || "GET",
|
||||
data: options.data || {},
|
||||
header: {
|
||||
"Content-Type": "application/json",
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
...(options.header || {}),
|
||||
},
|
||||
timeout: 15000,
|
||||
success(res) {
|
||||
const code = res.statusCode;
|
||||
if (code >= 200 && code < 300) { resolve(res.data); return; }
|
||||
if (code === 401) {
|
||||
if (url.includes("/auth/refresh") || url.includes("/auth/login")) { reject(res); return; }
|
||||
if (isRefreshing) {
|
||||
retryQueue.push({
|
||||
resolve: (newToken) => {
|
||||
options.header = options.header || {};
|
||||
options.header.Authorization = `Bearer ${newToken}`;
|
||||
request(options).then(resolve).catch(reject);
|
||||
},
|
||||
reject: (err) => reject(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
isRefreshing = true;
|
||||
refreshAccessToken()
|
||||
.then((data) => {
|
||||
setTokens(data.access_token, getRefreshToken());
|
||||
processQueue(null, data.access_token);
|
||||
options.header = options.header || {};
|
||||
options.header.Authorization = `Bearer ${data.access_token}`;
|
||||
request(options).then(resolve).catch(reject);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error("[Request] Refresh Token 也过期");
|
||||
processQueue(new Error("refresh_failed"), null);
|
||||
clearTokens();
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
|
||||
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
|
||||
reject(res);
|
||||
})
|
||||
.finally(() => { isRefreshing = false; });
|
||||
return;
|
||||
}
|
||||
if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
|
||||
else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
|
||||
else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
|
||||
reject(res);
|
||||
},
|
||||
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function get(url, params = {}) {
|
||||
const query = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
||||
return request({ url: query ? `${url}?${query}` : url, method: "GET" });
|
||||
}
|
||||
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
|
||||
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
|
||||
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }
|
||||
BIN
track-uniapp/static/logo.png
Normal file
BIN
track-uniapp/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
@ -1,9 +1,9 @@
|
||||
/**
|
||||
* uni.request 封装 — 统一的 HTTP 客户端
|
||||
* 自动携带 Token、401 跳转登录
|
||||
* uni.request 封装 — 生产环境(服务器 172.16.0.198)
|
||||
* 打包 APK 前:将 request.prod.js 重命名为 request.js 替换原文件
|
||||
*/
|
||||
|
||||
const BASE_URL = "http://192.168.9.80:8011/api/v1";
|
||||
const BASE_URL = "http://172.16.0.198:8011/api/v1";
|
||||
|
||||
export default function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -30,11 +30,18 @@ export default function request(options) {
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" });
|
||||
setTimeout(() => uni.reLaunch({ url: "/pages/login/login" }), 1000);
|
||||
reject(res);
|
||||
} else if (code === 403) {
|
||||
uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
|
||||
reject(res);
|
||||
} else if (code === 400) {
|
||||
uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
reject(res);
|
||||
} else if (code === 409) {
|
||||
uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
|
||||
reject(res);
|
||||
} else {
|
||||
uni.showToast({ title: `请求失败 (${code})`, icon: "none" });
|
||||
const detail = res.data?.detail || "";
|
||||
uni.showToast({ title: detail ? `${detail}` : `请求失败 (${code})`, icon: "none", duration: 3000 });
|
||||
reject(res);
|
||||
}
|
||||
},
|
||||
@ -57,3 +64,11 @@ export function get(url, params = {}) {
|
||||
export function post(url, data = {}) {
|
||||
return request({ url, method: "POST", data });
|
||||
}
|
||||
|
||||
export function patch(url, data = {}) {
|
||||
return request({ url, method: "PATCH", data });
|
||||
}
|
||||
|
||||
export function put(url, data = {}) {
|
||||
return request({ url, method: "PUT", data });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user