加入业务员快速处理信息
This commit is contained in:
parent
fe25444955
commit
1c8ae36203
@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.api.deps import require_permissions, require_roles
|
||||
from backend.app.db import get_db_session
|
||||
from backend.app.schemas.ai import CorrectRecognizeResultRequest, RecognizeImageRequest
|
||||
from backend.app.schemas.ai import CorrectRecognizeResultRequest, ParseOrderRequest, RecognizeImageRequest
|
||||
from backend.app.schemas.common import success_payload
|
||||
from backend.app.services.ai_service import ai_service
|
||||
|
||||
@ -29,3 +29,13 @@ def correct_recognize_result(
|
||||
_permission_user: dict = Depends(require_permissions("ai:correct")),
|
||||
) -> dict:
|
||||
return success_payload(ai_service.correct_result(log_id, payload.model_dump(), session))
|
||||
|
||||
|
||||
@router.post("/parse-order")
|
||||
def parse_order(
|
||||
payload: ParseOrderRequest,
|
||||
session: Session = Depends(get_db_session),
|
||||
current_user: dict = Depends(require_roles("salesman", "admin")),
|
||||
_permission_user: dict = Depends(require_permissions("ai:parse-order")),
|
||||
) -> dict:
|
||||
return success_payload(ai_service.parse_order(payload.model_dump(), session))
|
||||
|
||||
@ -15,6 +15,7 @@ from backend.app.services.reminder_service import reminder_service
|
||||
from backend.app.services.report_service import report_service
|
||||
from backend.app.services.supplier_service import supplier_service
|
||||
from backend.app.services.system_service import system_service
|
||||
from backend.app.services.ai_service import ai_service
|
||||
|
||||
|
||||
# 统一依赖入口,后续如果切换到真实容器或数据库实现,只需要改这里。
|
||||
@ -94,3 +95,7 @@ def get_audit_service():
|
||||
|
||||
def get_system_service():
|
||||
return system_service
|
||||
|
||||
|
||||
def get_ai_service():
|
||||
return ai_service
|
||||
|
||||
@ -57,6 +57,12 @@ class Settings(BaseSettings):
|
||||
wechat_template_logistics_timeout: str = Field(default="", alias="WECHAT_TEMPLATE_LOGISTICS_TIMEOUT")
|
||||
wechat_template_arrears: str = Field(default="", alias="WECHAT_TEMPLATE_ARREARS")
|
||||
wechat_template_inactive_customer: str = Field(default="", alias="WECHAT_TEMPLATE_INACTIVE_CUSTOMER")
|
||||
llm_parse_enabled: bool = Field(default=True, alias="LLM_PARSE_ENABLED")
|
||||
llm_parse_model: str = Field(default="qwen-plus", alias="LLM_PARSE_MODEL")
|
||||
llm_parse_api_url: str = Field(
|
||||
default="https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
alias="LLM_PARSE_API_URL",
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class RecognizeImageRequest(BaseModel):
|
||||
@ -9,3 +9,17 @@ class RecognizeImageRequest(BaseModel):
|
||||
|
||||
class CorrectRecognizeResultRequest(BaseModel):
|
||||
corrected_result: dict
|
||||
|
||||
|
||||
class ParseOrderRequest(BaseModel):
|
||||
input_type: str = Field(pattern="^(text|image)$")
|
||||
text: str | None = None
|
||||
image_url: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_input(self):
|
||||
if self.input_type == "text" and not (self.text or "").strip():
|
||||
raise ValueError("文本模式下 text 不能为空")
|
||||
if self.input_type == "image" and not (self.image_url or "").strip():
|
||||
raise ValueError("图片模式下 image_url 不能为空")
|
||||
return self
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from difflib import SequenceMatcher
|
||||
from urllib import error, parse, request
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@ -12,6 +14,345 @@ from backend.app.repositories.ai_repository import AIRepository
|
||||
from backend.app.services.audit_service import audit_service
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 智能填单:文本预处理
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TextPreprocessor:
|
||||
"""清洗粘贴文本中的噪声,保留有效订单信息。"""
|
||||
|
||||
TIMESTAMP_RE = re.compile(r'\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{2}(:\d{2})?')
|
||||
BRACKET_TIME_RE = re.compile(r'\[\d{1,2}:\d{2}(:\d{2})?\]')
|
||||
USERNAME_PREFIX_RE = re.compile(r'^[^:\s]{1,10}[::]\s*', re.MULTILINE)
|
||||
NOISE_MARKERS = [
|
||||
'[图片]', '[表情]', '[语音]', '[视频]', '[文件]', '[链接]',
|
||||
'[红包]', '[转账]', '[位置]', '[名片]', '—— ——',
|
||||
]
|
||||
|
||||
def preprocess(self, text: str) -> str:
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
result = text
|
||||
result = self.TIMESTAMP_RE.sub('', result)
|
||||
result = self.BRACKET_TIME_RE.sub('', result)
|
||||
for marker in self.NOISE_MARKERS:
|
||||
result = result.replace(marker, '')
|
||||
result = self.USERNAME_PREFIX_RE.sub('', result)
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
return result.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 智能填单:规则提取层
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RuleExtractor:
|
||||
"""基于规则的字段提取器,处理确定性高的字段。"""
|
||||
|
||||
PHONE_RE = re.compile(r'1[3-9]\d{9}')
|
||||
QTY_UNIT_RE = re.compile(
|
||||
r'(\d+\.?\d*)\s*(吨|件|箱|包|个|米|kg|KG|公斤|斤|卷|组|套|台|条|根|片|块)'
|
||||
)
|
||||
PRICE_RE = re.compile(r'(?:单价|价格|报价)\s*[::]?\s*(\d+\.?\d*)')
|
||||
ADDRESS_KEYWORDS = [
|
||||
'省', '市', '区', '县', '镇', '路', '街', '号',
|
||||
'楼', '室', '栋', '单元', '村', '大厦', '广场',
|
||||
]
|
||||
|
||||
def extract(self, text: str) -> dict:
|
||||
result: dict = {}
|
||||
phones = self.PHONE_RE.findall(text)
|
||||
if phones:
|
||||
result['customer_mobile'] = phones[0]
|
||||
qty_match = self.QTY_UNIT_RE.search(text)
|
||||
if qty_match:
|
||||
result['_raw_quantity'] = float(qty_match.group(1))
|
||||
result['_raw_unit'] = qty_match.group(2)
|
||||
price_match = self.PRICE_RE.search(text)
|
||||
if price_match:
|
||||
result['_raw_price'] = float(price_match.group(1))
|
||||
addr = self._extract_address(text)
|
||||
if addr:
|
||||
result['customer_address'] = addr
|
||||
return result
|
||||
|
||||
def _extract_address(self, text: str) -> str | None:
|
||||
lines = text.split('\n')
|
||||
best: list[str] = []
|
||||
current: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
addr_hits = sum(1 for kw in self.ADDRESS_KEYWORDS if kw in stripped)
|
||||
if addr_hits >= 2:
|
||||
current.append(stripped)
|
||||
else:
|
||||
if len(current) > len(best):
|
||||
best = list(current)
|
||||
current = []
|
||||
if len(current) > len(best):
|
||||
best = current
|
||||
return ''.join(best) if best else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 智能填单:OCR 版面分析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrderOCRParser:
|
||||
"""基于 OCR 行列表做订单版面分析。"""
|
||||
|
||||
PHONE_RE = re.compile(r'1[3-9]\d{9}')
|
||||
ADDRESS_KEYWORDS = {
|
||||
'省', '市', '区', '县', '镇', '路', '街', '号',
|
||||
'楼', '室', '栋', '单元', '村', '大厦', '广场',
|
||||
'弄', '巷', '苑', '园', '城',
|
||||
}
|
||||
QTY_RE = re.compile(
|
||||
r'(\d+\.?\d*)\s*(吨|件|箱|包|个|米|kg|KG|公斤|斤|卷|组|套|台|条|根|片|块)'
|
||||
)
|
||||
PRICE_RE = re.compile(r'(?:单价|价格|报价|¥|¥)\s*[::]?\s*(\d+\.?\d*)')
|
||||
TABLE_HEADER_KEYWORDS = {'产品', '品名', '名称', '规格', '数量', '单价', '金额', '合计'}
|
||||
|
||||
def parse(self, line_list: list[str], ocr_confidence: float) -> dict:
|
||||
raw_text = '\n'.join(line_list)
|
||||
zones = self._classify_lines(line_list)
|
||||
pre_filled = self._extract_from_zones(line_list, zones)
|
||||
table_rows = self._parse_table_lines(line_list, zones.get('table_lines', []))
|
||||
return {
|
||||
"raw_text": raw_text,
|
||||
"ocr_confidence": ocr_confidence,
|
||||
"layout_zones": zones,
|
||||
"pre_filled": pre_filled,
|
||||
"table_rows": table_rows,
|
||||
}
|
||||
|
||||
def _classify_lines(self, lines: list[str]) -> dict:
|
||||
zones: dict[str, list[int]] = {
|
||||
"header_lines": [], "address_lines": [],
|
||||
"table_lines": [], "other_lines": [],
|
||||
}
|
||||
table_started = False
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if any(kw in stripped for kw in self.TABLE_HEADER_KEYWORDS):
|
||||
table_started = True
|
||||
zones["table_lines"].append(i)
|
||||
continue
|
||||
if table_started:
|
||||
if self.QTY_RE.search(stripped) or self.PRICE_RE.search(stripped):
|
||||
zones["table_lines"].append(i)
|
||||
continue
|
||||
table_started = False
|
||||
addr_count = sum(1 for kw in self.ADDRESS_KEYWORDS if kw in stripped)
|
||||
if addr_count >= 2:
|
||||
zones["address_lines"].append(i)
|
||||
continue
|
||||
if i < 3:
|
||||
zones["header_lines"].append(i)
|
||||
else:
|
||||
zones["other_lines"].append(i)
|
||||
return zones
|
||||
|
||||
def _extract_from_zones(self, lines: list[str], zones: dict) -> dict:
|
||||
result: dict = {}
|
||||
for i in zones.get("header_lines", []):
|
||||
text = lines[i].strip()
|
||||
if not text:
|
||||
continue
|
||||
phone_match = self.PHONE_RE.search(text)
|
||||
if phone_match:
|
||||
result["customer_mobile"] = phone_match.group(0)
|
||||
name_part = self.PHONE_RE.sub('', text).strip()
|
||||
if name_part and len(name_part) <= 20 and not any(
|
||||
kw in name_part for kw in self.ADDRESS_KEYWORDS
|
||||
):
|
||||
result.setdefault("customer_name", name_part)
|
||||
addr_parts = [lines[i].strip() for i in zones.get("address_lines", [])]
|
||||
if addr_parts:
|
||||
result["customer_address"] = ''.join(addr_parts)
|
||||
return result
|
||||
|
||||
def _parse_table_lines(self, lines: list[str], table_indices: list[int]) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for i in table_indices:
|
||||
line = lines[i].strip()
|
||||
if not line:
|
||||
continue
|
||||
if any(kw in line for kw in self.TABLE_HEADER_KEYWORDS) and not self.QTY_RE.search(line):
|
||||
continue
|
||||
row: dict = {"raw": line, "fields": {}}
|
||||
qty_match = self.QTY_RE.search(line)
|
||||
if qty_match:
|
||||
row["fields"]["quantity"] = float(qty_match.group(1))
|
||||
row["fields"]["unit"] = qty_match.group(2)
|
||||
price_match = self.PRICE_RE.search(line)
|
||||
if price_match:
|
||||
row["fields"]["sale_price"] = float(price_match.group(1))
|
||||
name_part = self.QTY_RE.sub('', line)
|
||||
name_part = self.PRICE_RE.sub('', name_part)
|
||||
name_part = re.sub(r'\d+\.?\d*', '', name_part).strip()
|
||||
if name_part:
|
||||
row["fields"]["product_name"] = name_part
|
||||
if row["fields"]:
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 智能填单:LLM 结构化解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LLMOrderParser:
|
||||
"""调用 dashscope qwen-plus 进行订单文本结构化解析。"""
|
||||
|
||||
TEXT_SYSTEM_PROMPT = """你是订单信息解析助手。从用户提供的文本中提取订单信息。
|
||||
|
||||
严格按以下 JSON 格式输出,不要输出其他内容:
|
||||
{
|
||||
"customer_name": "客户姓名",
|
||||
"customer_mobile": "手机号",
|
||||
"customer_address": "地址",
|
||||
"order_source": "订单来源(如能识别)",
|
||||
"delivery_type": "配送方式(如能识别)",
|
||||
"remark": "备注",
|
||||
"items": [
|
||||
{
|
||||
"product_name": "产品名称",
|
||||
"specification": "规格",
|
||||
"unit": "单位",
|
||||
"quantity": 数量数字,
|
||||
"sale_price": 单价数字
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- 手机号必须是 11 位数字,以 1 开头
|
||||
- 数量和价格必须是数字(不是字符串)
|
||||
- 无法识别的字段填 null,不要编造
|
||||
- 如果文本中有多个产品,每个产品一个 items 条目
|
||||
|
||||
可选的产品库(名称 + 规格):
|
||||
{product_hints}"""
|
||||
|
||||
IMAGE_SYSTEM_PROMPT = """你是订单信息解析助手。OCR 系统已经从图片中提取了文本并做了初步分析。
|
||||
你需要基于 OCR 的结果,补充和完善订单信息。
|
||||
|
||||
OCR 已提取的信息:
|
||||
- OCR 置信度:{ocr_confidence}
|
||||
- 已识别的客户姓名:{customer_name}
|
||||
- 已识别的客户手机:{customer_mobile}
|
||||
- 已识别的客户地址:{customer_address}
|
||||
- 产品表格区域识别到的原始行:
|
||||
{table_rows}
|
||||
|
||||
你需要完成:
|
||||
1. 验证 OCR 提取的字段是否合理
|
||||
2. 从原始文本中补充 OCR 未提取到的字段
|
||||
3. 解析产品明细(如果 OCR 表格区域数据可用,优先使用)
|
||||
4. 识别订单来源、配送方式等附加信息
|
||||
|
||||
严格按以下 JSON 格式输出:
|
||||
{
|
||||
"customer_name": "客户姓名",
|
||||
"customer_mobile": "手机号",
|
||||
"customer_address": "地址",
|
||||
"order_source": "订单来源",
|
||||
"delivery_type": "配送方式",
|
||||
"remark": "备注",
|
||||
"items": [
|
||||
{
|
||||
"product_name": "产品名称",
|
||||
"specification": "规格",
|
||||
"unit": "单位",
|
||||
"quantity": 数量数字,
|
||||
"sale_price": 单价数字
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- 无法识别的字段填 null,不要编造
|
||||
- 如果 OCR 已提取的字段看起来正确,直接沿用
|
||||
|
||||
可选的产品库(名称 + 规格):
|
||||
{product_hints}"""
|
||||
|
||||
def _build_product_hints(self, product_groups: list[dict]) -> str:
|
||||
hints: list[str] = []
|
||||
for group in product_groups[:30]:
|
||||
specs = ', '.join(
|
||||
f"{s['specification']}({s['unit']})"
|
||||
for s in group.get("specifications", [])[:5]
|
||||
)
|
||||
hints.append(f"- {group['product_name']}: {specs}")
|
||||
return '\n'.join(hints) or "(产品库为空)"
|
||||
|
||||
def parse(self, text: str, product_groups: list[dict],
|
||||
api_key: str, api_url: str,
|
||||
ocr_context: dict | None = None) -> dict:
|
||||
product_hints = self._build_product_hints(product_groups)
|
||||
if ocr_context:
|
||||
system_prompt = self.IMAGE_SYSTEM_PROMPT.format(
|
||||
ocr_confidence=ocr_context.get("ocr_confidence", "N/A"),
|
||||
customer_name=ocr_context.get("pre_filled", {}).get("customer_name", "未识别"),
|
||||
customer_mobile=ocr_context.get("pre_filled", {}).get("customer_mobile", "未识别"),
|
||||
customer_address=ocr_context.get("pre_filled", {}).get("customer_address", "未识别"),
|
||||
table_rows='\n'.join(
|
||||
f" - {row['raw']}" for row in ocr_context.get("table_rows", [])
|
||||
) or " 无",
|
||||
product_hints=product_hints,
|
||||
)
|
||||
else:
|
||||
system_prompt = self.TEXT_SYSTEM_PROMPT.format(product_hints=product_hints)
|
||||
|
||||
payload = json.dumps({
|
||||
"model": "qwen-plus",
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"请解析以下订单内容:\n\n{text}"},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 1024,
|
||||
}).encode("utf-8")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
req = urllib_request.Request(
|
||||
url=api_url, data=payload, headers=headers, method="POST"
|
||||
)
|
||||
with urllib_request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
return self._extract_json(content)
|
||||
|
||||
def _extract_json(self, text: str) -> dict:
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1]
|
||||
text = text.rsplit("```", 1)[0]
|
||||
return json.loads(text.strip())
|
||||
|
||||
def safe_parse(self, text: str, product_groups: list[dict],
|
||||
api_key: str, api_url: str,
|
||||
ocr_context: dict | None = None) -> dict | None:
|
||||
try:
|
||||
return self.parse(text, product_groups, api_key, api_url, ocr_context)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# 避免和后面 urllib_request 冲突,这里在文件顶部已经 import 了
|
||||
urllib_request = request
|
||||
|
||||
|
||||
class BaseOCRProvider:
|
||||
provider_name = "base"
|
||||
|
||||
@ -292,5 +633,253 @@ class AIService:
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能填单:核心解析入口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def parse_order(self, payload: dict, session: Session | None = None) -> dict:
|
||||
input_type = payload["input_type"]
|
||||
|
||||
# 1. 获取原始文本 + OCR 中间结果
|
||||
ocr_context = None
|
||||
if input_type == "image":
|
||||
ocr_context = self._ocr_and_parse_image(payload["image_url"])
|
||||
raw_text = ocr_context["raw_text"]
|
||||
else:
|
||||
raw_text = payload["text"].strip()
|
||||
|
||||
# 2. 文本预处理
|
||||
preprocessor = TextPreprocessor()
|
||||
raw_text = preprocessor.preprocess(raw_text)
|
||||
if not raw_text:
|
||||
raise AppException(
|
||||
code=ErrorCode.PARAM_ERROR,
|
||||
message="无法提取到文本内容",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# 3. 正则提取
|
||||
rule_extractor = RuleExtractor()
|
||||
rule_result = rule_extractor.extract(raw_text)
|
||||
if ocr_context and ocr_context.get("pre_filled"):
|
||||
for key, value in ocr_context["pre_filled"].items():
|
||||
if value and not rule_result.get(key):
|
||||
rule_result[key] = value
|
||||
|
||||
# 4. LLM 结构化解析
|
||||
settings = self.settings
|
||||
product_groups = self._get_product_groups(session)
|
||||
llm_parser = LLMOrderParser()
|
||||
llm_result = llm_parser.safe_parse(
|
||||
raw_text,
|
||||
product_groups,
|
||||
settings.aliyun_ai_access_key_id,
|
||||
settings.llm_parse_api_url,
|
||||
ocr_context=ocr_context,
|
||||
)
|
||||
|
||||
# 5. 合并
|
||||
if llm_result:
|
||||
merged = self._merge_results(rule_result, llm_result)
|
||||
parse_source = "hybrid"
|
||||
else:
|
||||
merged = self._build_fallback_result(rule_result)
|
||||
parse_source = "rule"
|
||||
|
||||
# 6. 客户库预匹配
|
||||
if session is not None and merged.get("customer_mobile"):
|
||||
try:
|
||||
from backend.app.repositories.customer_repository import CustomerRepository
|
||||
customer_repo = CustomerRepository()
|
||||
existing_customer = customer_repo.find_by_mobile(
|
||||
session, merged["customer_mobile"]
|
||||
)
|
||||
if existing_customer:
|
||||
merged["customer_id"] = existing_customer.id
|
||||
if existing_customer.address and not merged.get("customer_address"):
|
||||
merged["customer_address"] = existing_customer.address
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 7. 产品库模糊匹配
|
||||
product_matches = self._match_products(
|
||||
merged.get("items", []), product_groups, session
|
||||
)
|
||||
|
||||
# 8. 校验 + 置信度
|
||||
warnings = self._validate_parsed_order(merged)
|
||||
if parse_source == "rule":
|
||||
warnings.append("LLM 服务不可用,仅使用规则提取,部分字段可能不完整")
|
||||
ocr_conf = ocr_context["ocr_confidence"] if ocr_context else None
|
||||
confidence = self._calc_confidence(merged, warnings, ocr_confidence=ocr_conf)
|
||||
|
||||
return {
|
||||
"parsed_order": merged,
|
||||
"raw_text": raw_text,
|
||||
"confidence": confidence,
|
||||
"ocr_confidence": ocr_conf,
|
||||
"parse_source": parse_source,
|
||||
"is_mock": session is None,
|
||||
"product_matches": product_matches,
|
||||
"factory_options": self._get_factory_options(session),
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
def _ocr_and_parse_image(self, image_url: str) -> dict:
|
||||
provider = self._build_provider()
|
||||
raw_result, suggested_result, ocr_confidence = provider.recognize(
|
||||
image_url, "order_parse", 0
|
||||
)
|
||||
line_list = suggested_result.get("line_list", [])
|
||||
if not line_list:
|
||||
text = suggested_result.get("recognized_text", "")
|
||||
line_list = [line for line in text.split("\n") if line.strip()]
|
||||
ocr_parser = OrderOCRParser()
|
||||
return ocr_parser.parse(line_list, ocr_confidence)
|
||||
|
||||
def _merge_results(self, rule_result: dict, llm_result: dict) -> dict:
|
||||
merged = dict(llm_result)
|
||||
if rule_result.get("customer_mobile"):
|
||||
merged["customer_mobile"] = rule_result["customer_mobile"]
|
||||
rule_addr = rule_result.get("customer_address", "")
|
||||
llm_addr = merged.get("customer_address", "")
|
||||
if rule_addr and len(rule_addr) > len(llm_addr or ""):
|
||||
merged["customer_address"] = rule_addr
|
||||
return merged
|
||||
|
||||
def _build_fallback_result(self, rule_result: dict) -> dict:
|
||||
items: list[dict] = []
|
||||
if rule_result.get("_raw_quantity"):
|
||||
items.append({
|
||||
"product_name": "",
|
||||
"specification": "",
|
||||
"unit": rule_result.get("_raw_unit", ""),
|
||||
"quantity": rule_result["_raw_quantity"],
|
||||
"sale_price": rule_result.get("_raw_price", 0),
|
||||
"cost_price": None,
|
||||
})
|
||||
return {
|
||||
"customer_name": rule_result.get("customer_name"),
|
||||
"customer_mobile": rule_result.get("customer_mobile"),
|
||||
"customer_address": rule_result.get("customer_address"),
|
||||
"order_source": None,
|
||||
"delivery_type": None,
|
||||
"factory_id": None,
|
||||
"remark": None,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
def _get_product_groups(self, session: Session | None) -> list[dict]:
|
||||
if session is not None:
|
||||
try:
|
||||
from backend.app.services.product_service import product_service
|
||||
result = product_service.list_products({}, session)
|
||||
return result.get("list", [])
|
||||
except Exception:
|
||||
pass
|
||||
return [
|
||||
{
|
||||
"product_name": "演示产品A",
|
||||
"product_id": 2001,
|
||||
"specifications": [
|
||||
{"product_id": 2001, "specification": "10kg", "unit": "吨", "sale_price": 100, "cost_price": 60}
|
||||
],
|
||||
},
|
||||
{
|
||||
"product_name": "演示产品B",
|
||||
"product_id": 2002,
|
||||
"specifications": [
|
||||
{"product_id": 2002, "specification": "20kg", "unit": "吨", "sale_price": 180, "cost_price": 120}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
def _match_products(self, items: list[dict],
|
||||
product_groups: list[dict],
|
||||
session: Session | None) -> list[dict]:
|
||||
flat_products: list[dict] = []
|
||||
for group in product_groups:
|
||||
for spec in group.get("specifications", []):
|
||||
flat_products.append({
|
||||
"product_id": spec["product_id"],
|
||||
"product_name": group["product_name"],
|
||||
"specification": spec["specification"],
|
||||
"unit": spec["unit"],
|
||||
"sale_price": spec.get("sale_price", 0),
|
||||
"cost_price": spec.get("cost_price", 0),
|
||||
})
|
||||
matches: list[dict] = []
|
||||
for item in items:
|
||||
input_name = item.get("product_name", "")
|
||||
input_spec = item.get("specification", "")
|
||||
if not input_name:
|
||||
continue
|
||||
scored: list[tuple] = []
|
||||
for prod in flat_products:
|
||||
name_score = SequenceMatcher(None, input_name, prod["product_name"]).ratio()
|
||||
spec_score = SequenceMatcher(None, input_spec, prod["specification"]).ratio() if input_spec else 0
|
||||
combined = name_score * 0.6 + spec_score * 0.4
|
||||
scored.append((prod, combined))
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
matches.append({
|
||||
"input_name": input_name,
|
||||
"input_specification": input_spec,
|
||||
"candidates": [
|
||||
{
|
||||
"product_id": prod["product_id"],
|
||||
"product_name": prod["product_name"],
|
||||
"specification": prod["specification"],
|
||||
"unit": prod["unit"],
|
||||
"sale_price": prod["sale_price"],
|
||||
"cost_price": prod["cost_price"],
|
||||
"match_score": round(score, 2),
|
||||
}
|
||||
for prod, score in scored[:3] if score > 0.3
|
||||
],
|
||||
})
|
||||
return matches
|
||||
|
||||
def _validate_parsed_order(self, order: dict) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
if not order.get("customer_name"):
|
||||
warnings.append("未识别到客户姓名")
|
||||
if not order.get("customer_mobile"):
|
||||
warnings.append("未识别到客户手机号")
|
||||
if not order.get("items"):
|
||||
warnings.append("未识别到产品明细")
|
||||
for item in order.get("items", []):
|
||||
if not item.get("product_name"):
|
||||
warnings.append("存在产品名称为空的明细行")
|
||||
if not item.get("quantity"):
|
||||
warnings.append(f"产品 {item.get('product_name', '?')} 未识别到数量")
|
||||
return warnings
|
||||
|
||||
def _calc_confidence(self, order: dict, warnings: list[str],
|
||||
ocr_confidence: float | None = None) -> float:
|
||||
base = 1.0
|
||||
if ocr_confidence is not None:
|
||||
base = ocr_confidence
|
||||
base -= len(warnings) * 0.1
|
||||
if order.get("items"):
|
||||
for item in order["items"]:
|
||||
if not item.get("sale_price"):
|
||||
base -= 0.05
|
||||
return round(max(base, 0.1), 2)
|
||||
|
||||
def _get_factory_options(self, session: Session | None) -> list[dict]:
|
||||
if session is not None:
|
||||
try:
|
||||
from backend.app.services.supplier_service import supplier_service
|
||||
result = supplier_service.list_suppliers(
|
||||
session, {"supplier_type": "factory"}
|
||||
)
|
||||
return [
|
||||
{"value": item["supplier_id"], "label": item["supplier_name"]}
|
||||
for item in result.get("list", [])
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
return [{"value": 1001, "label": "演示工厂A"}]
|
||||
|
||||
|
||||
ai_service = AIService()
|
||||
|
||||
@ -14,7 +14,7 @@ class DemoStore:
|
||||
"role_code": "salesman",
|
||||
"token": "demo-sales-token",
|
||||
"menus": [{"menu_name": "我的订单", "menu_path": "/orders"}],
|
||||
"permissions": ["order:create", "order:list", "order:submit"],
|
||||
"permissions": ["order:create", "order:list", "order:submit", "ai:parse-order"],
|
||||
},
|
||||
("admin01", "admin"): {
|
||||
"user_id": 99,
|
||||
|
||||
1788
docs/smart-order-entry-design.md
Normal file
1788
docs/smart-order-entry-design.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -346,3 +346,28 @@ export async function runArrearsCheck() {
|
||||
export async function runInactiveCustomerCheck() {
|
||||
return request("/api/reminders/inactive-customers/check", { method: "POST" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 智能填单
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createUploadToken(payload) {
|
||||
return request("/api/files/upload-token", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseOrderText(text) {
|
||||
return request("/api/ai/parse-order", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ input_type: "text", text }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseOrderImage(imageUrl) {
|
||||
return request("/api/ai/parse-order", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ input_type: "image", image_url: imageUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
@ -16,6 +16,106 @@
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<!-- 智能填单 -->
|
||||
<section v-if="!parsedResult" class="form-section parse-card">
|
||||
<div class="section-header">
|
||||
<h3>智能填单</h3>
|
||||
<span v-if="isMockMode" class="mode-tag fallback">演示数据</span>
|
||||
</div>
|
||||
<div class="parse-tabs">
|
||||
<button type="button" :class="{ active: parseMode === 'text' }" @click="parseMode = 'text'">粘贴文本</button>
|
||||
<button type="button" :class="{ active: parseMode === 'image' }" @click="parseMode = 'image'">拍照/上传图片</button>
|
||||
</div>
|
||||
<div v-if="parseMode === 'text'" class="parse-text-area">
|
||||
<textarea v-model="parseInput" rows="5" placeholder="请粘贴订单相关文本(微信聊天记录、电话记录等)"></textarea>
|
||||
<div class="parse-actions">
|
||||
<button type="button" class="primary-btn" :disabled="parseLoading || !parseInput.trim()" @click="handleParseSubmit">
|
||||
{{ parseLoading ? '解析中...' : '智能解析' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="parse-image-area">
|
||||
<div v-if="parseImagePreview" class="parse-image-preview">
|
||||
<img :src="parseImagePreview" alt="预览" />
|
||||
</div>
|
||||
<label class="ghost-btn parse-upload-label">
|
||||
选择图片
|
||||
<input type="file" accept="image/*" capture="environment" style="display:none" @change="handleParseImageChange" />
|
||||
</label>
|
||||
<div class="parse-actions">
|
||||
<button type="button" class="primary-btn" :disabled="parseLoading || !parseImageFile" @click="handleParseImageFile">
|
||||
{{ parseLoading ? '识别中...' : '上传识别' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 解析结果预览 -->
|
||||
<section v-if="parsedResult" class="form-section parse-result-card">
|
||||
<div class="section-header">
|
||||
<h3>智能填单 - 解析结果</h3>
|
||||
<div class="parse-result-meta">
|
||||
<span v-if="isMockMode" class="mode-tag fallback">演示数据</span>
|
||||
<span class="confidence-tag">置信度 {{ Math.round(parsedResult.confidence * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="parse-result-fields">
|
||||
<label>
|
||||
<span>客户姓名</span>
|
||||
<input v-model="parseEditable.customer_name" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>客户手机号</span>
|
||||
<input v-model="parseEditable.customer_mobile" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>客户地址</span>
|
||||
<input v-model="parseEditable.customer_address" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>订单来源</span>
|
||||
<input v-model="parseEditable.order_source" type="text" placeholder="可选" />
|
||||
</label>
|
||||
<label>
|
||||
<span>配送方式</span>
|
||||
<input v-model="parseEditable.delivery_type" type="text" placeholder="可选" />
|
||||
</label>
|
||||
<label>
|
||||
<span>备注</span>
|
||||
<input v-model="parseEditable.remark" type="text" placeholder="可选" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="parseEditable.items.length" class="parse-result-items">
|
||||
<h4>产品明细</h4>
|
||||
<div v-for="(item, idx) in parseEditable.items" :key="idx" class="parse-item-card">
|
||||
<div class="parse-item-main">
|
||||
<strong>{{ item.product_name || '未识别产品' }}</strong>
|
||||
<span>{{ item.specification || '-' }} / {{ item.unit || '-' }} / {{ item.quantity || 0 }} / 单价 {{ item.sale_price || 0 }}</span>
|
||||
</div>
|
||||
<div v-if="parsedResult.product_matches?.[idx]?.candidates?.length" class="parse-item-candidates">
|
||||
<span class="candidate-label">匹配建议:</span>
|
||||
<button
|
||||
v-for="(c, ci) in parsedResult.product_matches[idx].candidates"
|
||||
:key="ci"
|
||||
type="button"
|
||||
class="candidate-btn"
|
||||
:class="{ selected: item._selectedCandidate?.product_id === c.product_id }"
|
||||
@click="handleProductCandidateSelect(idx, c)"
|
||||
>
|
||||
{{ c.product_name }} / {{ c.specification }} ({{ Math.round(c.match_score * 100) }}%)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="parsedResult.warnings?.length" class="parse-warnings">
|
||||
<p v-for="(w, wi) in parsedResult.warnings" :key="wi">{{ w }}</p>
|
||||
</div>
|
||||
<div class="parse-actions">
|
||||
<button type="button" class="ghost-btn" @click="handleResetParse">重新解析</button>
|
||||
<button type="button" class="primary-btn" @click="handleConfirmParse">确认填入表单</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="form-grid" @submit.prevent="handleSubmit">
|
||||
<section class="form-section">
|
||||
<div class="section-header">
|
||||
@ -230,9 +330,12 @@ import { useRouter } from "vue-router";
|
||||
|
||||
import {
|
||||
createOrder,
|
||||
createUploadToken,
|
||||
fetchCustomerOptions,
|
||||
fetchFactoryOptions,
|
||||
fetchProductOptions,
|
||||
parseOrderImage,
|
||||
parseOrderText,
|
||||
} from "../mockApi";
|
||||
|
||||
const router = useRouter();
|
||||
@ -244,6 +347,22 @@ const customerOptions = ref([]);
|
||||
const factoryOptions = ref([]);
|
||||
const productOptions = ref([]);
|
||||
const selectedCustomerId = ref("");
|
||||
// 智能填单状态
|
||||
const parseMode = ref("text");
|
||||
const parseInput = ref("");
|
||||
const parseImageFile = ref(null);
|
||||
const parseImagePreview = ref("");
|
||||
const parsedResult = ref(null);
|
||||
const parseLoading = ref(false);
|
||||
const parseEditable = ref({
|
||||
customer_name: "",
|
||||
customer_mobile: "",
|
||||
customer_address: "",
|
||||
order_source: "",
|
||||
delivery_type: "",
|
||||
remark: "",
|
||||
items: [],
|
||||
});
|
||||
function buildDefaultItem() {
|
||||
return {
|
||||
rowKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
@ -355,6 +474,150 @@ function handleCustomerInputBlur() {
|
||||
customerHint.value = form.auto_sync_customer ? '系统将把当前客户视为新客户,并在提交后自动同步到客户库。' : '当前客户未在客户库中匹配到记录,请确认是否需要手动新增。';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 智能填单
|
||||
// ---------------------------------------------------------------------------
|
||||
let parseTimer = null;
|
||||
|
||||
const isMockMode = computed(() => parsedResult.value?.is_mock === true);
|
||||
|
||||
async function handleParseSubmit() {
|
||||
if (parseLoading.value || !parseInput.value.trim()) return;
|
||||
parseLoading.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const result = await parseOrderText(parseInput.value);
|
||||
parsedResult.value = result;
|
||||
parseEditable.value = {
|
||||
customer_name: result.parsed_order.customer_name || "",
|
||||
customer_mobile: result.parsed_order.customer_mobile || "",
|
||||
customer_address: result.parsed_order.customer_address || "",
|
||||
order_source: result.parsed_order.order_source || "",
|
||||
delivery_type: result.parsed_order.delivery_type || "",
|
||||
remark: result.parsed_order.remark || "",
|
||||
items: (result.parsed_order.items || []).map((item, idx) => ({
|
||||
...item,
|
||||
_selectedCandidate: result.product_matches?.[idx]?.candidates?.[0] || null,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
message.value = error.message || "解析失败";
|
||||
messageType.value = "error";
|
||||
} finally {
|
||||
parseLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleParseImageFile() {
|
||||
const file = parseImageFile.value;
|
||||
if (!file) return;
|
||||
parseLoading.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const tokenData = await createUploadToken({
|
||||
file_name: file.name,
|
||||
file_type: file.type,
|
||||
file_size: file.size,
|
||||
biz_type: "order_parse",
|
||||
biz_id: 0,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("key", tokenData.object_key);
|
||||
formData.append("policy", tokenData.policy);
|
||||
formData.append("OSSAccessKeyId", tokenData.access_key_id);
|
||||
formData.append("signature", tokenData.signature);
|
||||
formData.append("file", file);
|
||||
await fetch(tokenData.upload_url, { method: "POST", body: formData });
|
||||
const imageUrl = `${tokenData.public_base_url}/${tokenData.object_key}`;
|
||||
const result = await parseOrderImage(imageUrl);
|
||||
parsedResult.value = result;
|
||||
parseEditable.value = {
|
||||
customer_name: result.parsed_order.customer_name || "",
|
||||
customer_mobile: result.parsed_order.customer_mobile || "",
|
||||
customer_address: result.parsed_order.customer_address || "",
|
||||
order_source: result.parsed_order.order_source || "",
|
||||
delivery_type: result.parsed_order.delivery_type || "",
|
||||
remark: result.parsed_order.remark || "",
|
||||
items: (result.parsed_order.items || []).map((item, idx) => ({
|
||||
...item,
|
||||
_selectedCandidate: result.product_matches?.[idx]?.candidates?.[0] || null,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
message.value = error.message || "图片识别失败";
|
||||
messageType.value = "error";
|
||||
} finally {
|
||||
parseLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleParseImageChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
parseImageFile.value = file;
|
||||
parseImagePreview.value = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
function handleConfirmParse() {
|
||||
const parsed = parseEditable.value;
|
||||
form.customer_name = parsed.customer_name || "";
|
||||
form.customer_mobile = parsed.customer_mobile || "";
|
||||
form.customer_address = parsed.customer_address || "";
|
||||
form.order_source = parsed.order_source || "";
|
||||
form.delivery_type = parsed.delivery_type || "";
|
||||
form.remark = parsed.remark || "";
|
||||
|
||||
if (parsedResult.value?.parsed_order?.customer_id) {
|
||||
const cid = parsedResult.value.parsed_order.customer_id;
|
||||
selectedCustomerId.value = String(cid);
|
||||
handleCustomerChange();
|
||||
} else {
|
||||
selectedCustomerId.value = "";
|
||||
}
|
||||
|
||||
const newItems = [];
|
||||
for (const parsedItem of parsed.items) {
|
||||
const row = buildDefaultItem();
|
||||
if (parsedItem._selectedCandidate) {
|
||||
const c = parsedItem._selectedCandidate;
|
||||
row.selectedProductId = String(c.product_id);
|
||||
row.product_id = c.product_id;
|
||||
row.product_name = c.product_name;
|
||||
row.specification = c.specification;
|
||||
row.unit = c.unit;
|
||||
row.sale_price = c.sale_price;
|
||||
row.cost_price = c.cost_price;
|
||||
} else {
|
||||
row.product_name = parsedItem.product_name || "";
|
||||
row.specification = parsedItem.specification || "";
|
||||
row.unit = parsedItem.unit || "";
|
||||
row.quantity = parsedItem.quantity || 1;
|
||||
row.sale_price = parsedItem.sale_price || 0;
|
||||
row.cost_price = parsedItem.cost_price || 0;
|
||||
}
|
||||
if (!row.quantity || row.quantity <= 0) row.quantity = parsedItem.quantity || 1;
|
||||
if (row.sale_price <= 0 && parsedItem.sale_price > 0) row.sale_price = parsedItem.sale_price;
|
||||
newItems.push(row);
|
||||
}
|
||||
items.value = newItems.length > 0 ? newItems : [buildDefaultItem()];
|
||||
parsedResult.value = null;
|
||||
parseEditable.value = { customer_name: "", customer_mobile: "", customer_address: "", order_source: "", delivery_type: "", remark: "", items: [] };
|
||||
}
|
||||
|
||||
function handleProductCandidateSelect(itemIndex, candidate) {
|
||||
if (parseEditable.value.items[itemIndex]) {
|
||||
parseEditable.value.items[itemIndex]._selectedCandidate = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetParse() {
|
||||
parsedResult.value = null;
|
||||
parseEditable.value = { customer_name: "", customer_mobile: "", customer_address: "", order_source: "", delivery_type: "", remark: "", items: [] };
|
||||
parseInput.value = "";
|
||||
parseImageFile.value = null;
|
||||
parseImagePreview.value = "";
|
||||
}
|
||||
|
||||
function fillDemoData() {
|
||||
if (customerOptions.value.length) {
|
||||
selectedCustomerId.value = String(customerOptions.value[0].value);
|
||||
@ -887,4 +1150,34 @@ button:disabled {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 智能填单样式 */
|
||||
.parse-card { border: 2px dashed #93c5fd; background: linear-gradient(180deg, #f0f9ff, #fff); }
|
||||
.parse-tabs { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.parse-tabs button { border: 1px solid #d1d5db; background: #fff; border-radius: 10px; padding: 8px 16px; cursor: pointer; font-size: 13px; }
|
||||
.parse-tabs button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.parse-text-area textarea { width: 100%; border: 1px solid #d1d5db; border-radius: 12px; padding: 10px 12px; resize: vertical; font-size: 14px; }
|
||||
.parse-image-area { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
.parse-image-preview { max-width: 200px; border-radius: 10px; overflow: hidden; }
|
||||
.parse-image-preview img { width: 100%; display: block; }
|
||||
.parse-upload-label { cursor: pointer; }
|
||||
.parse-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
|
||||
.parse-result-card { border: 2px solid #2563eb; background: linear-gradient(180deg, #eff6ff, #fff); }
|
||||
.parse-result-meta { display: flex; gap: 8px; align-items: center; }
|
||||
.confidence-tag { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; font-weight: 600; }
|
||||
.mode-tag.fallback { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #fef3c7; color: #92400e; font-size: 12px; font-weight: 600; }
|
||||
.parse-result-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 14px; }
|
||||
.parse-result-items { margin-bottom: 14px; }
|
||||
.parse-result-items h4 { margin: 0 0 8px; font-size: 14px; }
|
||||
.parse-item-card { padding: 10px; border: 1px solid #e5e7eb; border-radius: 10px; margin-bottom: 8px; background: #f9fafb; }
|
||||
.parse-item-main { display: flex; flex-direction: column; gap: 4px; }
|
||||
.parse-item-main strong { font-size: 14px; }
|
||||
.parse-item-main span { font-size: 12px; color: #6b7280; }
|
||||
.parse-item-candidates { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; align-items: center; }
|
||||
.candidate-label { font-size: 12px; color: #6b7280; }
|
||||
.candidate-btn { border: 1px solid #d1d5db; background: #fff; border-radius: 8px; padding: 4px 10px; font-size: 11px; cursor: pointer; }
|
||||
.candidate-btn.selected { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.parse-warnings { margin-bottom: 10px; }
|
||||
.parse-warnings p { font-size: 12px; color: #b45309; margin: 2px 0; }
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user