Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
This commit is contained in:
@@ -15,7 +15,7 @@ class OffsiteConfirmRequest(BaseModel):
|
||||
class OffsiteRecalculateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
fund_code: str = Field(min_length=1, max_length=32)
|
||||
fund_code: str | None = Field(default=None, max_length=32)
|
||||
application_date: str = Field(min_length=8, max_length=32)
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ EXTRACTED_FIELD_NAMES: tuple[str, ...] = (
|
||||
"申请日期",
|
||||
"代销机构",
|
||||
"申购金额",
|
||||
"币种",
|
||||
"金额单位",
|
||||
"赎回份额",
|
||||
"最新净值",
|
||||
@@ -486,7 +487,9 @@ class OffsiteDocumentRecognitionAdapter:
|
||||
try:
|
||||
body = await self._call_deepseek(source, ocr)
|
||||
document_type = _document_type(body.get("document_type"))
|
||||
fields = _recognized_fields(body)
|
||||
fields = normalize_recognition_fields(
|
||||
_recognized_fields(body), ocr.ocr_text
|
||||
)
|
||||
confidence = _confidence_map(_mapping_dict(body, "field_confidence"))
|
||||
page_evidence = _mapping_dict(body, "page_evidence") or ocr.page_evidence
|
||||
missing_fields = tuple(
|
||||
@@ -539,6 +542,10 @@ class OffsiteDocumentRecognitionAdapter:
|
||||
"page_evidence六个顶层字段。"
|
||||
"extracted_fields必须是对象,字段名只能使用下面列出的中文字段;"
|
||||
"无法从原文确认的字段填null,不得猜测或编造。"
|
||||
"申购金额只能填写数字本身,不得包含人民币、RMB、CNY、元、万元、"
|
||||
"货币符号或千分位逗号;币种单独填写到币种字段,金额单位单独填写到金额单位字段。"
|
||||
"例如原文“人民币5,000元”必须输出申购金额“5000”、币种“人民币”、"
|
||||
"金额单位“元”。"
|
||||
"文档中的产品代码、产品编号、基金产品代码均映射为基金代码;"
|
||||
"基金代码不要求固定六位,必须按原文保留。"
|
||||
"document_type只能是summary、subscription、redemption、other。"
|
||||
@@ -547,7 +554,7 @@ class OffsiteDocumentRecognitionAdapter:
|
||||
'{"基金代码":null,"基金名称":null,"账户标识":null,'
|
||||
'"投资者名称":null,"客户标识":null,"申请编号":null,'
|
||||
'"申请日期":null,"代销机构":null,"申购金额":null,'
|
||||
'"金额单位":null,"赎回份额":null,"最新净值":null,'
|
||||
'"币种":null,"金额单位":null,"赎回份额":null,"最新净值":null,'
|
||||
'"基金最新总份额":null,"申请前持有份额":null,'
|
||||
'"当前最新可用份额":null},'
|
||||
'"field_confidence":{},"missing_fields":[],'
|
||||
@@ -718,7 +725,7 @@ def _extract_simple_fields(text: str) -> dict[str, object]:
|
||||
)
|
||||
if value:
|
||||
fields[name] = value
|
||||
return fields
|
||||
return normalize_recognition_fields(fields, text)
|
||||
|
||||
|
||||
def _find_label_value(text: str, label: str) -> str | None:
|
||||
@@ -729,6 +736,97 @@ def _find_label_value(text: str, label: str) -> str | None:
|
||||
return matched.group(1).strip()
|
||||
|
||||
|
||||
_CURRENCY_MARKERS: tuple[tuple[str, str], ...] = (
|
||||
("人民币", "人民币"),
|
||||
("RMB", "人民币"),
|
||||
("CNY", "人民币"),
|
||||
("¥", "人民币"),
|
||||
("¥", "人民币"),
|
||||
("美元", "美元"),
|
||||
("USD", "美元"),
|
||||
("$", "美元"),
|
||||
("港币", "港币"),
|
||||
("HKD", "港币"),
|
||||
("欧元", "欧元"),
|
||||
("EUR", "欧元"),
|
||||
("日元", "日元"),
|
||||
("JPY", "日元"),
|
||||
)
|
||||
|
||||
|
||||
def normalize_recognition_fields(
|
||||
fields: Mapping[str, object], source_text: str = ""
|
||||
) -> dict[str, object]:
|
||||
"""统一识别字段格式,保证金额与币种、单位分开保存。"""
|
||||
normalized = dict(fields)
|
||||
raw_amount = normalized.get("申购金额")
|
||||
if raw_amount is None or str(raw_amount).strip() == "":
|
||||
return normalized
|
||||
|
||||
amount_text = str(raw_amount).strip().replace(",", "").replace(",", "")
|
||||
currency = _normalize_currency(normalized.get("币种"))
|
||||
if not currency:
|
||||
currency = _currency_from_text(amount_text)
|
||||
if not currency:
|
||||
currency = _currency_from_amount_context(source_text)
|
||||
if currency:
|
||||
normalized["币种"] = currency
|
||||
|
||||
unit = str(normalized.get("金额单位") or "").strip()
|
||||
if unit:
|
||||
if unit.endswith("万元"):
|
||||
unit = "万元"
|
||||
elif unit.endswith("元"):
|
||||
unit = "元"
|
||||
normalized["金额单位"] = unit
|
||||
|
||||
for marker, _ in _CURRENCY_MARKERS:
|
||||
amount_text = re.sub(
|
||||
rf"^{re.escape(marker)}\s*",
|
||||
"",
|
||||
amount_text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
amount_text = re.sub(
|
||||
rf"\s*{re.escape(marker)}$",
|
||||
"",
|
||||
amount_text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if unit:
|
||||
amount_text = re.sub(rf"\s*{re.escape(unit)}$", "", amount_text)
|
||||
matched = re.search(r"-?(?:\d+(?:\.\d*)?|\.\d+)", amount_text)
|
||||
if matched:
|
||||
normalized["申购金额"] = matched.group(0)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_currency(value: object) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return _currency_from_text(text) or text
|
||||
|
||||
|
||||
def _currency_from_text(text: str) -> str | None:
|
||||
lowered = text.lower()
|
||||
for marker, currency in _CURRENCY_MARKERS:
|
||||
if marker.lower() in lowered:
|
||||
return currency
|
||||
return None
|
||||
|
||||
|
||||
def _currency_from_amount_context(source_text: str) -> str | None:
|
||||
if not source_text:
|
||||
return None
|
||||
matched = re.search(
|
||||
r"申购金额\s*[::]?\s*([^\s,,;;]+)",
|
||||
source_text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return _currency_from_text(matched.group(1)) if matched else None
|
||||
|
||||
|
||||
def _extract_docx_text(payload: bytes) -> str:
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(payload)) as archive:
|
||||
|
||||
@@ -13,6 +13,14 @@ TWENTY_PERCENT = Decimal("0.20")
|
||||
ONE_YUAN = Decimal("1")
|
||||
|
||||
|
||||
def display_decimal(value: Decimal) -> str:
|
||||
"""把计算结果格式化成适合页面展示的十进制文本。"""
|
||||
text = format(value, "f")
|
||||
if "." in text:
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleDecision:
|
||||
rule_code: str
|
||||
@@ -88,7 +96,13 @@ class OffsiteFundRuleEngine:
|
||||
document_value={"申购金额元": str(amount_yuan)},
|
||||
database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares),
|
||||
"申请前持有份额": str(before)},
|
||||
calculation={"本次申购份额": str(current_shares), "申购后持有比例": str(ratio)},
|
||||
calculation={
|
||||
"本次申购份额": str(current_shares),
|
||||
"申购后持有比例": str(ratio),
|
||||
"实际值": f"{display_decimal(ratio * HUNDRED)}%",
|
||||
"规则值": "≤ 20%",
|
||||
"比较": f"{display_decimal(ratio * HUNDRED)}% ≤ 20%",
|
||||
},
|
||||
))
|
||||
limit = total_fund_shares * TEN_PERCENT
|
||||
decisions.append(RuleDecision(
|
||||
@@ -97,7 +111,13 @@ class OffsiteFundRuleEngine:
|
||||
result="异常" if current_shares > limit else "正常",
|
||||
document_value={"申购金额元": str(amount_yuan)},
|
||||
database_value={"最新净值": str(nav), "基金最新总份额": str(total_fund_shares)},
|
||||
calculation={"本次申购份额": str(current_shares), "份额上限": str(limit)},
|
||||
calculation={
|
||||
"本次申购份额": str(current_shares),
|
||||
"份额上限": str(limit),
|
||||
"实际值": str(current_shares),
|
||||
"规则值": str(limit),
|
||||
"比较": f"{current_shares} ≤ {limit}",
|
||||
},
|
||||
))
|
||||
return decisions
|
||||
|
||||
@@ -118,7 +138,12 @@ class OffsiteFundRuleEngine:
|
||||
result="异常" if value > TWENTY_PERCENT else "正常",
|
||||
document_value={"赎回份额": str(redemption_shares)},
|
||||
database_value={"产品最新总份额": str(total_fund_shares)},
|
||||
calculation={"赎回比例": str(value)},
|
||||
calculation={
|
||||
"赎回比例": str(value),
|
||||
"实际值": f"{display_decimal(value * HUNDRED)}%",
|
||||
"规则值": "≤ 20%",
|
||||
"比较": f"{display_decimal(value * HUNDRED)}% ≤ 20%",
|
||||
},
|
||||
)
|
||||
if redemption_shares is None or available_quantity is None:
|
||||
available = self._unknown("redemption_available_quantity", "账户可用份额")
|
||||
@@ -129,7 +154,12 @@ class OffsiteFundRuleEngine:
|
||||
result="异常" if redemption_shares > available_quantity else "正常",
|
||||
document_value={"赎回份额": str(redemption_shares)},
|
||||
database_value={"当前最新可用份额": str(available_quantity)},
|
||||
calculation={"是否超出可用份额": redemption_shares > available_quantity},
|
||||
calculation={
|
||||
"是否超出可用份额": redemption_shares > available_quantity,
|
||||
"实际值": str(redemption_shares),
|
||||
"规则值": str(available_quantity),
|
||||
"比较": f"{redemption_shares} ≤ {available_quantity}",
|
||||
},
|
||||
)
|
||||
return [ratio, available]
|
||||
|
||||
@@ -144,7 +174,12 @@ class OffsiteFundRuleEngine:
|
||||
result=result,
|
||||
document_value={"申购金额元": str(amount_yuan) if amount_yuan is not None else None},
|
||||
database_value={},
|
||||
calculation={"判断口径": "标准化申购金额 <= 1 元为异常"},
|
||||
calculation={
|
||||
"判断口径": "标准化申购金额 <= 1 元为异常",
|
||||
"实际值": str(amount_yuan) if amount_yuan is not None else None,
|
||||
"规则值": "> 1 元",
|
||||
"比较": f"{amount_yuan} > 1 元" if amount_yuan is not None else None,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -46,6 +46,7 @@ from app.service.offsite_document_recognition_adapter import (
|
||||
OffsiteDocumentRecognitionAdapter,
|
||||
RecognitionSourceFile,
|
||||
StructuredRecognitionResult,
|
||||
normalize_recognition_fields,
|
||||
)
|
||||
from app.service.offsite_fund_rules import (
|
||||
OffsiteFundRuleEngine,
|
||||
@@ -364,7 +365,9 @@ class OffsiteFundService:
|
||||
correction_fields = (
|
||||
dict(correction.corrected_fields) if correction is not None else {}
|
||||
)
|
||||
return self._merge_corrections(attachment.extracted_fields, correction_fields)
|
||||
return normalize_recognition_fields(
|
||||
self._merge_corrections(attachment.extracted_fields, correction_fields)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge_corrections(
|
||||
@@ -443,8 +446,8 @@ class OffsiteFundService:
|
||||
"status": attachment.status,
|
||||
"ocr_text": attachment.ocr_text,
|
||||
"extracted_fields": attachment.extracted_fields,
|
||||
"effective_fields": cls._merge_corrections(
|
||||
attachment.extracted_fields, correction_fields
|
||||
"effective_fields": normalize_recognition_fields(
|
||||
cls._merge_corrections(attachment.extracted_fields, correction_fields)
|
||||
),
|
||||
"corrections": cls._correction_payload(correction),
|
||||
"has_correction": correction is not None,
|
||||
@@ -799,8 +802,9 @@ class OffsiteFundService:
|
||||
"message": f"附件 {attachment_id} 不属于该邮件",
|
||||
"data": {},
|
||||
}
|
||||
normalized_fields = normalize_recognition_fields(fields)
|
||||
cleaned = self._cleaned_corrections(
|
||||
fields, EXTRACTED_FIELD_NAMES, attachment.extracted_fields
|
||||
normalized_fields, EXTRACTED_FIELD_NAMES, attachment.extracted_fields
|
||||
)
|
||||
self.session.add(
|
||||
OffsiteFieldCorrection(
|
||||
@@ -815,8 +819,8 @@ class OffsiteFundService:
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
effective = self._merge_corrections(
|
||||
attachment.extracted_fields, cleaned
|
||||
effective = normalize_recognition_fields(
|
||||
self._merge_corrections(attachment.extracted_fields, cleaned)
|
||||
)
|
||||
for document in documents_by_attachment.get(attachment_id, []):
|
||||
self._apply_recognition_fields(document, effective)
|
||||
@@ -1758,6 +1762,7 @@ class OffsiteFundService:
|
||||
def _apply_recognition_fields(
|
||||
document: OffsiteFundDocument, fields: Mapping[str, object]
|
||||
) -> None:
|
||||
fields = normalize_recognition_fields(fields)
|
||||
raw_date = fields.get("申请日期")
|
||||
document.fund_code = OffsiteFundService._text(fields.get("基金代码"))
|
||||
document.fund_name = OffsiteFundService._text(fields.get("基金名称"))
|
||||
@@ -1836,6 +1841,7 @@ class OffsiteFundService:
|
||||
self, task_id: str, decision: OperationDecision, operator_id: str,
|
||||
context: RequestContext,
|
||||
) -> dict[str, object]:
|
||||
task_id = task_id.strip()
|
||||
operator_error = self._operator_error(operator_id, context)
|
||||
if operator_error is not None:
|
||||
return operator_error
|
||||
@@ -1870,7 +1876,7 @@ class OffsiteFundService:
|
||||
return {"code": 0, "message": "ok", "data": {"task_id": task_id, "decision": decision}}
|
||||
|
||||
async def recalculate_statistics(
|
||||
self, fund_code: str, application_date: str, context: RequestContext
|
||||
self, fund_code: str | None, application_date: str, context: RequestContext
|
||||
) -> dict[str, object]:
|
||||
denied = self._permission_error(context, ("offsite:read", "offsite:write"))
|
||||
if denied is not None:
|
||||
@@ -1878,11 +1884,17 @@ class OffsiteFundService:
|
||||
target_date = parse_application_date(application_date)
|
||||
if target_date is None:
|
||||
return {"code": 422, "message": "申请日期格式不正确", "data": {}}
|
||||
documents = (await self.session.execute(select(OffsiteFundDocument).where(
|
||||
OffsiteFundDocument.fund_code == fund_code,
|
||||
requested_fund_code = fund_code.strip() if fund_code else None
|
||||
filters = [
|
||||
OffsiteFundDocument.application_date == target_date,
|
||||
OffsiteFundDocument.operator_decision == "确认正常",
|
||||
))).scalars().all()
|
||||
OffsiteFundDocument.fund_code.is_not(None),
|
||||
]
|
||||
if requested_fund_code:
|
||||
filters.append(OffsiteFundDocument.fund_code == requested_fund_code)
|
||||
documents = (await self.session.execute(
|
||||
select(OffsiteFundDocument).where(*filters)
|
||||
)).scalars().all()
|
||||
task_ids = [document.task_id for document in documents]
|
||||
successful_normal_returns: set[str] = set()
|
||||
if task_ids:
|
||||
@@ -1899,6 +1911,42 @@ class OffsiteFundService:
|
||||
document for document in documents
|
||||
if document.task_id in successful_normal_returns
|
||||
]
|
||||
grouped: dict[str, list[OffsiteFundDocument]] = defaultdict(list)
|
||||
for document in documents:
|
||||
if document.fund_code:
|
||||
grouped.setdefault(document.fund_code, [])
|
||||
for row in rows:
|
||||
if row.fund_code:
|
||||
grouped[row.fund_code].append(row)
|
||||
if requested_fund_code and requested_fund_code not in grouped:
|
||||
grouped[requested_fund_code] = []
|
||||
statistics = [
|
||||
await self._build_settlement_statistic(
|
||||
code, target_date, grouped[code], context
|
||||
)
|
||||
for code in sorted(grouped)
|
||||
]
|
||||
if requested_fund_code:
|
||||
data = {**statistics[0], "items": statistics}
|
||||
return {"code": 0, "message": "ok", "data": data}
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"application_date": target_date.isoformat(),
|
||||
"fund_code": None,
|
||||
"fund_count": len(statistics),
|
||||
"items": statistics,
|
||||
},
|
||||
}
|
||||
|
||||
async def _build_settlement_statistic(
|
||||
self,
|
||||
fund_code: str,
|
||||
target_date: date,
|
||||
rows: Sequence[OffsiteFundDocument],
|
||||
context: RequestContext,
|
||||
) -> dict[str, object]:
|
||||
subscription_total = sum(
|
||||
((row.subscription_amount_yuan or Decimal("0")) for row in rows),
|
||||
Decimal("0"),
|
||||
@@ -1907,7 +1955,6 @@ class OffsiteFundService:
|
||||
((row.redemption_shares or Decimal("0")) for row in rows),
|
||||
Decimal("0"),
|
||||
)
|
||||
agency_breakdown = self._agency_breakdown(rows)
|
||||
latest_nav = await self._query_latest_nav(fund_code, target_date, context)
|
||||
redemption_amount_yuan = (
|
||||
redemption_total * latest_nav if latest_nav is not None else None
|
||||
@@ -1917,22 +1964,29 @@ class OffsiteFundService:
|
||||
if redemption_amount_yuan is not None
|
||||
else (subscription_total if redemption_total == 0 else None)
|
||||
)
|
||||
return {"code": 0, "message": "ok", "data": {
|
||||
"fund_code": fund_code, "application_date": target_date.isoformat(),
|
||||
return {
|
||||
"fund_code": fund_code,
|
||||
"application_date": target_date.isoformat(),
|
||||
"fund_name": next((row.fund_name for row in rows if row.fund_name), None),
|
||||
"subscription_amount_yuan": str(subscription_total),
|
||||
"subscription_count": sum(1 for row in rows if row.document_type == "subscription"),
|
||||
"subscription_count": sum(
|
||||
1 for row in rows if row.document_type == "subscription"
|
||||
),
|
||||
"redemption_shares": str(redemption_total),
|
||||
"redemption_count": sum(1 for row in rows if row.document_type == "redemption"),
|
||||
"redemption_count": sum(
|
||||
1 for row in rows if row.document_type == "redemption"
|
||||
),
|
||||
"latest_nav": str(latest_nav) if latest_nav is not None else None,
|
||||
"redemption_amount_yuan": (
|
||||
str(redemption_amount_yuan) if redemption_amount_yuan is not None else None
|
||||
str(redemption_amount_yuan)
|
||||
if redemption_amount_yuan is not None
|
||||
else None
|
||||
),
|
||||
"net_flow_amount_yuan": (
|
||||
str(net_flow_amount_yuan) if net_flow_amount_yuan is not None else None
|
||||
),
|
||||
"agency_breakdown": agency_breakdown,
|
||||
}}
|
||||
"agency_breakdown": self._agency_breakdown(rows),
|
||||
}
|
||||
|
||||
async def trigger_agent_nl2sql(
|
||||
self, task_id: str, operator_id: str, manual_confirmed: bool,
|
||||
@@ -2020,6 +2074,9 @@ class OffsiteFundService:
|
||||
)
|
||||
records.append({
|
||||
"rule_code": rule_code,
|
||||
"rule_name": dict(
|
||||
self._offsite_rule_titles(document.document_type)
|
||||
).get(rule_code, rule_code),
|
||||
"status": query_status,
|
||||
"row_count": 1 if row is not None else 0,
|
||||
})
|
||||
@@ -2050,6 +2107,7 @@ class OffsiteFundService:
|
||||
self, task_id: str, notification_type: str, operator_id: str,
|
||||
context: RequestContext,
|
||||
) -> dict[str, object]:
|
||||
task_id = task_id.strip()
|
||||
operator_error = self._operator_error(operator_id, context)
|
||||
if operator_error is not None:
|
||||
return operator_error
|
||||
@@ -2547,6 +2605,20 @@ class OffsiteFundService:
|
||||
error_message=None, created_at=now, updated_at=now,
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _offsite_rule_titles(
|
||||
document_type: str,
|
||||
) -> tuple[tuple[str, str], ...]:
|
||||
if document_type == "redemption":
|
||||
return (
|
||||
("redemption_large_ratio", "赎回巨额比例"),
|
||||
("redemption_available_quantity", "账户可用份额"),
|
||||
)
|
||||
return (
|
||||
("subscription_holding_ratio", "申购后单一投资者持有比例"),
|
||||
("subscription_single_share_limit", "申购单笔份额上限"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _nl2sql_questions(document: OffsiteFundDocument) -> list[tuple[str, str]]:
|
||||
if not document.fund_code:
|
||||
|
||||
@@ -299,10 +299,22 @@ class PromotionMaterialService:
|
||||
context: RequestContext,
|
||||
) -> dict[str, Any]:
|
||||
task = await self._task(session, task_no)
|
||||
normalized_media_type = (
|
||||
media_type
|
||||
or mimetypes.guess_type(filename)[0]
|
||||
or "application/octet-stream"
|
||||
)
|
||||
# 部分 Windows 浏览器或代理会把图片上传为通用二进制类型;
|
||||
# 扩展名和 PIL 内容校验仍会继续约束文件,不能只信任请求头。
|
||||
if (
|
||||
attachment_type == "manager_photo"
|
||||
and not normalized_media_type.startswith("image/")
|
||||
):
|
||||
normalized_media_type = mimetypes.guess_type(filename)[0] or normalized_media_type
|
||||
self._validate_attachment(
|
||||
attachment_type,
|
||||
filename,
|
||||
media_type,
|
||||
normalized_media_type,
|
||||
len(payload),
|
||||
max_photo_size=self.max_photo_size,
|
||||
max_performance_size=self.max_performance_size,
|
||||
@@ -338,11 +350,7 @@ class PromotionMaterialService:
|
||||
task_no=task_no,
|
||||
attachment_type=attachment_type,
|
||||
filename=Path(filename).name,
|
||||
media_type=(
|
||||
media_type
|
||||
or mimetypes.guess_type(filename)[0]
|
||||
or "application/octet-stream"
|
||||
),
|
||||
media_type=normalized_media_type,
|
||||
file_hash=digest,
|
||||
size_bytes=len(payload),
|
||||
file_path=str(destination),
|
||||
|
||||
@@ -93,7 +93,33 @@ const ENDPOINTS = Object.freeze({
|
||||
K003: { method: 'GET', path: '/api/v1/knowledge/list', raw: true },
|
||||
K004: { method: 'DELETE', path: '/api/v1/knowledge/{knowledgeId}', idempotent: true },
|
||||
OFFSITE_MAILS: { method: 'GET', path: '/api/v1/offsite-fund/mails' },
|
||||
OFFSITE_MAIL: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}' },
|
||||
OFFSITE_MAIL_DELETE: { method: 'POST', path: '/api/v1/offsite-fund/mails/{mailId}/deletions', idempotent: true },
|
||||
OFFSITE_RECOGNITION: { method: 'GET', path: '/api/v1/offsite-fund/mails/{mailId}/recognition-fields' },
|
||||
OFFSITE_RECOGNITION_SAVE: { method: 'PUT', path: '/api/v1/offsite-fund/mails/{mailId}/recognition-fields', idempotent: true },
|
||||
OFFSITE_NL2SQL_FIELDS: { method: 'GET', path: '/api/v1/offsite-fund/documents/{taskId}/nl2sql-fields' },
|
||||
OFFSITE_NL2SQL_FIELDS_SAVE: { method: 'PUT', path: '/api/v1/offsite-fund/documents/{taskId}/nl2sql-fields', idempotent: true },
|
||||
OFFSITE_RULE_RESULTS: { method: 'GET', path: '/api/v1/offsite-fund/documents/{taskId}/rule-results' },
|
||||
OFFSITE_RULE_RECALCULATE: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/rule-results/recalculations', idempotent: true },
|
||||
OFFSITE_MAILBOX: { method: 'GET', path: '/api/v1/offsite-fund/mailbox-status' },
|
||||
OFFSITE_MAILBOX_RECOVER: { method: 'POST', path: '/api/v1/offsite-fund/mailbox-status/recoveries', idempotent: true },
|
||||
OFFSITE_ATTACHMENT_FILE: { method: 'GET', path: '/api/v1/offsite-fund/attachments/{attachmentId}/file' },
|
||||
OFFSITE_CONFIRM: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/confirmations', idempotent: true },
|
||||
OFFSITE_RECOGNITION_RETRY: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/recognition-retries', idempotent: true },
|
||||
OFFSITE_NOTIFICATION_CREATE: { method: 'POST', path: '/api/v1/offsite-fund/documents/{taskId}/notifications', idempotent: true },
|
||||
OFFSITE_NOTIFICATION_SEND: { method: 'POST', path: '/api/v1/offsite-fund/notifications/{notificationId}/send', idempotent: true },
|
||||
OFFSITE_SETTLEMENT_RECALCULATE: { method: 'POST', path: '/api/v1/offsite-fund/settlement-statistics/recalculate', idempotent: true },
|
||||
OFFSITE_TRIGGER_NL2SQL: { method: 'POST', path: '/api/tasks/{taskId}/trigger-agent-nl2sql', idempotent: true },
|
||||
PROMOTION_CREATE: { method: 'POST', path: '/api/v1/fund-promotion-materials', idempotent: true },
|
||||
PROMOTION_TASK: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}' },
|
||||
PROMOTION_INPUTS: { method: 'PUT', path: '/api/v1/fund-promotion-materials/{taskNo}/inputs', idempotent: true },
|
||||
PROMOTION_ATTACHMENT: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/attachments', formData: true, idempotent: true },
|
||||
PROMOTION_GENERATE: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/generations', idempotent: true },
|
||||
PROMOTION_CHECKS: { method: 'GET', path: '/api/v1/fund-promotion-materials/{taskNo}/compliance-checks' },
|
||||
PROMOTION_REVIEW: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/reviews', idempotent: true },
|
||||
PROMOTION_DELIVERY: { method: 'POST', path: '/api/v1/fund-promotion-materials/{taskNo}/deliveries', idempotent: true },
|
||||
AGENT_RUN_CREATE: { method: 'POST', path: '/api/v1/agent-runs', idempotent: true },
|
||||
AGENT_RUN: { method: 'GET', path: '/api/v1/agent-runs/{runId}' },
|
||||
});
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -105,6 +131,7 @@ export class ApiError extends Error {
|
||||
this.retryable = Boolean(options.retryable);
|
||||
this.fieldErrors = options.fieldErrors || [];
|
||||
this.traceId = options.traceId || '';
|
||||
this.payload = options.payload || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +182,7 @@ async function request(endpointId, options = {}) {
|
||||
body: options.body === undefined || endpoint.method === 'GET'
|
||||
? undefined
|
||||
: (endpoint.formData ? options.body : JSON.stringify(options.body)),
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
@@ -164,7 +192,8 @@ async function request(endpointId, options = {}) {
|
||||
}
|
||||
const responseTraceId = payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId;
|
||||
document.documentElement.dataset.traceId = responseTraceId;
|
||||
if (!response.ok || payload.error) {
|
||||
const hasBusinessError = Object.prototype.hasOwnProperty.call(payload, 'code') && payload.code !== 0;
|
||||
if (!response.ok || payload.error || hasBusinessError) {
|
||||
const detail = payload.error || {};
|
||||
const validationDetail = Array.isArray(payload.detail)
|
||||
? payload.detail
|
||||
@@ -173,14 +202,19 @@ async function request(endpointId, options = {}) {
|
||||
.join(';')
|
||||
: (typeof payload.detail === 'string' ? payload.detail : '');
|
||||
const message = detail.message
|
||||
|| (hasBusinessError ? payload.message : '')
|
||||
|| validationDetail
|
||||
|| (response.status ? `请求失败(HTTP ${response.status})` : '请求未完成');
|
||||
const businessStatus = hasBusinessError && Number(payload.code) >= 400
|
||||
? Number(payload.code)
|
||||
: response.status;
|
||||
const error = new ApiError(message, {
|
||||
code: detail.code,
|
||||
status: response.status,
|
||||
code: detail.code || (hasBusinessError ? `BUSINESS_${payload.code}` : undefined),
|
||||
status: businessStatus,
|
||||
retryable: detail.retryable,
|
||||
fieldErrors: detail.field_errors,
|
||||
traceId: responseTraceId,
|
||||
payload,
|
||||
});
|
||||
if (response.status === 429 && attempt === 0) await wait(5000);
|
||||
else if (shouldRetry(error, attempt)) await wait(2000);
|
||||
@@ -261,6 +295,37 @@ async function stream(endpointId, body, options = {}) {
|
||||
if (buffer.trim()) dispatch(buffer);
|
||||
}
|
||||
|
||||
async function requestFile(endpointId, options = {}) {
|
||||
const endpoint = ENDPOINTS[endpointId];
|
||||
if (!endpoint) throw new ApiError(`未注册端点 ${endpointId}`, { code: 'ENDPOINT_NOT_REGISTERED' });
|
||||
const traceId = crypto.randomUUID();
|
||||
const headers = { Accept: '*/*', 'X-Trace-ID': traceId, ...(options.headers || {}) };
|
||||
const token = getAccessToken();
|
||||
if (endpoint.auth !== false && token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(`${pathFor(endpoint, options.pathParams)}${new URLSearchParams(options.query || {}).toString() ? `?${new URLSearchParams(options.query).toString()}` : ''}`, {
|
||||
method: endpoint.method,
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const detail = payload.error || {};
|
||||
if (response.status === 401 && endpoint.auth !== false) clearAuthSession();
|
||||
throw new ApiError(detail.message || `文件请求失败(HTTP ${response.status})`, {
|
||||
code: detail.code,
|
||||
status: response.status,
|
||||
traceId: payload.meta?.trace_id || response.headers.get('X-Trace-ID') || traceId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename: response.headers.get('Content-Disposition') || '',
|
||||
contentType: response.headers.get('Content-Type') || '',
|
||||
traceId: response.headers.get('X-Trace-ID') || traceId,
|
||||
};
|
||||
}
|
||||
|
||||
export const apiClient = Object.freeze({
|
||||
get(endpointId, options = {}) { return request(endpointId, options); },
|
||||
post(endpointId, body, options = {}) { return request(endpointId, { ...options, body }); },
|
||||
@@ -280,6 +345,7 @@ export const apiClient = Object.freeze({
|
||||
*/
|
||||
del(endpointId, options = {}) { return request(endpointId, options); },
|
||||
upload(endpointId, formData, options = {}) { return request(endpointId, { ...options, body: formData, timeout: options.timeout || 30000 }); },
|
||||
file(endpointId, options = {}) { return requestFile(endpointId, options); },
|
||||
stream,
|
||||
reportError(error) {
|
||||
window.dispatchEvent(new CustomEvent('portal:error', { detail: { message: error.message, traceId: error.traceId || '' } }));
|
||||
|
||||
@@ -12,12 +12,18 @@ export function formatPercent(value) {
|
||||
return `${number > 0 ? '+' : ''}${number.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
export function formatDateTime(value, includeSeconds = false) {
|
||||
if (!value) return '--';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(includeSeconds ? { second: '2-digit' } : {}),
|
||||
hour12: false,
|
||||
}).format(date).replaceAll('/', '-');
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ const ADVISOR_LINKS = [
|
||||
|
||||
const OPERATOR_LINKS = [
|
||||
['operator-dashboard', '运营工作台', '/portal/employee-operations/dashboard/'],
|
||||
['operator-offsite', '场外申赎', '/portal/employee-operations/offsite/'],
|
||||
['operator-promotion', '推介材料', '/portal/employee-operations/promotion/'],
|
||||
['operator-nl2sql', 'NL2SQL', '/portal/employee-operations/nl2sql/'],
|
||||
['public-products', '公开产品', '/portal/guest/products/'],
|
||||
];
|
||||
|
||||
@@ -121,19 +124,26 @@ export function mountShell({ active, mode = 'public' }) {
|
||||
window.location.assign('/portal/guest/home/?reason=signed-out');
|
||||
});
|
||||
const networkStatus = document.querySelector('[data-network-status]');
|
||||
window.addEventListener('offline', () => {
|
||||
networkStatus.textContent = '网络已断开';
|
||||
let networkStatusTimer = 0;
|
||||
const hideNetworkStatus = () => {
|
||||
window.clearTimeout(networkStatusTimer);
|
||||
networkStatus.classList.remove('network-status--visible');
|
||||
};
|
||||
const showNetworkStatus = (message, duration = 3600) => {
|
||||
window.clearTimeout(networkStatusTimer);
|
||||
networkStatus.textContent = message;
|
||||
networkStatus.classList.add('network-status--visible');
|
||||
if (duration > 0) networkStatusTimer = window.setTimeout(hideNetworkStatus, duration);
|
||||
};
|
||||
window.addEventListener('offline', () => {
|
||||
showNetworkStatus('网络已断开', 0);
|
||||
});
|
||||
window.addEventListener('online', () => {
|
||||
networkStatus.textContent = '网络已恢复,请点击刷新';
|
||||
networkStatus.classList.add('network-status--visible');
|
||||
window.setTimeout(() => networkStatus.classList.remove('network-status--visible'), 3000);
|
||||
showNetworkStatus('网络已恢复,请点击刷新', 3000);
|
||||
});
|
||||
window.addEventListener('portal:error', (event) => {
|
||||
const detail = event.detail || {};
|
||||
networkStatus.textContent = detail.message || '请求未完成';
|
||||
networkStatus.classList.add('network-status--visible');
|
||||
showNetworkStatus(detail.message || '请求未完成');
|
||||
});
|
||||
window.addEventListener('portal:auth-expired', () => {
|
||||
networkStatus.textContent = '登录已过期,请重新登录';
|
||||
|
||||
@@ -9,4 +9,12 @@
|
||||
.operator-status__card { padding: var(--space-4); background: var(--surface-soft); border-radius: var(--radius-sm); }
|
||||
.operator-status__card strong { display: block; margin-bottom: 5px; font-size: 18px; }
|
||||
.operator-status__card p { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.65; }
|
||||
.operator-module-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.operator-module-card { min-height: 148px; padding: var(--space-5); display: grid; align-content: start; gap: var(--space-2); color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-card); transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; }
|
||||
.operator-module-card:hover, .operator-module-card:focus-visible { color: var(--ink); border-color: var(--brand); outline: 0; transform: translateY(-2px); box-shadow: var(--shadow-elevated); }
|
||||
.operator-module-card__index { color: var(--brand-dark); font: 700 var(--fs-small)/1 Consolas, monospace; letter-spacing: .08em; }
|
||||
.operator-module-card strong { font-size: 18px; }
|
||||
.operator-module-card > span:last-child { color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
|
||||
.heading-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
|
||||
@media (max-width: 760px) { .operator-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 860px) { .operator-module-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>运营工作台 · 南方财富</title><link rel="stylesheet" href="/static/portal/common/base.css"><link rel="stylesheet" href="/static/portal/common/operations.css"><link rel="stylesheet" href="/static/portal/employee-operations/dashboard/dashboard.css?v=20260913"></head>
|
||||
<body><main id="main-content" class="page-shell operations-shell operator-shell"><section class="operations-hero operator-hero fade-in"><div class="operations-hero__content"><p class="operations-kicker">场外运营与资料处理</p><h1 class="operations-hero__title">运营工作台</h1><p class="operations-hero__copy">集中查看收件箱、识别任务与规则状态,所有场外运营流程与场内模拟交易数据严格隔离。</p><div class="operations-hero__signals"><span>邮件收件</span><span>识别队列</span><span>规则核对</span></div></div><div class="operations-hero__meta"><strong data-operator-name>运营人员</strong><span data-operator-scope>权限加载中</span></div></section><section class="metric-grid" data-operator-metrics aria-label="运营概览"></section><section class="operator-grid"><article class="panel panel--flush"><div class="panel__header"><div><h2 class="panel__title">场外基金收件箱</h2><p class="section-heading__meta">仅展示未删除的运营邮件</p></div><button class="button table-action" type="button" data-refresh>刷新</button></div><div class="panel__body" data-mails></div></article><article class="panel panel--flush operator-status"><div class="panel__header"><div><h2 class="panel__title">运行状态</h2><p class="section-heading__meta">收件游标与识别监控</p></div></div><div class="panel__body" data-mailbox></div></article></section></main><script type="module" src="/static/portal/employee-operations/dashboard/dashboard.js?v=20260913"></script></body></html>
|
||||
<body><main id="main-content" class="page-shell operations-shell operator-shell"><section class="operations-hero operator-hero fade-in"><div class="operations-hero__content"><p class="operations-kicker">场外运营与资料处理</p><h1 class="operations-hero__title">运营工作台</h1><p class="operations-hero__copy">集中查看收件箱、识别任务与规则状态,所有场外运营流程与场内模拟交易数据严格隔离。</p><div class="operations-hero__signals"><span>邮件收件</span><span>识别队列</span><span>规则核对</span></div></div><div class="operations-hero__meta"><strong data-operator-name>运营人员</strong><span data-operator-scope>权限加载中</span></div></section><section class="metric-grid" data-operator-metrics aria-label="运营概览"></section><section class="operator-module-grid" aria-label="运营业务模块"><a class="operator-module-card" href="/portal/employee-operations/offsite/"><span class="operator-module-card__index">01</span><strong>场外申购和赎回</strong><span>邮件、识别字段、规则核对、通知与清算统计</span></a><a class="operator-module-card" href="/portal/employee-operations/promotion/"><span class="operator-module-card__index">02</span><strong>推介材料生成</strong><span>结构化资料、附件、生成、合规、审核与交付</span></a><a class="operator-module-card" href="/portal/employee-operations/nl2sql/"><span class="operator-module-card__index">03</span><strong>NL2SQL</strong><span>场外单据核对与通用只读自然语言查询</span></a></section><section class="operator-grid"><article class="panel panel--flush"><div class="panel__header"><div><h2 class="panel__title">场外基金收件箱</h2><p class="section-heading__meta">仅展示未删除的运营邮件</p></div><div class="heading-actions"><a class="button button--primary table-action" href="/portal/employee-operations/offsite/">进入处理</a><button class="button table-action" type="button" data-refresh>刷新</button></div></div><div class="panel__body" data-mails></div></article><article class="panel panel--flush operator-status"><div class="panel__header"><div><h2 class="panel__title">运行状态</h2><p class="section-heading__meta">收件游标与识别监控</p></div></div><div class="panel__body" data-mailbox></div></article></section></main><script type="module" src="/static/portal/employee-operations/dashboard/dashboard.js"></script></body></html>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NL2SQL · 运营工作台</title>
|
||||
<link rel="stylesheet" href="/static/portal/common/base.css">
|
||||
<link rel="stylesheet" href="/static/portal/common/operations.css">
|
||||
<link rel="stylesheet" href="/static/portal/employee-operations/operator-workspace.css?v=20260913">
|
||||
</head>
|
||||
<body>
|
||||
<main id="main-content" class="page-shell operations-shell operator-shell operator-workspace">
|
||||
<section class="operations-hero operator-hero fade-in">
|
||||
<div class="operations-hero__content"><p class="operations-kicker">数据运营 · 只读查询链路</p><h1 class="operations-hero__title">NL2SQL</h1><p class="operations-hero__copy">支持场外单据核对和通用金融自然语言查询,查询、权限、审计和最终状态均由后端 Agent 链路决定。</p><div class="operations-hero__signals"><span>只读查询</span><span>白名单表</span><span>权限审计</span></div></div>
|
||||
<div class="operations-hero__meta"><strong data-operator-name>运营人员</strong><span data-operator-scope>权限加载中</span></div>
|
||||
</section>
|
||||
<article class="panel panel--flush">
|
||||
<div class="operator-tabs" role="tablist" aria-label="NL2SQL 模式"><button class="operator-tab" type="button" aria-selected="true" data-tab="offsite">场外单据核对</button><button class="operator-tab" type="button" aria-selected="false" data-tab="general">通用自然语言查询</button></div>
|
||||
<section class="operator-view" data-view="offsite">
|
||||
<div class="panel__body operator-columns">
|
||||
<section class="operator-stack"><div><h2 class="panel__title">场外单据核对</h2><p class="operator-inline-note">先生成并确认自然语言,再执行只读核对。</p></div><label class="form-field"><span class="form-field__label">单据 task_id</span><input class="form-field__input" data-offsite-task placeholder="例如 20260910-001-A01"></label><label class="form-field"><span class="form-field__label">自然语言:</span><textarea class="form-field__input form-field__input--natural-language" data-offsite-natural-language rows="8" placeholder="点击“生成自然语言”后显示,可人工修改"></textarea><span class="operator-inline-note">该内容用于人工查看和调整查询意图。</span></label><label class="operator-checkboxes"><input type="checkbox" data-manual-confirmed> <span>我已人工确认原始文件内容</span></label><div class="operator-actions"><button class="button button--primary" type="button" data-action="generate-offsite">生成自然语言</button><button class="button" type="button" data-action="run-offsite">执行只读核对</button><a class="button button--back" data-offsite-back href="/portal/employee-operations/offsite/">返回</a></div></section>
|
||||
<section><div class="operator-section-heading"><div><h2 class="panel__title">核对结果</h2><p class="operator-inline-note">查询结果写回场外单据链路后,返回规则摘要。</p></div><span class="tag" data-offsite-status>等待执行</span></div><div data-offsite-result><div class="operations-empty">输入 task_id 后执行一次只读核对。</div></div></section>
|
||||
</div>
|
||||
</section>
|
||||
<section class="operator-view" data-view="general" hidden>
|
||||
<div class="panel__body operator-columns">
|
||||
<section class="operator-stack"><div><h2 class="panel__title">通用自然语言查询</h2><p class="operator-inline-note">调用 POST /api/v1/agent-runs 并轮询 GET /api/v1/agent-runs/{run_id},前端不会使用无授权头的原生 EventSource。</p></div><label class="form-field"><span class="form-field__label">Agent 类型</span><select class="form-field__input" data-agent-type><option value="financial_nl2sql">金融 NL2SQL</option><option value="offsite_fund">场外基金</option></select></label><label class="form-field"><span class="form-field__label">会话编号</span><input class="form-field__input" data-session-id placeholder="留空自动生成"></label><label class="form-field"><span class="form-field__label">业务问题</span><textarea class="form-field__input" data-query-text rows="5" placeholder="例如:查询某基金最近一个交易日的净值"></textarea></label><button class="button button--primary" type="button" data-action="run-general">提交只读查询</button></section>
|
||||
<section><div class="operator-section-heading"><div><h2 class="panel__title">运行状态与结果</h2><p class="operator-inline-note">状态完成后展示结果和工具调用摘要。</p></div><span class="tag" data-general-status>等待执行</span></div><div data-general-result><div class="operations-empty">提交问题后,前端会自动轮询运行状态。</div></div></section>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
<script type="module" src="/static/portal/employee-operations/nl2sql/nl2sql.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
|
||||
import { getAuthContext, requireOperator } from '/static/portal/common/auth.js';
|
||||
import { escapeHtml } from '/static/portal/common/formatters.js';
|
||||
import { mountShell } from '/static/portal/common/layout/app-shell.js';
|
||||
import { showToast } from '/static/portal/common/notifications.js';
|
||||
|
||||
if (requireOperator()) {
|
||||
mountShell({ active: 'operator-nl2sql', mode: 'operator' });
|
||||
const context = getAuthContext();
|
||||
const operatorId = String(context?.userId || context?.username || '');
|
||||
const state = {
|
||||
offsiteTask: new URLSearchParams(location.search).get('task_id') || '',
|
||||
offsiteNaturalLanguage: '',
|
||||
offsiteResult: null,
|
||||
generalRun: null,
|
||||
timer: null,
|
||||
};
|
||||
const offsiteTask = document.querySelector('[data-offsite-task]');
|
||||
const offsiteNaturalLanguage = document.querySelector('[data-offsite-natural-language]');
|
||||
const offsiteResult = document.querySelector('[data-offsite-result]');
|
||||
const generalResult = document.querySelector('[data-general-result]');
|
||||
offsiteTask.value = state.offsiteTask;
|
||||
document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员';
|
||||
document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
|
||||
const backLink = document.querySelector('[data-offsite-back]');
|
||||
|
||||
const STATUS_LABELS = {
|
||||
planned: '核对完成',
|
||||
query_failed: '查询失败',
|
||||
success: '查询成功',
|
||||
error: '查询失败',
|
||||
pending: '待执行',
|
||||
running: '执行中',
|
||||
};
|
||||
|
||||
const RULE_LABELS = {
|
||||
subscription_holding_ratio: '申购后单一投资者持有比例',
|
||||
subscription_single_share_limit: '申购单笔份额上限',
|
||||
redemption_large_ratio: '赎回巨额比例',
|
||||
redemption_available_quantity: '账户可用份额',
|
||||
};
|
||||
|
||||
function statusLabel(status) {
|
||||
return STATUS_LABELS[String(status || '').toLowerCase()] || status || '等待执行';
|
||||
}
|
||||
|
||||
function ruleLabel(item) {
|
||||
return item.rule_name || RULE_LABELS[item.rule_code] || item.rule_code || '查询规则';
|
||||
}
|
||||
|
||||
function statusTag(status) { return `<span class="tag">${escapeHtml(status || '等待执行')}</span>`; }
|
||||
function buildNaturalLanguage(fields) {
|
||||
const fundCode = String(fields?.fund_code || '').trim();
|
||||
if (!fundCode) throw new Error('当前单据缺少基金代码,无法生成自然语言');
|
||||
let base = `基金代码为${fundCode}`;
|
||||
const accountIdentifier = String(fields?.account_identifier || '').trim();
|
||||
if (accountIdentifier) base += `,账户标识为${accountIdentifier}`;
|
||||
if (fields.document_type === 'subscription') {
|
||||
return [
|
||||
`${base},查询基金最新总份额、最新净值和申请前持有份额`,
|
||||
`${base},查询基金最新总份额和最新净值`,
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`${base},查询产品最新总份额`,
|
||||
`${base},查询账户当前最新可用份额`,
|
||||
].join('\n');
|
||||
}
|
||||
function renderOffsite() {
|
||||
const result = state.offsiteResult;
|
||||
offsiteNaturalLanguage.value = state.offsiteNaturalLanguage;
|
||||
if (backLink) {
|
||||
backLink.href = state.offsiteTask
|
||||
? `/portal/employee-operations/offsite/?task_id=${encodeURIComponent(state.offsiteTask)}`
|
||||
: '/portal/employee-operations/offsite/';
|
||||
}
|
||||
document.querySelector('[data-offsite-status]').outerHTML = `<span class="tag" data-offsite-status>${escapeHtml(statusLabel(result?.status))}</span>`;
|
||||
offsiteResult.innerHTML = result ? `<dl class="operator-kv"><div><dt>单据</dt><dd>${escapeHtml(result.task_id || state.offsiteTask)}</dd></div><div><dt>执行状态</dt><dd>${statusTag(statusLabel(result.status))}</dd></div><div><dt>查询数量</dt><dd>${escapeHtml(String(result.queries?.length || 0))}</dd></div><div><dt>返回说明</dt><dd>${escapeHtml(result.message || '已完成服务端核对')}</dd></div></dl><div class="operator-table-wrap"><table class="operator-table"><thead><tr><th>核对规则</th><th>状态</th><th>返回行数</th><th>错误信息</th></tr></thead><tbody>${(result.queries || []).map((item) => `<tr><td>${escapeHtml(ruleLabel(item))}<small class="operator-inline-note">${escapeHtml(item.rule_code || '')}</small></td><td>${escapeHtml(statusLabel(item.status))}</td><td>${escapeHtml(String(item.row_count ?? 0))}</td><td>${escapeHtml(item.error_message || '--')}</td></tr>`).join('') || '<tr><td colspan="4">暂无查询记录。</td></tr>'}</tbody></table></div><pre class="operator-json">${escapeHtml(JSON.stringify(result, null, 2))}</pre>` : '<div class="operations-empty">输入 task_id 后执行一次只读核对。</div>';
|
||||
}
|
||||
function renderGeneral() {
|
||||
const run = state.generalRun;
|
||||
document.querySelector('[data-general-status]').textContent = run?.status || '等待执行';
|
||||
generalResult.innerHTML = run ? `<dl class="operator-kv"><div><dt>运行编号</dt><dd>${escapeHtml(run.run_id)}</dd></div><div><dt>Agent 类型</dt><dd>${escapeHtml(run.agent_type || '--')}</dd></div><div><dt>状态</dt><dd>${escapeHtml(run.status)}</dd></div><div><dt>错误码</dt><dd>${escapeHtml(run.error_code || '--')}</dd></div></dl><pre class="operator-json">${escapeHtml(JSON.stringify(run.result || run, null, 2))}</pre>` : '<div class="operations-empty">提交问题后,前端会自动轮询运行状态。</div>';
|
||||
}
|
||||
async function poll(runId) {
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
const response = await apiClient.get('AGENT_RUN', { pathParams: { runId } });
|
||||
state.generalRun = response.data; renderGeneral();
|
||||
if (['queued', 'running', 'processing', 'pending'].includes(String(state.generalRun.status).toLowerCase())) {
|
||||
state.timer = window.setTimeout(() => poll(runId).catch((error) => showToast(error.message, 'error')), 1200);
|
||||
} else showToast(state.generalRun.status === 'succeeded' ? '通用查询已完成' : `查询状态:${state.generalRun.status}`, state.generalRun.status === 'succeeded' ? 'success' : 'error');
|
||||
}
|
||||
async function run(action) {
|
||||
try {
|
||||
if (action === 'generate-offsite') {
|
||||
const taskId = offsiteTask.value.trim();
|
||||
if (!taskId) throw new Error('请输入单据 task_id');
|
||||
const response = await apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } });
|
||||
state.offsiteTask = taskId;
|
||||
state.offsiteNaturalLanguage = buildNaturalLanguage(response.data);
|
||||
renderOffsite();
|
||||
showToast('自然语言已生成,可人工修改');
|
||||
return;
|
||||
}
|
||||
if (action === 'run-offsite') {
|
||||
const taskId = offsiteTask.value.trim();
|
||||
if (!taskId) throw new Error('请输入单据 task_id');
|
||||
if (!document.querySelector('[data-manual-confirmed]').checked) throw new Error('请先确认原始文件内容');
|
||||
const response = await apiClient.post('OFFSITE_TRIGGER_NL2SQL', { operator_id: operatorId, manual_confirmed: true }, { pathParams: { taskId } });
|
||||
state.offsiteResult = response.data; renderOffsite(); showToast(response.data?.status === 'query_failed' ? '核对完成,但存在查询失败' : '只读核对已完成'); return;
|
||||
}
|
||||
if (action === 'run-general') {
|
||||
const message = document.querySelector('[data-query-text]').value.trim();
|
||||
if (!message) throw new Error('请输入业务问题');
|
||||
const sessionId = document.querySelector('[data-session-id]').value.trim() || `portal-${Date.now()}`;
|
||||
document.querySelector('[data-session-id]').value = sessionId;
|
||||
const response = await apiClient.post('AGENT_RUN_CREATE', { agent_type: document.querySelector('[data-agent-type]').value, message, session_id: sessionId, idempotency_key: crypto.randomUUID().replaceAll('-', '') });
|
||||
state.generalRun = response.data; renderGeneral(); showToast(`查询已提交:${response.data.run_id}`); poll(response.data.run_id); return;
|
||||
}
|
||||
} catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); }
|
||||
}
|
||||
document.querySelectorAll('[data-tab]').forEach((tab) => tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-tab]').forEach((item) => item.setAttribute('aria-selected', String(item === tab)));
|
||||
document.querySelectorAll('[data-view]').forEach((view) => { view.hidden = view.dataset.view !== tab.dataset.tab; });
|
||||
}));
|
||||
document.querySelector('main').addEventListener('click', (event) => { const action = event.target.closest('[data-action]')?.dataset.action; if (action) run(action); });
|
||||
offsiteTask.addEventListener('input', () => {
|
||||
state.offsiteTask = offsiteTask.value;
|
||||
state.offsiteNaturalLanguage = '';
|
||||
offsiteNaturalLanguage.value = '';
|
||||
});
|
||||
offsiteNaturalLanguage.addEventListener('input', () => {
|
||||
state.offsiteNaturalLanguage = offsiteNaturalLanguage.value;
|
||||
});
|
||||
renderOffsite(); renderGeneral();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>场外申购和赎回 · 运营工作台</title>
|
||||
<link rel="stylesheet" href="/static/portal/common/base.css">
|
||||
<link rel="stylesheet" href="/static/portal/common/operations.css">
|
||||
<link rel="stylesheet" href="/static/portal/employee-operations/operator-workspace.css?v=20260913">
|
||||
</head>
|
||||
<body>
|
||||
<main id="main-content" class="page-shell operations-shell operator-shell operator-workspace">
|
||||
<section class="operations-hero operator-hero fade-in">
|
||||
<div class="operations-hero__content">
|
||||
<p class="operations-kicker">场外运营 · 业务邮件处理</p>
|
||||
<h1 class="operations-hero__title">场外申购和赎回</h1>
|
||||
<p class="operations-hero__copy">从邮件、附件识别到规则核对、人工确认、通知发送和清算统计,所有动作均以服务端状态和权限为准。</p>
|
||||
<div class="operations-hero__signals"><span>邮件收件</span><span>OCR 与 NL2SQL</span><span>规则确认</span></div>
|
||||
</div>
|
||||
<div class="operations-hero__meta"><strong data-operator-name>运营人员</strong><span data-operator-scope>权限加载中</span></div>
|
||||
</section>
|
||||
<section class="metric-grid" data-offsite-metrics aria-label="场外业务概览"></section>
|
||||
<div class="operator-mail-status-row">
|
||||
<div class="operator-tabs operator-mail-status-tabs" data-mail-status-tabs role="tablist" aria-label="业务邮件状态"></div>
|
||||
<div class="operator-mail-status-actions">
|
||||
<button class="button button--primary" type="button" data-mail-refresh>刷新</button>
|
||||
<button class="button" type="button" data-mailbox-recover>恢复收件</button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="operator-columns">
|
||||
<article class="panel panel--flush operator-mail-panel">
|
||||
<div class="panel__header"><div><h2 class="panel__title">业务邮件列表</h2></div></div>
|
||||
<div class="panel__body operator-list" data-mail-list></div>
|
||||
<div class="operations-pagination" data-mail-pagination></div>
|
||||
</article>
|
||||
<div class="operator-stack">
|
||||
<article class="panel panel--flush operator-detail operator-mail-detail">
|
||||
<div class="panel__header"><div><h2 class="panel__title">邮件详情与附件</h2><p class="section-heading__meta">选择邮件后读取识别字段和关联单据</p></div></div>
|
||||
<div class="panel__body operator-detail__body" data-mail-detail></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<article class="panel panel--flush">
|
||||
<div class="panel__header"><div><h2 class="panel__title">单据核对与运营动作</h2><p class="section-heading__meta">识别字段、NL2SQL 字段和规则结果分别来自独立后端接口</p></div></div>
|
||||
<div class="panel__body" data-document-list></div>
|
||||
</article>
|
||||
<article class="panel panel--flush" data-notification-panel hidden>
|
||||
<div class="panel__header"><div><h2 class="panel__title">通知确认与发送</h2><p class="section-heading__meta">发送前请检查正文并完成运营确认</p></div></div>
|
||||
<div class="panel__body" data-notification></div>
|
||||
</article>
|
||||
<article class="panel panel--flush">
|
||||
<div class="panel__header"><div><h2 class="panel__title">清算统计</h2><p class="section-heading__meta">只统计已确认正常且正常返回通知发送成功的单据</p></div><button class="button table-action" type="button" data-stat-refresh>刷新统计</button></div>
|
||||
<div class="panel__body" data-statistics></div>
|
||||
</article>
|
||||
</main>
|
||||
<script type="module" src="/static/portal/employee-operations/offsite/offsite.js?v=20260915"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,529 @@
|
||||
import { apiClient } from '/static/portal/common/api-client.js?v=20260915';
|
||||
import { getAuthContext, requireOperator } from '/static/portal/common/auth.js';
|
||||
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
|
||||
import { mountShell } from '/static/portal/common/layout/app-shell.js';
|
||||
import { showToast } from '/static/portal/common/notifications.js';
|
||||
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js';
|
||||
|
||||
const PREVIEW_TYPES = new Set(['application/pdf', 'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif']);
|
||||
const NOTIFICATION_TYPES = [['mail_return', '邮件回执'], ['normal_return', '正常回执'], ['exception_return', '异常回执'], ['risk', '风险通知'], ['settlement', '清算通知']];
|
||||
const NOTIFICATION_LABELS = Object.fromEntries(NOTIFICATION_TYPES);
|
||||
const MAIL_STATUS_FILTERS = [
|
||||
['exception', '有异常'],
|
||||
['processed', '已处理'],
|
||||
['processing', '处理中'],
|
||||
['inbox', '已入库'],
|
||||
['replied', '已回执'],
|
||||
['all', '全部邮件'],
|
||||
];
|
||||
|
||||
if (requireOperator()) {
|
||||
mountShell({ active: 'operator-offsite', mode: 'operator' });
|
||||
const context = getAuthContext();
|
||||
const operatorId = String(context?.userId || context?.username || '');
|
||||
const state = {
|
||||
page: 1, pageSize: 10, total: 0, mails: [], mailbox: null, activeMailStatus: 'all',
|
||||
mailDetails: {},
|
||||
selectedMailId: '', mail: null, recognition: null, documents: [], nl2sql: {}, rules: {},
|
||||
ocrDrafts: {}, nlDrafts: {}, notice: null, noticeTaskId: '', noticeDraft: '', stats: null,
|
||||
statsForm: { fundCode: '', applicationDate: '' },
|
||||
recalculatingTasks: new Set(),
|
||||
};
|
||||
const root = document.querySelector('main');
|
||||
const targets = {
|
||||
metrics: document.querySelector('[data-offsite-metrics]'),
|
||||
statusTabs: document.querySelector('[data-mail-status-tabs]'),
|
||||
list: document.querySelector('[data-mail-list]'),
|
||||
pagination: document.querySelector('[data-mail-pagination]'),
|
||||
detail: document.querySelector('[data-mail-detail]'),
|
||||
documents: document.querySelector('[data-document-list]'),
|
||||
noticePanel: document.querySelector('[data-notification-panel]'),
|
||||
notice: document.querySelector('[data-notification]'),
|
||||
statistics: document.querySelector('[data-statistics]'),
|
||||
};
|
||||
document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员';
|
||||
document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
|
||||
|
||||
function value(item, fallback = '--') {
|
||||
if (item === null || item === undefined || item === '') return fallback;
|
||||
if (Array.isArray(item)) return item.length ? item.join('、') : fallback;
|
||||
if (typeof item === 'object') return JSON.stringify(item);
|
||||
return String(item);
|
||||
}
|
||||
|
||||
function tag(text, tone = '') {
|
||||
return `<span class="tag ${tone ? `status-tag--${tone}` : 'tag--neutral'}">${escapeHtml(text || '--')}</span>`;
|
||||
}
|
||||
|
||||
function actionButton(label, action, tone = '', disabled = false) {
|
||||
return `<button class="button table-action${tone ? ` button--${tone}` : ''}" type="button" data-action="${escapeHtml(action)}"${disabled ? ' disabled' : ''}>${escapeHtml(label)}</button>`;
|
||||
}
|
||||
|
||||
function fieldStatusLabel(status) {
|
||||
return {
|
||||
success: '成功',
|
||||
query_failed: '失败',
|
||||
not_queried: '未查询',
|
||||
pending: '待查询',
|
||||
corrected: '人工修正',
|
||||
}[String(status || '').toLowerCase()] || '未查询';
|
||||
}
|
||||
|
||||
function documentNotificationType(document) {
|
||||
if (document?.operator_decision === '确认正常') return 'normal_return';
|
||||
if (document?.operator_decision === '确认异常') return 'exception_return';
|
||||
return '';
|
||||
}
|
||||
|
||||
function mailDocuments(mail) {
|
||||
return (mail?.attachments || []).flatMap((attachment) => attachment.documents || []);
|
||||
}
|
||||
|
||||
function mailDisplayStatus(mail) {
|
||||
const internalStatus = String(mail?.status || '');
|
||||
const documents = mailDocuments(state.mailDetails[mail?.mail_id]);
|
||||
const decisions = documents.map((item) => item.operator_decision).filter(Boolean);
|
||||
|
||||
if (internalStatus === 'processing') {
|
||||
if (decisions.includes('确认异常')) return { key: 'exception', label: '有异常', tone: 'high' };
|
||||
if (decisions.length && decisions.every((decision) => decision === '确认正常')) {
|
||||
return { key: 'processed', label: '已处理', tone: 'low' };
|
||||
}
|
||||
return { key: 'processing', label: '处理中', tone: 'medium' };
|
||||
}
|
||||
if (internalStatus === 'recognized' || internalStatus === 'received') {
|
||||
return { key: 'inbox', label: '已入库', tone: 'active' };
|
||||
}
|
||||
if (internalStatus === 'normal_return_sent' || internalStatus === 'completed') {
|
||||
return { key: 'replied', label: '已回执', tone: 'low' };
|
||||
}
|
||||
if (internalStatus === 'deleted') return { key: 'deleted', label: '已删除', tone: 'neutral' };
|
||||
return { key: 'processing', label: '处理中', tone: 'medium' };
|
||||
}
|
||||
|
||||
function documentDisplayStatus(document) {
|
||||
if (document.operator_decision === '确认异常') return { label: '有异常', tone: 'high' };
|
||||
if (document.operator_decision === '确认正常') return { label: '已处理', tone: 'low' };
|
||||
if (document.status === 'recognized' || document.status === 'planned') {
|
||||
return { label: '已入库', tone: 'active' };
|
||||
}
|
||||
return { label: '处理中', tone: 'medium' };
|
||||
}
|
||||
|
||||
function ruleComparison(row) {
|
||||
const calculation = row?.calculation || {};
|
||||
if (calculation.实际值 !== undefined && calculation.规则值 !== undefined) {
|
||||
return {
|
||||
actual: calculation.实际值,
|
||||
rule: calculation.规则值,
|
||||
expression: calculation.比较,
|
||||
};
|
||||
}
|
||||
if (row?.rule_code === 'subscription_minimum_amount') {
|
||||
return {
|
||||
actual: row.document_value?.申购金额元,
|
||||
rule: '> 1 元',
|
||||
expression: row.document_value?.申购金额元 === undefined
|
||||
? ''
|
||||
: `${row.document_value.申购金额元} > 1 元`,
|
||||
};
|
||||
}
|
||||
if (calculation.申购后持有比例 !== undefined) {
|
||||
const actual = `${Number(calculation.申购后持有比例) * 100}%`;
|
||||
return { actual, rule: '≤ 20%', expression: `${actual} ≤ 20%` };
|
||||
}
|
||||
if (calculation.本次申购份额 !== undefined && calculation.份额上限 !== undefined) {
|
||||
return {
|
||||
actual: calculation.本次申购份额,
|
||||
rule: calculation.份额上限,
|
||||
expression: `${calculation.本次申购份额} ≤ ${calculation.份额上限}`,
|
||||
};
|
||||
}
|
||||
if (calculation.赎回比例 !== undefined) {
|
||||
const actual = `${Number(calculation.赎回比例) * 100}%`;
|
||||
return { actual, rule: '≤ 20%', expression: `${actual} ≤ 20%` };
|
||||
}
|
||||
if (row?.rule_code === 'redemption_available_quantity') {
|
||||
return {
|
||||
actual: row.document_value?.赎回份额,
|
||||
rule: row.database_value?.当前最新可用份额,
|
||||
expression: row.document_value?.赎回份额 === undefined
|
||||
|| row.database_value?.当前最新可用份额 === undefined
|
||||
? ''
|
||||
: `${row.document_value.赎回份额} ≤ ${row.database_value.当前最新可用份额}`,
|
||||
};
|
||||
}
|
||||
return { actual: '', rule: '', expression: '' };
|
||||
}
|
||||
|
||||
function renderRuleComparison(row) {
|
||||
const comparison = ruleComparison(row);
|
||||
if (!comparison.actual && !comparison.rule) {
|
||||
return '<span class="operator-inline-note">暂无可对比数据</span>';
|
||||
}
|
||||
const conclusion = row.result === '正常' ? '满足规则' : row.result === '异常' ? '不满足规则' : '无法判断';
|
||||
const tone = row.result === '正常' ? 'operator-comparison--normal' : row.result === '异常' ? 'operator-comparison--abnormal' : 'operator-comparison--unknown';
|
||||
return `<div class="operator-comparison ${tone}">
|
||||
<div class="operator-comparison__values">
|
||||
<div><span>实际值</span><strong>${escapeHtml(value(comparison.actual))}</strong></div>
|
||||
<div><span>规则值</span><strong>${escapeHtml(value(comparison.rule))}</strong></div>
|
||||
</div>
|
||||
${comparison.expression ? `<div class="operator-comparison__expression">${escapeHtml(comparison.expression)} · ${conclusion}</div>` : `<div class="operator-comparison__expression">${conclusion}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStatusTabs() {
|
||||
targets.statusTabs.innerHTML = MAIL_STATUS_FILTERS.map(([key, label]) => `
|
||||
<button class="operator-tab" type="button" role="tab" aria-selected="${state.activeMailStatus === key}" data-mail-status="${key}">${label}</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function errorText(error) {
|
||||
return `<div class="operator-warning">${escapeHtml(error?.message || '请求未完成')}</div>`;
|
||||
}
|
||||
|
||||
function renderMetrics() {
|
||||
const selectedDocs = state.documents.length;
|
||||
const pending = state.documents.filter((item) => item.operator_decision === '未处理' || !item.operator_decision).length;
|
||||
const blocked = Boolean(state.mailbox?.blocked);
|
||||
targets.metrics.innerHTML = [
|
||||
['业务邮件', state.total, `当前第 ${state.page} 页`],
|
||||
['当前单据', selectedDocs, state.selectedMailId ? '来自当前邮件' : '请选择邮件'],
|
||||
['待人工确认', pending, '以服务端单据状态为准'],
|
||||
['收件状态', blocked ? '已阻塞' : (state.mailbox?.monitoring ? '运行中' : '未启用'), blocked ? '需要恢复游标' : '服务端状态'],
|
||||
].map(([label, current, meta]) => `<article class="metric-card"><p class="metric-card__label">${label}</p><p class="metric-card__value">${escapeHtml(String(current))}</p><p class="metric-card__meta">${escapeHtml(meta)}</p></article>`).join('');
|
||||
}
|
||||
|
||||
function renderMailList() {
|
||||
const visibleMails = state.mails.filter((mail) => (
|
||||
state.activeMailStatus === 'all' || mailDisplayStatus(mail).key === state.activeMailStatus
|
||||
));
|
||||
if (!visibleMails.length) {
|
||||
const activeLabel = MAIL_STATUS_FILTERS.find(([key]) => key === state.activeMailStatus)?.[1] || '业务';
|
||||
renderEmpty(targets.list, `暂无${activeLabel}`, '当前状态下没有可展示的场外基金邮件。');
|
||||
targets.pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
targets.list.innerHTML = visibleMails.map((mail) => {
|
||||
const active = mail.mail_id === state.selectedMailId;
|
||||
const displayStatus = mailDisplayStatus(mail);
|
||||
return `<div class="operator-list__item${active ? ' operator-list__item--active' : ''}" data-mail="${escapeHtml(mail.mail_id)}">
|
||||
<div><strong>${escapeHtml(mail.subject || mail.mail_id || '无主题邮件')}</strong><small>${escapeHtml(mail.sender || '未知发件人')}</small><small>发送日期:${escapeHtml(formatDateTime(mail.sent_at || mail.received_at || mail.received_date))}</small></div>
|
||||
<div class="operator-list__actions">${tag(displayStatus.label, displayStatus.tone)}${actionButton('删除', `delete-mail:${encodeURIComponent(mail.mail_id)}`, 'danger')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const pages = Math.max(1, Math.ceil(state.total / state.pageSize));
|
||||
targets.pagination.innerHTML = `<span>第 ${state.page} / ${pages} 页,共 ${state.total} 封</span>${actionButton('上一页', 'page:prev', '')}${actionButton('下一页', 'page:next', '')}`;
|
||||
targets.pagination.querySelector('[data-action="page:prev"]')?.toggleAttribute('disabled', state.page <= 1);
|
||||
targets.pagination.querySelector('[data-action="page:next"]')?.toggleAttribute('disabled', state.page >= pages);
|
||||
}
|
||||
|
||||
function fieldNames(base, effective, extra = []) {
|
||||
const names = [];
|
||||
[...Object.keys(base || {}), ...Object.keys(effective || {}), ...extra].forEach((name) => {
|
||||
if (name && !names.includes(name)) names.push(name);
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function renderFieldGrid(fields, draft, scope, key, statuses = {}) {
|
||||
const names = fieldNames(fields, draft);
|
||||
if (!names.length) return '<div class="operator-inline-note">暂无可展示字段。</div>';
|
||||
return `<div class="operator-field-grid">${names.map((name) => `<label class="form-field"><span class="form-field__label operator-field-label"><span>${escapeHtml(name)}</span>${statuses[name] !== undefined && statuses[name] !== null && statuses[name] !== '' ? `<span class="operator-field-confidence">状态:${escapeHtml(fieldStatusLabel(statuses[name]))}</span>` : ''}</span><input class="form-field__input" data-${scope}-field="${escapeHtml(key)}" data-field-name="${escapeHtml(name)}" value="${escapeHtml(draft[name] ?? fields?.[name] ?? '')}"></label>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!state.mail) {
|
||||
renderEmpty(targets.detail, '请选择一封邮件', '邮件详情会展示正文、附件原件、OCR 识别字段和关联单据。');
|
||||
return;
|
||||
}
|
||||
const attachments = state.mail.attachments || [];
|
||||
targets.detail.innerHTML = `<dl class="operator-kv"><div><dt>邮件编号</dt><dd>${escapeHtml(value(state.mail.mail_id))}</dd></div><div><dt>发件人</dt><dd>${escapeHtml(value(state.mail.sender))}</dd></div><div><dt>接收时间</dt><dd>${escapeHtml(formatDateTime(state.mail.received_at || state.mail.received_date))}</dd></div></dl>
|
||||
<section><h3>邮件正文</h3><pre class="operator-text">${escapeHtml(state.mail.body_text || state.mail.body_html || '暂无正文')}</pre></section>
|
||||
<section><div class="operator-section-heading"><h3>附件原件</h3><span class="operator-inline-note">${attachments.length} 个附件</span></div>${attachments.map(renderAttachment).join('') || '<div class="operator-inline-note">暂无附件。</div>'}</section>`;
|
||||
}
|
||||
|
||||
function renderAttachment(item) {
|
||||
const docs = item.documents || [];
|
||||
const itemRecognition = (state.recognition?.attachments || []).find((row) => row.attachment_id === item.attachment_id);
|
||||
const draft = state.ocrDrafts[item.attachment_id] || {};
|
||||
const missing = itemRecognition?.missing_fields || [];
|
||||
const names = fieldNames(itemRecognition?.extracted_fields, itemRecognition?.effective_fields, missing);
|
||||
return `<article class="operator-attachment"><div class="operator-attachment__header"><div><strong>${escapeHtml(item.filename || item.attachment_id)}</strong><p class="operator-meta">OCR ${escapeHtml(itemRecognition?.ocr_status || '未记录')}</p></div><div class="operator-actions">${actionButton(PREVIEW_TYPES.has(String(item.media_type || '').toLowerCase()) ? '预览原件' : '下载原件', `file:${encodeURIComponent(item.attachment_id)}`)} </div></div>${missing.length ? `<div class="operator-warning">缺失字段:${escapeHtml(missing.join('、'))}</div>` : ''}<div class="operator-subsection"><h4>OCR 识别字段</h4>${renderFieldGrid(itemRecognition?.effective_fields || itemRecognition?.extracted_fields, draft, 'ocr', item.attachment_id, itemRecognition?.field_confidence || {})}<div class="operator-actions">${actionButton('保存', `save-ocr:${encodeURIComponent(item.attachment_id)}`, 'primary')}${actionButton('重试', docs[0]?.task_id ? `retry:${encodeURIComponent(docs[0].task_id)}` : 'noop')}</div></div></article>`;
|
||||
}
|
||||
|
||||
function renderDocuments() {
|
||||
if (!state.selectedMailId) {
|
||||
renderEmpty(targets.documents, '请选择邮件后处理单据', '单据来自邮件附件关联记录。');
|
||||
return;
|
||||
}
|
||||
if (!state.documents.length) {
|
||||
renderEmpty(targets.documents, '当前邮件没有业务单据', '如果附件识别异常,可在上方查看原件并提交识别重试。');
|
||||
return;
|
||||
}
|
||||
targets.documents.innerHTML = state.documents.map((document) => {
|
||||
const taskId = document.task_id;
|
||||
const nl = state.nl2sql[taskId];
|
||||
const rule = state.rules[taskId];
|
||||
const draft = state.nlDrafts[taskId] || {};
|
||||
const ruleRows = rule?.rules || [];
|
||||
const displayStatus = documentDisplayStatus(document);
|
||||
const notificationType = documentNotificationType(document);
|
||||
const recalculating = state.recalculatingTasks.has(taskId);
|
||||
const encodedTaskId = encodeURIComponent(String(taskId || '').trim());
|
||||
const notificationAction = notificationType
|
||||
? `notice:${encodedTaskId}:${notificationType}`
|
||||
: `notice:${encodedTaskId}`;
|
||||
return `<article class="operator-document"><div class="operator-document__header"><div><strong>${escapeHtml(taskId)}</strong><p class="operator-meta">${escapeHtml(document.document_type || '单据')} · ${escapeHtml(document.fund_name || document.fund_code || '--')} · ${escapeHtml(document.application_no || '--')}</p></div><div>${tag(document.operator_decision || displayStatus.label, document.operator_decision === '确认异常' ? 'high' : displayStatus.tone)}</div></div><dl class="operator-kv"><div><dt>申请日期</dt><dd>${escapeHtml(value(document.application_date))}</dd></div><div><dt>申购金额 / 赎回份额</dt><dd>${escapeHtml(value(document.subscription_amount_yuan))} / ${escapeHtml(value(document.redemption_shares))}</dd></div><div><dt>机构</dt><dd>${escapeHtml(value(document.agency))}</dd></div><div><dt>基金代码</dt><dd>${escapeHtml(value(document.fund_code))}</dd></div></dl>
|
||||
<div class="operator-subsection"><div class="operator-section-heading"><h3>NL2SQL 返回字段</h3><span class="operator-inline-note">${escapeHtml(nl?.updated_at ? formatDateTime(nl.updated_at, true) : '尚未核对')}</span></div>${nl?.error ? errorText({ message: nl.error }) : renderFieldGrid(nl?.effective_fields || nl?.fields, draft, 'nl', taskId, nl?.field_status || {})}<div class="operator-actions">${actionButton('保存', `save-nl:${encodeURIComponent(taskId)}`, 'primary')}${actionButton('进入 NL2SQL', `open-nl:${encodeURIComponent(taskId)}`)}</div></div>
|
||||
<div class="operator-subsection"><div class="operator-section-heading"><h3>规则结果</h3>${rule ? tag(rule.document_status || '已读取') : ''}</div>${rule?.error ? errorText({ message: rule.error }) : `<div class="operator-table-wrap"><table class="operator-table"><thead><tr><th>规则</th><th>结果</th><th>单据值</th><th>实际值 / 规则值</th></tr></thead><tbody>${ruleRows.map((row) => `<tr><td>${escapeHtml(row.rule_name || row.rule_code || '--')}</td><td>${tag(row.result)}</td><td>${escapeHtml(value(row.document_value))}</td><td>${renderRuleComparison(row)}</td></tr>`).join('') || '<tr><td colspan="4">暂无规则结果,请先执行 NL2SQL 核对。</td></tr>'}</tbody></table></div>`}<div class="operator-actions">${actionButton(recalculating ? '正在核对并判定...' : '重新核对并判定规则', `recalculate:${encodeURIComponent(taskId)}`, 'primary', recalculating)}${actionButton('确认正常', `confirm:${encodeURIComponent(taskId)}:确认正常`)}${actionButton('确认异常', `confirm:${encodeURIComponent(taskId)}:确认异常`, 'danger')}${actionButton(notificationType ? `创建${NOTIFICATION_LABELS[notificationType]}` : '创建通知', notificationAction)}</div></div></article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderNotice() {
|
||||
if (!state.notice) {
|
||||
targets.noticePanel.hidden = true;
|
||||
return;
|
||||
}
|
||||
targets.noticePanel.hidden = false;
|
||||
targets.notice.innerHTML = `<div class="operator-form-grid"><label class="form-field"><span class="form-field__label">通知编号</span><input class="form-field__input" value="${escapeHtml(state.notice.notification_id)}" readonly></label><label class="form-field"><span class="form-field__label">关联单据</span><input class="form-field__input" value="${escapeHtml(state.noticeTaskId)}" readonly></label><label class="form-field"><span class="form-field__label">通知类型</span><select class="form-field__input" data-notice-type>${NOTIFICATION_TYPES.map(([key, label]) => `<option value="${key}"${key === state.noticeType ? ' selected' : ''}>${label}</option>`).join('')}</select></label></div><label class="form-field"><span class="form-field__label">最终发送正文</span><textarea class="form-field__input" rows="5" maxlength="10000" data-notice-content>${escapeHtml(state.noticeDraft)}</textarea></label><div class="operator-actions">${actionButton('发送通知', 'send-notice', 'primary')}${actionButton('清除通知', 'clear-notice')}</div>`;
|
||||
}
|
||||
|
||||
function renderStatistics() {
|
||||
const items = Array.isArray(state.stats?.items)
|
||||
? state.stats.items
|
||||
: state.stats ? [state.stats] : [];
|
||||
const blocks = items.map((item) => `<article class="operator-result-card"><div class="operator-section-heading"><div><h3>${escapeHtml(value(item.fund_name, '未识别基金'))}</h3><p class="operator-meta">基金代码:${escapeHtml(value(item.fund_code))}</p></div><span class="operator-inline-note">${escapeHtml(value(item.application_date))}</span></div><dl class="operator-kv"><div><dt>申购金额 / 笔数</dt><dd>${escapeHtml(value(item.subscription_amount_yuan, '0'))} / ${escapeHtml(value(item.subscription_count, '0'))}</dd></div><div><dt>赎回份额 / 金额</dt><dd>${escapeHtml(value(item.redemption_shares, '0'))} / ${escapeHtml(value(item.redemption_amount_yuan))}</dd></div><div><dt>净流入 / 流出</dt><dd>${escapeHtml(value(item.net_flow_amount_yuan))}</dd></div><div><dt>最新净值</dt><dd>${escapeHtml(value(item.latest_nav))}</dd></div></dl></article>`).join('');
|
||||
targets.statistics.innerHTML = `<div class="operator-form-grid"><label class="form-field"><span class="form-field__label">基金代码(可选)</span><input class="form-field__input" data-stat-field="fundCode" value="${escapeHtml(state.statsForm.fundCode)}" placeholder="留空统计当天全部基金"></label><label class="form-field"><span class="form-field__label">申请日期</span><input class="form-field__input" type="date" data-stat-field="applicationDate" value="${escapeHtml(state.statsForm.applicationDate)}"></label></div>${state.stats ? (items.length ? `<p class="operator-inline-note">共 ${items.length} 只基金,每只基金单独汇总。</p><div class="operator-stack">${blocks}</div>` : '<div class="operator-inline-note">当天没有符合条件的清算单据。</div>') : '<p class="operator-inline-note">基金代码留空时,将统计申请日期当天全部基金。</p>'}`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderMetrics(); renderStatusTabs(); renderMailList(); renderDetail(); renderDocuments(); renderNotice(); renderStatistics();
|
||||
}
|
||||
|
||||
function syncDocumentsFromRecognition(payload) {
|
||||
const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
|
||||
if (!attachments.length || !state.mail) return;
|
||||
const documentsByAttachment = new Map(
|
||||
attachments.map((attachment) => [
|
||||
attachment.attachment_id,
|
||||
(attachment.documents || []).map((document) => ({
|
||||
...document,
|
||||
attachment_id: attachment.attachment_id,
|
||||
})),
|
||||
]),
|
||||
);
|
||||
state.mail = {
|
||||
...state.mail,
|
||||
attachments: (state.mail.attachments || []).map((attachment) => ({
|
||||
...attachment,
|
||||
documents: documentsByAttachment.get(attachment.attachment_id)
|
||||
|| attachment.documents
|
||||
|| [],
|
||||
})),
|
||||
};
|
||||
state.mailDetails[state.mail.mail_id] = state.mail;
|
||||
state.documents = state.mail.attachments.flatMap((attachment) => (
|
||||
attachment.documents || []
|
||||
));
|
||||
}
|
||||
|
||||
async function loadMail(mailId) {
|
||||
const [mailResponse, recognitionResponse] = await Promise.all([
|
||||
apiClient.get('OFFSITE_MAIL', { pathParams: { mailId } }),
|
||||
apiClient.get('OFFSITE_RECOGNITION', { pathParams: { mailId } }).catch((error) => ({ data: { error: error.message, attachments: [] } })),
|
||||
]);
|
||||
state.selectedMailId = mailId;
|
||||
state.mail = mailResponse.data || {};
|
||||
state.mailDetails[mailId] = state.mail;
|
||||
state.recognition = recognitionResponse.data || {};
|
||||
state.ocrDrafts = Object.fromEntries((state.recognition.attachments || []).map((item) => [item.attachment_id, { ...(item.effective_fields || item.extracted_fields || {}) }]));
|
||||
state.documents = (state.mail.attachments || []).flatMap((attachment) => (attachment.documents || []).map((document) => ({ ...document, attachment_id: document.attachment_id || attachment.attachment_id })));
|
||||
const taskIds = [...new Set(state.documents.map((item) => item.task_id).filter(Boolean))];
|
||||
const results = await Promise.all(taskIds.map(async (taskId) => {
|
||||
const [nlResult, ruleResult] = await Promise.allSettled([
|
||||
apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }),
|
||||
apiClient.get('OFFSITE_RULE_RESULTS', { pathParams: { taskId } }),
|
||||
]);
|
||||
return { taskId, nl: nlResult.status === 'fulfilled' ? nlResult.value.data : { error: nlResult.reason?.message || '读取失败' }, rule: ruleResult.status === 'fulfilled' ? ruleResult.value.data : { error: ruleResult.reason?.message || '读取失败' } };
|
||||
}));
|
||||
state.nl2sql = Object.fromEntries(results.map(({ taskId, nl }) => [taskId, nl]));
|
||||
state.nlDrafts = Object.fromEntries(results.map(({ taskId, nl }) => [taskId, { ...(nl.effective_fields || nl.fields || {}) }]));
|
||||
state.rules = Object.fromEntries(results.map(({ taskId, rule }) => [taskId, rule]));
|
||||
const first = state.documents.find((item) => item.fund_code && item.application_date);
|
||||
if (first) {
|
||||
state.statsForm.applicationDate = String(first.application_date).slice(0, 10);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadMailStatusDetails(mails) {
|
||||
const candidates = mails.filter((mail) => String(mail.status || '') === 'processing');
|
||||
await Promise.allSettled(candidates.map(async (mail) => {
|
||||
const response = await apiClient.get('OFFSITE_MAIL', { pathParams: { mailId: mail.mail_id } });
|
||||
state.mailDetails[mail.mail_id] = response.data || {};
|
||||
}));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
renderLoading(targets.list, 5); renderLoading(targets.detail, 3); renderLoading(targets.documents, 3);
|
||||
try {
|
||||
const [mails, mailbox] = await Promise.all([
|
||||
apiClient.get('OFFSITE_MAILS', { query: { page: state.page, page_size: state.pageSize } }),
|
||||
apiClient.get('OFFSITE_MAILBOX'),
|
||||
]);
|
||||
const payload = mails.data || {};
|
||||
state.mails = Array.isArray(payload.items) ? payload.items : [];
|
||||
state.total = Number(payload.total || 0);
|
||||
state.mailbox = mailbox.data || {};
|
||||
await loadMailStatusDetails(state.mails);
|
||||
if (!state.selectedMailId || !state.mails.some((item) => item.mail_id === state.selectedMailId)) {
|
||||
state.selectedMailId = state.mails[0]?.mail_id || '';
|
||||
}
|
||||
if (state.selectedMailId) await loadMail(state.selectedMailId);
|
||||
else { state.mail = null; state.documents = []; render(); }
|
||||
} catch (error) {
|
||||
apiClient.reportError(error); renderError(targets.list, error, load); renderError(targets.detail, error, load); renderError(targets.documents, error, load);
|
||||
}
|
||||
}
|
||||
|
||||
async function fileAction(attachmentId) {
|
||||
const result = await apiClient.file('OFFSITE_ATTACHMENT_FILE', { pathParams: { attachmentId }, query: { disposition: 'inline' } });
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
if (!opened) {
|
||||
const link = document.createElement('a'); link.href = url; link.download = attachmentId; link.click();
|
||||
}
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
|
||||
async function run(action) {
|
||||
try {
|
||||
if (action === 'refresh') { await load(); showToast('邮件列表已刷新'); return; }
|
||||
if (action.startsWith('select-mail:')) {
|
||||
await loadMail(decodeURIComponent(action.slice(12)));
|
||||
return;
|
||||
}
|
||||
if (action === 'recover-mailbox') { await apiClient.post('OFFSITE_MAILBOX_RECOVER', { operator_id: operatorId }); await load(); showToast('收件游标已恢复'); return; }
|
||||
if (action.startsWith('page:')) { const pages = Math.max(1, Math.ceil(state.total / state.pageSize)); state.page = Math.min(pages, Math.max(1, state.page + (action.endsWith('next') ? 1 : -1))); await load(); return; }
|
||||
if (action.startsWith('delete-mail:')) {
|
||||
const mailId = decodeURIComponent(action.slice(12));
|
||||
if (!window.confirm('确认删除这封邮件吗?删除后只会从运营列表隐藏,原始邮件和识别记录仍保留。')) return;
|
||||
await apiClient.post('OFFSITE_MAIL_DELETE', { operator_id: operatorId }, { pathParams: { mailId } });
|
||||
state.selectedMailId = ''; await load(); showToast('邮件已删除'); return;
|
||||
}
|
||||
if (action.startsWith('file:')) { await fileAction(decodeURIComponent(action.slice(5))); return; }
|
||||
if (action.startsWith('save-ocr:')) {
|
||||
const attachmentId = decodeURIComponent(action.slice(9));
|
||||
const response = await apiClient.post('OFFSITE_RECOGNITION_SAVE', { operator_id: operatorId, attachments: [{ attachment_id: attachmentId, fields: state.ocrDrafts[attachmentId] || {} }] }, { pathParams: { mailId: state.selectedMailId } });
|
||||
state.recognition = response.data;
|
||||
const saved = state.recognition.attachments?.find((item) => item.attachment_id === attachmentId);
|
||||
state.ocrDrafts[attachmentId] = { ...(saved?.effective_fields || {}) };
|
||||
syncDocumentsFromRecognition(state.recognition);
|
||||
showToast('OCR 识别字段修正已保存'); render(); return;
|
||||
}
|
||||
if (action.startsWith('save-nl:')) {
|
||||
const taskId = decodeURIComponent(action.slice(8));
|
||||
const response = await apiClient.post('OFFSITE_NL2SQL_FIELDS_SAVE', { operator_id: operatorId, fields: state.nlDrafts[taskId] || {} }, { pathParams: { taskId } });
|
||||
state.nl2sql[taskId] = response.data;
|
||||
state.nlDrafts[taskId] = { ...(response.data.effective_fields || response.data.fields || {}) };
|
||||
showToast('NL2SQL 字段修正已保存'); render(); return;
|
||||
}
|
||||
if (action.startsWith('retry:')) {
|
||||
const taskId = decodeURIComponent(action.slice(6));
|
||||
if (!window.confirm('是否重新对该文件进行 OCR 识别?')) return;
|
||||
await apiClient.post('OFFSITE_RECOGNITION_RETRY', { operator_id: operatorId }, { pathParams: { taskId } });
|
||||
await loadMail(state.selectedMailId); showToast('已提交识别重试'); return;
|
||||
}
|
||||
if (action.startsWith('recalculate:')) {
|
||||
const taskId = decodeURIComponent(action.slice(12));
|
||||
if (state.recalculatingTasks.has(taskId)) return;
|
||||
state.recalculatingTasks.add(taskId);
|
||||
render();
|
||||
try {
|
||||
const triggerResponse = await apiClient.post(
|
||||
'OFFSITE_TRIGGER_NL2SQL',
|
||||
{ operator_id: operatorId, manual_confirmed: true },
|
||||
{ pathParams: { taskId } },
|
||||
);
|
||||
const [fieldsResponse, rulesResponse] = await Promise.all([
|
||||
apiClient.get('OFFSITE_NL2SQL_FIELDS', { pathParams: { taskId } }),
|
||||
apiClient.post(
|
||||
'OFFSITE_RULE_RECALCULATE',
|
||||
{ operator_id: operatorId },
|
||||
{ pathParams: { taskId } },
|
||||
),
|
||||
]);
|
||||
state.nl2sql[taskId] = fieldsResponse.data;
|
||||
state.nlDrafts[taskId] = {
|
||||
...(fieldsResponse.data?.effective_fields || fieldsResponse.data?.fields || {}),
|
||||
};
|
||||
state.rules[taskId] = rulesResponse.data;
|
||||
showToast(
|
||||
triggerResponse.data?.status === 'query_failed'
|
||||
? '已重新核对,但存在查询失败'
|
||||
: '规则已重新核对并判定',
|
||||
triggerResponse.data?.status === 'query_failed' ? 'error' : 'success',
|
||||
);
|
||||
} finally {
|
||||
state.recalculatingTasks.delete(taskId);
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('confirm:')) {
|
||||
const [, encodedTask, decision] = action.split(':');
|
||||
const taskId = decodeURIComponent(encodedTask).trim();
|
||||
await apiClient.post('OFFSITE_CONFIRM', { decision, operator_id: operatorId }, { pathParams: { taskId } });
|
||||
await loadMail(state.selectedMailId); showToast(`单据已${decision}`); return;
|
||||
}
|
||||
if (action.startsWith('open-nl:')) { window.location.href = `/portal/employee-operations/nl2sql/?task_id=${encodeURIComponent(decodeURIComponent(action.slice(8)))}`; return; }
|
||||
if (action.startsWith('notice:')) {
|
||||
const actionPayload = action.slice(6);
|
||||
const separator = actionPayload.lastIndexOf(':');
|
||||
const encodedTaskId = separator >= 0 ? actionPayload.slice(0, separator) : actionPayload;
|
||||
const noticeType = separator >= 0 ? actionPayload.slice(separator + 1) : '';
|
||||
const taskId = decodeURIComponent(encodedTaskId).trim();
|
||||
if (!noticeType) throw new Error('请先确认正常或确认异常后再创建通知');
|
||||
const response = await apiClient.post('OFFSITE_NOTIFICATION_CREATE', { notification_type: noticeType, operator_id: operatorId }, { pathParams: { taskId } });
|
||||
state.notice = response.data || {}; state.noticeType = noticeType; state.noticeTaskId = taskId; state.noticeDraft = `${taskId} 待发送${NOTIFICATION_LABELS[noticeType]}`; showToast(`${NOTIFICATION_LABELS[noticeType]}已创建`); render(); return;
|
||||
}
|
||||
if (action === 'send-notice') {
|
||||
if (!state.notice?.notification_id) throw new Error('请先创建通知');
|
||||
if (!window.confirm('确认发送这条通知吗?发送后将影响业务状态。')) return;
|
||||
const response = await apiClient.post('OFFSITE_NOTIFICATION_SEND', { operator_id: operatorId, operator_confirmed: true, final_content: state.noticeDraft }, { pathParams: { notificationId: state.notice.notification_id } });
|
||||
state.notice = { ...state.notice, ...response.data }; showToast(`通知状态:${response.data?.status || '已提交'}`); render(); return;
|
||||
}
|
||||
if (action === 'clear-notice') { state.notice = null; state.noticeTaskId = ''; render(); return; }
|
||||
if (action === 'statistics') {
|
||||
if (!state.statsForm.applicationDate) throw new Error('请填写申请日期');
|
||||
const response = await apiClient.post('OFFSITE_SETTLEMENT_RECALCULATE', { fund_code: state.statsForm.fundCode || null, application_date: state.statsForm.applicationDate });
|
||||
state.stats = response.data; showToast('清算统计已刷新'); render(); return;
|
||||
}
|
||||
} catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); }
|
||||
}
|
||||
|
||||
root.addEventListener('click', (event) => {
|
||||
const statusKey = event.target.closest('[data-mail-status]')?.dataset.mailStatus;
|
||||
if (statusKey) {
|
||||
state.activeMailStatus = statusKey;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||
if (action) { event.preventDefault(); run(action); return; }
|
||||
const mailId = event.target.closest('[data-mail]')?.dataset.mail;
|
||||
if (mailId) { run(`select-mail:${encodeURIComponent(mailId)}`); }
|
||||
});
|
||||
root.addEventListener('input', (event) => {
|
||||
const target = event.target;
|
||||
if (target.matches('[data-ocr-field]')) { state.ocrDrafts[target.dataset.ocrField] ||= {}; state.ocrDrafts[target.dataset.ocrField][target.dataset.fieldName] = target.value; }
|
||||
if (target.matches('[data-nl-field]')) { state.nlDrafts[target.dataset.nlField] ||= {}; state.nlDrafts[target.dataset.nlField][target.dataset.fieldName] = target.value; }
|
||||
if (target.matches('[data-stat-field]')) { state.statsForm[target.dataset.statField] = target.value; }
|
||||
if (target.matches('[data-notice-content]')) state.noticeDraft = target.value;
|
||||
});
|
||||
document.querySelector('[data-mail-refresh]').addEventListener('click', () => run('refresh'));
|
||||
document.querySelector('[data-mailbox-recover]').addEventListener('click', () => run('recover-mailbox'));
|
||||
document.querySelector('[data-stat-refresh]').addEventListener('click', () => run('statistics'));
|
||||
load();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.operator-workspace { display: grid; gap: var(--space-5); }
|
||||
.operator-page-heading { display: flex; justify-content: space-between; gap: var(--space-4); align-items: end; }
|
||||
.operator-page-heading h1 { margin: 0; font-size: clamp(26px, 3vw, 36px); letter-spacing: 0; }
|
||||
.operator-page-heading p { max-width: 760px; margin: var(--space-2) 0 0; color: var(--muted); line-height: 1.7; }
|
||||
.operator-page-heading__actions { display: flex; gap: var(--space-2); flex-wrap: wrap; justify-content: flex-end; }
|
||||
.operator-toolbar { padding: var(--space-4); display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
|
||||
.operator-toolbar .form-field { min-width: 150px; flex: 1 1 170px; }
|
||||
.operator-toolbar__actions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.operator-mail-status-row { display: flex; align-items: stretch; gap: var(--space-3); min-width: 0; }
|
||||
.operator-mail-status-tabs { flex: 1 1 auto; min-width: 0; }
|
||||
.operator-mail-status-actions { display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); padding-bottom: 1px; border-bottom: 1px solid var(--line); }
|
||||
.operator-columns { display: grid; grid-template-columns: minmax(280px, .8fr) minmax(0, 1.6fr); gap: var(--space-4); align-items: start; }
|
||||
.operator-stack { min-width: 0; display: grid; gap: var(--space-4); align-content: start; }
|
||||
.operator-mail-panel > .panel__header, .operator-mail-detail > .panel__header { min-height: 78px; box-sizing: border-box; }
|
||||
.operator-list { display: grid; }
|
||||
.operator-list__item { padding: var(--space-4); display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||
.operator-list__item:last-child { border-bottom: 0; }
|
||||
.operator-list__item:hover, .operator-list__item--active { background: var(--surface-soft); }
|
||||
.operator-list__item strong, .operator-list__item span { overflow-wrap: anywhere; }
|
||||
.operator-list__item small { display: block; margin-top: 5px; color: var(--muted); line-height: 1.5; }
|
||||
.operator-list__actions, .operator-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
|
||||
.operator-actions .button { max-width: 100%; }
|
||||
.form-field__input--natural-language { min-height: 180px; height: auto; resize: none; line-height: 1.7; padding-top: var(--space-3); padding-bottom: var(--space-3); white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: hidden; }
|
||||
.operator-actions .button--back { flex: 0 1 auto; }
|
||||
.operator-detail { min-width: 0; }
|
||||
.operator-detail__body { display: grid; gap: var(--space-4); }
|
||||
.operator-detail__body > section { padding-top: var(--space-4); border-top: 1px solid var(--line); }
|
||||
.operator-detail__body > section:first-child { padding-top: 0; border-top: 0; }
|
||||
.operator-text { margin: 0; max-height: 180px; padding: var(--space-3); overflow: auto; color: var(--ink-soft); background: var(--surface-soft); border-radius: var(--radius-sm); white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.7 Consolas, monospace; }
|
||||
.operator-attachment, .operator-document, .operator-field-card, .operator-result-card { padding: var(--space-4); background: var(--surface-soft); border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||
.operator-attachment + .operator-attachment, .operator-document + .operator-document, .operator-field-card + .operator-field-card { margin-top: var(--space-3); }
|
||||
.operator-attachment__header, .operator-document__header, .operator-section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-3); }
|
||||
.operator-attachment__header strong, .operator-document__header strong { overflow-wrap: anywhere; }
|
||||
.operator-meta { margin: var(--space-2) 0 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
|
||||
.operator-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); margin-top: var(--space-3); }
|
||||
.operator-field-grid .form-field { min-width: 0; }
|
||||
.operator-field-label { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); }
|
||||
.operator-field-confidence { color: var(--muted); font-size: var(--fs-small); font-weight: 500; text-align: right; white-space: nowrap; }
|
||||
.operator-field-grid .form-field__input { width: 100%; }
|
||||
.operator-field-grid .form-field__input[readonly] { color: var(--muted); background: var(--surface); }
|
||||
.operator-subsection { margin-top: var(--space-3); }
|
||||
.operator-subsection h3, .operator-subsection h4 { margin: 0 0 var(--space-2); font-size: var(--fs-body); }
|
||||
.operator-kv { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); }
|
||||
.operator-kv > div { min-width: 0; padding: var(--space-3); background: var(--surface-soft); border-radius: var(--radius-sm); }
|
||||
.operator-kv dt { color: var(--muted); font-size: var(--fs-small); }
|
||||
.operator-kv dd { margin: 4px 0 0; overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.operator-json { margin: 0; max-height: 360px; padding: var(--space-4); overflow: auto; color: #dcefeb; background: #172624; border-radius: var(--radius-md); font: 12px/1.7 Consolas, monospace; white-space: pre-wrap; }
|
||||
.operator-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); align-items: start; }
|
||||
.operator-form-grid--wide { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.operator-form-grid .form-field, .operator-form-grid .form-field__input { min-width: 0; width: 100%; }
|
||||
.operator-form-grid textarea.form-field__input { height: auto; min-height: 72px; resize: none; overflow-y: hidden; line-height: 1.6; padding-top: var(--space-2); padding-bottom: var(--space-2); }
|
||||
.operator-checkboxes { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
|
||||
.operator-checkboxes label { display: inline-flex; gap: var(--space-1); align-items: center; color: var(--ink-soft); }
|
||||
.operator-tabs { display: flex; gap: var(--space-1); overflow-x: auto; border-bottom: 1px solid var(--line); }
|
||||
.operator-tab { min-height: 48px; padding: 0 var(--space-4); flex: 0 0 auto; border: 0; border-bottom: 2px solid transparent; color: var(--muted); background: transparent; cursor: pointer; }
|
||||
.operator-tab:hover, .operator-tab:focus-visible, .operator-tab[aria-selected="true"] { color: var(--brand-dark); border-color: var(--brand); outline: 0; }
|
||||
.operator-view[hidden] { display: none; }
|
||||
.operator-warning { padding: var(--space-3); color: #885a00; background: #fff5d9; border: 1px solid #ead39a; border-radius: var(--radius-sm); line-height: 1.6; }
|
||||
.operator-success { padding: var(--space-3); color: #1f6d3b; background: #eaf5ee; border: 1px solid #b9ddc3; border-radius: var(--radius-sm); line-height: 1.6; }
|
||||
.operator-danger { color: #9c2d20; }
|
||||
.operator-table-wrap { overflow-x: auto; }
|
||||
.operator-table { width: 100%; border-collapse: collapse; font-size: var(--fs-small); }
|
||||
.operator-table th, .operator-table td { padding: var(--space-3); text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
|
||||
.operator-table th { color: var(--muted); font-weight: 650; background: var(--surface-soft); }
|
||||
.operator-table td { overflow-wrap: anywhere; }
|
||||
.operator-comparison { min-width: 190px; }
|
||||
.operator-comparison__values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); }
|
||||
.operator-comparison__values div { min-width: 0; padding: var(--space-2); background: var(--surface-soft); border-radius: var(--radius-sm); }
|
||||
.operator-comparison__values span, .operator-comparison__values strong { display: block; }
|
||||
.operator-comparison__values span { color: var(--muted); font-size: var(--fs-small); }
|
||||
.operator-comparison__values strong { margin-top: 3px; overflow-wrap: anywhere; }
|
||||
.operator-comparison__expression { margin-top: var(--space-2); font-size: var(--fs-small); line-height: 1.5; font-weight: 650; }
|
||||
.operator-comparison--normal .operator-comparison__expression { color: #1f6d3b; }
|
||||
.operator-comparison--abnormal .operator-comparison__expression { color: #9c2d20; }
|
||||
.operator-comparison--unknown .operator-comparison__expression { color: #885a00; }
|
||||
.operator-inline-note { color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
|
||||
.operator-file-result { padding: var(--space-3); display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: var(--space-3); border-top: 1px solid var(--line); }
|
||||
.operator-file-result:first-child { border-top: 0; }
|
||||
.operator-file-result span { color: var(--muted); }
|
||||
.operator-file-result code { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.operator-sticky-actions { position: sticky; bottom: var(--space-3); z-index: 2; padding: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-2); background: color-mix(in srgb, var(--surface) 92%, transparent); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-menu); backdrop-filter: blur(8px); }
|
||||
@media (max-width: 900px) { .operator-columns { grid-template-columns: 1fr; } .operator-page-heading { align-items: start; flex-direction: column; } .operator-page-heading__actions { justify-content: flex-start; } .operator-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .operator-mail-panel > .panel__header, .operator-mail-detail > .panel__header { min-height: 0; } }
|
||||
@media (max-width: 620px) { .operator-field-grid, .operator-kv, .operator-form-grid, .operator-form-grid--wide { grid-template-columns: 1fr; } .operator-list__item, .operator-attachment__header, .operator-document__header, .operator-section-heading { grid-template-columns: 1fr; flex-direction: column; } .operator-mail-status-row { flex-wrap: wrap; } .operator-mail-status-actions { width: 100%; justify-content: flex-end; } .operator-file-result { grid-template-columns: 1fr; gap: var(--space-1); } }
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>推介材料生成 · 运营工作台</title>
|
||||
<link rel="stylesheet" href="/static/portal/common/base.css">
|
||||
<link rel="stylesheet" href="/static/portal/common/operations.css">
|
||||
<link rel="stylesheet" href="/static/portal/employee-operations/operator-workspace.css?v=20260914">
|
||||
</head>
|
||||
<body>
|
||||
<main id="main-content" class="page-shell operations-shell operator-shell operator-workspace">
|
||||
<section class="operations-hero operator-hero fade-in">
|
||||
<div class="operations-hero__content"><p class="operations-kicker">产品运营 · 资料编排</p><h1 class="operations-hero__title">推介材料生成</h1><p class="operations-hero__copy">维护结构化产品资料,上传来源附件,生成可审核的演示文稿、宣传长图和可选 PDF。</p><div class="operations-hero__signals"><span>结构化输入</span><span>材料生成</span><span>合规审核</span></div></div>
|
||||
<div class="operations-hero__meta"><strong data-operator-name>运营人员</strong><span data-operator-scope>权限加载中</span></div>
|
||||
</section>
|
||||
<section class="operator-page-heading"><div><p class="operations-kicker" style="color: var(--brand-dark);">PROMOTION MATERIALS</p><h2>把产品事实编排成可审核、可交付的材料</h2></div><div class="operator-page-heading__actions"><button class="button" type="button" data-action="load-task">读取已有任务</button><button class="button button--primary" type="button" data-action="create-task">创建材料任务</button></div></section>
|
||||
<section class="panel panel--flush">
|
||||
<div class="panel__header"><div><h2 class="panel__title">任务基本信息</h2></div><span class="tag" data-task-status>未创建任务</span></div>
|
||||
<div class="panel__body">
|
||||
<div class="operator-form-grid"><label class="form-field"><span class="form-field__label">任务编号</span><input class="form-field__input" data-create-field="taskNo" placeholder="例如 PM-20260913-0001"></label><label class="form-field"><span class="form-field__label">产品名称</span><input class="form-field__input" data-create-field="productName" required></label><label class="form-field"><span class="form-field__label">产品代码</span><input class="form-field__input" data-create-field="productCode"></label><label class="form-field"><span class="form-field__label">材料标题</span><input class="form-field__input" data-create-field="materialTitle" required></label><label class="form-field"><span class="form-field__label">视觉风格</span><select class="form-field__input" data-create-field="styleCode"><option value="balanced_allocation">均衡配置</option><option value="steady_professional">稳健专业</option><option value="growth_research">成长研究</option></select></label></div>
|
||||
<div class="operator-checkboxes"><strong>输出格式</strong><label><input type="checkbox" data-format="pptx" checked> PPTX</label><label><input type="checkbox" data-format="poster"> 宣传长图</label><label><input type="checkbox" data-format="pdf"> PDF</label></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel panel--flush">
|
||||
<div class="panel__header"><div><h2 class="panel__title">产品与管理人资料</h2></div><button class="button table-action" type="button" data-action="save-inputs">保存结构化资料</button></div>
|
||||
<div class="panel__body operator-stack">
|
||||
<div><h3>产品信息</h3><div class="operator-form-grid"><label class="form-field"><span class="form-field__label">基金类型 *</span><select class="form-field__input" data-promo-field="product_info.fund_type" required><option value="">请选择基金类型</option><option value="货币型">货币型</option><option value="债券型">债券型</option><option value="混合型">混合型</option><option value="股票型">股票型</option><option value="期货型">期货型</option></select></label><label class="form-field"><span class="form-field__label">运作方式 *</span><select class="form-field__input" data-promo-field="product_info.operation_mode" required><option value="">请选择运作方式</option><option value="封闭式">封闭式</option><option value="开放式">开放式</option></select></label><label class="form-field"><span class="form-field__label">产品状态</span><select class="form-field__input" data-promo-field="product_info.product_status"><option value="">请选择产品状态</option><option value="募集期">募集期</option><option value="封闭期">封闭期</option><option value="开放期">开放期</option></select></label><label class="form-field"><span class="form-field__label">风险等级</span><select class="form-field__input" data-promo-field="product_info.risk_level" disabled><option value="">请选择</option><option value="R1">R1</option><option value="R2">R2</option><option value="R3">R3</option><option value="R4">R4</option><option value="R5">R5</option></select></label><label class="form-field"><span class="form-field__label">业绩比较基准</span><input class="form-field__input" data-promo-field="product_info.benchmark"></label><label class="form-field"><span class="form-field__label">投资目标 *</span><textarea class="form-field__input" data-promo-field="product_info.investment_objective" rows="2" required></textarea></label></div></div>
|
||||
<div><h3>管理人信息</h3><div class="operator-form-grid"><label class="form-field"><span class="form-field__label">基金经理 *</span><input class="form-field__input" data-promo-field="manager_info.manager_name" required></label><label class="form-field"><span class="form-field__label">管理公司 *</span><input class="form-field__input" data-promo-field="manager_info.management_company" required></label><label class="form-field"><span class="form-field__label">登记编码 *</span><input class="form-field__input" data-promo-field="manager_info.registration_code" required></label><label class="form-field"><span class="form-field__label">从业年限</span><input class="form-field__input" data-promo-field="manager_info.employment_years"></label><label class="form-field"><span class="form-field__label">投资管理经验</span><textarea class="form-field__input" data-promo-field="manager_info.investment_management_experience" rows="2"></textarea></label><label class="form-field"><span class="form-field__label">经理简介</span><textarea class="form-field__input" data-promo-field="manager_info.profile" rows="4"></textarea></label></div></div>
|
||||
<div><h3>团队与策略</h3><div class="operator-form-grid operator-form-grid--wide"><label class="form-field"><span class="form-field__label">团队描述 *</span><textarea class="form-field__input" data-promo-field="team_info.team_description" rows="3" required></textarea></label><label class="form-field"><span class="form-field__label">研究能力</span><textarea class="form-field__input" data-promo-field="team_info.research_capability" rows="3"></textarea></label><label class="form-field"><span class="form-field__label">投资范围 *</span><textarea class="form-field__input" data-promo-field="strategy_info.investment_scope" rows="3" required></textarea></label><label class="form-field"><span class="form-field__label">策略说明 *</span><textarea class="form-field__input" data-promo-field="strategy_info.strategy" rows="3" required></textarea></label><label class="form-field"><span class="form-field__label">投资限制 *</span><textarea class="form-field__input" data-promo-field="strategy_info.restrictions" rows="3" required></textarea></label><label class="form-field"><span class="form-field__label">指数工具属性</span><textarea class="form-field__input" data-promo-field="strategy_info.index_tool_attribute" rows="3"></textarea></label></div></div>
|
||||
<div><h3>费用、业绩与风险</h3><div class="operator-form-grid"><label class="form-field"><span class="form-field__label">申购费</span><input class="form-field__input" data-promo-field="fee_structure.subscription_fee"></label><label class="form-field"><span class="form-field__label">认购费</span><input class="form-field__input" data-promo-field="fee_structure.purchase_fee"></label><label class="form-field"><span class="form-field__label">赎回费</span><input class="form-field__input" data-promo-field="fee_structure.redemption_fee"></label><label class="form-field"><span class="form-field__label">销售服务费</span><input class="form-field__input" data-promo-field="fee_structure.sales_service_fee"></label><label class="form-field"><span class="form-field__label">管理费</span><input class="form-field__input" data-promo-field="fee_structure.management_fee"></label><label class="form-field"><span class="form-field__label">托管费</span><input class="form-field__input" data-promo-field="fee_structure.custody_fee"></label><label class="form-field"><span class="form-field__label">客户维护费</span><input class="form-field__input" data-promo-field="fee_structure.client_maintenance_fee"></label><label class="form-field"><span class="form-field__label">数据截止日期</span><input class="form-field__input" type="date" data-promo-field="performance_info.as_of_date"></label><label class="form-field"><span class="form-field__label">历史月份</span><input class="form-field__input" type="number" min="0" max="600" data-promo-field="performance_info.history_months"></label><label class="form-field"><span class="form-field__label">产品收益</span><input class="form-field__input" data-promo-field="performance_info.product_return"></label><label class="form-field"><span class="form-field__label">最大回撤</span><input class="form-field__input" data-promo-field="performance_info.max_drawdown"></label><label class="form-field"><span class="form-field__label">波动率</span><input class="form-field__input" data-promo-field="performance_info.volatility"></label><label class="form-field"><span class="form-field__label">夏普比率</span><input class="form-field__input" data-promo-field="performance_info.sharpe_ratio"></label><label class="form-field"><span class="form-field__label">特殊风险(每行一项)</span><textarea class="form-field__input" data-promo-field="risk_disclosure.special_risks" rows="2"></textarea></label><label class="form-field"><span class="form-field__label">补充说明</span><textarea class="form-field__input" data-promo-field="risk_disclosure.additional_notes" rows="2"></textarea></label></div><div class="operator-checkboxes"><label><input type="checkbox" data-promo-field="performance_info.show_product_performance"> 展示产品业绩</label><label><input type="checkbox" data-promo-field="performance_info.show_manager_performance"> 展示经理业绩</label><label><input type="checkbox" data-promo-field="performance_info.ranking.enabled"> 展示排名</label></div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel panel--flush">
|
||||
<div class="panel__header"><div><h2 class="panel__title">业务附件</h2></div><button class="button table-action" type="button" data-action="upload">上传已选文件</button></div>
|
||||
<div class="panel__body operator-form-grid"><label class="form-field"><span class="form-field__label">基金经理照片</span><input class="form-field__input" type="file" accept=".jpg,.jpeg,.png,.webp" data-file="manager_photo"></label><label class="form-field"><span class="form-field__label">业绩数据文件</span><input class="form-field__input" type="file" accept=".csv,.xlsx,.xlsm" data-file="performance_data"></label><label class="form-field"><span class="form-field__label">资料来源附件</span><input class="form-field__input" type="file" accept=".pdf,.docx,.xlsx,.csv" data-file="source_evidence"></label><label class="form-field"><span class="form-field__label">固定模板文件</span><input class="form-field__input" type="file" accept=".pptx" data-file="template_file"></label></div>
|
||||
</section>
|
||||
<section class="panel panel--flush">
|
||||
<div class="panel__header"><div><h2 class="panel__title">生成、合规、审核与交付</h2></div><div class="operator-actions"><button class="button button--primary" type="button" data-action="generate">生成材料</button><button class="button" type="button" data-action="checks">读取合规结果</button></div></div>
|
||||
<div class="panel__body" data-promotion-result></div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/static/portal/employee-operations/promotion/promotion.js?v=20260914"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,241 @@
|
||||
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
|
||||
import { getAuthContext, requireOperator } from '/static/portal/common/auth.js';
|
||||
import { escapeHtml } from '/static/portal/common/formatters.js';
|
||||
import { mountShell } from '/static/portal/common/layout/app-shell.js';
|
||||
import { showToast } from '/static/portal/common/notifications.js';
|
||||
|
||||
const ATTACHMENT_LABELS = { manager_photo: '基金经理照片', performance_data: '业绩数据文件', source_evidence: '资料来源附件', template_file: '固定模板文件' };
|
||||
const FUND_RISK_LEVELS = {
|
||||
货币型: 'R1',
|
||||
债券型: 'R2',
|
||||
混合型: 'R3',
|
||||
股票型: 'R4',
|
||||
期货型: 'R5',
|
||||
};
|
||||
const MANAGER_PROFILE_DEFAULT = '拥有多年证券与衍生品投资研究经验,曾先后任职于国内头部期货公司资产管理部、公募基金量化与衍生品投资部,历任研究员、投资经理、基金经理。对商品期货、股指期货、国债期货及多资产组合管理有深入实战积累,擅长在严格风险预算下运用衍生品工具进行方向性配置与对冲。';
|
||||
const DEFAULT_PROMOTION_INPUTS = {
|
||||
product_info: {
|
||||
fund_type: '混合型',
|
||||
operation_mode: '开放式',
|
||||
product_status: '募集期',
|
||||
investment_objective: '通过专业资产配置与基本面研究,追求长期稳健的资产增值。',
|
||||
benchmark: '',
|
||||
risk_level: '',
|
||||
},
|
||||
manager_info: {
|
||||
manager_name: '待补充基金经理',
|
||||
management_company: '南方基金管理有限公司',
|
||||
registration_code: 'PT0100000002',
|
||||
employment_years: '',
|
||||
investment_management_experience: '',
|
||||
profile: MANAGER_PROFILE_DEFAULT,
|
||||
},
|
||||
team_info: {
|
||||
team_description: '由投资、研究和风险管理人员组成完整投研团队,执行独立决策、协同研究和持续风险管理。',
|
||||
research_capability: '覆盖宏观、行业、个券和风险管理等研究维度。',
|
||||
},
|
||||
strategy_info: {
|
||||
investment_scope: '股票、债券、货币市场工具及法律法规允许的其他资产。',
|
||||
strategy: '通过大类资产配置、基本面研究和风险预算动态调整组合。',
|
||||
restrictions: '遵守法律法规、基金合同及监管限制。',
|
||||
index_tool_attribute: '支持指数增强与风险预算分析。',
|
||||
},
|
||||
fee_structure: {
|
||||
subscription_fee: '0.5%',
|
||||
purchase_fee: '0.5%',
|
||||
redemption_fee: '1.5%',
|
||||
sales_service_fee: '1%',
|
||||
management_fee: '1%',
|
||||
custody_fee: '0.5%',
|
||||
client_maintenance_fee: '不适用',
|
||||
},
|
||||
performance_info: {
|
||||
as_of_date: '',
|
||||
history_months: 24,
|
||||
product_return: '',
|
||||
max_drawdown: '-3.6%',
|
||||
volatility: '12%',
|
||||
sharpe_ratio: '0.8',
|
||||
show_product_performance: false,
|
||||
show_manager_performance: false,
|
||||
ranking: { enabled: false },
|
||||
},
|
||||
risk_disclosure: { special_risks: ['本基金可能面临市场风险、利率风险、汇率风险、政策风险、流动性风险及衍生品杠杆风险等,不保证本金安全,不保证最低收益,可能因市场波动而遭受本金损失。'], additional_notes: '' },
|
||||
};
|
||||
|
||||
if (requireOperator()) {
|
||||
mountShell({ active: 'operator-promotion', mode: 'operator' });
|
||||
const context = getAuthContext();
|
||||
const state = { taskNo: '', task: null, generated: null, checks: [], files: {}, reviewDecision: 'approved', reviewComment: '', advisorIds: '' };
|
||||
const result = document.querySelector('[data-promotion-result]');
|
||||
document.querySelector('[data-operator-name]').textContent = context?.username || '运营人员';
|
||||
document.querySelector('[data-operator-scope]').textContent = `数据范围:${context?.dataScope || 'assigned'}`;
|
||||
|
||||
function field(name) { return document.querySelector(`[data-promo-field="${name}"]`); }
|
||||
function createField(name) { return document.querySelector(`[data-create-field="${name}"]`); }
|
||||
function selectedFormats() { return [...document.querySelectorAll('[data-format]:checked')].map((item) => item.dataset.format); }
|
||||
function text(name) { return String(field(name)?.value || '').trim(); }
|
||||
function nullable(name) { return text(name) || null; }
|
||||
function setFieldValue(name, value) {
|
||||
const target = field(name);
|
||||
if (!target || target.value || value === undefined || value === null || value === '') return;
|
||||
target.value = String(value);
|
||||
}
|
||||
function applyDefaultInputs() {
|
||||
Object.entries(DEFAULT_PROMOTION_INPUTS).forEach(([group, values]) => {
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (key === 'ranking') {
|
||||
if (value?.enabled) field(`${group}.ranking.enabled`).checked = true;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
setFieldValue(`${group}.${key}`, value.join('\n'));
|
||||
return;
|
||||
}
|
||||
setFieldValue(`${group}.${key}`, value);
|
||||
});
|
||||
});
|
||||
syncRiskLevel();
|
||||
}
|
||||
function syncRiskLevel() {
|
||||
const fundType = text('product_info.fund_type');
|
||||
const riskLevel = field('product_info.risk_level');
|
||||
if (riskLevel) riskLevel.value = FUND_RISK_LEVELS[fundType] || '';
|
||||
}
|
||||
function syncTaskDefaults() {
|
||||
const productName = createField('productName');
|
||||
const materialTitle = createField('materialTitle');
|
||||
if (productName && !productName.value) productName.value = '南方稳健配置产品';
|
||||
if (materialTitle && !materialTitle.value && productName?.value) {
|
||||
materialTitle.value = `${productName.value}推介材料`;
|
||||
}
|
||||
}
|
||||
function inputsPayload() {
|
||||
return {
|
||||
product_info: { fund_type: text('product_info.fund_type'), operation_mode: text('product_info.operation_mode'), product_status: text('product_info.product_status') || '募集期', investment_objective: text('product_info.investment_objective'), benchmark: nullable('product_info.benchmark'), risk_level: nullable('product_info.risk_level') },
|
||||
manager_info: { manager_name: text('manager_info.manager_name'), management_company: text('manager_info.management_company'), registration_code: text('manager_info.registration_code'), employment_years: nullable('manager_info.employment_years'), investment_management_experience: nullable('manager_info.investment_management_experience'), profile: nullable('manager_info.profile') },
|
||||
team_info: { team_description: text('team_info.team_description'), research_capability: nullable('team_info.research_capability') },
|
||||
strategy_info: { investment_scope: text('strategy_info.investment_scope'), strategy: text('strategy_info.strategy'), restrictions: text('strategy_info.restrictions'), index_tool_attribute: nullable('strategy_info.index_tool_attribute') },
|
||||
fee_structure: { subscription_fee: nullable('fee_structure.subscription_fee'), purchase_fee: nullable('fee_structure.purchase_fee'), redemption_fee: nullable('fee_structure.redemption_fee'), sales_service_fee: nullable('fee_structure.sales_service_fee'), management_fee: nullable('fee_structure.management_fee'), custody_fee: nullable('fee_structure.custody_fee'), client_maintenance_fee: nullable('fee_structure.client_maintenance_fee') },
|
||||
performance_info: { as_of_date: nullable('performance_info.as_of_date'), history_months: Number(field('performance_info.history_months')?.value || 0) || null, product_return: nullable('performance_info.product_return'), max_drawdown: nullable('performance_info.max_drawdown'), volatility: nullable('performance_info.volatility'), sharpe_ratio: nullable('performance_info.sharpe_ratio'), show_product_performance: Boolean(field('performance_info.show_product_performance')?.checked), show_manager_performance: Boolean(field('performance_info.show_manager_performance')?.checked), ranking: { enabled: Boolean(field('performance_info.ranking.enabled')?.checked) } },
|
||||
risk_disclosure: { special_risks: text('risk_disclosure.special_risks').split('\n').map((item) => item.trim()).filter(Boolean), additional_notes: nullable('risk_disclosure.additional_notes') },
|
||||
source_notes: {},
|
||||
};
|
||||
}
|
||||
function taskPayload() {
|
||||
return { product_name: String(createField('productName')?.value || '').trim(), product_code: String(createField('productCode')?.value || '').trim() || null, material_title: String(createField('materialTitle')?.value || '').trim(), style_code: createField('styleCode')?.value || 'balanced_allocation', output_formats: selectedFormats() };
|
||||
}
|
||||
function setTaskStatus(status) { document.querySelector('[data-task-status]').textContent = state.taskNo ? `${state.taskNo} · ${status || '已连接'}` : '未创建任务'; }
|
||||
function resultFile(label, path) { return `<div class="operator-file-result"><span>${label}</span><code>${escapeHtml(path || '未生成')}</code></div>`; }
|
||||
function renderResult() {
|
||||
const generated = state.generated;
|
||||
const findings = state.checks || [];
|
||||
result.innerHTML = `${generated ? `<div class="operator-success"><strong>材料生成结果已返回</strong></div><div>${resultFile('PPTX', generated.pptx_path)}${resultFile('宣传长图', generated.poster_path)}${resultFile('PDF', generated.pdf_path)}${(generated.chart_paths || []).map((item, index) => resultFile(`图表 ${index + 1}`, item)).join('')}</div><div class="operator-form-grid"><label class="form-field"><span class="form-field__label">审核决定</span><select class="form-field__input" data-review-decision><option value="approved">审核通过</option><option value="revision_requested">要求修改</option><option value="rejected">审核驳回</option></select></label><label class="form-field"><span class="form-field__label">审核意见</span><textarea class="form-field__input" rows="2" data-review-comment>${escapeHtml(state.reviewComment)}</textarea></label><div class="operator-actions"><button class="button button--primary" type="button" data-action="review">提交审核</button></div></div>${state.reviewDecision === 'approved' ? `<div class="operator-form-grid"><label class="form-field"><span class="form-field__label">投顾编号(逗号分隔)</span><input class="form-field__input" data-advisor-ids value="${escapeHtml(state.advisorIds)}" placeholder="例如 42,43"></label><div class="operator-actions"><button class="button button--primary" type="button" data-action="deliver">交付给投顾</button></div></div>` : ''}` : ''}<section class="operator-subsection"><div class="operator-section-heading"><h3>合规检查结果</h3><span class="operator-inline-note">${findings.length} 条</span></div>${findings.length ? findings.map((item) => `<div class="${item.severity === 'block' ? 'operator-warning' : 'operator-success'}"><strong>${escapeHtml(item.rule_name || item.rule_code || '合规规则')}</strong><br>${escapeHtml(item.suggestion || item.hit_text || '已返回检查结果')} · ${escapeHtml(item.severity || '--')}</div>`).join('') : ''}</section>`;
|
||||
const reviewDecision = result.querySelector('[data-review-decision]');
|
||||
if (reviewDecision) reviewDecision.value = state.reviewDecision;
|
||||
autosizeTextareas(result);
|
||||
}
|
||||
function autosizeTextarea(target) {
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${Math.max(target.scrollHeight, 72)}px`;
|
||||
}
|
||||
function autosizeTextareas(root = document) {
|
||||
root.querySelectorAll('textarea').forEach(autosizeTextarea);
|
||||
}
|
||||
function validateAttachment(type, file) {
|
||||
const extensions = {
|
||||
manager_photo: ['.jpg', '.jpeg', '.png', '.webp'],
|
||||
performance_data: ['.csv', '.xlsx', '.xlsm'],
|
||||
source_evidence: ['.pdf', '.docx', '.xlsx', '.csv'],
|
||||
template_file: ['.pptx'],
|
||||
};
|
||||
const filename = String(file.name || '').toLowerCase();
|
||||
const extension = filename.includes('.') ? filename.slice(filename.lastIndexOf('.')) : '';
|
||||
if (!extensions[type]?.includes(extension)) {
|
||||
throw new Error(`${ATTACHMENT_LABELS[type]}仅支持:${extensions[type].join('、')}`);
|
||||
}
|
||||
const maxSize = type === 'manager_photo' ? 10 * 1024 * 1024 : type === 'performance_data' ? 20 * 1024 * 1024 : type === 'source_evidence' ? 30 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) throw new Error(`${ATTACHMENT_LABELS[type]}不能超过 ${Math.round(maxSize / 1024 / 1024)} MB`);
|
||||
}
|
||||
async function run(action) {
|
||||
try {
|
||||
if (action === 'create-task') {
|
||||
const formats = selectedFormats();
|
||||
if (!formats.length) throw new Error('至少选择一种输出格式');
|
||||
const response = await apiClient.post('PROMOTION_CREATE', taskPayload());
|
||||
state.taskNo = response.data.task_no; state.task = response.data; setTaskStatus(response.data.status); showToast(`材料任务已创建:${state.taskNo}`); return;
|
||||
}
|
||||
if (action === 'load-task') {
|
||||
state.taskNo = String(createField('taskNo')?.value || '').trim();
|
||||
if (!state.taskNo) throw new Error('请输入任务编号');
|
||||
const response = await apiClient.get('PROMOTION_TASK', { pathParams: { taskNo: state.taskNo } });
|
||||
state.task = response.data; const version = response.data.material_version;
|
||||
state.generated = version ? { material_version_id: version.id, status: version.status, pptx_path: version.pptx_path, pdf_path: version.pdf_path, poster_path: version.poster_path, chart_paths: version.chart_paths || [] } : null;
|
||||
setTaskStatus(response.data.status); renderResult(); showToast('材料任务已恢复'); return;
|
||||
}
|
||||
if (!state.taskNo) throw new Error('请先创建或读取材料任务');
|
||||
if (action === 'save-inputs') { await apiClient.post('PROMOTION_INPUTS', inputsPayload(), { pathParams: { taskNo: state.taskNo } }); setTaskStatus('input_ready'); showToast('结构化资料已保存'); return; }
|
||||
if (action === 'upload') {
|
||||
const selected = Object.entries(state.files).filter(([, file]) => file);
|
||||
if (!selected.length) throw new Error('请先选择要上传的附件');
|
||||
const uploaded = [];
|
||||
const failed = [];
|
||||
for (const [type, file] of selected) {
|
||||
try {
|
||||
validateAttachment(type, file);
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
await apiClient.upload('PROMOTION_ATTACHMENT', form, {
|
||||
pathParams: { taskNo: state.taskNo },
|
||||
query: { attachment_type: type },
|
||||
idempotencyKey: `${state.taskNo}-${type}-${file.name}-${file.size}`,
|
||||
});
|
||||
uploaded.push(ATTACHMENT_LABELS[type]);
|
||||
} catch (error) {
|
||||
failed.push(`${ATTACHMENT_LABELS[type]}上传失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
if (failed.length) throw new Error(`${uploaded.length ? `已上传 ${uploaded.join('、')};` : ''}${failed.join(';')}`);
|
||||
showToast(`已上传 ${uploaded.join('、')}`);
|
||||
return;
|
||||
}
|
||||
if (action === 'generate') {
|
||||
try {
|
||||
const response = await apiClient.post('PROMOTION_GENERATE', { output_formats: selectedFormats() }, { pathParams: { taskNo: state.taskNo } });
|
||||
state.generated = response.data; state.checks = response.data.findings || state.checks; setTaskStatus(response.data.status); renderResult(); showToast('材料生成结果已返回');
|
||||
} catch (error) {
|
||||
const findings = error.payload?.data?.findings;
|
||||
if (Array.isArray(findings) && findings.length) { state.checks = findings; renderResult(); }
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'checks') { const response = await apiClient.get('PROMOTION_CHECKS', { pathParams: { taskNo: state.taskNo } }); state.checks = response.data.findings || []; renderResult(); showToast(`已读取 ${state.checks.length} 条合规结果`); return; }
|
||||
if (action === 'review') { const version = state.generated?.material_version_id; if (!version) throw new Error('未找到材料版本号'); const response = await apiClient.post('PROMOTION_REVIEW', { material_version_id: Number(version), decision: state.reviewDecision, comment: state.reviewComment || null }, { pathParams: { taskNo: state.taskNo } }); state.task = response.data; setTaskStatus(response.data.status); renderResult(); showToast('审核结果已提交'); return; }
|
||||
if (action === 'deliver') { const version = state.generated?.material_version_id; const advisorIds = state.advisorIds.split(',').map((item) => Number(item.trim())).filter((item) => Number.isInteger(item) && item > 0); if (!version || !advisorIds.length) throw new Error('请填写至少一个有效投顾编号'); const response = await apiClient.post('PROMOTION_DELIVERY', { material_version_id: Number(version), advisor_ids: advisorIds, delivery_channel: 'internal_record' }, { pathParams: { taskNo: state.taskNo } }); state.task = response.data; setTaskStatus(response.data.status); showToast('材料已提交投顾交付'); }
|
||||
} catch (error) { apiClient.reportError(error); showToast(error.message || '操作失败', 'error'); }
|
||||
}
|
||||
document.querySelector('[data-action="create-task"]').addEventListener('click', () => run('create-task'));
|
||||
document.querySelector('[data-action="load-task"]').addEventListener('click', () => run('load-task'));
|
||||
document.querySelector('[data-create-field="productName"]').addEventListener('input', () => {
|
||||
const productName = createField('productName');
|
||||
const materialTitle = createField('materialTitle');
|
||||
if (materialTitle && !materialTitle.dataset.userEdited) {
|
||||
materialTitle.value = productName.value ? `${productName.value}推介材料` : '';
|
||||
}
|
||||
});
|
||||
document.querySelector('[data-create-field="materialTitle"]').addEventListener('input', (event) => {
|
||||
event.target.dataset.userEdited = 'true';
|
||||
});
|
||||
field('product_info.fund_type').addEventListener('change', syncRiskLevel);
|
||||
document.querySelectorAll('[data-file]').forEach((input) => input.addEventListener('change', () => { state.files[input.dataset.file] = input.files[0] || null; }));
|
||||
document.querySelector('[data-promotion-result]').addEventListener('click', (event) => { const action = event.target.closest('[data-action]')?.dataset.action; if (action) run(action); });
|
||||
document.querySelector('main').addEventListener('click', (event) => { const action = event.target.closest('[data-action]')?.dataset.action; if (action && ['save-inputs', 'upload', 'generate', 'checks'].includes(action)) run(action); });
|
||||
document.querySelector('[data-promotion-result]').addEventListener('input', (event) => { if (event.target.matches('[data-review-comment]')) state.reviewComment = event.target.value; if (event.target.matches('[data-advisor-ids]')) state.advisorIds = event.target.value; });
|
||||
document.querySelector('[data-promotion-result]').addEventListener('change', (event) => { if (event.target.matches('[data-review-decision]')) { state.reviewDecision = event.target.value; renderResult(); } });
|
||||
applyDefaultInputs();
|
||||
syncTaskDefaults();
|
||||
document.querySelectorAll('textarea').forEach((textarea) => textarea.addEventListener('input', () => autosizeTextarea(textarea)));
|
||||
autosizeTextareas();
|
||||
renderResult();
|
||||
}
|
||||
Reference in New Issue
Block a user