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:
2026-05-05 19:37:10 +08:00
parent c18039f790
commit 81bafaf557
20 changed files with 1610 additions and 502 deletions
+13 -12
View File
@@ -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='')
self.config.set('API', option, '') except Exception:
saved_keys[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:
"""获取配置值""" """获取配置值"""
+308 -229
View File
@@ -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()
try:
cursor = conn.execute(
"SELECT name, specification, unit, confidence, usage_count, "
"avg_price, min_price, max_price, price_count FROM products WHERE barcode=?",
(barcode,)).fetchone()
finally:
conn.close()
if cursor is None:
exists = False
else:
old_name, old_spec, old_unit, old_conf, old_count, old_avg, old_min, old_max, pc = cursor
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
new_count = old_count + 1 if exists else 1
# ── 置信度 ──
if source == 'user_confirmed':
new_conf = 90
elif source == 'template':
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 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() conn = self._connect()
try: try:
cursor = conn.execute( if not exists:
"SELECT confidence, usage_count FROM products WHERE barcode = ?",
(barcode,)
)
row = cursor.fetchone()
if row is None:
# 新记录
conf = {'template': 100, 'user_confirmed': 90}.get(source, 50)
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 (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)", "avg_price, min_price, max_price, price_count) "
(barcode, name, spec, unit, price, source, conf, now, now) "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: else:
old_conf, old_count = row # 高可信度源全字段覆盖;低可信度仅填空
new_count = old_count + 1 if source in ('template', 'user_confirmed') or new_conf > 50:
if source == 'template':
new_conf = 100
elif source == 'user_confirmed':
new_conf = 90
else: # ocr
new_conf = min(80, old_conf + 10) if old_conf < 80 else old_conf
if source in ('template', 'user_confirmed'):
# 高权威来源:全字段覆盖
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)
count += 1 if result:
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
+63
View File
@@ -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
View File
@@ -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
+64 -10
View File
@@ -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}")
+49 -2
View File
@@ -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}")
+39 -3
View File
@@ -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,
+189 -23
View File
@@ -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
View File
@@ -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:
+10 -2
View File
@@ -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()
+4 -2
View File
@@ -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 }
}) })
+289 -111
View File
@@ -7,98 +7,207 @@
<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">
<el-input <div class="tab-toolbar">
v-model="search" <el-input
placeholder="搜索条码..." v-model="search"
clearable placeholder="搜索条码..."
style="width: 200px" clearable
@keyup.enter="loadData" style="width: 220px"
@clear="loadData" @keyup.enter="loadData"
> @clear="loadData"
<template #prefix> >
<el-icon><Search /></el-icon> <template #prefix><el-icon><Search /></el-icon></template>
</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>
</el-table-column> </el-table-column>
<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>
<el-table :data="specialItems" v-loading="loading" stripe max-height="500" size="small">
<el-table-column prop="barcode" label="条码" width="180">
<template #default="{ row }">
<span class="barcode-cell special-type">{{ row.barcode }}</span>
</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> </div>
<!-- Add/Edit dialog --> <!-- Mapping add/edit dialog -->
<el-dialog v-model="showAdd" :title="isEdit ? '编辑映射' : '新增映射'" width="450px" :close-on-click-modal="false"> <el-dialog v-model="showMapping" :title="mappingEdit ? '编辑条码映射' : '新增条码映射'" width="450px" :close-on-click-modal="false">
<el-form :model="form" label-width="80px"> <el-form :model="mappingForm" label-width="80px">
<el-form-item label="原始条码"> <el-form-item label="条码">
<el-input v-model="form.barcode" :disabled="isEdit" placeholder="输入原始条码" /> <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>
+1 -1
View File
@@ -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 配置',
+9 -6
View File
@@ -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')
+101 -28
View File
@@ -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">可信 (&gt;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">可信 (&lt;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 {
await api.put(`/memory/${editForm.barcode}`, { if (isAdd.value) {
name: editForm.name, await api.post('/memory', {
spec: editForm.spec, barcode: editForm.barcode,
unit: editForm.unit, name: editForm.name,
price: editForm.price, specification: editForm.specification,
confidence: editForm.confidence, unit: editForm.unit,
}) price: editForm.price,
ElMessage.success('保存成功') confidence: editForm.confidence,
})
ElMessage.success('添加成功')
} else {
await api.put(`/memory/${editForm.barcode}`, {
name: editForm.name,
specification: editForm.specification,
unit: editForm.unit,
price: editForm.price,
confidence: editForm.confidence,
})
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;
+2 -2
View File
@@ -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 {
+25 -8
View File
@@ -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 {
+133 -11
View File
@@ -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>
+146 -8
View File
@@ -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>
+132 -8
View File
@@ -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>