feat: shadcn主题 + 文件关系追踪 + 处理流程修复

前端:
- 全站应用 shadcn/ui 主题 (zinc灰调, Inter字体, 1px细边框, 无硬阴影)
- 重写 global.css / Dashboard.vue / Login.vue / Layout.vue 样式
- 新增文件处理子页面: 采购单(Orders), 表格处理(Tables), 图片处理(Images)
- 侧边栏使用 el-sub-menu 组织文件处理导航

后端:
- 新增 file_relations 表追踪 input→output→result 链路
- 新增 /files/relations, /files/stats/detailed 等关系查询API
- 新增 ocr-single, excel-single, pipeline-single, merge-batch 端点
- 处理流程增加跳过逻辑 (已处理文件自动跳过)
- 全流程不再自动合并, 合并仅在采购单页面手动触发

Bug修复:
- TaskManager: asyncio.create_task 在线程池中无事件循环 → 改用 _schedule() 调度
- PurchaseOrderMerger 缺少 config 参数 → 传入 ConfigManager()
- FastAPI regex= 弃用 → 改为 pattern=
- merger.process() 接收 Path 对象 → 转为字符串
@
This commit is contained in:
2026-05-05 14:16:30 +08:00
parent dedc3b4183
commit 0721ed099c
13 changed files with 2341 additions and 602 deletions
+4 -1
View File
@@ -42,10 +42,13 @@ async def lifespan(app: FastAPI):
ConfigManager() ConfigManager()
# Initialize DB and cleanup old records # Initialize DB and cleanup old records
from .services.db_schema import init_db, cleanup_old_records from .services.db_schema import init_db, cleanup_old_records, sync_file_relations
init_db() init_db()
cleanup_old_records() cleanup_old_records()
# Sync file relations from existing files
sync_file_relations()
# Wire up DB pool to task manager # Wire up DB pool to task manager
task_manager.set_db_pool(db_pool) task_manager.set_db_pool(db_pool)
+101 -4
View File
@@ -12,7 +12,11 @@ from pydantic import BaseModel
from ..auth.dependencies import get_current_user, get_current_user_flexible from ..auth.dependencies import get_current_user, get_current_user_flexible
from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS
from ..services.db_schema import insert_file_metadata, query_file_history, query_file_stats from ..services.db_schema import (
insert_file_metadata, query_file_history, query_file_stats,
query_file_relations, delete_file_relations, sync_file_relations,
query_file_relations_stats,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,6 +58,7 @@ def _record_file_action(filename: str, directory: str, size: int, action: str, u
@router.post("/upload", response_model=UploadResponse) @router.post("/upload", response_model=UploadResponse)
async def upload_file( async def upload_file(
file: UploadFile = File(...), file: UploadFile = File(...),
target: str = Query("input", pattern="^(input|output)$"),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
_ensure_dirs() _ensure_dirs()
@@ -68,10 +73,13 @@ async def upload_file(
if len(content) > MAX_UPLOAD_SIZE: if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(400, f"文件过大,最大 {MAX_UPLOAD_SIZE // 1024 // 1024}MB") raise HTTPException(400, f"文件过大,最大 {MAX_UPLOAD_SIZE // 1024 // 1024}MB")
# Choose target directory
target_dir = _output_dir if target == "output" else _input_dir
# Save with secure name # Save with secure name
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
safe_name = secure_filename(file.filename) or file.filename safe_name = secure_filename(file.filename) or file.filename
dest = _input_dir / safe_name dest = target_dir / safe_name
# Avoid overwrite: add suffix if exists # Avoid overwrite: add suffix if exists
counter = 0 counter = 0
@@ -79,10 +87,10 @@ async def upload_file(
suffix = Path(safe_name).suffix suffix = Path(safe_name).suffix
while dest.exists(): while dest.exists():
counter += 1 counter += 1
dest = _input_dir / f"{stem}_{counter}{suffix}" dest = target_dir / f"{stem}_{counter}{suffix}"
dest.write_bytes(content) dest.write_bytes(content)
_record_file_action(dest.name, "input", len(content), "upload", current_user.get("username")) _record_file_action(dest.name, target, len(content), "upload", current_user.get("username"))
return UploadResponse( return UploadResponse(
filename=dest.name, filename=dest.name,
@@ -154,6 +162,10 @@ async def delete_file(
size = file_path.stat().st_size size = file_path.stat().st_size
file_path.unlink() file_path.unlink()
_record_file_action(filename, directory, size, "delete", current_user.get("username")) _record_file_action(filename, directory, size, "delete", current_user.get("username"))
# Cascade: clean up relation table
_cleanup_relation_for_deleted_file(directory, filename)
return {"message": f"已删除 {filename}"} return {"message": f"已删除 {filename}"}
@@ -202,3 +214,88 @@ async def get_file_stats(
): ):
"""Return file storage statistics per directory.""" """Return file storage statistics per directory."""
return {"directories": query_file_stats()} return {"directories": query_file_stats()}
# ---------------------------------------------------------------------------
# File relations
# ---------------------------------------------------------------------------
class RelationDeleteRequest(BaseModel):
ids: List[int]
def _cleanup_relation_for_deleted_file(directory: str, filename: str):
"""Clean up relation table when a file is deleted."""
import sqlite3
from ..services.db_schema import _db_path
try:
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
if directory == "input":
row = conn.execute("SELECT id FROM file_relations WHERE input_image = ?", (filename,)).fetchone()
if row:
conn.execute("UPDATE file_relations SET input_image = NULL, updated_at = datetime('now') WHERE id = ?", (row['id'],))
# Delete if no other fields
check = conn.execute("SELECT * FROM file_relations WHERE id = ?", (row['id'],)).fetchone()
if check and not check['output_excel'] and not check['result_purchase']:
conn.execute("DELETE FROM file_relations WHERE id = ?", (row['id'],))
elif directory == "output":
row = conn.execute("SELECT id FROM file_relations WHERE output_excel = ?", (filename,)).fetchone()
if row:
conn.execute("UPDATE file_relations SET output_excel = NULL, updated_at = datetime('now') WHERE id = ?", (row['id'],))
check = conn.execute("SELECT * FROM file_relations WHERE id = ?", (row['id'],)).fetchone()
if check and not check['input_image'] and not check['result_purchase']:
conn.execute("DELETE FROM file_relations WHERE id = ?", (row['id'],))
elif directory == "result":
row = conn.execute("SELECT id FROM file_relations WHERE result_purchase = ?", (filename,)).fetchone()
if row:
conn.execute("UPDATE file_relations SET result_purchase = NULL, updated_at = datetime('now') WHERE id = ?", (row['id'],))
check = conn.execute("SELECT * FROM file_relations WHERE id = ?", (row['id'],)).fetchone()
if check and not check['input_image'] and not check['output_excel']:
conn.execute("DELETE FROM file_relations WHERE id = ?", (row['id'],))
conn.commit()
finally:
conn.close()
except Exception:
logger.debug("Failed to cleanup relation for %s/%s", directory, filename, exc_info=True)
@router.get("/relations")
async def get_file_relations(
view: Optional[str] = Query(None, pattern="^(orders|tables|images)$"),
status: Optional[str] = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: dict = Depends(get_current_user),
):
"""Query file relations with optional view filter."""
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size)
return {"items": items, "total": total}
@router.get("/stats/detailed")
async def get_detailed_stats(
current_user: dict = Depends(get_current_user),
):
"""Get detailed file statistics for Dashboard."""
return query_file_relations_stats()
@router.post("/relations/sync")
async def sync_relations(
current_user: dict = Depends(get_current_user),
):
"""Scan directories and rebuild file_relations table."""
sync_file_relations()
return {"message": "文件关系表已重建"}
@router.delete("/relations")
async def delete_relations(
body: RelationDeleteRequest,
current_user: dict = Depends(get_current_user),
):
"""Delete file relation records by IDs."""
delete_file_relations(body.ids)
return {"message": f"已删除 {len(body.ids)} 条关系记录"}
+348 -64
View File
@@ -1,5 +1,6 @@
"""Processing endpoints: OCR, Excel conversion, merge, and full pipeline.""" """Processing endpoints: OCR, Excel conversion, merge, and full pipeline."""
import asyncio
import os import os
import sys import sys
import traceback import traceback
@@ -11,6 +12,7 @@ from pydantic import BaseModel
from ..auth.dependencies import get_current_user from ..auth.dependencies import get_current_user
from ..services.service_wrapper import ServiceWrapper from ..services.service_wrapper import ServiceWrapper
from ..services.db_schema import upsert_file_relation
router = APIRouter(prefix="/api/processing", tags=["processing"]) router = APIRouter(prefix="/api/processing", tags=["processing"])
@@ -23,8 +25,16 @@ _result_dir = _project_root / "data" / "result"
class PipelineRequest(BaseModel): class PipelineRequest(BaseModel):
files: Optional[List[str]] = None # specific files, or None = all in input/ files: Optional[List[str]] = None
supplier: Optional[str] = None # force supplier type supplier: Optional[str] = None
class SingleFileRequest(BaseModel):
filename: str
class MergeBatchRequest(BaseModel):
filenames: List[str]
class TaskResponse(BaseModel): class TaskResponse(BaseModel):
@@ -48,6 +58,26 @@ def _list_input_files(filter_ext: Optional[List[str]] = None) -> List[Path]:
return files return files
def _list_files_in(directory: Path, filter_ext: List[str] = None) -> List[Path]:
if not directory.is_dir():
return []
files = []
for f in sorted(directory.iterdir()):
if f.is_file():
if filter_ext is None or f.suffix.lower() in filter_ext:
files.append(f)
return files
def _run_background(coro):
"""Schedule a coroutine as a background task."""
asyncio.ensure_future(coro)
# ---------------------------------------------------------------------------
# Batch endpoints
# ---------------------------------------------------------------------------
@router.post("/ocr-batch", response_model=TaskResponse) @router.post("/ocr-batch", response_model=TaskResponse)
async def ocr_batch( async def ocr_batch(
request: Request, request: Request,
@@ -62,27 +92,43 @@ async def ocr_batch(
if not files: if not files:
raise HTTPException(400, "input/ 目录中没有图片文件") raise HTTPException(400, "input/ 目录中没有图片文件")
async def _run(): async def _bg():
try: def do_work():
from app.services.ocr_service import OCRService from app.services.ocr_service import OCRService
svc = OCRService() svc = OCRService()
total = len(files) total = len(files)
for i, f in enumerate(files): for i, f in enumerate(files):
# Skip check
out_stem = f.stem
# OCR output could be .xlsx or .xls
out_xlsx = _output_dir / f"{out_stem}.xlsx"
out_xls = _output_dir / f"{out_stem}.xls"
if out_xlsx.exists() or out_xls.exists():
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
tm.add_log(task.id, f"[跳过] {f.name} 已OCR过 → {out_name}")
upsert_file_relation(input_image=f.name, output_excel=out_name, status='ocr_done')
continue
tm.update_progress(task.id, int((i / total) * 100), f"正在识别: {f.name}") tm.update_progress(task.id, int((i / total) * 100), f"正在识别: {f.name}")
tm.add_log(task.id, f"[OCR] 处理 {f.name}") tm.add_log(task.id, f"[OCR] 处理 {f.name}")
try: try:
svc.process_single(str(f), str(_output_dir)) svc.process_image(str(f))
# Find the output file
for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{out_stem}{ext}"
if candidate.exists():
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done')
break
tm.add_log(task.id, f"[OCR] 完成: {f.name}") tm.add_log(task.id, f"[OCR] 完成: {f.name}")
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}") tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}")
result_files = [f.name for f in _output_dir.iterdir() if f.is_file()] result_files = [f.name for f in _output_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message=f"OCR完成,共处理 {total} 个文件") tm.set_completed(task.id, result_files=result_files, message=f"OCR完成,共处理 {total} 个文件")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio await _wrapper.run_sync(do_work)
asyncio.create_task(_run())
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="OCR任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="OCR任务已创建")
@@ -92,7 +138,7 @@ async def process_excel(
body: PipelineRequest = PipelineRequest(), body: PipelineRequest = PipelineRequest(),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""Convert OCR output Excel files to standardized format.""" """Convert OCR output Excel files to standardized purchase orders."""
tm = _get_task_manager(request) tm = _get_task_manager(request)
task = tm.create_task("Excel标准化处理") task = tm.create_task("Excel标准化处理")
@@ -100,61 +146,87 @@ async def process_excel(
if body.files: if body.files:
files = [_output_dir / f for f in body.files if (_output_dir / f).is_file()] files = [_output_dir / f for f in body.files if (_output_dir / f).is_file()]
else: else:
files = _list_input_files(filter_ext=list(excel_exts)) files = _list_files_in(_output_dir, filter_ext=list(excel_exts))
if not files:
files = _list_input_files_from(_output_dir, filter_ext=list(excel_exts))
if not files: if not files:
raise HTTPException(400, "没有找到Excel文件") raise HTTPException(400, "output/ 目录中没有Excel文件")
async def _run(): async def _bg():
try: def do_work():
from app.services.order_service import OrderService from app.services.order_service import OrderService
svc = OrderService() svc = OrderService()
total = len(files) total = len(files)
for i, f in enumerate(files): for i, f in enumerate(files):
# Skip check
result_name = f"采购单_{f.stem}.xls"
result_path = _result_dir / result_name
if result_path.exists():
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
continue
tm.update_progress(task.id, int((i / total) * 100), f"正在处理: {f.name}") tm.update_progress(task.id, int((i / total) * 100), f"正在处理: {f.name}")
tm.add_log(task.id, f"[Excel] 处理 {f.name}") tm.add_log(task.id, f"[Excel] 处理 {f.name}")
try: try:
svc.process_excel(str(f), str(_result_dir)) svc.process_excel(str(f))
# Find result file
if result_path.exists():
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
tm.add_log(task.id, f"[Excel] 完成: {f.name}") tm.add_log(task.id, f"[Excel] 完成: {f.name}")
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}") tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}")
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()] result_files = [f.name for f in _result_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成,共 {total} 个文件") tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成,共 {total} 个文件")
except Exception as e:
tm.set_failed(task.id, str(e))
import asyncio await _wrapper.run_sync(do_work)
asyncio.create_task(_run())
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="Excel处理任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="Excel处理任务已创建")
@router.post("/merge", response_model=TaskResponse) @router.post("/merge", response_model=TaskResponse)
async def merge_orders( async def merge_orders(
request: Request, request: Request,
body: MergeBatchRequest = MergeBatchRequest(filenames=[]),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""Merge all processed Excel files into a single purchase order.""" """Merge selected purchase order files into one PosPal template."""
tm = _get_task_manager(request) tm = _get_task_manager(request)
task = tm.create_task("合并采购单") task = tm.create_task("合并采购单")
async def _run(): # If specific files provided, use them; otherwise merge all
try: if body.filenames:
from app.services.order_service import OrderService file_paths = [_result_dir / f for f in body.filenames if (_result_dir / f).is_file()]
svc = OrderService() else:
file_paths = list(_result_dir.glob("采购单_*.xls"))
if not file_paths:
raise HTTPException(400, "没有找到可合并的采购单文件")
async def _bg():
def do_work():
from app.core.excel.merger import PurchaseOrderMerger
tm.update_progress(task.id, 20, "正在合并采购单...") tm.update_progress(task.id, 20, "正在合并采购单...")
tm.add_log(task.id, "[合并] 开始合并") tm.add_log(task.id, f"[合并] 合并 {len(file_paths)} 个文件")
result = svc.merge_orders(str(_result_dir)) try:
tm.add_log(task.id, f"[合并] 完成: {result}") from app.config.settings import ConfigManager
tm.set_completed(task.id, result_files=[result] if result else [], message="合并完成") merger = PurchaseOrderMerger(ConfigManager())
result = merger.process([str(f) for f in file_paths])
if result:
merged_name = Path(result).name
upsert_file_relation(result_purchase=merged_name, status='merged')
tm.add_log(task.id, f"[合并] 完成: {merged_name}")
tm.set_completed(task.id, result_files=[merged_name], message="合并完成")
else:
tm.set_failed(task.id, "合并返回空结果")
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[合并] 失败: {e}")
tm.set_failed(task.id, str(e)) tm.set_failed(task.id, str(e))
import asyncio await _wrapper.run_sync(do_work)
asyncio.create_task(_run())
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="合并任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="合并任务已创建")
@@ -164,14 +236,15 @@ async def full_pipeline(
body: PipelineRequest = PipelineRequest(), body: PipelineRequest = PipelineRequest(),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""Run the full pipeline: OCR Excel → Merge.""" """Run the full pipeline: OCR -> Excel -> Result (NO merge)."""
tm = _get_task_manager(request) tm = _get_task_manager(request)
task = tm.create_task("一键全流程处理") task = tm.create_task("一键全流程处理")
async def _run(): async def _bg():
def do_work():
try: try:
# Step 1: OCR # Step 1: OCR
tm.update_progress(task.id, 0, "步骤 1/3: OCR识别") tm.update_progress(task.id, 0, "步骤 1/2: OCR识别")
tm.add_log(task.id, "[Pipeline] 开始OCR识别") tm.add_log(task.id, "[Pipeline] 开始OCR识别")
from app.services.ocr_service import OCRService from app.services.ocr_service import OCRService
ocr_svc = OCRService() ocr_svc = OCRService()
@@ -179,53 +252,275 @@ async def full_pipeline(
image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'} image_exts = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
images = _list_input_files(filter_ext=list(image_exts)) images = _list_input_files(filter_ext=list(image_exts))
for i, f in enumerate(images): for i, f in enumerate(images):
pct = int((i / max(len(images), 1)) * 30) pct = int((i / max(len(images), 1)) * 40)
# Skip check
out_stem = f.stem
out_xlsx = _output_dir / f"{out_stem}.xlsx"
out_xls = _output_dir / f"{out_stem}.xls"
if out_xlsx.exists() or out_xls.exists():
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
tm.add_log(task.id, f"[跳过] {f.name} 已OCR过 → {out_name}")
upsert_file_relation(input_image=f.name, output_excel=out_name, status='ocr_done')
tm.update_progress(task.id, pct, f"跳过: {f.name}")
continue
tm.update_progress(task.id, pct, f"OCR: {f.name}") tm.update_progress(task.id, pct, f"OCR: {f.name}")
try: try:
ocr_svc.process_single(str(f), str(_output_dir)) ocr_svc.process_image(str(f))
for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{out_stem}{ext}"
if candidate.exists():
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done')
break
tm.add_log(task.id, f"[OCR] 完成: {f.name}") tm.add_log(task.id, f"[OCR] 完成: {f.name}")
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}") tm.add_log(task.id, f"[OCR] 失败: {f.name} - {e}")
# Step 2: Excel conversion # Step 2: Excel conversion
tm.update_progress(task.id, 35, "步骤 2/3: Excel标准化") tm.update_progress(task.id, 45, "步骤 2/2: Excel标准化")
tm.add_log(task.id, "[Pipeline] 开始Excel处理") tm.add_log(task.id, "[Pipeline] 开始Excel处理")
from app.services.order_service import OrderService from app.services.order_service import OrderService
order_svc = OrderService() order_svc = OrderService()
excel_files = list(_output_dir.glob("*.xls")) + list(_output_dir.glob("*.xlsx")) excel_files = list(_output_dir.glob("*.xls")) + list(_output_dir.glob("*.xlsx"))
for i, f in enumerate(excel_files): for i, f in enumerate(excel_files):
pct = 35 + int((i / max(len(excel_files), 1)) * 35) pct = 45 + int((i / max(len(excel_files), 1)) * 55)
# Skip check
result_name = f"采购单_{f.stem}.xls"
result_path = _result_dir / result_name
if result_path.exists():
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
tm.update_progress(task.id, pct, f"跳过: {f.name}")
continue
tm.update_progress(task.id, pct, f"Excel: {f.name}") tm.update_progress(task.id, pct, f"Excel: {f.name}")
try: try:
order_svc.process_excel(str(f), str(_result_dir)) order_svc.process_excel(str(f))
if result_path.exists():
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
tm.add_log(task.id, f"[Excel] 完成: {f.name}") tm.add_log(task.id, f"[Excel] 完成: {f.name}")
except Exception as e: except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}") tm.add_log(task.id, f"[Excel] 失败: {f.name} - {e}")
# Step 3: Merge
tm.update_progress(task.id, 75, "步骤 3/3: 合并采购单")
tm.add_log(task.id, "[Pipeline] 开始合并")
try:
result = order_svc.merge_orders(str(_result_dir))
tm.add_log(task.id, f"[合并] 完成: {result}")
except Exception as e:
tm.add_log(task.id, f"[合并] 失败: {e}")
result = None
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()] result_files = [f.name for f in _result_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message="全流程处理完成") tm.set_completed(task.id, result_files=result_files, message="全流程处理完成(不含合并)")
except Exception as e: except Exception as e:
tb = traceback.format_exc() tb = traceback.format_exc()
tm.add_log(task.id, f"[错误] {tb}") tm.add_log(task.id, f"[错误] {tb}")
tm.set_failed(task.id, str(e)) tm.set_failed(task.id, str(e))
import asyncio await _wrapper.run_sync(do_work)
asyncio.create_task(_run())
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="全流程任务已创建") return TaskResponse(task_id=task.id, status="accepted", message="全流程任务已创建")
# ---------------------------------------------------------------------------
# Single-file endpoints
# ---------------------------------------------------------------------------
@router.post("/ocr-single", response_model=TaskResponse)
async def ocr_single(
request: Request,
body: SingleFileRequest,
current_user: dict = Depends(get_current_user),
):
"""OCR a single image file."""
tm = _get_task_manager(request)
task = tm.create_task(f"OCR: {body.filename}")
file_path = _input_dir / body.filename
if not file_path.is_file():
raise HTTPException(404, f"文件不存在: {body.filename}")
async def _bg():
def do_work():
from app.services.ocr_service import OCRService
svc = OCRService()
tm.update_progress(task.id, 10, f"正在识别: {body.filename}")
tm.add_log(task.id, f"[OCR] 处理 {body.filename}")
try:
svc.process_image(str(file_path))
# Find output
stem = file_path.stem
for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{stem}{ext}"
if candidate.exists():
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done')
break
tm.add_log(task.id, f"[OCR] 完成: {body.filename}")
result_files = [f.name for f in _output_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message=f"OCR完成: {body.filename}")
except Exception as e:
tm.add_log(task.id, f"[OCR] 失败: {e}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"OCR任务已创建: {body.filename}")
@router.post("/excel-single", response_model=TaskResponse)
async def excel_single(
request: Request,
body: SingleFileRequest,
current_user: dict = Depends(get_current_user),
):
"""Process a single Excel file to purchase order."""
tm = _get_task_manager(request)
task = tm.create_task(f"Excel处理: {body.filename}")
file_path = _output_dir / body.filename
if not file_path.is_file():
raise HTTPException(404, f"文件不存在: {body.filename}")
async def _bg():
def do_work():
from app.services.order_service import OrderService
svc = OrderService()
tm.update_progress(task.id, 10, f"正在处理: {body.filename}")
tm.add_log(task.id, f"[Excel] 处理 {body.filename}")
try:
svc.process_excel(str(file_path))
result_name = f"采购单_{file_path.stem}.xls"
if (_result_dir / result_name).exists():
upsert_file_relation(output_excel=body.filename, result_purchase=result_name, status='done')
tm.add_log(task.id, f"[Excel] 完成: {body.filename}")
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成: {body.filename}")
except Exception as e:
tm.add_log(task.id, f"[Excel] 失败: {e}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"Excel处理任务已创建: {body.filename}")
@router.post("/pipeline-single", response_model=TaskResponse)
async def pipeline_single(
request: Request,
body: SingleFileRequest,
current_user: dict = Depends(get_current_user),
):
"""Full pipeline for a single image: OCR -> Excel -> Result (no merge)."""
tm = _get_task_manager(request)
task = tm.create_task(f"全流程: {body.filename}")
file_path = _input_dir / body.filename
if not file_path.is_file():
raise HTTPException(404, f"文件不存在: {body.filename}")
async def _bg():
def do_work():
try:
stem = file_path.stem
# Step 1: OCR
tm.update_progress(task.id, 10, "步骤 1/2: OCR识别")
tm.add_log(task.id, f"[Pipeline] OCR: {body.filename}")
from app.services.ocr_service import OCRService
ocr_svc = OCRService()
out_xlsx = _output_dir / f"{stem}.xlsx"
out_xls = _output_dir / f"{stem}.xls"
if out_xlsx.exists() or out_xls.exists():
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
tm.add_log(task.id, f"[跳过] 已OCR过 → {out_name}")
upsert_file_relation(input_image=body.filename, output_excel=out_name, status='ocr_done')
else:
ocr_svc.process_image(str(file_path))
for ext in ['.xlsx', '.xls']:
candidate = _output_dir / f"{stem}{ext}"
if candidate.exists():
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done')
break
tm.add_log(task.id, f"[OCR] 完成")
# Step 2: Excel
tm.update_progress(task.id, 50, "步骤 2/2: Excel处理")
tm.add_log(task.id, f"[Pipeline] Excel处理")
from app.services.order_service import OrderService
order_svc = OrderService()
result_name = f"采购单_{stem}.xls"
result_path = _result_dir / result_name
if result_path.exists():
tm.add_log(task.id, f"[跳过] 已处理过 → {result_name}")
upsert_file_relation(output_excel=f"{stem}.xlsx", result_purchase=result_name, status='done')
else:
# Find the output excel
excel_file = out_xlsx if out_xlsx.exists() else (out_xls if out_xls.exists() else None)
if excel_file:
order_svc.process_excel(str(excel_file))
if result_path.exists():
upsert_file_relation(output_excel=excel_file.name, result_purchase=result_name, status='done')
tm.add_log(task.id, f"[Excel] 完成")
else:
tm.add_log(task.id, f"[错误] OCR未生成Excel文件")
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()]
tm.set_completed(task.id, result_files=result_files, message=f"全流程完成: {body.filename}")
except Exception as e:
tb = traceback.format_exc()
tm.add_log(task.id, f"[错误] {tb}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message=f"全流程任务已创建: {body.filename}")
@router.post("/merge-batch", response_model=TaskResponse)
async def merge_batch(
request: Request,
body: MergeBatchRequest,
current_user: dict = Depends(get_current_user),
):
"""Merge selected purchase order files into one PosPal template."""
tm = _get_task_manager(request)
task = tm.create_task("批量合并采购单")
file_paths = [_result_dir / f for f in body.filenames if (_result_dir / f).is_file()]
if not file_paths:
raise HTTPException(400, "没有找到可合并的采购单文件")
async def _bg():
def do_work():
from app.core.excel.merger import PurchaseOrderMerger
tm.update_progress(task.id, 20, f"正在合并 {len(file_paths)} 个采购单...")
tm.add_log(task.id, f"[合并] 合并文件: {', '.join(f.name for f in file_paths)}")
try:
from app.config.settings import ConfigManager
merger = PurchaseOrderMerger(ConfigManager())
result = merger.process([str(f) for f in file_paths])
if result:
merged_name = Path(result).name
upsert_file_relation(result_purchase=merged_name, status='merged')
tm.add_log(task.id, f"[合并] 完成: {merged_name}")
tm.set_completed(task.id, result_files=[merged_name], message="批量合并完成")
else:
tm.set_failed(task.id, "合并返回空结果")
except Exception as e:
tm.add_log(task.id, f"[合并] 失败: {e}")
tm.set_failed(task.id, str(e))
await _wrapper.run_sync(do_work)
_run_background(_bg())
return TaskResponse(task_id=task.id, status="accepted", message="批量合并任务已创建")
# ---------------------------------------------------------------------------
# Status endpoint
# ---------------------------------------------------------------------------
@router.get("/status/{task_id}") @router.get("/status/{task_id}")
async def get_task_status( async def get_task_status(
task_id: str, task_id: str,
@@ -237,14 +532,3 @@ async def get_task_status(
if not task: if not task:
raise HTTPException(404, "任务不存在") raise HTTPException(404, "任务不存在")
return task.to_dict() return task.to_dict()
def _list_input_files_from(directory: Path, filter_ext: List[str] = None) -> List[Path]:
if not directory.is_dir():
return []
files = []
for f in sorted(directory.iterdir()):
if f.is_file():
if filter_ext is None or f.suffix.lower() in filter_ext:
files.append(f)
return files
+307
View File
@@ -4,6 +4,7 @@ Tables:
- http_logs: HTTP request/response logging - http_logs: HTTP request/response logging
- task_history: Background task tracking - task_history: Background task tracking
- file_metadata: File operation records - file_metadata: File operation records
- file_relations: Input→Output→Result file chain tracking
All functions are synchronous; the async db_pool.DBPool wraps them via run_in_executor. All functions are synchronous; the async db_pool.DBPool wraps them via run_in_executor.
""" """
@@ -69,6 +70,19 @@ def init_db():
task_id TEXT task_id TEXT
); );
CREATE INDEX IF NOT EXISTS idx_file_metadata_timestamp ON file_metadata(timestamp); CREATE INDEX IF NOT EXISTS idx_file_metadata_timestamp ON file_metadata(timestamp);
CREATE TABLE IF NOT EXISTS file_relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
input_image TEXT,
output_excel TEXT,
result_purchase TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_file_relations_input ON file_relations(input_image);
CREATE INDEX IF NOT EXISTS idx_file_relations_output ON file_relations(output_excel);
CREATE INDEX IF NOT EXISTS idx_file_relations_result ON file_relations(result_purchase);
""") """)
conn.commit() conn.commit()
finally: finally:
@@ -83,6 +97,7 @@ def cleanup_old_records():
conn.execute("DELETE FROM http_logs WHERE timestamp < ?", (cutoff,)) conn.execute("DELETE FROM http_logs WHERE timestamp < ?", (cutoff,))
conn.execute("DELETE FROM task_history WHERE created_at < ?", (cutoff,)) conn.execute("DELETE FROM task_history WHERE created_at < ?", (cutoff,))
conn.execute("DELETE FROM file_metadata WHERE timestamp < ?", (cutoff,)) conn.execute("DELETE FROM file_metadata WHERE timestamp < ?", (cutoff,))
conn.execute("DELETE FROM file_relations WHERE updated_at < ?", (cutoff,))
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
@@ -395,6 +410,298 @@ def query_file_stats() -> list[dict]:
return stats return stats
# ---------------------------------------------------------------------------
# File relations — CRUD
# ---------------------------------------------------------------------------
def upsert_file_relation(input_image: str = None, output_excel: str = None,
result_purchase: str = None, status: str = 'pending'):
"""Insert or update a file relation.
Match strategy:
- If input_image provided, try to find existing row by input_image
- Else if output_excel provided, try to find by output_excel
- Otherwise insert new row.
"""
now = datetime.now().isoformat()
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
existing = None
if input_image:
existing = conn.execute(
"SELECT * FROM file_relations WHERE input_image = ?", (input_image,)
).fetchone()
if not existing and output_excel:
existing = conn.execute(
"SELECT * FROM file_relations WHERE output_excel = ?", (output_excel,)
).fetchone()
if not existing and result_purchase:
existing = conn.execute(
"SELECT * FROM file_relations WHERE result_purchase = ?", (result_purchase,)
).fetchone()
if existing:
updates = []
params = []
if input_image and not existing['input_image']:
updates.append("input_image = ?")
params.append(input_image)
if output_excel and not existing['output_excel']:
updates.append("output_excel = ?")
params.append(output_excel)
if result_purchase and not existing['result_purchase']:
updates.append("result_purchase = ?")
params.append(result_purchase)
if status:
updates.append("status = ?")
params.append(status)
updates.append("updated_at = ?")
params.append(now)
params.append(existing['id'])
conn.execute(
f"UPDATE file_relations SET {', '.join(updates)} WHERE id = ?",
params,
)
else:
conn.execute(
"INSERT INTO file_relations (input_image, output_excel, result_purchase, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(input_image, output_excel, result_purchase, status, now, now),
)
conn.commit()
finally:
conn.close()
def query_file_relations(view: str = None, status: str = None,
page: int = 1, page_size: int = 50) -> tuple[list[dict], int]:
"""Query file relations with optional view filter and pagination.
view='orders': only rows with result_purchase, sorted by result_purchase
view='tables': only rows with output_excel, sorted by output_excel
view='images': only rows with input_image, sorted by input_image
view=None: all rows
Returns (items, total).
"""
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
try:
clauses = []
params = []
if view == 'orders':
clauses.append("result_purchase IS NOT NULL")
order_by = "result_purchase DESC"
elif view == 'tables':
clauses.append("output_excel IS NOT NULL")
order_by = "output_excel DESC"
elif view == 'images':
clauses.append("input_image IS NOT NULL")
order_by = "input_image DESC"
else:
order_by = "id DESC"
if status:
clauses.append("status = ?")
params.append(status)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
# Count
row = conn.execute(
f"SELECT COUNT(*) as cnt FROM file_relations{where}", params
).fetchone()
total = row[0] if row else 0
# Page
offset = (page - 1) * page_size
params.extend([page_size, offset])
rows = conn.execute(
f"SELECT * FROM file_relations{where} ORDER BY {order_by} LIMIT ? OFFSET ?",
params,
).fetchall()
items = []
project_root = Path(__file__).resolve().parent.parent.parent.parent
for r in rows:
d = dict(r)
# Check file existence
if d.get('input_image'):
d['input_exists'] = (project_root / 'data' / 'input' / d['input_image']).exists()
else:
d['input_exists'] = False
if d.get('output_excel'):
d['output_exists'] = (project_root / 'data' / 'output' / d['output_excel']).exists()
else:
d['output_exists'] = False
if d.get('result_purchase'):
d['result_exists'] = (project_root / 'data' / 'result' / d['result_purchase']).exists()
else:
d['result_exists'] = False
items.append(d)
return items, total
finally:
conn.close()
def delete_file_relations(ids: list[int]):
"""Delete file relation records by IDs."""
if not ids:
return
conn = sqlite3.connect(_db_path)
try:
placeholders = ','.join('?' * len(ids))
conn.execute(f"DELETE FROM file_relations WHERE id IN ({placeholders})", ids)
conn.commit()
finally:
conn.close()
def sync_file_relations():
"""Scan input/output/result directories and rebuild file_relations table.
Matches files by stem:
- input: {stem}.jpg/.png/.bmp
- output: {stem}.xlsx or {stem}.xls
- result: 采购单_{stem}.xls
"""
project_root = Path(__file__).resolve().parent.parent.parent.parent
input_dir = project_root / 'data' / 'input'
output_dir = project_root / 'data' / 'output'
result_dir = project_root / 'data' / 'result'
image_exts = {'.jpg', '.jpeg', '.png', '.bmp'}
excel_exts = {'.xls', '.xlsx'}
# Collect files by stem
input_files = {} # stem -> filename
if input_dir.exists():
for f in input_dir.iterdir():
if f.is_file() and f.suffix.lower() in image_exts:
input_files[f.stem] = f.name
output_files = {}
if output_dir.exists():
for f in output_dir.iterdir():
if f.is_file() and f.suffix.lower() in excel_exts:
output_files[f.stem] = f.name
result_files = {}
if result_dir.exists():
for f in result_dir.iterdir():
if f.is_file() and f.suffix.lower() in excel_exts:
name = f.name
# Strip 采购单_ prefix for matching
if name.startswith('采购单_'):
stem = name[len('采购单_'):-len(f.suffix)]
elif name.startswith('合并采购单_'):
continue # Skip merged files
else:
stem = f.stem
result_files[stem] = name
# Build relations
all_stems = set(input_files.keys()) | set(output_files.keys()) | set(result_files.keys())
now = datetime.now().isoformat()
conn = sqlite3.connect(_db_path)
try:
# Clear existing and rebuild
conn.execute("DELETE FROM file_relations")
for stem in sorted(all_stems):
inp = input_files.get(stem)
out = output_files.get(stem)
res = result_files.get(stem)
if res:
status = 'done'
elif out:
status = 'ocr_done'
else:
status = 'pending'
conn.execute(
"INSERT INTO file_relations (input_image, output_excel, result_purchase, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(inp, out, res, status, now, now),
)
conn.commit()
finally:
conn.close()
def query_file_relations_stats() -> dict:
"""Get detailed file statistics for Dashboard.
Returns dict with:
- input_images: count of image files in input/
- output_excel: count of excel files in output/
- unprocessed_images: images without corresponding output
- unprocessed_excel: excel without corresponding result
- completed_results: purchase order files in result/
- total_processed: relations with status done/merged
"""
project_root = Path(__file__).resolve().parent.parent.parent.parent
input_dir = project_root / 'data' / 'input'
output_dir = project_root / 'data' / 'output'
result_dir = project_root / 'data' / 'result'
image_exts = {'.jpg', '.jpeg', '.png', '.bmp'}
excel_exts = {'.xls', '.xlsx'}
# Count files
input_images = 0
input_stems = set()
if input_dir.exists():
for f in input_dir.iterdir():
if f.is_file() and f.suffix.lower() in image_exts:
input_images += 1
input_stems.add(f.stem)
output_excel = 0
output_stems = set()
if output_dir.exists():
for f in output_dir.iterdir():
if f.is_file() and f.suffix.lower() in excel_exts:
output_excel += 1
output_stems.add(f.stem)
completed_results = 0
result_stems = set()
if result_dir.exists():
for f in result_dir.iterdir():
if f.is_file() and f.suffix.lower() in excel_exts:
if f.name.startswith('采购单_'):
completed_results += 1
stem = f.name[len('采购单_'):-len(f.suffix)]
result_stems.add(stem)
unprocessed_images = len(input_stems - output_stems)
unprocessed_excel = len(output_stems - result_stems)
# Count from relations table
conn = sqlite3.connect(_db_path)
try:
row = conn.execute(
"SELECT COUNT(*) FROM file_relations WHERE status IN ('done', 'merged')"
).fetchone()
total_processed = row[0] if row else 0
finally:
conn.close()
return {
'input_images': input_images,
'output_excel': output_excel,
'unprocessed_images': unprocessed_images,
'unprocessed_excel': unprocessed_excel,
'completed_results': completed_results,
'total_processed': total_processed,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+24 -9
View File
@@ -47,10 +47,25 @@ class TaskManager:
self._tasks: Dict[str, Task] = {} self._tasks: Dict[str, Task] = {}
self._connections: Dict[str, Set] = {} self._connections: Dict[str, Set] = {}
self._db = None # type: ignore self._db = None # type: ignore
self._loop = None # captured event loop
def set_db_pool(self, db_pool): def set_db_pool(self, db_pool):
"""Set the DBPool reference for database persistence.""" """Set the DBPool reference for database persistence."""
self._db = db_pool self._db = db_pool
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
pass
def _schedule(self, coro):
"""Schedule a coroutine from either async or thread context."""
try:
loop = asyncio.get_running_loop()
asyncio.ensure_future(coro, loop=loop)
except RuntimeError:
# No running loop — we're in a thread; schedule onto the main loop
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(coro, self._loop)
def create_task(self, name: str) -> Task: def create_task(self, name: str) -> Task:
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
@@ -58,7 +73,7 @@ class TaskManager:
self._tasks[task_id] = task self._tasks[task_id] = task
self._connections[task_id] = set() self._connections[task_id] = set()
if self._db: if self._db:
asyncio.create_task( self._schedule(
self._db.execute_write(insert_task, task_id, name, TaskStatus.PENDING.value) self._db.execute_write(insert_task, task_id, name, TaskStatus.PENDING.value)
) )
return task return task
@@ -76,13 +91,13 @@ class TaskManager:
task.progress = progress task.progress = progress
task.message = message task.message = message
if self._db: if self._db:
asyncio.create_task( self._schedule(
self._db.execute_write( self._db.execute_write(
update_task, task_id, update_task, task_id,
status=task.status.value, progress=progress, message=message, status=task.status.value, progress=progress, message=message,
) )
) )
asyncio.create_task(self._broadcast(task_id)) self._schedule(self._broadcast(task_id))
def add_log(self, task_id: str, line: str): def add_log(self, task_id: str, line: str):
task = self._tasks.get(task_id) task = self._tasks.get(task_id)
@@ -90,13 +105,13 @@ class TaskManager:
return return
task.log_lines.append(line) task.log_lines.append(line)
if self._db: if self._db:
asyncio.create_task( self._schedule(
self._db.execute_write( self._db.execute_write(
update_task, task_id, update_task, task_id,
log_lines=json.dumps(task.log_lines[-200:]), log_lines=json.dumps(task.log_lines[-200:]),
) )
) )
asyncio.create_task(self._broadcast(task_id)) self._schedule(self._broadcast(task_id))
def set_completed(self, task_id: str, result_files: List[str] = None, message: str = ""): def set_completed(self, task_id: str, result_files: List[str] = None, message: str = ""):
task = self._tasks.get(task_id) task = self._tasks.get(task_id)
@@ -109,7 +124,7 @@ class TaskManager:
task.result_files = result_files task.result_files = result_files
now = datetime.now().isoformat() now = datetime.now().isoformat()
if self._db: if self._db:
asyncio.create_task( self._schedule(
self._db.execute_write( self._db.execute_write(
update_task, task_id, update_task, task_id,
status=TaskStatus.COMPLETED.value, progress=100, status=TaskStatus.COMPLETED.value, progress=100,
@@ -118,7 +133,7 @@ class TaskManager:
completed_at=now, completed_at=now,
) )
) )
asyncio.create_task(self._broadcast(task_id)) self._schedule(self._broadcast(task_id))
def set_failed(self, task_id: str, error: str): def set_failed(self, task_id: str, error: str):
task = self._tasks.get(task_id) task = self._tasks.get(task_id)
@@ -129,14 +144,14 @@ class TaskManager:
task.message = f"处理失败: {error}" task.message = f"处理失败: {error}"
now = datetime.now().isoformat() now = datetime.now().isoformat()
if self._db: if self._db:
asyncio.create_task( self._schedule(
self._db.execute_write( self._db.execute_write(
update_task, task_id, update_task, task_id,
status=TaskStatus.FAILED.value, error=error, status=TaskStatus.FAILED.value, error=error,
message=task.message, completed_at=now, message=task.message, completed_at=now,
) )
) )
asyncio.create_task(self._broadcast(task_id)) self._schedule(self._broadcast(task_id))
def subscribe(self, task_id: str, websocket): def subscribe(self, task_id: str, websocket):
if task_id in self._connections: if task_id in self._connections:
+19
View File
@@ -50,6 +50,25 @@ const router = createRouter({
name: 'Logs', name: 'Logs',
component: () => import('../views/Logs.vue'), component: () => import('../views/Logs.vue'),
}, },
{
path: 'files',
redirect: '/files/orders',
},
{
path: 'files/orders',
name: 'FilesOrders',
component: () => import('../views/files/Orders.vue'),
},
{
path: 'files/tables',
name: 'FilesTables',
component: () => import('../views/files/Tables.vue'),
},
{
path: 'files/images',
name: 'FilesImages',
component: () => import('../views/files/Images.vue'),
},
], ],
}, },
], ],
+279 -69
View File
@@ -1,53 +1,59 @@
/* ═══════════════════════════════════════════ /* ═══════════════════════════════════════════
益选 OCR — Industrial Command Center Theme 益选 OCR — shadcn/ui Theme
Clean · Minimal · Zinc palette
═══════════════════════════════════════════ */ ═══════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,500;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root { :root {
/* Core palette */ /* ── Backgrounds ── */
--bg-dark: #0f1117; --bg-page: #fafafa;
--bg-sidebar: #161922;
--bg-card: #ffffff; --bg-card: #ffffff;
--bg-page: #f0f2f5; --bg-sidebar: #09090b;
--bg-hover: #f7f8fa; --bg-hover: #f4f4f5;
--bg-dark: #09090b;
/* Amber accent system */ /* ── Semantic colors (shadcn zinc) ── */
--amber-50: #fff8e1; --primary: #18181b;
--amber-100: #ffecb3; --primary-hover: #27272a;
--amber-400: #ffca28; --primary-active: #09090b;
--amber-500: #ffc107; --primary-light: #f5f5f5;
--amber-600: #ffb300; --success: #22c55e;
--success-light: #f0fdf4;
/* Semantic */ --warning: #f97316;
--primary: #2563eb; --warning-light: #fff7ed;
--primary-hover: #1d4ed8;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444; --danger: #ef4444;
--info: #6366f1; --danger-light: #fef2f2;
--info: #18181b;
/* Text */ /* ── Text ── */
--text-primary: #111827; --text-primary: #18181b;
--text-secondary: #6b7280; --text-secondary: #525252;
--text-muted: #9ca3af; --text-muted: #a1a1aa;
--text-inverse: #e5e7eb; --text-inverse: #ffffff;
--text-sidebar: #94a3b8; --text-sidebar: #a1a1aa;
--text-sidebar-active: #ffffff; --text-sidebar-active: #fafafa;
/* Borders & shadows */ /* ── Borders ── */
--border-light: #e5e7eb; --border-light: #e4e4e7;
--border-subtle: #f3f4f6; --border-subtle: #f4f4f5;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04); --border-focus: #18181b;
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.08);
--shadow-glow: 0 0 20px rgba(255,193,7,0.15);
/* Typography */ /* ── Shadows ── */
--font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05);
--shadow-md: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
/* ── Radius ── */
--radius: 10px;
--radius-lg: 14px;
--radius-sm: 6px;
/* ── Typography ── */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Transitions */ /* ── Transitions ── */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@@ -66,62 +72,268 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Element Plus overrides */ /* ═══════════════════════════════════════════
Element Plus — shadcn Overrides
═══════════════════════════════════════════ */
/* ── Card ── */
.el-card { .el-card {
border: 1px solid var(--border-light) !important; border: 1px solid var(--border-light) !important;
border-radius: 12px !important; border-radius: var(--radius) !important;
box-shadow: var(--shadow-sm) !important; box-shadow: var(--shadow-sm) !important;
transition: box-shadow 0.2s var(--ease-out) !important; background: var(--bg-card) !important;
transition: box-shadow 0.2s, border-color 0.2s !important;
} }
.el-card:hover { .el-card:hover {
box-shadow: var(--shadow-md) !important; box-shadow: var(--shadow-md) !important;
border-color: #d4d4d8 !important;
}
/* ── Buttons ── */
.el-button {
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
letter-spacing: 0;
transition: all 0.15s ease !important;
box-shadow: none !important;
}
.el-button--default {
border: 1px solid #e4e4e7 !important;
box-shadow: none !important;
background: #ffffff !important;
color: #18181b !important;
}
.el-button--default:hover {
background: #f4f4f5 !important;
border-color: #d4d4d8 !important;
color: #18181b !important;
}
.el-button--default:active {
background: #e4e4e7 !important;
} }
.el-button--primary { .el-button--primary {
background: var(--primary) !important; background: #18181b !important;
border-color: var(--primary) !important; border: 1px solid #18181b !important;
font-weight: 500; box-shadow: none !important;
border-radius: 8px; color: #fff !important;
transition: all 0.2s var(--ease-out);
} }
.el-button--primary:hover { .el-button--primary:hover {
background: var(--primary-hover) !important; background: #27272a !important;
transform: translateY(-1px); border-color: #27272a !important;
box-shadow: 0 4px 12px rgba(37,99,235,0.3);
} }
.el-button--primary:active {
background: #09090b !important;
}
.el-button--danger {
background: #ef4444 !important;
border: 1px solid #ef4444 !important;
box-shadow: none !important;
color: #fff !important;
}
.el-button--danger:hover {
background: #dc2626 !important;
border-color: #dc2626 !important;
}
.el-button--warning {
border: 1px solid #f97316 !important;
box-shadow: none !important;
}
.el-button--success {
border: 1px solid #22c55e !important;
box-shadow: none !important;
}
/* Link buttons — no border/shadow */
.el-button--primary.is-link,
.el-button--danger.is-link,
.el-button--default.is-link {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
.el-button--primary.is-link:hover {
background: transparent !important;
}
/* Small buttons */
.el-button--small {
border-radius: var(--radius-sm) !important;
}
/* ── Input / Select ── */
.el-input__wrapper,
.el-select .el-input__wrapper {
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius-sm) !important;
box-shadow: none !important;
background: #ffffff !important;
transition: border-color 0.15s ease !important;
}
.el-input__wrapper:hover,
.el-select .el-input__wrapper:hover {
border-color: #a1a1aa !important;
}
.el-input__wrapper.is-focus,
.el-select .el-input__wrapper.is-focus {
border-color: #18181b !important;
box-shadow: 0 0 0 1px #18181b !important;
}
/* ── Table ── */
.el-table { .el-table {
--el-table-border-color: var(--border-light); --el-table-border-color: #e4e4e7;
--el-table-header-bg-color: #fafbfc; --el-table-header-bg-color: #fafafa;
border-radius: 8px; border: 1px solid #e4e4e7 !important;
border-radius: var(--radius) !important;
overflow: hidden; overflow: hidden;
box-shadow: none !important;
} }
.el-table th.el-table__cell { .el-table th.el-table__cell {
font-weight: 600; font-weight: 600 !important;
font-size: 12px; font-size: 12px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--text-secondary); color: #525252 !important;
background: #fafafa !important;
} }
.el-table td.el-table__cell {
border-bottom: 1px solid #f4f4f5 !important;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: #fafafa !important;
}
/* ── Dialog ── */
.el-dialog {
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-lg) !important;
overflow: hidden;
}
.el-dialog__header {
border-bottom: 1px solid #e4e4e7;
padding: 16px 20px !important;
}
.el-dialog__footer {
border-top: 1px solid #e4e4e7;
padding: 12px 20px !important;
}
/* ── Tag ── */
.el-tag {
border: 1px solid transparent !important;
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
font-size: 11px;
letter-spacing: 0.2px;
}
.el-tag--success {
background: #f0fdf4 !important;
color: #16a34a !important;
border-color: #bbf7d0 !important;
}
.el-tag--warning {
background: #fff7ed !important;
color: #ea580c !important;
border-color: #fed7aa !important;
}
.el-tag--danger {
background: #fef2f2 !important;
color: #dc2626 !important;
border-color: #fecaca !important;
}
.el-tag--info {
background: #f5f5f5 !important;
color: #525252 !important;
border-color: #e4e4e7 !important;
}
/* ── Progress ── */
.el-progress-bar__outer { .el-progress-bar__outer {
border-radius: 6px; border: none !important;
border-radius: 999px !important;
box-shadow: none !important;
background: #f4f4f5 !important;
height: 8px !important;
} }
.el-progress-bar__inner { .el-progress-bar__inner {
border-radius: 6px; border-radius: 999px !important;
transition: width 0.4s var(--ease-out); transition: width 0.4s ease;
border: none !important;
} }
.el-tag { /* ── Dropdown / Popover ── */
border-radius: 6px; .el-dropdown-menu,
.el-popover.el-popper {
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius-sm) !important;
box-shadow: var(--shadow-lg) !important;
}
/* ── Menu (sidebar sub-menu) ── */
.el-menu {
border-right: none !important;
}
.el-sub-menu .el-menu {
background: rgba(255,255,255,0.03) !important;
}
.el-sub-menu .el-menu-item {
padding-left: 52px !important;
font-size: 13px;
}
.el-menu-item.is-active {
color: var(--primary) !important;
}
/* ── Breadcrumb ── */
.el-breadcrumb__inner {
font-weight: 500; font-weight: 500;
} }
/* Scrollbar */ /* ── Message / Notification ── */
.el-message {
border: 1px solid #e4e4e7 !important;
border-radius: var(--radius-sm) !important;
box-shadow: var(--shadow-lg) !important;
}
/* ── Tooltip ── */
.el-tooltip__popper.is-dark {
border: none !important;
border-radius: var(--radius-sm) !important;
}
/* ═══════════════════════════════════════════
Scrollbar
═══════════════════════════════════════════ */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -132,19 +344,22 @@ body {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #d1d5db; background: #d4d4d8;
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #9ca3af; background: #a1a1aa;
} }
/* Animations */ /* ═══════════════════════════════════════════
Animations
═══════════════════════════════════════════ */
@keyframes fadeInUp { @keyframes fadeInUp {
from { from {
opacity: 0; opacity: 0;
transform: translateY(12px); transform: translateY(8px);
} }
to { to {
opacity: 1; opacity: 1;
@@ -157,13 +372,8 @@ body {
100% { background-position: 200% 0; } 100% { background-position: 200% 0; }
} }
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(255,193,7,0); }
50% { box-shadow: 0 0 16px 4px rgba(255,193,7,0.15); }
}
.animate-in { .animate-in {
animation: fadeInUp 0.4s var(--ease-out) both; animation: fadeInUp 0.3s var(--ease-out) both;
} }
.animate-in-delay-1 { animation-delay: 0.05s; } .animate-in-delay-1 { animation-delay: 0.05s; }
File diff suppressed because it is too large Load Diff
+124 -56
View File
@@ -16,23 +16,57 @@
</div> </div>
<!-- Navigation --> <!-- Navigation -->
<nav class="sidebar-nav"> <el-menu
<router-link :default-active="route.path"
v-for="item in navItems" :default-openeds="filesMenuOpen"
:key="item.path" :collapse="isCollapse"
:to="item.path" mode="vertical"
class="nav-item" background-color="transparent"
:class="{ active: route.path === item.path }" text-color="var(--text-sidebar)"
active-text-color="#fafafa"
class="sidebar-nav"
router
> >
<el-icon :size="20"><component :is="item.icon" /></el-icon> <el-menu-item index="/">
<transition name="fade"> <el-icon><HomeFilled /></el-icon>
<span v-if="!isCollapse" class="nav-label">{{ item.label }}</span> <template #title>处理中心</template>
</transition> </el-menu-item>
<transition name="fade">
<span v-if="!isCollapse && item.badge" class="nav-badge">{{ item.badge }}</span> <el-sub-menu index="/files">
</transition> <template #title>
</router-link> <el-icon><FolderOpened /></el-icon>
</nav> <span>文件处理</span>
</template>
<el-menu-item index="/files/orders">采购单</el-menu-item>
<el-menu-item index="/files/tables">表格处理</el-menu-item>
<el-menu-item index="/files/images">图片处理</el-menu-item>
</el-sub-menu>
<el-menu-item index="/tasks">
<el-icon><Timer /></el-icon>
<template #title>任务历史</template>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Notebook /></el-icon>
<template #title>日志中心</template>
</el-menu-item>
<el-menu-item index="/memory">
<el-icon><Memo /></el-icon>
<template #title>记忆库</template>
</el-menu-item>
<el-menu-item index="/barcodes">
<el-icon><Connection /></el-icon>
<template #title>条码映射</template>
</el-menu-item>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<template #title>系统配置</template>
</el-menu-item>
<el-menu-item index="/sync">
<el-icon><Cloudy /></el-icon>
<template #title>云端同步</template>
</el-menu-item>
</el-menu>
<!-- Collapse toggle --> <!-- Collapse toggle -->
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -105,7 +139,7 @@ import { ref, computed, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook, HomeFilled, Memo, Connection, Setting, Cloudy, Timer, Notebook, FolderOpened,
ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight ArrowDown, Lock, SwitchButton, DArrowLeft, DArrowRight
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -129,9 +163,23 @@ const navItems: { path: string; label: string; icon: any; badge?: string }[] = [
{ path: '/sync', label: '云端同步', icon: Cloudy }, { path: '/sync', label: '云端同步', icon: Cloudy },
] ]
const filesMenuOpen = ['/files']
const pageTitles: Record<string, string> = {
'/': '处理中心',
'/files/orders': '采购单',
'/files/tables': '表格处理',
'/files/images': '图片处理',
'/tasks': '任务历史',
'/logs': '日志中心',
'/memory': '记忆库',
'/barcodes': '条码映射',
'/config': '系统配置',
'/sync': '云端同步',
}
const pageTitle = computed(() => { const pageTitle = computed(() => {
const item = navItems.find(n => n.path === route.path) return pageTitles[route.path] || '处理中心'
return item?.label || '处理中心'
}) })
function handleCommand(cmd: string) { function handleCommand(cmd: string) {
@@ -164,12 +212,12 @@ async function changePassword() {
/* ── Sidebar ── */ /* ── Sidebar ── */
.sidebar { .sidebar {
background: var(--bg-sidebar); background: #09090b;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: width 0.3s var(--ease-out); transition: width 0.3s var(--ease-out);
overflow: hidden; overflow: hidden;
border-right: 1px solid rgba(255,255,255,0.06); border-right: 1px solid #18181b;
} }
.sidebar-logo { .sidebar-logo {
@@ -188,9 +236,9 @@ async function changePassword() {
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 10px; border-radius: 10px;
background: linear-gradient(135deg, rgba(255,193,7,0.15), rgba(255,193,7,0.05)); background: #18181b;
border: 1px solid rgba(255,193,7,0.2); border: 1px solid #27272a;
color: var(--amber-400); color: #fafafa;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -202,60 +250,80 @@ async function changePassword() {
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
/* ── Nav items ── */ /* ── Nav items (el-menu) ── */
.sidebar-nav { .sidebar-nav {
flex: 1; flex: 1;
padding: 0 12px; padding: 0 12px;
display: flex; border-right: none;
flex-direction: column; overflow: hidden;
gap: 2px;
} }
.nav-item { .sidebar-nav :deep(.el-menu-item),
display: flex; .sidebar-nav :deep(.el-sub-menu__title) {
align-items: center; height: 42px;
gap: 12px; line-height: 42px;
padding: 10px 12px;
border-radius: 10px; border-radius: 10px;
color: var(--text-sidebar); margin-bottom: 2px;
text-decoration: none;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
transition: all 0.2s var(--ease-out); transition: all 0.2s var(--ease-out);
}
.sidebar-nav :deep(.el-menu-item:hover),
.sidebar-nav :deep(.el-sub-menu__title:hover) {
background: rgba(255,255,255,0.06) !important;
}
.sidebar-nav :deep(.el-menu-item.is-active) {
background: rgba(255,255,255,0.1) !important;
color: #fafafa !important;
position: relative; position: relative;
} }
.nav-item:hover { .sidebar-nav :deep(.el-menu-item.is-active)::before {
background: rgba(255,255,255,0.06);
color: var(--text-sidebar-active);
}
.nav-item.active {
background: rgba(255,193,7,0.1);
color: var(--amber-400);
}
.nav-item.active::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 0; left: 0;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 3px; width: 3px;
height: 20px; height: 18px;
border-radius: 0 3px 3px 0; border-radius: 0 3px 3px 0;
background: var(--amber-400); background: #fafafa;
} }
.nav-label { .sidebar-nav :deep(.el-sub-menu .el-menu-item) {
white-space: nowrap; padding-left: 52px !important;
height: 38px;
line-height: 38px;
font-size: 13px;
}
.sidebar-nav :deep(.el-sub-menu .el-menu) {
background: rgba(255,255,255,0.03) !important;
border-radius: 8px;
margin: 2px 0;
}
.sidebar-nav :deep(.el-sub-menu__icon-arrow) {
color: var(--text-sidebar);
}
.sidebar-nav :deep(.el-menu--collapse .el-sub-menu__title span),
.sidebar-nav :deep(.el-menu--collapse .el-sub-menu__title .el-sub-menu__icon-arrow) {
display: none;
}
.sidebar-nav :deep(.el-menu--collapse .el-menu-item .el-icon),
.sidebar-nav :deep(.el-menu--collapse .el-sub-menu__title .el-icon) {
margin: 0;
} }
.nav-badge { .nav-badge {
margin-left: auto; margin-left: auto;
font-size: 11px; font-size: 11px;
background: var(--amber-400); background: var(--primary);
color: #000; color: #fff;
padding: 1px 6px; padding: 1px 6px;
border-radius: 8px; border-radius: 8px;
font-weight: 600; font-weight: 600;
@@ -264,7 +332,7 @@ async function changePassword() {
/* ── Footer ── */ /* ── Footer ── */
.sidebar-footer { .sidebar-footer {
padding: 12px; padding: 12px;
border-top: 1px solid rgba(255,255,255,0.06); border-top: 1px solid #18181b;
} }
.collapse-btn { .collapse-btn {
@@ -301,8 +369,8 @@ async function changePassword() {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 28px; padding: 0 28px;
background: #fff; background: #ffffff;
border-bottom: 1px solid var(--border-light); border-bottom: 1px solid #e4e4e7;
flex-shrink: 0; flex-shrink: 0;
z-index: 10; z-index: 10;
} }
@@ -332,7 +400,7 @@ async function changePassword() {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 8px; border-radius: 8px;
background: linear-gradient(135deg, var(--primary), #7c3aed); background: #18181b;
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
+49 -64
View File
@@ -106,70 +106,47 @@ async function handleLogin() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--bg-dark); background: #fafafa;
position: relative; position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 48px 48px;
}
.bg-glow {
position: absolute;
top: 30%;
left: 50%;
width: 600px;
height: 600px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(255,193,7,0.08) 0%, transparent 70%);
pointer-events: none;
} }
.login-container { .login-container {
position: relative; position: relative;
width: 400px; width: 380px;
padding: 48px 40px; padding: 40px 36px;
background: rgba(22, 25, 34, 0.8); background: #ffffff;
backdrop-filter: blur(20px); border: 1px solid #e4e4e7;
border: 1px solid rgba(255,255,255,0.08); border-radius: 14px;
border-radius: 20px; box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1);
box-shadow: 0 24px 48px rgba(0,0,0,0.4);
} }
.brand { .brand {
text-align: center; text-align: center;
margin-bottom: 36px; margin-bottom: 32px;
} }
.brand-icon { .brand-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 64px; width: 56px;
height: 64px; height: 56px;
border-radius: 16px; border-radius: 14px;
background: linear-gradient(135deg, rgba(255,193,7,0.15), rgba(255,193,7,0.05)); background: #f4f4f5;
border: 1px solid rgba(255,193,7,0.2); color: var(--text-primary);
color: var(--amber-400);
margin-bottom: 16px; margin-bottom: 16px;
} }
.brand h1 { .brand h1 {
font-size: 24px; font-size: 22px;
font-weight: 700; font-weight: 700;
color: #ffffff; color: var(--text-primary);
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
.brand p { .brand p {
font-size: 14px; font-size: 14px;
color: var(--text-sidebar); color: var(--text-muted);
margin-top: 4px; margin-top: 4px;
} }
@@ -178,48 +155,57 @@ async function handleLogin() {
} }
.login-form :deep(.el-input__wrapper) { .login-form :deep(.el-input__wrapper) {
background: rgba(255,255,255,0.06); border: 1px solid #e4e4e7 !important;
border: 1px solid rgba(255,255,255,0.1); border-radius: 6px !important;
border-radius: 10px; box-shadow: none !important;
box-shadow: none; background: #ffffff !important;
transition: all 0.2s; transition: border-color 0.15s ease;
}
.login-form :deep(.el-input__wrapper:hover) {
border-color: #a1a1aa !important;
} }
.login-form :deep(.el-input__wrapper:hover),
.login-form :deep(.el-input__wrapper.is-focus) { .login-form :deep(.el-input__wrapper.is-focus) {
border-color: var(--amber-400); border-color: #18181b !important;
background: rgba(255,255,255,0.08); box-shadow: 0 0 0 1px #18181b !important;
box-shadow: 0 0 0 3px rgba(255,193,7,0.1);
} }
.login-form :deep(.el-input__inner) { .login-form :deep(.el-input__inner) {
color: #ffffff; color: #18181b;
font-size: 15px; font-size: 14px;
} }
.login-form :deep(.el-input__inner::placeholder) { .login-form :deep(.el-input__inner::placeholder) {
color: rgba(255,255,255,0.35); color: #a1a1aa;
} }
.login-form :deep(.el-input__prefix .el-icon) { .login-form :deep(.el-input__prefix .el-icon) {
color: rgba(255,255,255,0.4); color: #a1a1aa;
} }
.login-btn { .login-btn {
width: 100%; width: 100%;
height: 48px; height: 44px;
font-size: 16px; font-size: 14px;
font-weight: 600; font-weight: 600;
border-radius: 10px !important; letter-spacing: 0;
background: linear-gradient(135deg, var(--amber-500), var(--amber-600)) !important; text-transform: none;
border: none !important; border-radius: 6px !important;
color: #000 !important; background: #18181b !important;
transition: all 0.25s var(--ease-out); border: 1px solid #18181b !important;
color: #fff !important;
box-shadow: none !important;
transition: all 0.15s ease;
} }
.login-btn:hover { .login-btn:hover {
transform: translateY(-2px); background: #27272a !important;
box-shadow: 0 8px 24px rgba(255,193,7,0.3) !important; border-color: #27272a !important;
}
.login-btn:active {
background: #09090b !important;
} }
.hint { .hint {
@@ -228,14 +214,13 @@ async function handleLogin() {
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
font-size: 12px; font-size: 12px;
color: rgba(255,255,255,0.3); color: var(--text-muted);
} }
.hint-dot { .hint-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: var(--amber-400); background: #d4d4d8;
opacity: 0.6;
} }
</style> </style>
+249
View File
@@ -0,0 +1,249 @@
<template>
<div class="file-page animate-in">
<div class="page-header">
<div class="header-left">
<h3>图片处理</h3>
<el-tag type="info" size="small"> {{ total }} </el-tag>
</div>
<div class="header-actions">
<el-button type="primary" :disabled="!selected.length" @click="batchPipeline">
批量生成采购单 ({{ selected.length }})
</el-button>
<el-button :disabled="!selected.length" @click="batchOcr">
批量OCR
</el-button>
<el-button :disabled="!selected.length" @click="batchDownload">
批量下载
</el-button>
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
批量删除
</el-button>
</div>
</div>
<el-table
:data="items"
v-loading="loading"
@selection-change="onSelect"
stripe
style="width: 100%"
>
<el-table-column type="selection" width="45" />
<el-table-column label="图片文件名" min-width="200">
<template #default="{ row }">
<span class="file-name primary">{{ row.input_image || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.output_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="Excel文件" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.output_excel || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.result_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="采购单" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.result_purchase || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="pipelineFile(row)">
生成采购单
</el-button>
<el-button link type="primary" size="small" @click="ocrFile(row)">
仅OCR
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import api from '../../api'
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
return m[s] || 'info'
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize } })
items.value = res.data.items
total.value = res.data.total
} catch {}
loading.value = false
}
function onSelect(rows: any[]) { selected.value = rows }
async function pipelineFile(row: any) {
try {
const res = await api.post('/processing/pipeline-single', { filename: row.input_image })
ElMessage.success(`处理任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '处理失败')
}
}
async function ocrFile(row: any) {
try {
const res = await api.post('/processing/ocr-single', { filename: row.input_image })
ElMessage.success(`OCR任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || 'OCR失败')
}
}
async function downloadFile(row: any) {
const token = localStorage.getItem('token')
if (row.result_purchase) {
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
} else if (row.output_excel) {
window.open(`/api/files/download/output/${encodeURIComponent(row.output_excel)}?token=${token}`, '_blank')
}
}
async function deleteFile(row: any) {
try {
await ElMessageBox.confirm(`确定删除 ${row.input_image}`, '确认')
await api.delete(`/files/input/${encodeURIComponent(row.input_image)}`)
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
ElMessage.success('已删除')
loadData()
} catch {}
}
async function batchPipeline() {
try {
const filenames = selected.value.map(r => r.input_image).filter(Boolean)
const res = await api.post('/processing/pipeline', { files: filenames })
ElMessage.success(`批量处理任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '处理失败')
}
}
async function batchOcr() {
try {
const filenames = selected.value.map(r => r.input_image).filter(Boolean)
const res = await api.post('/processing/ocr-batch', { files: filenames })
ElMessage.success(`批量OCR任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || 'OCR失败')
}
}
async function batchDownload() {
const token = localStorage.getItem('token')
for (const row of selected.value) {
if (row.result_purchase) {
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
}
}
}
async function batchDelete() {
try {
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
for (const row of selected.value) {
if (row.input_image) {
await api.delete(`/files/input/${encodeURIComponent(row.input_image)}`)
}
if (row.id) {
await api.delete('/files/relations', { data: { ids: [row.id] } })
}
}
ElMessage.success('批量删除完成')
loadData()
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.file-page {
max-width: 1400px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
}
.file-name.primary {
font-weight: 600;
color: var(--text-primary);
}
.file-name.secondary {
color: var(--text-muted);
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="file-page animate-in">
<div class="page-header">
<div class="header-left">
<h3>采购单管理</h3>
<el-tag type="info" size="small"> {{ total }} </el-tag>
</div>
<div class="header-actions">
<el-button type="primary" :disabled="!selected.length" @click="batchMerge">
合并选中 ({{ selected.length }})
</el-button>
<el-button :disabled="!selected.length" @click="batchDownload">
批量下载
</el-button>
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
批量删除
</el-button>
</div>
</div>
<el-table
:data="items"
v-loading="loading"
@selection-change="onSelect"
stripe
style="width: 100%"
>
<el-table-column type="selection" width="45" />
<el-table-column label="采购单文件名" min-width="200">
<template #default="{ row }">
<span class="file-name primary">{{ row.result_purchase || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.output_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="Excel处理文件" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.output_excel || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.input_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="Input图片" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.input_image || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadFile(row)">
下载
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import api from '../../api'
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
return m[s] || 'info'
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize } })
items.value = res.data.items
total.value = res.data.total
} catch {}
loading.value = false
}
function onSelect(rows: any[]) { selected.value = rows }
async function downloadFile(row: any) {
const token = localStorage.getItem('token')
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
}
async function deleteFile(row: any) {
try {
await ElMessageBox.confirm(`确定删除 ${row.result_purchase}`, '确认')
await api.delete(`/files/result/${encodeURIComponent(row.result_purchase)}`)
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
ElMessage.success('已删除')
loadData()
} catch {}
}
async function batchMerge() {
if (!selected.value.length) return
try {
const filenames = selected.value.map(r => r.result_purchase).filter(Boolean)
const res = await api.post('/processing/merge-batch', { filenames })
ElMessage.success(`合并任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '合并失败')
}
}
async function batchDownload() {
const token = localStorage.getItem('token')
for (const row of selected.value) {
if (row.result_purchase) {
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
}
}
}
async function batchDelete() {
try {
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
for (const row of selected.value) {
if (row.result_purchase) {
await api.delete(`/files/result/${encodeURIComponent(row.result_purchase)}`)
}
if (row.id) {
await api.delete('/files/relations', { data: { ids: [row.id] } })
}
}
ElMessage.success('批量删除完成')
loadData()
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.file-page {
max-width: 1400px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
}
.file-name.primary {
font-weight: 600;
color: var(--text-primary);
}
.file-name.secondary {
color: var(--text-muted);
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<template>
<div class="file-page animate-in">
<div class="page-header">
<div class="header-left">
<h3>表格处理</h3>
<el-tag type="info" size="small"> {{ total }} </el-tag>
</div>
<div class="header-actions">
<el-button type="primary" :disabled="!selected.length" @click="batchProcess">
批量处理 ({{ selected.length }})
</el-button>
<el-button :disabled="!selected.length" @click="batchDelete">
批量删除
</el-button>
<el-button type="danger" @click="clearAll">
删除全部
</el-button>
</div>
</div>
<el-table
:data="items"
v-loading="loading"
@selection-change="onSelect"
stripe
style="width: 100%"
>
<el-table-column type="selection" width="45" />
<el-table-column label="Excel处理文件" min-width="200">
<template #default="{ row }">
<span class="file-name primary">{{ row.output_excel || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.result_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="采购单文件" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.result_purchase || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="" width="40" align="center">
<template #default="{ row }">
<el-icon :color="row.input_exists ? '#52C41A' : '#d1d5db'" :size="16">
<Right />
</el-icon>
</template>
</el-table-column>
<el-table-column label="Input图片" min-width="180">
<template #default="{ row }">
<span class="file-name secondary">{{ row.input_image || '--' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="processFile(row)">
处理
</el-button>
<el-button link type="danger" size="small" @click="deleteFile(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Right } from '@element-plus/icons-vue'
import api from '../../api'
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const selected = ref<any[]>([])
function statusType(s: string) {
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
return m[s] || 'info'
}
function statusText(s: string) {
const m: Record<string, string> = { done: '已完成', merged: '已合并', excel_done: '已处理', ocr_done: '已OCR', pending: '待处理' }
return m[s] || s
}
async function loadData() {
loading.value = true
try {
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize } })
items.value = res.data.items
total.value = res.data.total
} catch {}
loading.value = false
}
function onSelect(rows: any[]) { selected.value = rows }
async function processFile(row: any) {
try {
const res = await api.post('/processing/excel-single', { filename: row.output_excel })
ElMessage.success(`处理任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '处理失败')
}
}
async function deleteFile(row: any) {
try {
await ElMessageBox.confirm(`确定删除 ${row.output_excel}`, '确认')
await api.delete(`/files/output/${encodeURIComponent(row.output_excel)}`)
if (row.id) await api.delete('/files/relations', { data: { ids: [row.id] } })
ElMessage.success('已删除')
loadData()
} catch {}
}
async function batchProcess() {
try {
const filenames = selected.value.map(r => r.output_excel).filter(Boolean)
const res = await api.post('/processing/excel', { files: filenames })
ElMessage.success(`批量处理任务已创建: ${res.data.task_id}`)
} catch (err: any) {
ElMessage.error(err.response?.data?.detail || '处理失败')
}
}
async function batchDelete() {
try {
await ElMessageBox.confirm(`确定删除选中的 ${selected.value.length} 个文件?`, '确认')
for (const row of selected.value) {
if (row.output_excel) {
await api.delete(`/files/output/${encodeURIComponent(row.output_excel)}`)
}
if (row.id) {
await api.delete('/files/relations', { data: { ids: [row.id] } })
}
}
ElMessage.success('批量删除完成')
loadData()
} catch {}
}
async function clearAll() {
try {
await ElMessageBox.confirm('确定清空所有 Excel 处理文件?此操作不可恢复。', '确认')
await api.post('/files/clear/output')
await api.post('/files/relations/sync')
ElMessage.success('已清空')
loadData()
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.file-page {
max-width: 1400px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-left h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
}
.file-name.primary {
font-weight: 600;
color: var(--text-primary);
}
.file-name.secondary {
color: var(--text-muted);
font-size: 13px;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>