fix: sync/barcode/memory overhaul + detailed logs + preview + result tracking
- Sync: fix GiteaSync constructor + add push()/pull() methods - Barcode: two-tab layout matching GUI (mapping + special rules) - Memory: spec→specification unification, manual add, confidence/price tracking - Processing: TaskLogHandler captures detailed logs (barcode mapping, unit conversion) - Preview: fullscreen dialog for file preview (image/Excel) in Orders/Tables/Images - Detail: per-file log filtering in file pages - Tasks: result files now per-task, add copy path button - Config: reactive edited state + save_config fix - Dashboard: sync task isolation, log limit 10 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -105,23 +105,24 @@ class ConfigManager:
|
|||||||
|
|
||||||
def save_config(self) -> None:
|
def save_config(self) -> None:
|
||||||
"""保存配置到文件(API 密钥不写入文件)"""
|
"""保存配置到文件(API 密钥不写入文件)"""
|
||||||
try:
|
|
||||||
# 保存前临时清空 API 密钥,避免写入文件
|
# 保存前临时清空 API 密钥,避免写入文件
|
||||||
saved_keys = {}
|
saved_keys = {}
|
||||||
for option in ('api_key', 'secret_key'):
|
for option in ('api_key', 'secret_key'):
|
||||||
|
try:
|
||||||
saved_keys[option] = self.config.get('API', option, fallback='')
|
saved_keys[option] = self.config.get('API', option, fallback='')
|
||||||
|
except Exception:
|
||||||
|
saved_keys[option] = ''
|
||||||
self.config.set('API', option, '')
|
self.config.set('API', option, '')
|
||||||
|
|
||||||
|
try:
|
||||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||||
self.config.write(f)
|
self.config.write(f)
|
||||||
|
|
||||||
# 恢复内存中的值
|
|
||||||
for option, val in saved_keys.items():
|
|
||||||
self.config.set('API', option, val)
|
|
||||||
|
|
||||||
logger.info(f"配置已保存到: {self.config_file}")
|
logger.info(f"配置已保存到: {self.config_file}")
|
||||||
except Exception as e:
|
finally:
|
||||||
logger.error(f"保存配置文件时出错: {e}")
|
# 恢复内存中的值(即使写入失败也恢复)
|
||||||
|
for option, val in saved_keys.items():
|
||||||
|
if val:
|
||||||
|
self.config.set('API', option, val)
|
||||||
|
|
||||||
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
def get(self, section: str, option: str, fallback: Any = None) -> Any:
|
||||||
"""获取配置值"""
|
"""获取配置值"""
|
||||||
|
|||||||
+304
-225
@@ -1,21 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
商品资料 SQLite 数据库 + 商品记忆库
|
商品资料 SQLite 数据库 + 商品记忆库
|
||||||
|
|
||||||
将商品资料 (条码/名称/进货价/单位/规格) 存储在 SQLite 中,
|
|
||||||
支持从 Excel 自动导入、按条码快速查询、以及从 OCR 处理结果中学习。
|
|
||||||
|
|
||||||
记忆库功能:
|
记忆库功能:
|
||||||
- 处理完每单后自动学习商品数据
|
- 处理每步后自动学习商品数据(置信度+一致性加速)
|
||||||
- 下次处理时用记忆库补全 OCR 缺失/错误的字段
|
- OCR 字段缺失时用记忆库补全 (conf > 50 直接采用)
|
||||||
- 通过置信度系统控制数据质量
|
- 价格异常检测:偏差 > 2倍触发补全,偏差 > 50% 记录预警
|
||||||
- 支持云端同步
|
- 批量预加载 → 内存操作 → 批量写回,保障性能
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Tuple, Callable
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
@@ -40,26 +37,27 @@ class ProductDatabase:
|
|||||||
source TEXT DEFAULT 'template',
|
source TEXT DEFAULT 'template',
|
||||||
confidence INTEGER DEFAULT 0,
|
confidence INTEGER DEFAULT 0,
|
||||||
usage_count INTEGER DEFAULT 0,
|
usage_count INTEGER DEFAULT 0,
|
||||||
last_seen TEXT
|
last_seen TEXT,
|
||||||
|
avg_price REAL DEFAULT 0.0,
|
||||||
|
min_price REAL DEFAULT 0.0,
|
||||||
|
max_price REAL DEFAULT 0.0,
|
||||||
|
price_count INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 新增列定义(用于迁移)
|
|
||||||
_NEW_COLUMNS = {
|
_NEW_COLUMNS = {
|
||||||
'specification': "TEXT DEFAULT ''",
|
'specification': "TEXT DEFAULT ''",
|
||||||
'source': "TEXT DEFAULT 'template'",
|
'source': "TEXT DEFAULT 'template'",
|
||||||
'confidence': 'INTEGER DEFAULT 0',
|
'confidence': 'INTEGER DEFAULT 0',
|
||||||
'usage_count': 'INTEGER DEFAULT 0',
|
'usage_count': 'INTEGER DEFAULT 0',
|
||||||
'last_seen': 'TEXT',
|
'last_seen': 'TEXT',
|
||||||
|
'avg_price': 'REAL DEFAULT 0.0',
|
||||||
|
'min_price': 'REAL DEFAULT 0.0',
|
||||||
|
'max_price': 'REAL DEFAULT 0.0',
|
||||||
|
'price_count': 'INTEGER DEFAULT 0',
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, db_path: str, excel_source: str):
|
def __init__(self, db_path: str, excel_source: str):
|
||||||
"""初始化数据库,如果 SQLite 不存在则自动从 Excel 导入
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: SQLite 数据库文件路径
|
|
||||||
excel_source: 商品资料 Excel 文件路径
|
|
||||||
"""
|
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.excel_source = excel_source
|
self.excel_source = excel_source
|
||||||
self._ensure_db()
|
self._ensure_db()
|
||||||
@@ -68,16 +66,13 @@ class ProductDatabase:
|
|||||||
return sqlite3.connect(self.db_path)
|
return sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
def _ensure_db(self):
|
def _ensure_db(self):
|
||||||
"""确保数据库存在,不存在则从 Excel 导入"""
|
|
||||||
if os.path.exists(self.db_path):
|
if os.path.exists(self.db_path):
|
||||||
self._migrate_schema()
|
self._migrate_schema()
|
||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.exists(self.excel_source):
|
if not os.path.exists(self.excel_source):
|
||||||
logger.warning(f"商品资料 Excel 不存在,跳过导入: {self.excel_source}")
|
logger.warning(f"商品资料 Excel 不存在: {self.excel_source}")
|
||||||
self._create_empty_db()
|
self._create_empty_db()
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"首次运行,从 Excel 导入商品资料: {self.excel_source}")
|
logger.info(f"首次运行,从 Excel 导入商品资料: {self.excel_source}")
|
||||||
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
|
||||||
self._create_empty_db()
|
self._create_empty_db()
|
||||||
@@ -85,7 +80,6 @@ class ProductDatabase:
|
|||||||
logger.info(f"商品资料导入完成: {count} 条记录")
|
logger.info(f"商品资料导入完成: {count} 条记录")
|
||||||
|
|
||||||
def _create_empty_db(self):
|
def _create_empty_db(self):
|
||||||
"""创建空数据库"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.executescript(self.SCHEMA)
|
conn.executescript(self.SCHEMA)
|
||||||
@@ -94,52 +88,35 @@ class ProductDatabase:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def _migrate_schema(self):
|
def _migrate_schema(self):
|
||||||
"""幂等迁移:为已有数据库添加新列"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute("PRAGMA table_info(products)")
|
cursor = conn.execute("PRAGMA table_info(products)")
|
||||||
existing_cols = {row[1] for row in cursor.fetchall()}
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
for col_name, col_type in self._NEW_COLUMNS.items():
|
for col_name, col_type in self._NEW_COLUMNS.items():
|
||||||
if col_name not in existing_cols:
|
if col_name not in existing_cols:
|
||||||
conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}")
|
conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}")
|
||||||
logger.info(f"数据库迁移: 添加列 {col_name}")
|
logger.info(f"数据库迁移: 添加列 {col_name}")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 导入
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def import_from_excel(self, excel_path: str) -> int:
|
def import_from_excel(self, excel_path: str) -> int:
|
||||||
"""从 Excel 导入商品资料(source=template, confidence=100)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
excel_path: Excel 文件路径
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
导入的记录数
|
|
||||||
"""
|
|
||||||
df = smart_read_excel(excel_path)
|
df = smart_read_excel(excel_path)
|
||||||
if df is None or df.empty:
|
if df is None or df.empty:
|
||||||
logger.warning(f"Excel 文件为空或读取失败: {excel_path}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 查找条码列
|
|
||||||
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
|
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
|
||||||
if not barcode_col:
|
if not barcode_col:
|
||||||
logger.error(f"Excel 中未找到条码列: {list(df.columns)}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 查找进货价列
|
|
||||||
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price')
|
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price')
|
||||||
# 进货价可能没有标准别名,补充查找
|
|
||||||
if not price_col:
|
if not price_col:
|
||||||
for col in df.columns:
|
for col in df.columns:
|
||||||
col_str = str(col).strip()
|
if '进货价' in str(col).strip():
|
||||||
if '进货价' in col_str:
|
|
||||||
price_col = col
|
price_col = col
|
||||||
break
|
break
|
||||||
|
|
||||||
# 查找名称列、单位列、规格列 (可选)
|
|
||||||
name_col = ColumnMapper.find_column(list(df.columns), 'name')
|
name_col = ColumnMapper.find_column(list(df.columns), 'name')
|
||||||
unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
|
unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
|
||||||
spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
|
spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
|
||||||
@@ -150,7 +127,6 @@ class ProductDatabase:
|
|||||||
barcode = str(row.get(barcode_col, '')).strip()
|
barcode = str(row.get(barcode_col, '')).strip()
|
||||||
if not barcode or barcode == 'nan':
|
if not barcode or barcode == 'nan':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
price = 0.0
|
price = 0.0
|
||||||
if price_col:
|
if price_col:
|
||||||
try:
|
try:
|
||||||
@@ -159,43 +135,32 @@ class ProductDatabase:
|
|||||||
price = float(p)
|
price = float(p)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
name = str(row.get(name_col, '')).strip() if name_col else ''
|
name = str(row.get(name_col, '')).strip() if name_col else ''
|
||||||
if name == 'nan':
|
if name == 'nan': name = ''
|
||||||
name = ''
|
|
||||||
unit = str(row.get(unit_col, '')).strip() if unit_col else ''
|
unit = str(row.get(unit_col, '')).strip() if unit_col else ''
|
||||||
if unit == 'nan':
|
if unit == 'nan': unit = ''
|
||||||
unit = ''
|
|
||||||
spec = str(row.get(spec_col, '')).strip() if spec_col else ''
|
spec = str(row.get(spec_col, '')).strip() if spec_col else ''
|
||||||
if spec == 'nan':
|
if spec == 'nan': spec = ''
|
||||||
spec = ''
|
# template 源置信度 50
|
||||||
|
rows.append((barcode, name, price, unit, now, spec, 'template', 50, 0, now,
|
||||||
rows.append((barcode, name, price, unit, now, spec, 'template', 100, 0, now))
|
price, price, price, 1 if price > 0 else 0))
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
logger.warning(f"Excel 中未解析出有效记录: {excel_path}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT OR REPLACE INTO products "
|
"INSERT OR REPLACE INTO products "
|
||||||
"(barcode, name, price, unit, updated_at, specification, source, confidence, usage_count, last_seen) "
|
"(barcode, name, price, unit, updated_at, specification, source, confidence, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"usage_count, last_seen, avg_price, min_price, max_price, price_count) "
|
||||||
rows
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
rows)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
def reimport(self) -> int:
|
def reimport(self) -> int:
|
||||||
"""重新从 Excel 导入(清空现有数据后重新导入)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
导入的记录数
|
|
||||||
"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.execute("DELETE FROM products")
|
conn.execute("DELETE FROM products")
|
||||||
@@ -204,203 +169,343 @@ class ProductDatabase:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return self.import_from_excel(self.excel_source)
|
return self.import_from_excel(self.excel_source)
|
||||||
|
|
||||||
# ── 基础查询(保持兼容) ──────────────────────────────────
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 查询
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def get_price(self, barcode: str) -> Optional[float]:
|
def get_price(self, barcode: str) -> Optional[float]:
|
||||||
"""按条码查询进货价"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
row = conn.execute("SELECT avg_price FROM products WHERE barcode=?",
|
||||||
"SELECT price FROM products WHERE barcode = ?",
|
(str(barcode).strip(),)).fetchone()
|
||||||
(str(barcode).strip(),)
|
return row[0] if row and row[0] else None
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
return row[0] if row else None
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_prices(self, barcodes: List[str]) -> Dict[str, float]:
|
def get_prices(self, barcodes: List[str]) -> Dict[str, float]:
|
||||||
"""批量查询进货价"""
|
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
placeholders = ','.join('?' * len(barcodes))
|
placeholders = ','.join('?' * len(barcodes))
|
||||||
cursor = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT barcode, price FROM products WHERE barcode IN ({placeholders})",
|
f"SELECT barcode, avg_price FROM products WHERE barcode IN ({placeholders})",
|
||||||
[str(b).strip() for b in barcodes]
|
[str(b).strip() for b in barcodes]).fetchall()
|
||||||
)
|
return {r[0]: r[1] for r in rows if r[1]}
|
||||||
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
"""返回商品总数"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute("SELECT COUNT(*) FROM products")
|
return conn.execute("SELECT COUNT(*) FROM products").fetchone()[0]
|
||||||
return cursor.fetchone()[0]
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ── 记忆库查询 ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_memory(self, barcode: str) -> Optional[Dict]:
|
def get_memory(self, barcode: str) -> Optional[Dict]:
|
||||||
"""查询单条商品记忆"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
row = conn.execute("SELECT * FROM products WHERE barcode=?",
|
||||||
"SELECT * FROM products WHERE barcode = ?",
|
(str(barcode).strip(),)).fetchone()
|
||||||
(str(barcode).strip(),)
|
return dict(row) if row else None
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
return dict(row)
|
|
||||||
return None
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_memories(self, barcodes: List[str]) -> Dict[str, Dict]:
|
def get_memories(self, barcodes: List[str]) -> Dict[str, Dict]:
|
||||||
"""批量查询商品记忆"""
|
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
placeholders = ','.join('?' * len(barcodes))
|
placeholders = ','.join('?' * len(barcodes))
|
||||||
cursor = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
|
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
|
||||||
[str(b).strip() for b in barcodes]
|
[str(b).strip() for b in barcodes]).fetchall()
|
||||||
)
|
return {r['barcode']: dict(r) for r in rows}
|
||||||
return {row['barcode']: dict(row) for row in cursor.fetchall()}
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_all_memories(self) -> List[Dict]:
|
def get_all_memories(self) -> List[Dict]:
|
||||||
"""返回全部记录(UI 用)"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
return [dict(row) for row in
|
||||||
"SELECT * FROM products ORDER BY usage_count DESC, barcode"
|
conn.execute("SELECT * FROM products ORDER BY usage_count DESC, barcode").fetchall()]
|
||||||
)
|
|
||||||
return [dict(row) for row in cursor.fetchall()]
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ── 学习逻辑 ──────────────────────────────────────────────
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 批量预加载 — 性能核心
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def learn_from_product(self, product: Dict, source: str = 'ocr') -> None:
|
def load_batch(self, barcodes: List[str]) -> Dict[str, Dict]:
|
||||||
"""从处理结果中学习单条商品数据
|
"""批量预加载条码记忆到 dict — 单次 SQL,后续纯内存操作"""
|
||||||
|
if not barcodes:
|
||||||
|
return {}
|
||||||
|
conn = self._connect()
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
placeholders = ','.join('?' * len(barcodes))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM products WHERE barcode IN ({placeholders})",
|
||||||
|
[str(b).strip() for b in barcodes]).fetchall()
|
||||||
|
return {r['barcode']: dict(r) for r in rows}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
Args:
|
# ══════════════════════════════════════════════════════════════
|
||||||
product: 商品字典 (barcode, name, specification, unit, price, ...)
|
# 学习逻辑 — 一致性加速 + 价格区间
|
||||||
source: 数据来源 ('template', 'ocr', 'user_confirmed')
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def learn_from_product(self, product: Dict, source: str = 'ocr',
|
||||||
|
memory: Dict[str, Dict] = None,
|
||||||
|
add_log: Callable = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从处理结果中学习,返回日志字符串。
|
||||||
|
memory: 可选的预加载批量内存,传入则零 DB 查询。
|
||||||
"""
|
"""
|
||||||
barcode = str(product.get('barcode', '')).strip()
|
barcode = str(product.get('barcode', '')).strip()
|
||||||
if not barcode:
|
if not barcode:
|
||||||
return
|
return None
|
||||||
|
|
||||||
now = datetime.now().isoformat()
|
|
||||||
name = str(product.get('name', ''))
|
name = str(product.get('name', ''))
|
||||||
spec = str(product.get('specification', ''))
|
spec = str(product.get('specification', ''))
|
||||||
unit = str(product.get('unit', ''))
|
unit = str(product.get('unit', ''))
|
||||||
price = float(product.get('price', 0))
|
price = float(product.get('price', 0))
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# 查现有记录(优先从内存查)
|
||||||
|
if memory is not None and barcode in memory:
|
||||||
|
row = memory[barcode]
|
||||||
|
old_name = row.get('name', '')
|
||||||
|
old_spec = row.get('specification', '')
|
||||||
|
old_unit = row.get('unit', '')
|
||||||
|
old_conf = row.get('confidence', 0)
|
||||||
|
old_count = row.get('usage_count', 0)
|
||||||
|
old_avg = row.get('avg_price', 0) or 0
|
||||||
|
old_min = row.get('min_price') or price
|
||||||
|
old_max = row.get('max_price') or price
|
||||||
|
pc = row.get('price_count', 0) or 0
|
||||||
|
exists = True
|
||||||
|
else:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT confidence, usage_count FROM products WHERE barcode = ?",
|
"SELECT name, specification, unit, confidence, usage_count, "
|
||||||
(barcode,)
|
"avg_price, min_price, max_price, price_count FROM products WHERE barcode=?",
|
||||||
)
|
(barcode,)).fetchone()
|
||||||
row = cursor.fetchone()
|
finally:
|
||||||
|
conn.close()
|
||||||
if row is None:
|
if cursor is None:
|
||||||
# 新记录
|
exists = False
|
||||||
conf = {'template': 100, 'user_confirmed': 90}.get(source, 50)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO products "
|
|
||||||
"(barcode, name, specification, unit, price, source, confidence, usage_count, last_seen, updated_at) "
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
|
|
||||||
(barcode, name, spec, unit, price, source, conf, now, now)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
old_conf, old_count = row
|
old_name, old_spec, old_unit, old_conf, old_count, old_avg, old_min, old_max, pc = cursor
|
||||||
new_count = old_count + 1
|
old_avg = old_avg or 0
|
||||||
|
pc = pc or 0
|
||||||
|
old_min = old_min if old_min is not None else price
|
||||||
|
old_max = old_max if old_max is not None else price
|
||||||
|
exists = True
|
||||||
|
|
||||||
if source == 'template':
|
new_count = old_count + 1 if exists else 1
|
||||||
new_conf = 100
|
|
||||||
elif source == 'user_confirmed':
|
# ── 置信度 ──
|
||||||
|
if source == 'user_confirmed':
|
||||||
new_conf = 90
|
new_conf = 90
|
||||||
else: # ocr
|
elif source == 'template':
|
||||||
new_conf = min(80, old_conf + 10) if old_conf < 80 else old_conf
|
new_conf = 50
|
||||||
|
elif exists and old_conf < 50:
|
||||||
|
# 一致性加速
|
||||||
|
spec_match = bool(spec and old_spec and spec == old_spec)
|
||||||
|
unit_match = bool(unit and old_unit and unit == old_unit)
|
||||||
|
if spec_match and unit_match:
|
||||||
|
boost = 10
|
||||||
|
elif unit_match:
|
||||||
|
boost = 5
|
||||||
|
else:
|
||||||
|
boost = 3
|
||||||
|
new_conf = min(50, old_conf + boost)
|
||||||
|
elif exists:
|
||||||
|
new_conf = old_conf # > 50 稳定不变
|
||||||
|
else:
|
||||||
|
new_conf = 10 # 新 OCR 记录
|
||||||
|
|
||||||
if source in ('template', 'user_confirmed'):
|
# ── 价格区间 ──
|
||||||
# 高权威来源:全字段覆盖
|
if price > 0:
|
||||||
|
new_pc = (pc if exists else 0) + 1
|
||||||
|
new_avg = ((old_avg * (new_pc - 1)) + price) / new_pc if exists else price
|
||||||
|
new_min = min(old_min, price) if exists else price
|
||||||
|
new_max = max(old_max, price) if exists else price
|
||||||
|
else:
|
||||||
|
new_avg = old_avg if exists else 0
|
||||||
|
new_min = old_min if exists else 0
|
||||||
|
new_max = old_max if exists else 0
|
||||||
|
new_pc = pc if exists else 0
|
||||||
|
|
||||||
|
# ── 写入 ──
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
if not exists:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO products (barcode, name, specification, unit, price, "
|
||||||
|
"source, confidence, usage_count, last_seen, updated_at, "
|
||||||
|
"avg_price, min_price, max_price, price_count) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(barcode, name, spec, unit, price, source, new_conf, 1, now, now,
|
||||||
|
new_avg, new_min, new_max, new_pc))
|
||||||
|
log = f"记忆库新增: {barcode} {name} 源={source} 可信度={new_conf}"
|
||||||
|
else:
|
||||||
|
# 高可信度源全字段覆盖;低可信度仅填空
|
||||||
|
if source in ('template', 'user_confirmed') or new_conf > 50:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE products SET name=?, specification=?, unit=?, price=?, "
|
"UPDATE products SET name=?, specification=?, unit=?, price=?, "
|
||||||
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? "
|
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
|
||||||
"WHERE barcode=?",
|
"avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
|
||||||
(name, spec, unit, price, source, new_conf, new_count, now, now, barcode)
|
(name or old_name, spec or old_spec, unit or old_unit, price,
|
||||||
)
|
source, new_conf, new_count, now, now,
|
||||||
|
new_avg, new_min, new_max, new_pc, barcode))
|
||||||
else:
|
else:
|
||||||
# OCR:仅填充空字段,不更新 price
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE products SET "
|
"UPDATE products SET "
|
||||||
"name = CASE WHEN name='' THEN ? ELSE name END, "
|
"name=CASE WHEN name='' THEN ? ELSE name END, "
|
||||||
"specification = CASE WHEN specification='' THEN ? ELSE specification END, "
|
"specification=CASE WHEN specification='' THEN ? ELSE specification END, "
|
||||||
"unit = CASE WHEN unit='' THEN ? ELSE unit END, "
|
"unit=CASE WHEN unit='' THEN ? ELSE unit END, "
|
||||||
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? "
|
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
|
||||||
"WHERE barcode=?",
|
"avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
|
||||||
(name, spec, unit, source, new_conf, new_count, now, now, barcode)
|
(name, spec, unit, source, new_conf, new_count, now, now,
|
||||||
)
|
new_avg, new_min, new_max, new_pc, barcode))
|
||||||
|
log = f"记忆库更新: {barcode} 可信度{old_conf if exists else 0}→{new_conf}"
|
||||||
|
if price > 0:
|
||||||
|
log += f" 均价{new_avg:.4f}({new_pc}次)"
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# 更新内存 dict(如果传入了)
|
||||||
|
if memory is not None and barcode in memory:
|
||||||
|
memory[barcode].update({
|
||||||
|
'confidence': new_conf, 'usage_count': new_count,
|
||||||
|
'avg_price': new_avg, 'min_price': new_min,
|
||||||
|
'max_price': new_max, 'price_count': new_pc,
|
||||||
|
'name': name or old_name,
|
||||||
|
'specification': spec or old_spec,
|
||||||
|
'unit': unit or old_unit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if add_log:
|
||||||
|
add_log(log)
|
||||||
|
return log
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def learn_from_products(self, products: List[Dict], source: str = 'ocr') -> int:
|
def learn_from_products(self, products: List[Dict], source: str = 'ocr',
|
||||||
"""批量学习,返回更新条数"""
|
add_log: Callable = None) -> int:
|
||||||
|
"""批量学习 — 先批量预加载,再逐条处理,返回更新条数"""
|
||||||
|
barcodes = [str(p.get('barcode', '')) for p in products if p.get('barcode')]
|
||||||
|
memory = self.load_batch(barcodes)
|
||||||
count = 0
|
count = 0
|
||||||
for p in products:
|
for p in products:
|
||||||
try:
|
try:
|
||||||
self.learn_from_product(p, source)
|
result = self.learn_from_product(p, source, memory=memory, add_log=add_log)
|
||||||
|
if result:
|
||||||
count += 1
|
count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"学习商品记忆失败: {e}")
|
logger.warning(f"学习商品记忆失败: {e}")
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 记忆辅助 — OCR 补全
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _price_anomaly(self, product: Dict, mem: Dict) -> bool:
|
||||||
|
"""价格异常:> 2倍偏差"""
|
||||||
|
price = float(product.get('price', 0))
|
||||||
|
avg = mem.get('avg_price', 0)
|
||||||
|
if not price or not avg:
|
||||||
|
return False
|
||||||
|
return price > avg * 2 or price < avg * 0.5
|
||||||
|
|
||||||
|
def fill_from_memory(self, barcode: str, ocr_result: Dict,
|
||||||
|
memory: Dict[str, Dict] = None) -> Tuple[Dict, str]:
|
||||||
|
"""用记忆库补全 OCR 缺失字段。返回 (补全后的dict, 日志字符串)"""
|
||||||
|
if memory:
|
||||||
|
mem = memory.get(barcode)
|
||||||
|
else:
|
||||||
|
mem = self.get_memory(barcode)
|
||||||
|
|
||||||
|
if not mem or mem.get('confidence', 0) < 10:
|
||||||
|
return ocr_result, ""
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
result = dict(ocr_result)
|
||||||
|
conf = mem.get('confidence', 0)
|
||||||
|
|
||||||
|
has_spec = result.get('specification')
|
||||||
|
has_unit = result.get('unit')
|
||||||
|
price = float(result.get('price', 0))
|
||||||
|
|
||||||
|
if conf > 50 and not has_spec and mem.get('specification'):
|
||||||
|
result['specification'] = mem['specification']
|
||||||
|
logs.append(f"规格补全(可信{conf}): {barcode} → {mem['specification']}")
|
||||||
|
elif not has_spec and mem.get('specification') and self._price_anomaly(result, mem):
|
||||||
|
result['specification'] = mem['specification']
|
||||||
|
logs.append(f"价格异常→规格补全: {barcode} 本次{price:.2f} vs 均价{mem['avg_price']:.2f} → {mem['specification']}")
|
||||||
|
|
||||||
|
if conf > 50 and not has_unit and mem.get('unit'):
|
||||||
|
result['unit'] = mem['unit']
|
||||||
|
logs.append(f"单位补全(可信{conf}): {barcode} → {mem['unit']}")
|
||||||
|
elif not has_unit and mem.get('unit') and self._price_anomaly(result, mem):
|
||||||
|
result['unit'] = mem['unit']
|
||||||
|
logs.append(f"价格异常→单位补全: {barcode} → {mem['unit']}")
|
||||||
|
|
||||||
|
return result, "; ".join(logs)
|
||||||
|
|
||||||
|
def price_warning(self, barcode: str, price: float,
|
||||||
|
memory: Dict[str, Dict] = None) -> Optional[str]:
|
||||||
|
"""价格预警。> 50% 偏差告警"""
|
||||||
|
if memory:
|
||||||
|
mem = memory.get(barcode)
|
||||||
|
else:
|
||||||
|
mem = self.get_memory(barcode)
|
||||||
|
if not mem or not mem.get('avg_price'):
|
||||||
|
return None
|
||||||
|
avg = mem['avg_price']
|
||||||
|
min_p = mem.get('min_price', avg)
|
||||||
|
max_p = mem.get('max_price', avg)
|
||||||
|
pc = mem.get('price_count', 0)
|
||||||
|
if price > avg * 1.5 or price < avg * 0.5:
|
||||||
|
return (f"单价预警: {barcode} 本次{price:.4f}元 vs "
|
||||||
|
f"历史均价{avg:.4f} (范围{min_p:.4f}~{max_p:.4f}, {pc}次)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 手动编辑
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def update_memory(self, barcode: str, fields: Dict) -> bool:
|
def update_memory(self, barcode: str, fields: Dict) -> bool:
|
||||||
"""手动编辑记录(UI 用,source→user_confirmed, confidence→90)"""
|
|
||||||
barcode = str(barcode).strip()
|
barcode = str(barcode).strip()
|
||||||
if not barcode:
|
if not barcode:
|
||||||
return False
|
return False
|
||||||
|
allowed = {'name', 'specification', 'unit', 'price', 'confidence'}
|
||||||
allowed = {'name', 'specification', 'unit', 'price'}
|
|
||||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
set_clause = ', '.join(f"{k}=?" for k in updates)
|
set_clause = ', '.join(f"{k}=?" for k in updates)
|
||||||
values = list(updates.values())
|
values = list(updates.values())
|
||||||
|
extra_sql = ", source='user_confirmed'"
|
||||||
|
if 'confidence' not in updates:
|
||||||
|
extra_sql += ", confidence=90"
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"UPDATE products SET {set_clause}, source='user_confirmed', confidence=90, "
|
f"UPDATE products SET {set_clause}{extra_sql}, updated_at=? WHERE barcode=?",
|
||||||
"updated_at=? WHERE barcode=?",
|
values + [now, barcode])
|
||||||
values + [now, barcode]
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn.total_changes > 0
|
return conn.total_changes > 0
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def delete_memory(self, barcode: str) -> bool:
|
def delete_memory(self, barcode: str) -> bool:
|
||||||
"""删除记录"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
conn.execute("DELETE FROM products WHERE barcode=?", (str(barcode).strip(),))
|
conn.execute("DELETE FROM products WHERE barcode=?", (str(barcode).strip(),))
|
||||||
@@ -409,51 +514,39 @@ class ProductDatabase:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# ── 云端同步 ──────────────────────────────────────────────
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# 云端同步
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def export_for_sync(self) -> Dict:
|
def export_for_sync(self) -> Dict:
|
||||||
"""导出全部记录为 JSON-serializable dict(按条码索引)"""
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT barcode, name, specification, unit, price, source, "
|
"SELECT barcode, name, specification, unit, price, source, "
|
||||||
"confidence, usage_count, last_seen FROM products"
|
"confidence, usage_count, last_seen, avg_price, min_price, max_price, price_count "
|
||||||
)
|
"FROM products")
|
||||||
result = {}
|
result = {}
|
||||||
for row in cursor.fetchall():
|
for row in cursor.fetchall():
|
||||||
result[row[0]] = {
|
result[row[0]] = {
|
||||||
'name': row[1],
|
'name': row[1], 'specification': row[2], 'unit': row[3],
|
||||||
'specification': row[2],
|
'price': row[4], 'source': row[5], 'confidence': row[6],
|
||||||
'unit': row[3],
|
'usage_count': row[7], 'last_seen': row[8],
|
||||||
'price': row[4],
|
'avg_price': row[9], 'min_price': row[10],
|
||||||
'source': row[5],
|
'max_price': row[11], 'price_count': row[12],
|
||||||
'confidence': row[6],
|
|
||||||
'usage_count': row[7],
|
|
||||||
'last_seen': row[8],
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def import_from_sync(self, data: Dict) -> int:
|
def import_from_sync(self, data: Dict) -> int:
|
||||||
"""从云端 JSON 导入,高置信度优先合并
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: {barcode: {name, specification, unit, price, source, confidence, ...}}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
导入/更新的记录数
|
|
||||||
"""
|
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
for barcode, info in data.items():
|
for barcode, info in data.items():
|
||||||
barcode = str(barcode).strip()
|
barcode = str(barcode).strip()
|
||||||
if not barcode:
|
if not barcode:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
name = str(info.get('name', ''))
|
name = str(info.get('name', ''))
|
||||||
spec = str(info.get('specification', ''))
|
spec = str(info.get('specification', ''))
|
||||||
unit = str(info.get('unit', ''))
|
unit = str(info.get('unit', ''))
|
||||||
@@ -462,69 +555,55 @@ class ProductDatabase:
|
|||||||
remote_conf = int(info.get('confidence', 50))
|
remote_conf = int(info.get('confidence', 50))
|
||||||
remote_count = int(info.get('usage_count', 1))
|
remote_count = int(info.get('usage_count', 1))
|
||||||
remote_seen = str(info.get('last_seen', now))
|
remote_seen = str(info.get('last_seen', now))
|
||||||
|
remote_avg = float(info.get('avg_price', price))
|
||||||
|
remote_min = float(info.get('min_price', price))
|
||||||
|
remote_max = float(info.get('max_price', price))
|
||||||
|
remote_pc = int(info.get('price_count', 1))
|
||||||
|
|
||||||
cursor = conn.execute(
|
row = conn.execute("SELECT confidence FROM products WHERE barcode=?",
|
||||||
"SELECT confidence FROM products WHERE barcode = ?",
|
(barcode,)).fetchone()
|
||||||
(barcode,)
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
# 新记录,直接插入
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO products "
|
"INSERT INTO products (barcode, name, specification, unit, price, "
|
||||||
"(barcode, name, specification, unit, price, source, confidence, usage_count, last_seen, updated_at) "
|
"source, confidence, usage_count, last_seen, updated_at, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"avg_price, min_price, max_price, price_count) "
|
||||||
(barcode, name, spec, unit, price, remote_source, remote_conf, remote_count, remote_seen, now)
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
(barcode, name, spec, unit, price, remote_source, remote_conf,
|
||||||
|
remote_count, remote_seen, now,
|
||||||
|
remote_avg, remote_min, remote_max, remote_pc))
|
||||||
count += 1
|
count += 1
|
||||||
else:
|
else:
|
||||||
local_conf = row[0]
|
local_conf = row[0]
|
||||||
if remote_conf > local_conf:
|
if remote_conf > local_conf:
|
||||||
# 云端置信度更高,覆盖
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE products SET name=?, specification=?, unit=?, price=?, "
|
"UPDATE products SET name=?, specification=?, unit=?, price=?, "
|
||||||
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=? "
|
"source=?, confidence=?, usage_count=?, last_seen=?, updated_at=?, "
|
||||||
"WHERE barcode=?",
|
"avg_price=?, min_price=?, max_price=?, price_count=? WHERE barcode=?",
|
||||||
(name, spec, unit, price, remote_source, remote_conf, remote_count, remote_seen, now, barcode)
|
(name, spec, unit, price, remote_source, remote_conf,
|
||||||
)
|
remote_count, remote_seen, now,
|
||||||
|
remote_avg, remote_min, remote_max, remote_pc, barcode))
|
||||||
count += 1
|
count += 1
|
||||||
elif remote_conf == local_conf:
|
elif remote_conf == local_conf:
|
||||||
# 置信度相同,填充空字段
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE products SET "
|
"UPDATE products SET "
|
||||||
"name = CASE WHEN name='' THEN ? ELSE name END, "
|
"name=CASE WHEN name='' THEN ? ELSE name END, "
|
||||||
"specification = CASE WHEN specification='' THEN ? ELSE specification END, "
|
"specification=CASE WHEN specification='' THEN ? ELSE specification END, "
|
||||||
"unit = CASE WHEN unit='' THEN ? ELSE unit END, "
|
"unit=CASE WHEN unit='' THEN ? ELSE unit END, "
|
||||||
"usage_count = MAX(usage_count, ?), "
|
"usage_count=MAX(usage_count, ?), updated_at=? WHERE barcode=?",
|
||||||
"updated_at=? WHERE barcode=?",
|
(name, spec, unit, remote_count, now, barcode))
|
||||||
(name, spec, unit, remote_count, now, barcode)
|
|
||||||
)
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def _export_memory_json(self, json_path: str = None) -> str:
|
def _export_memory_json(self, json_path=None):
|
||||||
"""导出记忆库为本地 JSON 文件
|
"""导出记忆库为 JSON(兼容旧代码调用)"""
|
||||||
|
import os as _os
|
||||||
Args:
|
|
||||||
json_path: 输出路径,默认 data/product_memory.json
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
写入的文件路径
|
|
||||||
"""
|
|
||||||
if json_path is None:
|
if json_path is None:
|
||||||
json_path = os.path.join(os.path.dirname(self.db_path), 'product_memory.json')
|
json_path = _os.path.join(_os.path.dirname(self.db_path), 'product_memory.json')
|
||||||
|
|
||||||
data = self.export_for_sync()
|
data = self.export_for_sync()
|
||||||
os.makedirs(os.path.dirname(json_path), exist_ok=True)
|
_os.makedirs(_os.path.dirname(json_path), exist_ok=True)
|
||||||
|
|
||||||
with open(json_path, 'w', encoding='utf-8') as f:
|
with open(json_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
logger.debug(f"商品记忆库已导出: {json_path} ({len(data)} 条)")
|
|
||||||
return json_path
|
return json_path
|
||||||
|
|||||||
@@ -165,6 +165,69 @@ class GiteaSync:
|
|||||||
existing_sha = self.file_exists(remote_path)
|
existing_sha = self.file_exists(remote_path)
|
||||||
return self.push_file(remote_path, content, message, sha=existing_sha)
|
return self.push_file(remote_path, content, message, sha=existing_sha)
|
||||||
|
|
||||||
|
def push(self) -> str:
|
||||||
|
"""推送本地数据到云端:product_cache.json + barcode_mappings.json"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
|
||||||
|
results = []
|
||||||
|
# 1. Product cache
|
||||||
|
from app.core.db.product_db import ProductDatabase
|
||||||
|
excel_source = str(project_root / "templates" / "商品资料.xlsx")
|
||||||
|
db_path = str(project_root / "data" / "product_cache.db")
|
||||||
|
product_db = ProductDatabase(db_path, excel_source)
|
||||||
|
product_data = product_db.export_for_sync()
|
||||||
|
sha = self.push_json("product_cache.json", product_data, "sync: update product cache")
|
||||||
|
results.append(f"product_cache: {'ok' if sha else 'skip'}")
|
||||||
|
|
||||||
|
# 2. Barcode mappings
|
||||||
|
barcode_path = project_root / "config" / "barcode_mappings.json"
|
||||||
|
if barcode_path.exists():
|
||||||
|
with open(barcode_path, "r", encoding="utf-8") as f:
|
||||||
|
barcode_data = json.loads(f.read())
|
||||||
|
sha = self.push_json("barcode_mappings.json", barcode_data, "sync: update barcode mappings")
|
||||||
|
results.append(f"barcode_mappings: {'ok' if sha else 'skip'}")
|
||||||
|
|
||||||
|
return "; ".join(results) if results else "无数据需要同步"
|
||||||
|
|
||||||
|
def pull(self) -> str:
|
||||||
|
"""从云端拉取数据并写入本地文件"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
|
||||||
|
results = []
|
||||||
|
# 1. Product cache
|
||||||
|
result = self.pull_json("product_cache.json")
|
||||||
|
if result is not None:
|
||||||
|
data, sha = result
|
||||||
|
from app.core.db.product_db import ProductDatabase
|
||||||
|
excel_source = str(project_root / "templates" / "商品资料.xlsx")
|
||||||
|
db_path = str(project_root / "data" / "product_cache.db")
|
||||||
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
|
product_db = ProductDatabase(db_path, excel_source)
|
||||||
|
count = product_db.import_from_sync(data)
|
||||||
|
results.append(f"product_cache: 导入 {count} 条")
|
||||||
|
else:
|
||||||
|
results.append("product_cache: 云端无数据")
|
||||||
|
|
||||||
|
# 2. Barcode mappings
|
||||||
|
barcode_result = self.pull_json("barcode_mappings.json")
|
||||||
|
if barcode_result is not None:
|
||||||
|
barcode_data, sha = barcode_result
|
||||||
|
barcode_path = project_root / "config" / "barcode_mappings.json"
|
||||||
|
barcode_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(barcode_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(barcode_data, f, ensure_ascii=False, indent=2)
|
||||||
|
results.append(f"barcode_mappings: 已更新")
|
||||||
|
else:
|
||||||
|
results.append("barcode_mappings: 云端无数据")
|
||||||
|
|
||||||
|
return "; ".join(results) if results else "无数据需要同步"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config) -> Optional["GiteaSync"]:
|
def from_config(cls, config) -> Optional["GiteaSync"]:
|
||||||
"""从 ConfigManager 创建实例
|
"""从 ConfigManager 创建实例
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ skip_existing = true
|
|||||||
[File]
|
[File]
|
||||||
allowed_extensions = .jpg,.jpeg,.png,.bmp
|
allowed_extensions = .jpg,.jpeg,.png,.bmp
|
||||||
excel_extension = .xlsx
|
excel_extension = .xlsx
|
||||||
max_file_size_mb = 4
|
max_file_size_mb = 5
|
||||||
|
|
||||||
[Templates]
|
[Templates]
|
||||||
purchase_order = 银豹-采购单模板.xls
|
purchase_order = 银豹-采购单模板.xls
|
||||||
|
|||||||
@@ -17,13 +17,22 @@ _mappings_file = _project_root / "config" / "barcode_mappings.json"
|
|||||||
|
|
||||||
class BarcodeMapping(BaseModel):
|
class BarcodeMapping(BaseModel):
|
||||||
barcode: str
|
barcode: str
|
||||||
target: str
|
target: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
# Special rule fields
|
||||||
|
multiplier: Optional[int] = None
|
||||||
|
target_unit: Optional[str] = None
|
||||||
|
fixed_price: Optional[float] = None
|
||||||
|
specification: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class BarcodeMappingUpdate(BaseModel):
|
class BarcodeMappingUpdate(BaseModel):
|
||||||
target: Optional[str] = None
|
target: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
multiplier: Optional[int] = None
|
||||||
|
target_unit: Optional[str] = None
|
||||||
|
fixed_price: Optional[float] = None
|
||||||
|
specification: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _load_mappings() -> Dict:
|
def _load_mappings() -> Dict:
|
||||||
@@ -51,12 +60,29 @@ async def list_barcodes(
|
|||||||
if isinstance(info, dict):
|
if isinstance(info, dict):
|
||||||
target = info.get("map_to", info.get("target", ""))
|
target = info.get("map_to", info.get("target", ""))
|
||||||
desc = info.get("description", "")
|
desc = info.get("description", "")
|
||||||
|
item = {
|
||||||
|
"barcode": barcode,
|
||||||
|
"target": target,
|
||||||
|
"description": desc,
|
||||||
|
"multiplier": info.get("multiplier"),
|
||||||
|
"target_unit": info.get("target_unit"),
|
||||||
|
"fixed_price": info.get("fixed_price"),
|
||||||
|
"specification": info.get("specification"),
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
target = str(info)
|
item = {
|
||||||
desc = ""
|
"barcode": barcode,
|
||||||
if search and search not in barcode and search not in target and search not in desc:
|
"target": str(info),
|
||||||
|
"description": "",
|
||||||
|
"multiplier": None,
|
||||||
|
"target_unit": None,
|
||||||
|
"fixed_price": None,
|
||||||
|
"specification": None,
|
||||||
|
}
|
||||||
|
s = search.lower() if search else ""
|
||||||
|
if s and s not in barcode.lower() and s not in item["target"].lower() and s not in (desc or "").lower():
|
||||||
continue
|
continue
|
||||||
items.append({"barcode": barcode, "target": target, "description": desc})
|
items.append(item)
|
||||||
return {"items": items, "total": len(items)}
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
|
|
||||||
@@ -82,9 +108,22 @@ async def create_barcode(
|
|||||||
mappings = _load_mappings()
|
mappings = _load_mappings()
|
||||||
if body.barcode in mappings:
|
if body.barcode in mappings:
|
||||||
raise HTTPException(409, f"条码 {body.barcode} 已存在")
|
raise HTTPException(409, f"条码 {body.barcode} 已存在")
|
||||||
mappings[body.barcode] = {"map_to": body.target, "description": body.description or ""}
|
|
||||||
|
entry: dict = {"description": body.description or ""}
|
||||||
|
if body.multiplier:
|
||||||
|
entry["multiplier"] = body.multiplier
|
||||||
|
if body.target_unit:
|
||||||
|
entry["target_unit"] = body.target_unit
|
||||||
|
if body.fixed_price is not None:
|
||||||
|
entry["fixed_price"] = body.fixed_price
|
||||||
|
if body.specification:
|
||||||
|
entry["specification"] = body.specification
|
||||||
|
else:
|
||||||
|
entry["map_to"] = body.target or ""
|
||||||
|
|
||||||
|
mappings[body.barcode] = entry
|
||||||
_save_mappings(mappings)
|
_save_mappings(mappings)
|
||||||
return {"message": f"已创建映射 {body.barcode} → {body.target}"}
|
return {"message": f"已创建规则 {body.barcode}"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{barcode}")
|
@router.put("/{barcode}")
|
||||||
@@ -95,20 +134,35 @@ async def update_barcode(
|
|||||||
):
|
):
|
||||||
mappings = _load_mappings()
|
mappings = _load_mappings()
|
||||||
if barcode not in mappings:
|
if barcode not in mappings:
|
||||||
raise HTTPException(404, f"未找到条码映射 {barcode}")
|
raise HTTPException(404, f"未找到条码规则 {barcode}")
|
||||||
|
|
||||||
existing = mappings[barcode]
|
existing = mappings[barcode]
|
||||||
if not isinstance(existing, dict):
|
if not isinstance(existing, dict):
|
||||||
existing = {"map_to": str(existing), "description": ""}
|
existing = {"map_to": str(existing), "description": ""}
|
||||||
|
|
||||||
if body.target is not None:
|
# Check if this is a special rule (has multiplier) or being converted to one
|
||||||
|
if body.multiplier is not None:
|
||||||
|
# Convert to special rule: remove map_to, add multiplier fields
|
||||||
|
existing.pop("map_to", None)
|
||||||
|
existing["multiplier"] = body.multiplier
|
||||||
|
if body.target_unit is not None:
|
||||||
|
existing["target_unit"] = body.target_unit
|
||||||
|
if body.fixed_price is not None:
|
||||||
|
existing["fixed_price"] = body.fixed_price
|
||||||
|
if body.specification is not None:
|
||||||
|
existing["specification"] = body.specification
|
||||||
|
elif body.target is not None:
|
||||||
|
# Convert to simple mapping: remove special fields, add map_to
|
||||||
|
for k in ("multiplier", "target_unit", "fixed_price", "specification"):
|
||||||
|
existing.pop(k, None)
|
||||||
existing["map_to"] = body.target
|
existing["map_to"] = body.target
|
||||||
|
|
||||||
if body.description is not None:
|
if body.description is not None:
|
||||||
existing["description"] = body.description
|
existing["description"] = body.description
|
||||||
|
|
||||||
mappings[barcode] = existing
|
mappings[barcode] = existing
|
||||||
_save_mappings(mappings)
|
_save_mappings(mappings)
|
||||||
return {"message": f"已更新映射 {barcode}"}
|
return {"message": f"已更新规则 {barcode}"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{barcode}")
|
@router.delete("/{barcode}")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Request
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from pydantic import BaseModel
|
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
|
||||||
@@ -267,10 +267,13 @@ async def get_file_relations(
|
|||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
sort_by: Optional[str] = None,
|
||||||
|
sort_order: str = "desc",
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Query file relations with optional view filter."""
|
"""Query file relations with optional view filter."""
|
||||||
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size)
|
items, total = query_file_relations(view=view, status=status, page=page, page_size=page_size,
|
||||||
|
sort_by=sort_by, sort_order=sort_order)
|
||||||
return {"items": items, "total": total}
|
return {"items": items, "total": total}
|
||||||
|
|
||||||
|
|
||||||
@@ -299,3 +302,47 @@ async def delete_relations(
|
|||||||
"""Delete file relation records by IDs."""
|
"""Delete file relation records by IDs."""
|
||||||
delete_file_relations(body.ids)
|
delete_file_relations(body.ids)
|
||||||
return {"message": f"已删除 {len(body.ids)} 条关系记录"}
|
return {"message": f"已删除 {len(body.ids)} 条关系记录"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File preview
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/preview/{directory}/{filename:path}")
|
||||||
|
async def preview_file(
|
||||||
|
directory: str,
|
||||||
|
filename: str,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Preview file content: images served directly, Excel returned as JSON grid."""
|
||||||
|
# Security: only allow specific directories
|
||||||
|
if directory not in ("input", "output", "result"):
|
||||||
|
raise HTTPException(403, "不允许访问该目录")
|
||||||
|
|
||||||
|
dir_map = {"input": _input_dir, "output": _output_dir, "result": _result_dir}
|
||||||
|
file_path = dir_map[directory] / filename
|
||||||
|
if not file_path.is_file():
|
||||||
|
raise HTTPException(404, f"文件不存在: {filename}")
|
||||||
|
|
||||||
|
ext = file_path.suffix.lower()
|
||||||
|
# Images: serve directly
|
||||||
|
if ext in ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp'):
|
||||||
|
return FileResponse(str(file_path))
|
||||||
|
|
||||||
|
# Excel: read and return as JSON grid
|
||||||
|
if ext in ('.xls', '.xlsx'):
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
df = pd.read_excel(str(file_path), header=None)
|
||||||
|
# Fill NaN with empty string
|
||||||
|
df = df.fillna('')
|
||||||
|
rows = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
rows.append([str(v) if v != '' else '' for v in row])
|
||||||
|
# Limit to first 200 rows
|
||||||
|
return JSONResponse({"type": "excel", "rows": rows[:200], "total_rows": len(rows)})
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"读取文件失败: {e}")
|
||||||
|
|
||||||
|
raise HTTPException(400, f"不支持预览的文件类型: {ext}")
|
||||||
|
|||||||
@@ -18,18 +18,31 @@ _excel_source = str(_project_root / "templates" / "商品资料.xlsx")
|
|||||||
class MemoryItem(BaseModel):
|
class MemoryItem(BaseModel):
|
||||||
barcode: str
|
barcode: str
|
||||||
name: str
|
name: str
|
||||||
spec: Optional[str] = None
|
specification: Optional[str] = None
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
price: Optional[float] = None
|
price: Optional[float] = None
|
||||||
|
avg_price: Optional[float] = None
|
||||||
|
min_price: Optional[float] = None
|
||||||
|
max_price: Optional[float] = None
|
||||||
|
price_count: int = 0
|
||||||
confidence: int = 0
|
confidence: int = 0
|
||||||
source: str = "ocr"
|
source: str = "ocr"
|
||||||
last_used: Optional[str] = None
|
last_used: Optional[str] = None
|
||||||
use_count: int = 0
|
use_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCreate(BaseModel):
|
||||||
|
barcode: str
|
||||||
|
name: Optional[str] = ""
|
||||||
|
specification: Optional[str] = None
|
||||||
|
unit: Optional[str] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
confidence: int = 50
|
||||||
|
|
||||||
|
|
||||||
class MemoryUpdate(BaseModel):
|
class MemoryUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
spec: Optional[str] = None
|
specification: Optional[str] = None
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
price: Optional[float] = None
|
price: Optional[float] = None
|
||||||
confidence: Optional[int] = None
|
confidence: Optional[int] = None
|
||||||
@@ -51,9 +64,13 @@ def _row_to_item(row: Dict) -> MemoryItem:
|
|||||||
return MemoryItem(
|
return MemoryItem(
|
||||||
barcode=row.get("barcode", ""),
|
barcode=row.get("barcode", ""),
|
||||||
name=row.get("name", ""),
|
name=row.get("name", ""),
|
||||||
spec=row.get("spec"),
|
specification=row.get("specification"),
|
||||||
unit=row.get("unit"),
|
unit=row.get("unit"),
|
||||||
price=row.get("price"),
|
price=row.get("price"),
|
||||||
|
avg_price=row.get("avg_price"),
|
||||||
|
min_price=row.get("min_price"),
|
||||||
|
max_price=row.get("max_price"),
|
||||||
|
price_count=row.get("price_count", 0),
|
||||||
confidence=row.get("confidence", 0),
|
confidence=row.get("confidence", 0),
|
||||||
source=row.get("source", "ocr"),
|
source=row.get("source", "ocr"),
|
||||||
last_used=row.get("last_used"),
|
last_used=row.get("last_used"),
|
||||||
@@ -99,6 +116,25 @@ async def get_memory(
|
|||||||
return product
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_memory(
|
||||||
|
body: MemoryCreate,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = _get_db()
|
||||||
|
existing = db.get_memory(body.barcode)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(409, f"条码 {body.barcode} 已存在,请使用编辑功能")
|
||||||
|
db.learn_from_product({
|
||||||
|
"barcode": body.barcode,
|
||||||
|
"name": body.name or "",
|
||||||
|
"specification": body.specification or "",
|
||||||
|
"unit": body.unit or "",
|
||||||
|
"price": body.price or 0,
|
||||||
|
}, source="user_confirmed")
|
||||||
|
return {"message": f"已创建记忆记录 {body.barcode}"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{barcode}")
|
@router.put("/{barcode}")
|
||||||
async def update_memory(
|
async def update_memory(
|
||||||
barcode: str,
|
barcode: str,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Processing endpoints: OCR, Excel conversion, merge, and full pipeline."""
|
"""Processing endpoints: OCR, Excel conversion, merge, and full pipeline."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
@@ -18,6 +20,66 @@ router = APIRouter(prefix="/api/processing", tags=["processing"])
|
|||||||
|
|
||||||
_wrapper = ServiceWrapper(max_workers=3)
|
_wrapper = ServiceWrapper(max_workers=3)
|
||||||
|
|
||||||
|
# ── Thread-safe log capture ──
|
||||||
|
_tlocal = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
class TaskLogHandler(logging.Handler):
|
||||||
|
"""Capture all log records during task execution and forward to tm.add_log()"""
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord):
|
||||||
|
ctx = getattr(_tlocal, 'ctx', None)
|
||||||
|
if ctx:
|
||||||
|
tm = ctx.get('tm')
|
||||||
|
task_id = ctx.get('task_id')
|
||||||
|
if tm and task_id:
|
||||||
|
msg = self.format(record)
|
||||||
|
if any(skip in msg for skip in ['DEBUG:', 'urllib3', 'charset_normalizer']):
|
||||||
|
return
|
||||||
|
tm.add_log(task_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
_log_handler = TaskLogHandler()
|
||||||
|
_log_handler.setLevel(logging.DEBUG)
|
||||||
|
_log_handler.setFormatter(logging.Formatter('%(message)s'))
|
||||||
|
_root_logger = logging.getLogger()
|
||||||
|
_configured = False
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_log_capture():
|
||||||
|
global _configured
|
||||||
|
if not _configured:
|
||||||
|
_root_logger.addHandler(_log_handler)
|
||||||
|
_configured = True
|
||||||
|
|
||||||
|
|
||||||
|
def _start_log_capture(tm, task_id: str):
|
||||||
|
_setup_log_capture()
|
||||||
|
_root_logger.setLevel(logging.DEBUG)
|
||||||
|
_tlocal.ctx = {'tm': tm, 'task_id': task_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_log_capture():
|
||||||
|
_tlocal.ctx = None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_result_file(name: str):
|
||||||
|
files = getattr(_tlocal, 'result_files', None)
|
||||||
|
if files is not None:
|
||||||
|
files.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_with_capture(tm, task_id, func):
|
||||||
|
"""Wrap a do_work function with log capture setup/teardown."""
|
||||||
|
def wrapped():
|
||||||
|
_start_log_capture(tm, task_id)
|
||||||
|
_tlocal.result_files = []
|
||||||
|
try:
|
||||||
|
return func()
|
||||||
|
finally:
|
||||||
|
_stop_log_capture()
|
||||||
|
return wrapped
|
||||||
|
|
||||||
_project_root = Path(__file__).resolve().parent.parent.parent.parent
|
_project_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
_input_dir = _project_root / "data" / "input"
|
_input_dir = _project_root / "data" / "input"
|
||||||
_output_dir = _project_root / "data" / "output"
|
_output_dir = _project_root / "data" / "output"
|
||||||
@@ -74,6 +136,92 @@ def _run_background(coro):
|
|||||||
asyncio.ensure_future(coro)
|
asyncio.ensure_future(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_background_with_log(coro, tm, task_id: str):
|
||||||
|
"""Schedule a coroutine with log capture during execution."""
|
||||||
|
|
||||||
|
async def _wrapped():
|
||||||
|
_start_log_capture(tm, task_id)
|
||||||
|
try:
|
||||||
|
await coro
|
||||||
|
finally:
|
||||||
|
_stop_log_capture()
|
||||||
|
|
||||||
|
asyncio.ensure_future(_wrapped())
|
||||||
|
|
||||||
|
|
||||||
|
def _get_product_db():
|
||||||
|
from app.core.db.product_db import ProductDatabase
|
||||||
|
return ProductDatabase(
|
||||||
|
str(_project_root / 'data' / 'product_cache.db'),
|
||||||
|
str(_project_root / 'templates' / '商品资料.xlsx')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _learn_products_from_excel(excel_path: Path, tm, task_id, source: str = 'ocr'):
|
||||||
|
"""从处理后的Excel文件学习商品数据到记忆库"""
|
||||||
|
try:
|
||||||
|
from app.core.utils.file_utils import smart_read_excel
|
||||||
|
df = smart_read_excel(str(excel_path))
|
||||||
|
if df is None or df.empty:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.core.handlers.column_mapper import ColumnMapper
|
||||||
|
barcode_col = ColumnMapper.find_column(list(df.columns), 'barcode')
|
||||||
|
if not barcode_col:
|
||||||
|
return
|
||||||
|
name_col = ColumnMapper.find_column(list(df.columns), 'name')
|
||||||
|
spec_col = ColumnMapper.find_column(list(df.columns), 'specification')
|
||||||
|
unit_col = ColumnMapper.find_column(list(df.columns), 'unit')
|
||||||
|
price_col = ColumnMapper.find_column(list(df.columns), 'unit_price') or ColumnMapper.find_column(list(df.columns), 'price')
|
||||||
|
|
||||||
|
db = _get_product_db()
|
||||||
|
barcodes = [str(r.get(barcode_col, '')).strip() for _, r in df.iterrows() if str(r.get(barcode_col, '')).strip()]
|
||||||
|
memory = db.load_batch(barcodes)
|
||||||
|
|
||||||
|
learned = 0
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
barcode = str(row.get(barcode_col, '')).strip()
|
||||||
|
if not barcode or barcode == 'nan':
|
||||||
|
continue
|
||||||
|
price = 0.0
|
||||||
|
if price_col:
|
||||||
|
try:
|
||||||
|
p = row.get(price_col)
|
||||||
|
if p is not None and str(p).strip() not in ('', 'nan', 'None'):
|
||||||
|
price = float(p)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
product = {
|
||||||
|
'barcode': barcode,
|
||||||
|
'name': str(row.get(name_col, '')).strip() if name_col else '',
|
||||||
|
'specification': str(row.get(spec_col, '')).strip() if spec_col else '',
|
||||||
|
'unit': str(row.get(unit_col, '')).strip() if unit_col else '',
|
||||||
|
'price': price,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. 记忆辅助补全
|
||||||
|
filled, fill_log = db.fill_from_memory(barcode, product, memory)
|
||||||
|
if fill_log:
|
||||||
|
tm.add_log(task_id, f" {fill_log}")
|
||||||
|
|
||||||
|
# 2. 价格预警
|
||||||
|
warn = db.price_warning(barcode, price, memory)
|
||||||
|
if warn:
|
||||||
|
tm.add_log(task_id, f" {warn}")
|
||||||
|
|
||||||
|
# 3. 学习
|
||||||
|
log = db.learn_from_product(filled, source=source, memory=memory, add_log=None)
|
||||||
|
if log:
|
||||||
|
tm.add_log(task_id, f" {log}")
|
||||||
|
learned += 1
|
||||||
|
|
||||||
|
if learned:
|
||||||
|
tm.add_log(task_id, f"[记忆库] 从 {excel_path.name} 学习了 {learned} 条商品数据")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Batch endpoints
|
# Batch endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -117,16 +265,23 @@ async def ocr_batch(
|
|||||||
for ext in ['.xlsx', '.xls']:
|
for ext in ['.xlsx', '.xls']:
|
||||||
candidate = _output_dir / f"{out_stem}{ext}"
|
candidate = _output_dir / f"{out_stem}{ext}"
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done')
|
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done'); _add_result_file(candidate.name)
|
||||||
|
_add_result_file(candidate.name)
|
||||||
break
|
break
|
||||||
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
||||||
|
# Learn products into memory from OCR output
|
||||||
|
out_file = _output_dir / f"{out_stem}.xlsx"
|
||||||
|
if not out_file.exists():
|
||||||
|
out_file = _output_dir / f"{out_stem}.xls"
|
||||||
|
if out_file.exists():
|
||||||
|
_learn_products_from_excel(out_file, tm, task.id, source='ocr')
|
||||||
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 = list(getattr(_tlocal, 'result_files', []))
|
||||||
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} 个文件")
|
||||||
|
|
||||||
await _wrapper.run_sync(do_work)
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
|
||||||
|
|
||||||
_run_background(_bg())
|
_run_background(_bg())
|
||||||
return TaskResponse(task_id=task.id, status="accepted", message="OCR任务已创建")
|
return TaskResponse(task_id=task.id, status="accepted", message="OCR任务已创建")
|
||||||
@@ -162,7 +317,7 @@ async def process_excel(
|
|||||||
result_path = _result_dir / result_name
|
result_path = _result_dir / result_name
|
||||||
if result_path.exists():
|
if result_path.exists():
|
||||||
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
|
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
|
||||||
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
|
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done'); _add_result_file(result_name)
|
||||||
continue
|
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}")
|
||||||
@@ -171,15 +326,19 @@ async def process_excel(
|
|||||||
svc.process_excel(str(f))
|
svc.process_excel(str(f))
|
||||||
# Find result file
|
# Find result file
|
||||||
if result_path.exists():
|
if result_path.exists():
|
||||||
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
|
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done'); _add_result_file(result_name)
|
||||||
|
_add_result_file(result_name)
|
||||||
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
||||||
|
# Learn products into memory from purchase order result
|
||||||
|
if result_path.exists():
|
||||||
|
_learn_products_from_excel(result_path, tm, task.id, source='ocr')
|
||||||
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 = list(getattr(_tlocal, 'result_files', []))
|
||||||
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} 个文件")
|
||||||
|
|
||||||
await _wrapper.run_sync(do_work)
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
|
||||||
|
|
||||||
_run_background(_bg())
|
_run_background(_bg())
|
||||||
return TaskResponse(task_id=task.id, status="accepted", message="Excel处理任务已创建")
|
return TaskResponse(task_id=task.id, status="accepted", message="Excel处理任务已创建")
|
||||||
@@ -224,7 +383,7 @@ async def merge_orders(
|
|||||||
tm.add_log(task.id, f"[合并] 失败: {e}")
|
tm.add_log(task.id, f"[合并] 失败: {e}")
|
||||||
tm.set_failed(task.id, str(e))
|
tm.set_failed(task.id, str(e))
|
||||||
|
|
||||||
await _wrapper.run_sync(do_work)
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
|
||||||
|
|
||||||
_run_background(_bg())
|
_run_background(_bg())
|
||||||
return TaskResponse(task_id=task.id, status="accepted", message="合并任务已创建")
|
return TaskResponse(task_id=task.id, status="accepted", message="合并任务已创建")
|
||||||
@@ -271,9 +430,14 @@ async def full_pipeline(
|
|||||||
for ext in ['.xlsx', '.xls']:
|
for ext in ['.xlsx', '.xls']:
|
||||||
candidate = _output_dir / f"{out_stem}{ext}"
|
candidate = _output_dir / f"{out_stem}{ext}"
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done')
|
upsert_file_relation(input_image=f.name, output_excel=candidate.name, status='ocr_done'); _add_result_file(candidate.name)
|
||||||
break
|
break
|
||||||
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
tm.add_log(task.id, f"[OCR] 完成: {f.name}")
|
||||||
|
out_file = _output_dir / f"{out_stem}.xlsx"
|
||||||
|
if not out_file.exists():
|
||||||
|
out_file = _output_dir / f"{out_stem}.xls"
|
||||||
|
if out_file.exists():
|
||||||
|
_learn_products_from_excel(out_file, tm, task.id, source='ocr')
|
||||||
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}")
|
||||||
|
|
||||||
@@ -292,7 +456,7 @@ async def full_pipeline(
|
|||||||
result_path = _result_dir / result_name
|
result_path = _result_dir / result_name
|
||||||
if result_path.exists():
|
if result_path.exists():
|
||||||
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
|
tm.add_log(task.id, f"[跳过] {f.name} 已处理过 → {result_name}")
|
||||||
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
|
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done'); _add_result_file(result_name)
|
||||||
tm.update_progress(task.id, pct, f"跳过: {f.name}")
|
tm.update_progress(task.id, pct, f"跳过: {f.name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -300,19 +464,21 @@ async def full_pipeline(
|
|||||||
try:
|
try:
|
||||||
order_svc.process_excel(str(f))
|
order_svc.process_excel(str(f))
|
||||||
if result_path.exists():
|
if result_path.exists():
|
||||||
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done')
|
upsert_file_relation(output_excel=f.name, result_purchase=result_name, status='done'); _add_result_file(result_name)
|
||||||
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
tm.add_log(task.id, f"[Excel] 完成: {f.name}")
|
||||||
|
if result_path.exists():
|
||||||
|
_learn_products_from_excel(result_path, tm, task.id, source='ocr')
|
||||||
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 = list(getattr(_tlocal, 'result_files', []))
|
||||||
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))
|
||||||
|
|
||||||
await _wrapper.run_sync(do_work)
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
|
||||||
|
|
||||||
_run_background(_bg())
|
_run_background(_bg())
|
||||||
return TaskResponse(task_id=task.id, status="accepted", message="全流程任务已创建")
|
return TaskResponse(task_id=task.id, status="accepted", message="全流程任务已创建")
|
||||||
@@ -349,16 +515,16 @@ async def ocr_single(
|
|||||||
for ext in ['.xlsx', '.xls']:
|
for ext in ['.xlsx', '.xls']:
|
||||||
candidate = _output_dir / f"{stem}{ext}"
|
candidate = _output_dir / f"{stem}{ext}"
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done')
|
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done'); _add_result_file(candidate.name)
|
||||||
break
|
break
|
||||||
tm.add_log(task.id, f"[OCR] 完成: {body.filename}")
|
tm.add_log(task.id, f"[OCR] 完成: {body.filename}")
|
||||||
result_files = [f.name for f in _output_dir.iterdir() if f.is_file()]
|
result_files = list(getattr(_tlocal, 'result_files', []))
|
||||||
tm.set_completed(task.id, result_files=result_files, message=f"OCR完成: {body.filename}")
|
tm.set_completed(task.id, result_files=result_files, message=f"OCR完成: {body.filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tm.add_log(task.id, f"[OCR] 失败: {e}")
|
tm.add_log(task.id, f"[OCR] 失败: {e}")
|
||||||
tm.set_failed(task.id, str(e))
|
tm.set_failed(task.id, str(e))
|
||||||
|
|
||||||
await _wrapper.run_sync(do_work)
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
|
||||||
|
|
||||||
_run_background(_bg())
|
_run_background(_bg())
|
||||||
return TaskResponse(task_id=task.id, status="accepted", message=f"OCR任务已创建: {body.filename}")
|
return TaskResponse(task_id=task.id, status="accepted", message=f"OCR任务已创建: {body.filename}")
|
||||||
@@ -390,13 +556,13 @@ async def excel_single(
|
|||||||
if (_result_dir / result_name).exists():
|
if (_result_dir / result_name).exists():
|
||||||
upsert_file_relation(output_excel=body.filename, result_purchase=result_name, status='done')
|
upsert_file_relation(output_excel=body.filename, result_purchase=result_name, status='done')
|
||||||
tm.add_log(task.id, f"[Excel] 完成: {body.filename}")
|
tm.add_log(task.id, f"[Excel] 完成: {body.filename}")
|
||||||
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()]
|
result_files = list(getattr(_tlocal, 'result_files', []))
|
||||||
tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成: {body.filename}")
|
tm.set_completed(task.id, result_files=result_files, message=f"Excel处理完成: {body.filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tm.add_log(task.id, f"[Excel] 失败: {e}")
|
tm.add_log(task.id, f"[Excel] 失败: {e}")
|
||||||
tm.set_failed(task.id, str(e))
|
tm.set_failed(task.id, str(e))
|
||||||
|
|
||||||
await _wrapper.run_sync(do_work)
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
|
||||||
|
|
||||||
_run_background(_bg())
|
_run_background(_bg())
|
||||||
return TaskResponse(task_id=task.id, status="accepted", message=f"Excel处理任务已创建: {body.filename}")
|
return TaskResponse(task_id=task.id, status="accepted", message=f"Excel处理任务已创建: {body.filename}")
|
||||||
@@ -432,13 +598,13 @@ async def pipeline_single(
|
|||||||
if out_xlsx.exists() or out_xls.exists():
|
if out_xlsx.exists() or out_xls.exists():
|
||||||
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
|
out_name = out_xlsx.name if out_xlsx.exists() else out_xls.name
|
||||||
tm.add_log(task.id, f"[跳过] 已OCR过 → {out_name}")
|
tm.add_log(task.id, f"[跳过] 已OCR过 → {out_name}")
|
||||||
upsert_file_relation(input_image=body.filename, output_excel=out_name, status='ocr_done')
|
upsert_file_relation(input_image=body.filename, output_excel=out_name, status='ocr_done'); _add_result_file(out_name)
|
||||||
else:
|
else:
|
||||||
ocr_svc.process_image(str(file_path))
|
ocr_svc.process_image(str(file_path))
|
||||||
for ext in ['.xlsx', '.xls']:
|
for ext in ['.xlsx', '.xls']:
|
||||||
candidate = _output_dir / f"{stem}{ext}"
|
candidate = _output_dir / f"{stem}{ext}"
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done')
|
upsert_file_relation(input_image=body.filename, output_excel=candidate.name, status='ocr_done'); _add_result_file(candidate.name)
|
||||||
break
|
break
|
||||||
tm.add_log(task.id, f"[OCR] 完成")
|
tm.add_log(task.id, f"[OCR] 完成")
|
||||||
|
|
||||||
@@ -464,14 +630,14 @@ async def pipeline_single(
|
|||||||
else:
|
else:
|
||||||
tm.add_log(task.id, f"[错误] OCR未生成Excel文件")
|
tm.add_log(task.id, f"[错误] OCR未生成Excel文件")
|
||||||
|
|
||||||
result_files = [f.name for f in _result_dir.iterdir() if f.is_file()]
|
result_files = list(getattr(_tlocal, 'result_files', []))
|
||||||
tm.set_completed(task.id, result_files=result_files, message=f"全流程完成: {body.filename}")
|
tm.set_completed(task.id, result_files=result_files, message=f"全流程完成: {body.filename}")
|
||||||
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))
|
||||||
|
|
||||||
await _wrapper.run_sync(do_work)
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
|
||||||
|
|
||||||
_run_background(_bg())
|
_run_background(_bg())
|
||||||
return TaskResponse(task_id=task.id, status="accepted", message=f"全流程任务已创建: {body.filename}")
|
return TaskResponse(task_id=task.id, status="accepted", message=f"全流程任务已创建: {body.filename}")
|
||||||
@@ -511,7 +677,7 @@ async def merge_batch(
|
|||||||
tm.add_log(task.id, f"[合并] 失败: {e}")
|
tm.add_log(task.id, f"[合并] 失败: {e}")
|
||||||
tm.set_failed(task.id, str(e))
|
tm.set_failed(task.id, str(e))
|
||||||
|
|
||||||
await _wrapper.run_sync(do_work)
|
await _wrapper.run_sync(_wrap_with_capture(tm, task.id, do_work))
|
||||||
|
|
||||||
_run_background(_bg())
|
_run_background(_bg())
|
||||||
return TaskResponse(task_id=task.id, status="accepted", message="批量合并任务已创建")
|
return TaskResponse(task_id=task.id, status="accepted", message="批量合并任务已创建")
|
||||||
|
|||||||
+32
-35
@@ -1,5 +1,6 @@
|
|||||||
"""Cloud sync endpoints (Gitea-based)."""
|
"""Cloud sync endpoints (Gitea-based)."""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
@@ -23,7 +24,30 @@ def _get_sync():
|
|||||||
from app.core.utils.cloud_sync import GiteaSync
|
from app.core.utils.cloud_sync import GiteaSync
|
||||||
from app.config.settings import ConfigManager
|
from app.config.settings import ConfigManager
|
||||||
cfg = ConfigManager()
|
cfg = ConfigManager()
|
||||||
return GiteaSync(cfg)
|
return GiteaSync.from_config(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sync_in_thread(tm, task_id, action_name, sync_method):
|
||||||
|
"""Run a blocking sync operation in a thread."""
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
tm.update_progress(task_id, 10, "正在初始化同步...")
|
||||||
|
sync = _get_sync()
|
||||||
|
if sync is None:
|
||||||
|
tm.set_failed(task_id, "Gitea 配置不完整,请先在系统配置中设置 base_url/owner/repo/token")
|
||||||
|
return
|
||||||
|
tm.update_progress(task_id, 30, f"正在{action_name}文件...")
|
||||||
|
tm.add_log(task_id, f"[{action_name}] 开始{action_name}")
|
||||||
|
result = sync_method(sync)
|
||||||
|
tm.add_log(task_id, f"[{action_name}] 完成: {result}")
|
||||||
|
tm.set_completed(task_id, message=f"{action_name}完成")
|
||||||
|
except Exception as e:
|
||||||
|
tm.set_failed(task_id, str(e))
|
||||||
|
|
||||||
|
pool = ThreadPoolExecutor(max_workers=1)
|
||||||
|
pool.submit(_run)
|
||||||
|
pool.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/push", response_model=SyncResponse)
|
@router.post("/push", response_model=SyncResponse)
|
||||||
@@ -33,21 +57,7 @@ async def sync_push(
|
|||||||
):
|
):
|
||||||
tm = request.state.task_manager
|
tm = request.state.task_manager
|
||||||
task = tm.create_task("推送到云端")
|
task = tm.create_task("推送到云端")
|
||||||
|
_run_sync_in_thread(tm, task.id, "Push", lambda s: s.push())
|
||||||
async def _run():
|
|
||||||
try:
|
|
||||||
tm.update_progress(task.id, 10, "正在初始化同步...")
|
|
||||||
sync = _get_sync()
|
|
||||||
tm.update_progress(task.id, 30, "正在推送文件...")
|
|
||||||
tm.add_log(task.id, "[Push] 开始推送")
|
|
||||||
result = sync.push()
|
|
||||||
tm.add_log(task.id, f"[Push] 完成: {result}")
|
|
||||||
tm.set_completed(task.id, message="推送完成")
|
|
||||||
except Exception as e:
|
|
||||||
tm.set_failed(task.id, str(e))
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
asyncio.create_task(_run())
|
|
||||||
return SyncResponse(task_id=task.id, status="accepted", message="推送任务已创建")
|
return SyncResponse(task_id=task.id, status="accepted", message="推送任务已创建")
|
||||||
|
|
||||||
|
|
||||||
@@ -58,21 +68,7 @@ async def sync_pull(
|
|||||||
):
|
):
|
||||||
tm = request.state.task_manager
|
tm = request.state.task_manager
|
||||||
task = tm.create_task("从云端拉取")
|
task = tm.create_task("从云端拉取")
|
||||||
|
_run_sync_in_thread(tm, task.id, "Pull", lambda s: s.pull())
|
||||||
async def _run():
|
|
||||||
try:
|
|
||||||
tm.update_progress(task.id, 10, "正在初始化同步...")
|
|
||||||
sync = _get_sync()
|
|
||||||
tm.update_progress(task.id, 30, "正在拉取文件...")
|
|
||||||
tm.add_log(task.id, "[Pull] 开始拉取")
|
|
||||||
result = sync.pull()
|
|
||||||
tm.add_log(task.id, f"[Pull] 完成: {result}")
|
|
||||||
tm.set_completed(task.id, message="拉取完成")
|
|
||||||
except Exception as e:
|
|
||||||
tm.set_failed(task.id, str(e))
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
asyncio.create_task(_run())
|
|
||||||
return SyncResponse(task_id=task.id, status="accepted", message="拉取任务已创建")
|
return SyncResponse(task_id=task.id, status="accepted", message="拉取任务已创建")
|
||||||
|
|
||||||
|
|
||||||
@@ -83,10 +79,11 @@ async def sync_status(
|
|||||||
try:
|
try:
|
||||||
from app.config.settings import ConfigManager
|
from app.config.settings import ConfigManager
|
||||||
cfg = ConfigManager()
|
cfg = ConfigManager()
|
||||||
base_url = cfg.get("Gitea", "base_url", fallback="")
|
base_url = cfg.get("Gitea", "base_url", fallback="").strip()
|
||||||
owner = cfg.get("Gitea", "owner", fallback="")
|
owner = cfg.get("Gitea", "owner", fallback="").strip()
|
||||||
repo = cfg.get("Gitea", "repo", fallback="")
|
repo = cfg.get("Gitea", "repo", fallback="").strip()
|
||||||
enabled = bool(base_url and owner and repo)
|
token = cfg.get("Gitea", "token", fallback="").strip()
|
||||||
|
enabled = bool(base_url and owner and repo and token)
|
||||||
repo_url = f"{base_url}/{owner}/{repo}" if enabled else ""
|
repo_url = f"{base_url}/{owner}/{repo}" if enabled else ""
|
||||||
return {"enabled": enabled, "repo_url": repo_url}
|
return {"enabled": enabled, "repo_url": repo_url}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -475,7 +475,8 @@ def upsert_file_relation(input_image: str = None, output_excel: str = None,
|
|||||||
|
|
||||||
|
|
||||||
def query_file_relations(view: str = None, status: str = None,
|
def query_file_relations(view: str = None, status: str = None,
|
||||||
page: int = 1, page_size: int = 50) -> tuple[list[dict], int]:
|
page: int = 1, page_size: int = 50,
|
||||||
|
sort_by: str = None, sort_order: str = "desc") -> tuple[list[dict], int]:
|
||||||
"""Query file relations with optional view filter and pagination.
|
"""Query file relations with optional view filter and pagination.
|
||||||
|
|
||||||
view='orders': only rows with result_purchase, sorted by result_purchase
|
view='orders': only rows with result_purchase, sorted by result_purchase
|
||||||
@@ -508,6 +509,13 @@ def query_file_relations(view: str = None, status: str = None,
|
|||||||
|
|
||||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
if sort_by and sort_by in ('created_at', 'updated_at', 'input_image', 'output_excel', 'result_purchase', 'status'):
|
||||||
|
sort_col = sort_by
|
||||||
|
else:
|
||||||
|
sort_col = order_by.split()[0] if order_by else 'id'
|
||||||
|
sort_dir = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
|
||||||
|
|
||||||
# Count
|
# Count
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
f"SELECT COUNT(*) as cnt FROM file_relations{where}", params
|
f"SELECT COUNT(*) as cnt FROM file_relations{where}", params
|
||||||
@@ -518,7 +526,7 @@ def query_file_relations(view: str = None, status: str = None,
|
|||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
params.extend([page_size, offset])
|
params.extend([page_size, offset])
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT * FROM file_relations{where} ORDER BY {order_by} LIMIT ? OFFSET ?",
|
f"SELECT * FROM file_relations{where} ORDER BY {sort_col} {sort_dir} LIMIT ? OFFSET ?",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
const currentTask = ref<TaskInfo | null>(null)
|
const currentTask = ref<TaskInfo | null>(null)
|
||||||
const tasks = ref<TaskInfo[]>([])
|
const tasks = ref<TaskInfo[]>([])
|
||||||
const logs = ref<string[]>([])
|
const logs = ref<string[]>([])
|
||||||
|
const taskSource = ref<string>('')
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
|
|
||||||
@@ -67,9 +68,10 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startTask(endpoint: string, body?: any) {
|
async function startTask(endpoint: string, body?: any, source: string = 'processing') {
|
||||||
const res = await api.post(endpoint, body || {})
|
const res = await api.post(endpoint, body || {})
|
||||||
const taskId = res.data.task_id
|
const taskId = res.data.task_id
|
||||||
|
taskSource.value = source
|
||||||
currentTask.value = {
|
currentTask.value = {
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
name: res.data.message || '',
|
name: res.data.message || '',
|
||||||
@@ -90,5 +92,5 @@ export const useProcessingStore = defineStore('processing', () => {
|
|||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
return { currentTask, tasks, logs, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
|
return { currentTask, tasks, logs, taskSource, connectWebSocket, disconnectWebSocket, startTask, pollTaskStatus }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,48 +7,66 @@
|
|||||||
<el-icon :size="20" color="#6366f1"><Connection /></el-icon>
|
<el-icon :size="20" color="#6366f1"><Connection /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-value">{{ items.length }}</span>
|
<span class="stat-value">{{ mappingItems.length + specialItems.length }}</span>
|
||||||
<span class="stat-label">映射规则</span>
|
<span class="stat-label">总规则数</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: rgba(16,185,129,0.1)">
|
||||||
|
<el-icon :size="20" color="#10b981"><Right /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ mappingItems.length }}</span>
|
||||||
|
<span class="stat-label">条码映射</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
|
||||||
|
<el-icon :size="20" color="#f59e0b"><Setting /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ specialItems.length }}</span>
|
||||||
|
<span class="stat-label">特殊处理</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main table card -->
|
<!-- Two-tab layout matching GUI -->
|
||||||
<div class="card animate-in animate-in-delay-1">
|
<div class="card animate-in animate-in-delay-1">
|
||||||
<div class="card-head">
|
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||||
<h3>条码映射管理</h3>
|
<!-- ═══ Tab 1: 条码映射 ═══ -->
|
||||||
<div class="card-actions">
|
<el-tab-pane label="条码映射" name="mapping">
|
||||||
|
<div class="tab-toolbar">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="search"
|
v-model="search"
|
||||||
placeholder="搜索条码..."
|
placeholder="搜索条码..."
|
||||||
clearable
|
clearable
|
||||||
style="width: 200px"
|
style="width: 220px"
|
||||||
@keyup.enter="loadData"
|
@keyup.enter="loadData"
|
||||||
@clear="loadData"
|
@clear="loadData"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
<el-icon><Search /></el-icon>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
</el-input>
|
||||||
|
<div class="tab-actions">
|
||||||
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||||
<el-button size="small" type="primary" @click="openAdd" :icon="Plus">新增映射</el-button>
|
<el-button size="small" type="primary" @click="openMappingAdd">新增映射</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="items" v-loading="loading" stripe max-height="600" size="small" class="barcode-table">
|
<el-table :data="mappingItems" v-loading="loading" stripe max-height="500" size="small">
|
||||||
<el-table-column prop="barcode" label="原始条码" width="200">
|
<el-table-column prop="barcode" label="源条码" width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="barcode-cell">{{ row.barcode }}</span>
|
<span class="barcode-cell">{{ row.barcode }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="映射" width="60" align="center">
|
<el-table-column label="" width="40" align="center">
|
||||||
<template #default>
|
<template #default>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--amber-500)" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--amber-500)" stroke-width="2">
|
||||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="target" label="目标条码" width="200">
|
<el-table-column label="目标条码" width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="barcode-cell target">{{ row.target }}</span>
|
<span class="barcode-cell target">{{ row.target }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -56,49 +74,140 @@
|
|||||||
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
||||||
<el-table-column label="操作" width="130" fixed="right">
|
<el-table-column label="操作" width="130" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
|
<el-button type="primary" link size="small" @click="editMapping(row)">编辑</el-button>
|
||||||
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
|
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ═══ Tab 2: 特殊处理 ═══ -->
|
||||||
|
<el-tab-pane label="特殊处理" name="special">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-input
|
||||||
|
v-model="search"
|
||||||
|
placeholder="搜索条码..."
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="loadData"
|
||||||
|
@clear="loadData"
|
||||||
|
>
|
||||||
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
|
</el-input>
|
||||||
|
<div class="tab-actions">
|
||||||
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openSpecialAdd">新增特殊处理</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit dialog -->
|
<el-table :data="specialItems" v-loading="loading" stripe max-height="500" size="small">
|
||||||
<el-dialog v-model="showAdd" :title="isEdit ? '编辑映射' : '新增映射'" width="450px" :close-on-click-modal="false">
|
<el-table-column prop="barcode" label="条码" width="180">
|
||||||
<el-form :model="form" label-width="80px">
|
<template #default="{ row }">
|
||||||
<el-form-item label="原始条码">
|
<span class="barcode-cell special-type">{{ row.barcode }}</span>
|
||||||
<el-input v-model="form.barcode" :disabled="isEdit" placeholder="输入原始条码" />
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="multiplier" label="乘数" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="multiplier-badge">{{ row.multiplier }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="target_unit" label="目标单位" width="90" align="center" />
|
||||||
|
<el-table-column prop="fixed_price" label="固定单价" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.fixed_price != null" class="price-cell">{{ row.fixed_price.toFixed(4) }}</span>
|
||||||
|
<span v-else class="text-muted">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="specification" label="规格" width="90" align="center" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="130" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="editSpecial(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mapping add/edit dialog -->
|
||||||
|
<el-dialog v-model="showMapping" :title="mappingEdit ? '编辑条码映射' : '新增条码映射'" width="450px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="mappingForm" label-width="80px">
|
||||||
|
<el-form-item label="源条码">
|
||||||
|
<el-input v-model="mappingForm.barcode" :disabled="mappingEdit" placeholder="输入原始条码" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="目标条码">
|
<el-form-item label="目标条码">
|
||||||
<el-input v-model="form.target" placeholder="输入目标条码" />
|
<el-input v-model="mappingForm.target" placeholder="输入目标条码" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="说明">
|
<el-form-item label="说明">
|
||||||
<el-input v-model="form.description" placeholder="映射说明(可选)" />
|
<el-input v-model="mappingForm.description" placeholder="可选" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="showAdd = false">取消</el-button>
|
<el-button @click="showMapping = false">取消</el-button>
|
||||||
<el-button type="primary" @click="saveMapping">保存</el-button>
|
<el-button type="primary" @click="saveMapping">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Special rule add/edit dialog -->
|
||||||
|
<el-dialog v-model="showSpecial" :title="specialEdit ? '编辑特殊处理' : '新增特殊处理'" width="480px" :close-on-click-modal="false">
|
||||||
|
<el-form :model="specialForm" label-width="80px">
|
||||||
|
<el-form-item label="条码">
|
||||||
|
<el-input v-model="specialForm.barcode" :disabled="specialEdit" placeholder="输入条码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="乘数">
|
||||||
|
<el-input-number v-model="specialForm.multiplier" :min="1" :step="1" style="width: 100%" placeholder="如: 10" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标单位">
|
||||||
|
<el-input v-model="specialForm.targetUnit" placeholder="如: 瓶、个、对" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="固定单价">
|
||||||
|
<el-input-number v-model="specialForm.fixedPrice" :precision="4" :step="0.01" :min="0" style="width: 100%" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规格">
|
||||||
|
<el-input v-model="specialForm.specification" placeholder="如: 1*30" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="specialForm.description" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showSpecial = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveSpecial">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, Refresh, Plus, Connection } from '@element-plus/icons-vue'
|
import { Search, Refresh, Plus, Connection, Right, Setting } from '@element-plus/icons-vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const items = ref<any[]>([])
|
const rawItems = ref<any[]>([])
|
||||||
const showAdd = ref(false)
|
const activeTab = ref('mapping')
|
||||||
const isEdit = ref(false)
|
|
||||||
|
|
||||||
const form = reactive({
|
const mappingItems = computed(() => rawItems.value.filter(r => !r.multiplier))
|
||||||
|
const specialItems = computed(() => rawItems.value.filter(r => r.multiplier))
|
||||||
|
|
||||||
|
// ── Mapping form ──
|
||||||
|
const showMapping = ref(false)
|
||||||
|
const mappingEdit = ref(false)
|
||||||
|
const mappingForm = reactive({ barcode: '', target: '', description: '' })
|
||||||
|
|
||||||
|
// ── Special form ──
|
||||||
|
const showSpecial = ref(false)
|
||||||
|
const specialEdit = ref(false)
|
||||||
|
const specialForm = reactive({
|
||||||
barcode: '',
|
barcode: '',
|
||||||
target: '',
|
multiplier: null as number | null,
|
||||||
|
targetUnit: '',
|
||||||
|
fixedPrice: null as number | null,
|
||||||
|
specification: '',
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,7 +215,7 @@ async function loadData() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/barcodes', { params: { search: search.value } })
|
const res = await api.get('/barcodes', { params: { search: search.value } })
|
||||||
items.value = res.data.items
|
rawItems.value = res.data.items
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('加载失败')
|
ElMessage.error('加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,53 +223,113 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAdd() {
|
function onTabChange() {
|
||||||
resetForm()
|
// Keep search across tabs
|
||||||
showAdd.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editItem(row: any) {
|
// ── Mapping CRUD ──
|
||||||
isEdit.value = true
|
function openMappingAdd() {
|
||||||
form.barcode = row.barcode
|
mappingEdit.value = false
|
||||||
form.target = row.target
|
mappingForm.barcode = ''
|
||||||
form.description = row.description || ''
|
mappingForm.target = ''
|
||||||
showAdd.value = true
|
mappingForm.description = ''
|
||||||
|
showMapping.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function editMapping(row: any) {
|
||||||
form.barcode = ''
|
mappingEdit.value = true
|
||||||
form.target = ''
|
mappingForm.barcode = row.barcode
|
||||||
form.description = ''
|
mappingForm.target = row.target
|
||||||
isEdit.value = false
|
mappingForm.description = row.description || ''
|
||||||
|
showMapping.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveMapping() {
|
async function saveMapping() {
|
||||||
if (!form.barcode || !form.target) {
|
if (!mappingForm.barcode || !mappingForm.target) {
|
||||||
ElMessage.warning('请填写条码和目标')
|
ElMessage.warning('请填写源条码和目标条码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (isEdit.value) {
|
if (mappingEdit.value) {
|
||||||
await api.put(`/barcodes/${form.barcode}`, {
|
await api.put(`/barcodes/${mappingForm.barcode}`, {
|
||||||
target: form.target,
|
target: mappingForm.target,
|
||||||
description: form.description,
|
description: mappingForm.description,
|
||||||
})
|
})
|
||||||
ElMessage.success('已更新')
|
ElMessage.success('已更新')
|
||||||
} else {
|
} else {
|
||||||
await api.post('/barcodes', form)
|
await api.post('/barcodes', {
|
||||||
|
barcode: mappingForm.barcode,
|
||||||
|
target: mappingForm.target,
|
||||||
|
description: mappingForm.description,
|
||||||
|
})
|
||||||
ElMessage.success('已创建')
|
ElMessage.success('已创建')
|
||||||
}
|
}
|
||||||
showAdd.value = false
|
showMapping.value = false
|
||||||
resetForm()
|
|
||||||
loadData()
|
loadData()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error(err.response?.data?.detail || '操作失败')
|
ElMessage.error(err.response?.data?.detail || '操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Special CRUD ──
|
||||||
|
function openSpecialAdd() {
|
||||||
|
specialEdit.value = false
|
||||||
|
specialForm.barcode = ''
|
||||||
|
specialForm.multiplier = null
|
||||||
|
specialForm.targetUnit = ''
|
||||||
|
specialForm.fixedPrice = null
|
||||||
|
specialForm.specification = ''
|
||||||
|
specialForm.description = ''
|
||||||
|
showSpecial.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSpecial(row: any) {
|
||||||
|
specialEdit.value = true
|
||||||
|
specialForm.barcode = row.barcode
|
||||||
|
specialForm.multiplier = row.multiplier
|
||||||
|
specialForm.targetUnit = row.target_unit || ''
|
||||||
|
specialForm.fixedPrice = row.fixed_price ?? null
|
||||||
|
specialForm.specification = row.specification || ''
|
||||||
|
specialForm.description = row.description || ''
|
||||||
|
showSpecial.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSpecial() {
|
||||||
|
if (!specialForm.barcode) {
|
||||||
|
ElMessage.warning('请填写条码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!specialForm.multiplier) {
|
||||||
|
ElMessage.warning('请填写乘数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
multiplier: specialForm.multiplier,
|
||||||
|
target_unit: specialForm.targetUnit || null,
|
||||||
|
fixed_price: specialForm.fixedPrice ?? null,
|
||||||
|
specification: specialForm.specification || null,
|
||||||
|
description: specialForm.description,
|
||||||
|
}
|
||||||
|
if (specialEdit.value) {
|
||||||
|
await api.put(`/barcodes/${specialForm.barcode}`, body)
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
} else {
|
||||||
|
await api.post('/barcodes', { barcode: specialForm.barcode, ...body })
|
||||||
|
ElMessage.success('已创建')
|
||||||
|
}
|
||||||
|
showSpecial.value = false
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.response?.data?.detail || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared ──
|
||||||
async function deleteItem(row: any) {
|
async function deleteItem(row: any) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除映射 ${row.barcode} → ${row.target}?`, '确认')
|
const desc = row.target ? `${row.barcode} → ${row.target}` : `${row.barcode}`
|
||||||
|
await ElMessageBox.confirm(`确定删除规则 ${desc}?`, '确认')
|
||||||
await api.delete(`/barcodes/${row.barcode}`)
|
await api.delete(`/barcodes/${row.barcode}`)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadData()
|
loadData()
|
||||||
@@ -172,16 +341,16 @@ onMounted(loadData)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.barcodes-page {
|
.barcodes-page {
|
||||||
max-width: 1200px;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Stats row ── */
|
/* ── Stats row ── */
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(1, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
max-width: 300px;
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@@ -230,38 +399,23 @@ onMounted(loadData)
|
|||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
/* ── Tab toolbar ── */
|
||||||
box-shadow: var(--shadow-md);
|
.tab-toolbar {
|
||||||
}
|
|
||||||
|
|
||||||
.card-head {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-head h3 {
|
.tab-actions {
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Table ── */
|
/* ── Barcode cells ── */
|
||||||
.barcode-table {
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.barcode-cell {
|
.barcode-cell {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -271,4 +425,28 @@ onMounted(loadData)
|
|||||||
.barcode-cell.target {
|
.barcode-cell.target {
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.barcode-cell.special-type {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiplier-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(245,158,11,0.1);
|
||||||
|
color: var(--warning);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-cell {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const loading = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const activeTab = ref('')
|
const activeTab = ref('')
|
||||||
const config = ref<Record<string, Record<string, string>>>({})
|
const config = ref<Record<string, Record<string, string>>>({})
|
||||||
const edited: Record<string, Record<string, string>> = {}
|
const edited = reactive<Record<string, Record<string, string>>>({})
|
||||||
|
|
||||||
const sectionLabels: Record<string, string> = {
|
const sectionLabels: Record<string, string> = {
|
||||||
API: 'API 配置',
|
API: 'API 配置',
|
||||||
|
|||||||
@@ -193,8 +193,11 @@ const detailedStats = ref({
|
|||||||
total_processed: 0,
|
total_processed: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentTask = computed(() => ps.currentTask)
|
const currentTask = computed(() => {
|
||||||
const logs = computed(() => ps.logs)
|
if (ps.taskSource !== 'sync') return ps.currentTask
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const logs = computed(() => ps.logs.slice(0, 10))
|
||||||
|
|
||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
const m: Record<string, string> = {
|
const m: Record<string, string> = {
|
||||||
@@ -341,7 +344,7 @@ async function upload(files: File[]): Promise<void> {
|
|||||||
})
|
})
|
||||||
const typeLabel = getFileTypeLabel(file.name)
|
const typeLabel = getFileTypeLabel(file.name)
|
||||||
uploadedFiles.push({ name: file.name, type: typeLabel })
|
uploadedFiles.push({ name: file.name, type: typeLabel })
|
||||||
ElMessage.success(`${file.name} → ${typeLabel === 'OCR' ? 'OCR识别队列' : 'Excel处理队列'}`)
|
ElMessage.success(`${file.name} → ${typeLabel === 'OCR' ? '全流程处理队列' : 'Excel处理队列'}`)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error(`上传失败: ${file.name}`)
|
ElMessage.error(`上传失败: ${file.name}`)
|
||||||
}
|
}
|
||||||
@@ -351,13 +354,13 @@ async function upload(files: File[]): Promise<void> {
|
|||||||
uploadPct.value = 0
|
uploadPct.value = 0
|
||||||
refreshStats()
|
refreshStats()
|
||||||
|
|
||||||
// Auto-process: run pipeline for images, excel for Excel files
|
// Auto-process: pipeline for images, excel for Excel files
|
||||||
if (uploadedFiles.length > 0) {
|
if (uploadedFiles.length > 0) {
|
||||||
const hasImages = uploadedFiles.some(f => f.type === 'OCR')
|
const hasImages = uploadedFiles.some(f => f.type === 'OCR')
|
||||||
const hasExcel = uploadedFiles.some(f => f.type === 'Excel')
|
const hasExcel = uploadedFiles.some(f => f.type === 'Excel')
|
||||||
if (hasImages) {
|
if (hasImages) {
|
||||||
ElMessage.info('自动启动OCR识别...')
|
ElMessage.info('自动启动一键全流程...')
|
||||||
await doAction('/processing/ocr-batch')
|
await doAction('/processing/pipeline')
|
||||||
} else if (hasExcel) {
|
} else if (hasExcel) {
|
||||||
ElMessage.info('自动启动Excel处理...')
|
ElMessage.info('自动启动Excel处理...')
|
||||||
await doAction('/processing/excel')
|
await doAction('/processing/excel')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-value">{{ total }}</span>
|
<span class="stat-value">{{ total }}</span>
|
||||||
<span class="stat-label">总记录数</span>
|
<span class="stat-label">总记录</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -17,16 +17,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-value">{{ highConfidence }}</span>
|
<span class="stat-value">{{ highConfidence }}</span>
|
||||||
<span class="stat-label">高置信度</span>
|
<span class="stat-label">高可信 (>50)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
|
<div class="stat-icon" style="background: rgba(245,158,11,0.1)">
|
||||||
<el-icon :size="20" color="#f59e0b"><Warning /></el-icon>
|
<el-icon :size="20" color="#f59e0b"><Warning /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-value">{{ mediumConfidence }}</span>
|
||||||
|
<span class="stat-label">中可信 (10~50)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: rgba(239,68,68,0.1)">
|
||||||
|
<el-icon :size="20" color="#ef4444"><Warning /></el-icon>
|
||||||
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<span class="stat-value">{{ lowConfidence }}</span>
|
<span class="stat-value">{{ lowConfidence }}</span>
|
||||||
<span class="stat-label">低置信度</span>
|
<span class="stat-label">低可信 (<10)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +58,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
<el-button size="small" @click="loadData" :icon="Refresh">刷新</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openAdd">新增记录</el-button>
|
||||||
<el-button size="small" type="warning" plain @click="reimport">重新导入</el-button>
|
<el-button size="small" type="warning" plain @click="reimport">重新导入</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,22 +77,35 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip />
|
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip />
|
||||||
<el-table-column prop="spec" label="规格" width="120" />
|
<el-table-column prop="specification" label="规格" width="120" />
|
||||||
<el-table-column prop="unit" label="单位" width="80" />
|
<el-table-column prop="unit" label="单位" width="80" />
|
||||||
<el-table-column prop="price" label="单价" width="100">
|
<el-table-column label="均价" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="price-cell">{{ row.price != null ? row.price.toFixed(4) : '-' }}</span>
|
<span class="price-cell">{{ row.avg_price != null ? row.avg_price.toFixed(4) : '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="confidence" label="置信度" width="100">
|
<el-table-column label="价格范围" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.price_count > 0" class="price-range">
|
||||||
|
{{ row.min_price?.toFixed(2) }}~{{ row.max_price?.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="记录次数" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="row.price_count > 3 ? 'count-high' : 'count-low'">{{ row.price_count || 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="可信度" width="90" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="confidence-badge" :class="confCls(row.confidence)">
|
<span class="confidence-badge" :class="confCls(row.confidence)">
|
||||||
{{ row.confidence }}
|
{{ row.confidence }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="source" label="来源" width="80" />
|
<el-table-column prop="source" label="来源" width="75" align="center" />
|
||||||
<el-table-column prop="use_count" label="使用次数" width="90" />
|
<el-table-column prop="use_count" label="出现" width="60" align="center" />
|
||||||
<el-table-column label="操作" width="130" fixed="right">
|
<el-table-column label="操作" width="130" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
|
<el-button type="primary" link size="small" @click="editItem(row)">编辑</el-button>
|
||||||
@@ -104,16 +127,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit dialog -->
|
<!-- Edit dialog -->
|
||||||
<el-dialog v-model="showEdit" title="编辑记忆记录" width="480px" :close-on-click-modal="false">
|
<el-dialog v-model="showEdit" :title="isAdd ? '新增记忆记录' : '编辑记忆记录'" width="480px" :close-on-click-modal="false">
|
||||||
<el-form :model="editForm" label-width="80px">
|
<el-form :model="editForm" label-width="80px">
|
||||||
<el-form-item label="条码">
|
<el-form-item label="条码">
|
||||||
<el-input :model-value="editForm.barcode" disabled />
|
<el-input v-model="editForm.barcode" :disabled="!isAdd" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="名称">
|
<el-form-item label="名称">
|
||||||
<el-input v-model="editForm.name" />
|
<el-input v-model="editForm.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="规格">
|
<el-form-item label="规格">
|
||||||
<el-input v-model="editForm.spec" />
|
<el-input v-model="editForm.specification" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="单位">
|
<el-form-item label="单位">
|
||||||
<el-input v-model="editForm.unit" />
|
<el-input v-model="editForm.unit" />
|
||||||
@@ -146,22 +169,24 @@ const page = ref(1)
|
|||||||
const pageSize = ref(50)
|
const pageSize = ref(50)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
const highConfidence = computed(() => items.value.filter(i => i.confidence >= 80).length)
|
const highConfidence = computed(() => items.value.filter(i => i.confidence > 50).length)
|
||||||
const lowConfidence = computed(() => items.value.filter(i => i.confidence < 50).length)
|
const mediumConfidence = computed(() => items.value.filter(i => i.confidence >= 10 && i.confidence <= 50).length)
|
||||||
|
const lowConfidence = computed(() => items.value.filter(i => i.confidence < 10).length)
|
||||||
|
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
|
const isAdd = ref(false)
|
||||||
const editForm = reactive({
|
const editForm = reactive({
|
||||||
barcode: '',
|
barcode: '',
|
||||||
name: '',
|
name: '',
|
||||||
spec: '',
|
specification: '',
|
||||||
unit: '',
|
unit: '',
|
||||||
price: 0,
|
price: 0,
|
||||||
confidence: 0,
|
confidence: 50,
|
||||||
})
|
})
|
||||||
|
|
||||||
function confCls(c: number) {
|
function confCls(c: number) {
|
||||||
if (c >= 80) return 'high'
|
if (c > 50) return 'high'
|
||||||
if (c >= 50) return 'mid'
|
if (c >= 10) return 'mid'
|
||||||
return 'low'
|
return 'low'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +205,22 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
isAdd.value = true
|
||||||
|
editForm.barcode = ''
|
||||||
|
editForm.name = ''
|
||||||
|
editForm.specification = ''
|
||||||
|
editForm.unit = ''
|
||||||
|
editForm.price = 0
|
||||||
|
editForm.confidence = 50
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function editItem(row: any) {
|
function editItem(row: any) {
|
||||||
|
isAdd.value = false
|
||||||
editForm.barcode = row.barcode
|
editForm.barcode = row.barcode
|
||||||
editForm.name = row.name || ''
|
editForm.name = row.name || ''
|
||||||
editForm.spec = row.spec || ''
|
editForm.specification = row.specification || ''
|
||||||
editForm.unit = row.unit || ''
|
editForm.unit = row.unit || ''
|
||||||
editForm.price = row.price || 0
|
editForm.price = row.price || 0
|
||||||
editForm.confidence = row.confidence || 0
|
editForm.confidence = row.confidence || 0
|
||||||
@@ -191,15 +228,31 @@ function editItem(row: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
|
if (!editForm.barcode) {
|
||||||
|
ElMessage.warning('请输入条码')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
if (isAdd.value) {
|
||||||
|
await api.post('/memory', {
|
||||||
|
barcode: editForm.barcode,
|
||||||
|
name: editForm.name,
|
||||||
|
specification: editForm.specification,
|
||||||
|
unit: editForm.unit,
|
||||||
|
price: editForm.price,
|
||||||
|
confidence: editForm.confidence,
|
||||||
|
})
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
} else {
|
||||||
await api.put(`/memory/${editForm.barcode}`, {
|
await api.put(`/memory/${editForm.barcode}`, {
|
||||||
name: editForm.name,
|
name: editForm.name,
|
||||||
spec: editForm.spec,
|
specification: editForm.specification,
|
||||||
unit: editForm.unit,
|
unit: editForm.unit,
|
||||||
price: editForm.price,
|
price: editForm.price,
|
||||||
confidence: editForm.confidence,
|
confidence: editForm.confidence,
|
||||||
})
|
})
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
|
}
|
||||||
showEdit.value = false
|
showEdit.value = false
|
||||||
loadData()
|
loadData()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -243,7 +296,7 @@ onMounted(loadData)
|
|||||||
/* ── Stats row ── */
|
/* ── Stats row ── */
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
@@ -363,6 +416,26 @@ onMounted(loadData)
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.price-range {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-high {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-low {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Pagination ── */
|
/* ── Pagination ── */
|
||||||
.pagination-bar {
|
.pagination-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ async function checkStatus() {
|
|||||||
async function doPush() {
|
async function doPush() {
|
||||||
syncing.value = true
|
syncing.value = true
|
||||||
try {
|
try {
|
||||||
await processingStore.startTask('/sync/push')
|
await processingStore.startTask('/sync/push', undefined, 'sync')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error(err.response?.data?.detail || '推送失败')
|
ElMessage.error(err.response?.data?.detail || '推送失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,7 +137,7 @@ async function doPush() {
|
|||||||
async function doPull() {
|
async function doPull() {
|
||||||
syncing.value = true
|
syncing.value = true
|
||||||
try {
|
try {
|
||||||
await processingStore.startTask('/sync/pull')
|
await processingStore.startTask('/sync/pull', undefined, 'sync')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ElMessage.error(err.response?.data?.detail || '拉取失败')
|
ElMessage.error(err.response?.data?.detail || '拉取失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -114,7 +114,10 @@
|
|||||||
|
|
||||||
<div v-if="detailTask.result_files && detailTask.result_files.length > 0" class="detail-files">
|
<div v-if="detailTask.result_files && detailTask.result_files.length > 0" class="detail-files">
|
||||||
<h4>结果文件</h4>
|
<h4>结果文件</h4>
|
||||||
<div v-for="f in detailTask.result_files" :key="f" class="file-chip">{{ f }}</div>
|
<div v-for="f in detailTask.result_files" :key="f" class="file-row-detail">
|
||||||
|
<span class="file-path-text">{{ f }}</span>
|
||||||
|
<el-button size="small" @click="copyPath(f)">复制路径</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-logs">
|
<div class="detail-logs">
|
||||||
@@ -196,6 +199,15 @@ function showDetail(row: any) {
|
|||||||
showDetailDialog.value = true
|
showDetailDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyPath(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制路径')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function retryTask(row: any) {
|
async function retryTask(row: any) {
|
||||||
try {
|
try {
|
||||||
await api.post(`/tasks/${row.id}/retry`)
|
await api.post(`/tasks/${row.id}/retry`)
|
||||||
@@ -394,15 +406,20 @@ onMounted(() => {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-chip {
|
.file-row-detail {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
padding: 4px 10px;
|
align-items: center;
|
||||||
background: rgba(16,185,129,0.08);
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.file-path-text {
|
||||||
|
font-size: 13px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--success);
|
color: var(--text-primary);
|
||||||
margin: 0 4px 4px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-box {
|
.log-box {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<el-tag type="info" size="small">共 {{ total }} 个</el-tag>
|
<el-tag type="info" size="small">共 {{ total }} 个</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<el-button @click="triggerUpload">上传图片</el-button>
|
||||||
<el-button type="primary" :disabled="!selected.length" @click="batchPipeline">
|
<el-button type="primary" :disabled="!selected.length" @click="batchPipeline">
|
||||||
批量生成采购单 ({{ selected.length }})
|
批量生成采购单 ({{ selected.length }})
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -18,6 +19,14 @@
|
|||||||
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
|
<el-button type="danger" :disabled="!selected.length" @click="batchDelete">
|
||||||
批量删除
|
批量删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<input
|
||||||
|
ref="uploadInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".jpg,.jpeg,.png,.bmp"
|
||||||
|
hidden
|
||||||
|
@change="handleUpload"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,6 +34,7 @@
|
|||||||
:data="items"
|
:data="items"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
@selection-change="onSelect"
|
@selection-change="onSelect"
|
||||||
|
@sort-change="onSortChange"
|
||||||
stripe
|
stripe
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
@@ -63,21 +73,37 @@
|
|||||||
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200" align="center">
|
<el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click="pipelineFile(row)">
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
生成采购单
|
</template>
|
||||||
</el-button>
|
</el-table-column>
|
||||||
<el-button link type="primary" size="small" @click="ocrFile(row)">
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
仅OCR
|
<template #default="{ row }">
|
||||||
</el-button>
|
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
|
||||||
<el-button link type="danger" size="small" @click="deleteFile(row)">
|
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||||
删除
|
<el-button link type="primary" size="small" @click="pipelineFile(row)">生成采购单</el-button>
|
||||||
</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
|
||||||
|
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
|
||||||
|
<div class="detail-logs">
|
||||||
|
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
||||||
|
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
<template #footer><el-button @click="showDetailDlg = false">关闭</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
<div class="pagination-wrap">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="page"
|
v-model:current-page="page"
|
||||||
@@ -94,14 +120,27 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
|
const processingStore = useProcessingStore()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selected = ref<any[]>([])
|
const selected = ref<any[]>([])
|
||||||
|
const sortBy = ref('created_at')
|
||||||
|
const sortOrder = ref('desc')
|
||||||
|
const uploadInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const showPreview = ref(false)
|
||||||
|
const previewType = ref('')
|
||||||
|
const previewSrc = ref('')
|
||||||
|
const previewRows = ref<string[][]>([])
|
||||||
|
const showDetailDlg = ref(false)
|
||||||
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
function statusType(s: string) {
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
||||||
@@ -112,10 +151,15 @@ function statusText(s: string) {
|
|||||||
return m[s] || s
|
return m[s] || s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTime(t: string): string {
|
||||||
|
if (!t) return '--'
|
||||||
|
return t.replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize } })
|
const res = await api.get('/files/relations', { params: { view: 'images', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
items.value = res.data.items
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -124,6 +168,64 @@ async function loadData() {
|
|||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
|
async function previewFile(row: any) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const fname = row.input_image || row.output_excel || row.result_purchase
|
||||||
|
const dir = row.input_image ? 'input' : row.output_excel ? 'output' : 'result'
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
const ct = resp.headers.get('content-type') || ''
|
||||||
|
if (ct.includes('image')) {
|
||||||
|
previewType.value = 'image'
|
||||||
|
const blob = await resp.blob()
|
||||||
|
previewSrc.value = URL.createObjectURL(blob)
|
||||||
|
} else {
|
||||||
|
const data = await resp.json()
|
||||||
|
if (data.type === 'excel') { previewType.value = 'excel'; previewRows.value = data.rows }
|
||||||
|
}
|
||||||
|
showPreview.value = true
|
||||||
|
} catch { ElMessage.error('预览失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(row: any) {
|
||||||
|
const fname = row.input_image || row.output_excel || row.result_purchase
|
||||||
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
detailLogs.value = (processingStore.logs || []).filter((l: string) => l.includes(fname) || l.includes(stem))
|
||||||
|
showDetailDlg.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSortChange({ prop, order }: any) {
|
||||||
|
if (prop === 'created_at') {
|
||||||
|
sortBy.value = 'created_at'
|
||||||
|
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = ''
|
||||||
|
sortOrder.value = 'desc'
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() {
|
||||||
|
uploadInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: Event) {
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
if (!el.files || !el.files.length) return
|
||||||
|
for (const file of Array.from(el.files)) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
try {
|
||||||
|
await api.post('/files/upload?target=input', fd)
|
||||||
|
ElMessage.success(`已上传: ${file.name}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(`上传失败: ${file.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.value = ''
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
async function pipelineFile(row: any) {
|
async function pipelineFile(row: any) {
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/processing/pipeline-single', { filename: row.input_image })
|
const res = await api.post('/processing/pipeline-single', { filename: row.input_image })
|
||||||
@@ -241,9 +343,29 @@ onMounted(loadData)
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
.pagination-wrap {
|
.pagination-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-table td { border:1px solid var(--border-light);padding:4px 8px;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis }
|
||||||
|
.preview-table tr:nth-child(even) { background:#fafafa }
|
||||||
|
.preview-table tr:first-child { background:#f0f0f0;font-weight:600 }
|
||||||
|
.detail-logs { max-height:60vh;overflow-y:auto;background:#09090b;border-radius:8px;padding:14px;font-family:var(--font-mono);font-size:12px;line-height:1.8 }
|
||||||
|
.detail-line { color:#a1a1aa }
|
||||||
|
.detail-line.err { color:#ef4444 }
|
||||||
|
.detail-line.ok { color:#22c55e }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex;flex-direction:column;width:96vw;height:94vh;margin:3vh 2vw;border-radius:12px!important }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1;min-height:0;padding:8px 16px 16px;overflow:hidden;display:flex;flex-direction:column }
|
||||||
|
.preview-body { flex:1;min-height:0;display:flex;flex-direction:column }
|
||||||
|
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
||||||
|
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
|
||||||
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
:data="items"
|
:data="items"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
@selection-change="onSelect"
|
@selection-change="onSelect"
|
||||||
|
@sort-change="onSortChange"
|
||||||
stripe
|
stripe
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
@@ -60,18 +61,49 @@
|
|||||||
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140" align="center">
|
<el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click="downloadFile(row)">
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
下载
|
</template>
|
||||||
</el-button>
|
</el-table-column>
|
||||||
<el-button link type="danger" size="small" @click="deleteFile(row)">
|
<el-table-column label="操作" width="240" fixed="right">
|
||||||
删除
|
<template #default="{ row }">
|
||||||
</el-button>
|
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||||
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<!-- Preview dialog -->
|
||||||
|
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div v-if="previewType === 'image'" class="preview-image-wrap">
|
||||||
|
<img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="previewType === 'excel'" class="preview-table-wrap">
|
||||||
|
<table class="preview-table">
|
||||||
|
<tr v-for="(row, ri) in previewRows" :key="ri">
|
||||||
|
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-else style="text-align:center;color:var(--text-muted);padding:40px">暂无可预览内容</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Detail dialog -->
|
||||||
|
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
|
||||||
|
<div class="detail-logs">
|
||||||
|
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
||||||
|
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showDetailDlg = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
<div class="pagination-wrap">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="page"
|
v-model:current-page="page"
|
||||||
@@ -88,14 +120,29 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
|
const processingStore = useProcessingStore()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selected = ref<any[]>([])
|
const selected = ref<any[]>([])
|
||||||
|
const sortBy = ref('created_at')
|
||||||
|
const sortOrder = ref('desc')
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
const showPreview = ref(false)
|
||||||
|
const previewType = ref('')
|
||||||
|
const previewSrc = ref('')
|
||||||
|
const previewRows = ref<string[][]>([])
|
||||||
|
|
||||||
|
// Detail
|
||||||
|
const showDetailDlg = ref(false)
|
||||||
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
function statusType(s: string) {
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
||||||
@@ -106,10 +153,15 @@ function statusText(s: string) {
|
|||||||
return m[s] || s
|
return m[s] || s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTime(t: string): string {
|
||||||
|
if (!t) return '--'
|
||||||
|
return t.replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize } })
|
const res = await api.get('/files/relations', { params: { view: 'orders', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
items.value = res.data.items
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -118,6 +170,54 @@ async function loadData() {
|
|||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
|
async function previewFile(row: any) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const fname = row.result_purchase || row.output_excel || row.input_image
|
||||||
|
const dir = row.result_purchase ? 'result' : row.output_excel ? 'output' : 'input'
|
||||||
|
const url = `/api/files/preview/${dir}/${encodeURIComponent(fname)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
const ct = resp.headers.get('content-type') || ''
|
||||||
|
|
||||||
|
if (ct.includes('image')) {
|
||||||
|
previewType.value = 'image'
|
||||||
|
const blob = await resp.blob()
|
||||||
|
previewSrc.value = URL.createObjectURL(blob)
|
||||||
|
} else {
|
||||||
|
const data = await resp.json()
|
||||||
|
if (data.type === 'excel') {
|
||||||
|
previewType.value = 'excel'
|
||||||
|
previewRows.value = data.rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showPreview.value = true
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('预览失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(row: any) {
|
||||||
|
const fname = row.result_purchase || row.output_excel || row.input_image
|
||||||
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
// Filter logs from the processing store that mention this file
|
||||||
|
detailLogs.value = (processingStore.logs || []).filter(
|
||||||
|
(l: string) => l.includes(fname) || l.includes(stem)
|
||||||
|
)
|
||||||
|
showDetailDlg.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSortChange({ prop, order }: any) {
|
||||||
|
if (prop === 'created_at') {
|
||||||
|
sortBy.value = 'created_at'
|
||||||
|
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = ''
|
||||||
|
sortOrder.value = 'desc'
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadFile(row: any) {
|
async function downloadFile(row: any) {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
|
window.open(`/api/files/download/result/${encodeURIComponent(row.result_purchase)}?token=${token}`, '_blank')
|
||||||
@@ -204,9 +304,47 @@ onMounted(loadData)
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
.pagination-wrap {
|
.pagination-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-table td {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
padding: 4px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.preview-table tr:nth-child(even) { background: #fafafa; }
|
||||||
|
.preview-table tr:first-child { background: #f0f0f0; font-weight: 600; }
|
||||||
|
|
||||||
|
.detail-logs {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #09090b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.detail-line { color: #a1a1aa; }
|
||||||
|
.detail-line.err { color: #ef4444; }
|
||||||
|
.detail-line.ok { color: #22c55e; }
|
||||||
|
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex; flex-direction:column; width:96vw; height:94vh; margin:3vh 2vw; border-radius:12px!important }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1; min-height:0; padding:8px 16px 16px; overflow:hidden; display:flex; flex-direction:column }
|
||||||
|
.preview-body { flex:1; min-height:0; display:flex; flex-direction:column }
|
||||||
|
.preview-image-wrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0 }
|
||||||
|
.preview-table-wrap { flex:1; overflow:auto; min-height:0; border:1px solid var(--border-light); border-radius:8px }
|
||||||
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<el-tag type="info" size="small">共 {{ total }} 个</el-tag>
|
<el-tag type="info" size="small">共 {{ total }} 个</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<el-button @click="triggerUpload">上传Excel</el-button>
|
||||||
<el-button type="primary" :disabled="!selected.length" @click="batchProcess">
|
<el-button type="primary" :disabled="!selected.length" @click="batchProcess">
|
||||||
批量处理 ({{ selected.length }})
|
批量处理 ({{ selected.length }})
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -15,6 +16,14 @@
|
|||||||
<el-button type="danger" @click="clearAll">
|
<el-button type="danger" @click="clearAll">
|
||||||
删除全部
|
删除全部
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<input
|
||||||
|
ref="uploadInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".xls,.xlsx"
|
||||||
|
hidden
|
||||||
|
@change="handleUpload"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,6 +31,7 @@
|
|||||||
:data="items"
|
:data="items"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
@selection-change="onSelect"
|
@selection-change="onSelect"
|
||||||
|
@sort-change="onSortChange"
|
||||||
stripe
|
stripe
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
@@ -60,18 +70,36 @@
|
|||||||
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
<el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140" align="center">
|
<el-table-column label="创建时间" width="170" align="center" sortable="custom" prop="created_at">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click="processFile(row)">
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
处理
|
</template>
|
||||||
</el-button>
|
</el-table-column>
|
||||||
<el-button link type="danger" size="small" @click="deleteFile(row)">
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
删除
|
<template #default="{ row }">
|
||||||
</el-button>
|
<el-button link type="primary" size="small" @click="previewFile(row)">预览</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||||
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<el-dialog v-model="showPreview" title="文件预览" :fullscreen="true" append-to-body :close-on-click-modal="false" class="preview-dialog">
|
||||||
|
<div class="preview-body">
|
||||||
|
<div v-if="previewType === 'image'" class="preview-image-wrap"><img :src="previewSrc" style="max-width:100%;max-height:100%;object-fit:contain" /></div>
|
||||||
|
<div v-else-if="previewType === 'excel'" class="preview-table-wrap"><table class="preview-table"><tr v-for="(row, ri) in previewRows" :key="ri"><td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td></tr></table></div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showDetailDlg" title="处理详情" width="70%" :close-on-click-modal="false" top="5vh">
|
||||||
|
<div class="detail-logs">
|
||||||
|
<div v-if="detailLogs.length === 0" style="text-align:center;color:var(--text-muted);padding:40px">暂无该文件的处理日志</div>
|
||||||
|
<div v-for="(line, i) in detailLogs" :key="i" class="detail-line" :class="{err: line.includes('失败')||line.includes('错误'), ok: line.includes('完成')}">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
<template #footer><el-button @click="showDetailDlg = false">关闭</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<div class="pagination-wrap">
|
<div class="pagination-wrap">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="page"
|
v-model:current-page="page"
|
||||||
@@ -88,14 +116,27 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Right } from '@element-plus/icons-vue'
|
import { Right } from '@element-plus/icons-vue'
|
||||||
|
import { useProcessingStore } from '../../stores/processing'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
|
||||||
|
const processingStore = useProcessingStore()
|
||||||
|
|
||||||
const items = ref<any[]>([])
|
const items = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 50
|
const pageSize = 50
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selected = ref<any[]>([])
|
const selected = ref<any[]>([])
|
||||||
|
const sortBy = ref('created_at')
|
||||||
|
const sortOrder = ref('desc')
|
||||||
|
const uploadInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const showPreview = ref(false)
|
||||||
|
const previewType = ref('')
|
||||||
|
const previewSrc = ref('')
|
||||||
|
const previewRows = ref<string[][]>([])
|
||||||
|
const showDetailDlg = ref(false)
|
||||||
|
const detailLogs = ref<string[]>([])
|
||||||
|
|
||||||
function statusType(s: string) {
|
function statusType(s: string) {
|
||||||
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
const m: Record<string, string> = { done: 'success', merged: 'success', excel_done: 'warning', ocr_done: 'info', pending: 'info' }
|
||||||
@@ -106,10 +147,15 @@ function statusText(s: string) {
|
|||||||
return m[s] || s
|
return m[s] || s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTime(t: string): string {
|
||||||
|
if (!t) return '--'
|
||||||
|
return t.replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize } })
|
const res = await api.get('/files/relations', { params: { view: 'tables', page: page.value, page_size: pageSize, sort_by: sortBy.value, sort_order: sortOrder.value } })
|
||||||
items.value = res.data.items
|
items.value = res.data.items
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -118,6 +164,64 @@ async function loadData() {
|
|||||||
|
|
||||||
function onSelect(rows: any[]) { selected.value = rows }
|
function onSelect(rows: any[]) { selected.value = rows }
|
||||||
|
|
||||||
|
async function previewFile(row: any) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const fname = row.output_excel || row.result_purchase || row.input_image
|
||||||
|
const dir = row.output_excel ? 'output' : row.result_purchase ? 'result' : 'input'
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/files/preview/${dir}/${encodeURIComponent(fname)}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
const ct = resp.headers.get('content-type') || ''
|
||||||
|
if (ct.includes('image')) {
|
||||||
|
previewType.value = 'image'
|
||||||
|
const blob = await resp.blob()
|
||||||
|
previewSrc.value = URL.createObjectURL(blob)
|
||||||
|
} else {
|
||||||
|
const data = await resp.json()
|
||||||
|
if (data.type === 'excel') { previewType.value = 'excel'; previewRows.value = data.rows }
|
||||||
|
}
|
||||||
|
showPreview.value = true
|
||||||
|
} catch { ElMessage.error('预览失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail(row: any) {
|
||||||
|
const fname = row.output_excel || row.result_purchase || row.input_image
|
||||||
|
const stem = fname.replace(/\.[^.]+$/, '')
|
||||||
|
detailLogs.value = (processingStore.logs || []).filter((l: string) => l.includes(fname) || l.includes(stem))
|
||||||
|
showDetailDlg.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSortChange({ prop, order }: any) {
|
||||||
|
if (prop === 'created_at') {
|
||||||
|
sortBy.value = 'created_at'
|
||||||
|
sortOrder.value = order === 'ascending' ? 'asc' : 'desc'
|
||||||
|
} else {
|
||||||
|
sortBy.value = ''
|
||||||
|
sortOrder.value = 'desc'
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() {
|
||||||
|
uploadInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: Event) {
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
if (!el.files || !el.files.length) return
|
||||||
|
for (const file of Array.from(el.files)) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
try {
|
||||||
|
await api.post('/files/upload?target=output', fd)
|
||||||
|
ElMessage.success(`已上传: ${file.name}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(`上传失败: ${file.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.value = ''
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
async function processFile(row: any) {
|
async function processFile(row: any) {
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/processing/excel-single', { filename: row.output_excel })
|
const res = await api.post('/processing/excel-single', { filename: row.output_excel })
|
||||||
@@ -208,9 +312,29 @@ onMounted(loadData)
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
.pagination-wrap {
|
.pagination-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-table td { border:1px solid var(--border-light);padding:4px 8px;white-space:nowrap;max-width:200px;overflow:hidden;text-overflow:ellipsis }
|
||||||
|
.preview-table tr:nth-child(even) { background:#fafafa }
|
||||||
|
.preview-table tr:first-child { background:#f0f0f0;font-weight:600 }
|
||||||
|
.detail-logs { max-height:60vh;overflow-y:auto;background:#09090b;border-radius:8px;padding:14px;font-family:var(--font-mono);font-size:12px;line-height:1.8 }
|
||||||
|
.detail-line { color:#a1a1aa }
|
||||||
|
.detail-line.err { color:#ef4444 }
|
||||||
|
.detail-line.ok { color:#22c55e }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) { display:flex;flex-direction:column;width:96vw;height:94vh;margin:3vh 2vw;border-radius:12px!important }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__header { flex-shrink:0 }
|
||||||
|
:global(.preview-dialog.el-dialog.is-fullscreen) .el-dialog__body { flex:1;min-height:0;padding:8px 16px 16px;overflow:hidden;display:flex;flex-direction:column }
|
||||||
|
.preview-body { flex:1;min-height:0;display:flex;flex-direction:column }
|
||||||
|
.preview-image-wrap { flex:1;display:flex;align-items:center;justify-content:center;min-height:0 }
|
||||||
|
.preview-table-wrap { flex:1;overflow:auto;min-height:0;border:1px solid var(--border-light);border-radius:8px }
|
||||||
|
.preview-table { border-collapse:collapse;font-size:12px;width:100% }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user