Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a45708d67 | ||
|
|
bd7a578252 | ||
|
|
561a34f108 | ||
|
|
7f851593fb | ||
|
|
00239b0431 | ||
|
|
858fc132ef | ||
|
|
9a4dd3ed0f | ||
|
|
a18f75f735 | ||
|
|
d888d9231f | ||
|
|
a25b774521 | ||
|
|
b552ac56b0 | ||
|
|
2b01e0adec | ||
|
|
29ae53e593 | ||
|
|
fa3517afbb | ||
|
|
8f07b6bcf1 | ||
|
|
e70866c4b6 | ||
|
|
5f9264fbb2 | ||
|
|
ccae5a3a3c | ||
|
|
61039682e9 | ||
|
|
0b83663f77 | ||
|
|
ee0f7d0a68 | ||
|
|
86a1a33693 | ||
|
|
68b59d2eab | ||
|
|
3ab8b52593 | ||
|
|
3f15dd0ea3 | ||
|
|
7f7311a47c | ||
|
|
73ac3e8163 | ||
| 66aca010f1 | |||
| 3f1da0e159 | |||
| 0b79c6812c | |||
| 598a2cbc29 | |||
| 551c68c456 | |||
| 5b10096bbf | |||
| 970dc8bfee | |||
| 20ca5384fc | |||
| cf83de291c |
26
.env.example
26
.env.example
@ -1,26 +0,0 @@
|
||||
DATABASE_URL=mysql+pymysql://ai_agent_test:WGzc6mKawk@49.233.179.206:3306/wendajia
|
||||
SECRET_KEY=replace-with-a-long-random-secret
|
||||
COOKIE_SECURE=false
|
||||
SESSION_HOURS=12
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-admin-password
|
||||
OPERATOR_USERNAME=operator
|
||||
OPERATOR_PASSWORD=change-operator-password
|
||||
LLM_API_KEY=
|
||||
LLM_BASE_URL=https://api.openlux.ai/v1
|
||||
# 普通问答模型
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
# 识图/OCR 模型;OpenLux 可填写 qwen3-vl-plus、qwen3-vl-32b-instruct 等视觉模型
|
||||
LLM_VISION_MODEL=qwen3-vl-plus
|
||||
LLM_TIMEOUT_SECONDS=90
|
||||
# 开发环境显示接口返回的验证码;生产环境请设为 false 并接入短信服务
|
||||
SMS_DEV_MODE=true
|
||||
# 钉钉工作台登录(创建钉钉企业内部应用后填写)
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
DINGTALK_REDIRECT_URI=http://localhost:8000/api/admin/auth/dingtalk/callback
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
# 可选:限制可登录的 unionId;多个值用逗号分隔
|
||||
DINGTALK_ALLOWED_UNION_IDS=
|
||||
# 可选:指定钉钉 unionId 自动授予超级管理员
|
||||
DINGTALK_SUPER_ADMIN_UNION_IDS=
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,6 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
backend/.env
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
backend/data/uploads/
|
||||
backend/keys/
|
||||
|
||||
12
README.md
12
README.md
@ -27,11 +27,13 @@ npm run dev
|
||||
|
||||
正式环境请通过环境变量覆盖账号、密码、`SECRET_KEY` 和 `DATABASE_URL`。
|
||||
|
||||
## 钉钉工作台登录
|
||||
## 钉钉工作台免登
|
||||
|
||||
运营后台支持从钉钉工作台发起 OAuth 登录。请在钉钉开放平台创建企业内部应用,配置 OAuth 回调地址(必须使用 HTTPS,开发环境可使用内网穿透地址),然后在项目根目录 `.env` 填写:
|
||||
从钉钉工作台打开时,前端通过钉钉 JSAPI `requestAuthCode` 获取临时授权码,后端换取用户身份并建立会话,不再跳转到 OAuth 确认页。请在钉钉开放平台创建并发布企业内部应用,开通工作台免登/获取用户身份权限,然后在 `backend/.env` 填写:
|
||||
|
||||
```env
|
||||
DINGTALK_CORP_ID=企业CorpId
|
||||
DINGTALK_AGENT_ID=应用AgentId
|
||||
DINGTALK_CLIENT_ID=钉钉应用ClientID
|
||||
DINGTALK_CLIENT_SECRET=钉钉应用ClientSecret
|
||||
DINGTALK_REDIRECT_URI=https://你的域名/api/admin/auth/dingtalk/callback
|
||||
@ -42,15 +44,15 @@ DINGTALK_ALLOWED_UNION_IDS=
|
||||
DINGTALK_SUPER_ADMIN_UNION_IDS=
|
||||
```
|
||||
|
||||
将钉钉应用的登录回调配置为与 `DINGTALK_REDIRECT_URI` 完全一致。未配置 ClientID/ClientSecret 时,登录页仍可使用原有账号密码登录,并会提示钉钉登录尚未配置。
|
||||
`DINGTALK_CLIENT_ID/SECRET` 填应用的 AppKey/AppSecret;`DINGTALK_CORP_ID` 填企业 CorpId;`DINGTALK_AGENT_ID` 用于标识工作台应用。当前免登接口不使用浏览器 OAuth 回调,但保留回调配置兼容旧入口。未配置免登参数时,普通浏览器仍可使用账号密码登录。
|
||||
|
||||
MySQL 建表脚本位于 `backend/schema.sql`。未配置 `DATABASE_URL` 时,开发环境会自动使用项目 `data/app.db` 运行,便于本地快速验收。
|
||||
MySQL 建表脚本位于 `backend/schema.sql`。未配置 `DATABASE_URL` 时,开发环境会自动使用 `backend/data/app.db` 运行,便于本地快速验收。上传截图只保存在服务器文件夹中,不在数据库保留副本。服务器部署时必须通过 `SCREENSHOT_STORAGE_DIR` 指向代码发布目录之外的持久化绝对路径,例如 `/www/wwwroot/wendajia_shared/screenshots`,并对该目录单独备份。目录不存在或不可写时,上传接口会返回明确的存储错误。
|
||||
|
||||
## 问答助手大模型配置
|
||||
|
||||
问答助手使用 OpenAI 兼容的 Chat Completions 接口。启动后端前设置:
|
||||
|
||||
也可以在项目根目录创建 `.env` 文件填写这些配置;后端启动时会自动读取。
|
||||
也可以在 `backend/.env` 文件填写这些配置;后端启动时会自动读取。
|
||||
|
||||
```powershell
|
||||
$env:LLM_API_KEY="你的 API Key"
|
||||
|
||||
58
backend/.env.example
Normal file
58
backend/.env.example
Normal file
@ -0,0 +1,58 @@
|
||||
DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/wendajia
|
||||
# 服务器建议填写代码目录之外、不会被发布覆盖的绝对路径,并单独备份。
|
||||
SCREENSHOT_STORAGE_DIR=/www/wwwroot/wendajia_shared/screenshots
|
||||
SECRET_KEY=replace-with-a-long-random-secret
|
||||
COOKIE_SECURE=false
|
||||
SESSION_HOURS=12
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-admin-password
|
||||
OPERATOR_USERNAME=operator
|
||||
OPERATOR_PASSWORD=change-operator-password
|
||||
LLM_API_KEY=
|
||||
LLM_BASE_URL=https://api.openlux.ai/v1
|
||||
# 普通问答模型
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
# 识图/OCR 模型;OpenLux 可填写 qwen3-vl-plus、qwen3-vl-32b-instruct 等视觉模型
|
||||
LLM_VISION_MODEL=qwen3-vl-plus
|
||||
LLM_TIMEOUT_SECONDS=90
|
||||
# 开发环境显示接口返回的验证码;生产环境请设为 false 并接入短信服务
|
||||
SMS_DEV_MODE=true
|
||||
# 钉钉工作台免登(企业内部应用)
|
||||
DINGTALK_CORP_ID=
|
||||
DINGTALK_AGENT_ID=
|
||||
DINGTALK_CLIENT_ID=
|
||||
DINGTALK_CLIENT_SECRET=
|
||||
DINGTALK_REDIRECT_URI=http://localhost:8000/api/admin/auth/dingtalk/callback
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
# 可选:限制可登录的 unionId;多个值用逗号分隔
|
||||
DINGTALK_ALLOWED_UNION_IDS=
|
||||
# 新钉钉用户仅允许来自以下部门(包含其下级部门);部门 ID 比名称更稳定
|
||||
DINGTALK_ALLOWED_DEPT_IDS=
|
||||
DINGTALK_ALLOWED_DEPT_NAMES=战狼事业部,雪狼事业部
|
||||
# 可选:指定钉钉 unionId 自动授予超级管理员
|
||||
DINGTALK_SUPER_ADMIN_UNION_IDS=
|
||||
# 以下部门及其下级部门成员自动授予超级管理员;部门 ID 比名称更稳定
|
||||
DINGTALK_SUPER_ADMIN_DEPT_IDS=
|
||||
DINGTALK_SUPER_ADMIN_DEPT_NAMES=AI指挥部
|
||||
|
||||
# 支付宝单笔转账配置(填写后仍需将 ALIPAY_ENABLED 改为 true 才会调用真实接口)
|
||||
ALIPAY_ENABLED=false
|
||||
ALIPAY_APP_ID=
|
||||
ALIPAY_PAYER_ACCOUNT=
|
||||
# PEM 私钥和支付宝公钥建议保存为服务器文件,这里填写绝对路径
|
||||
ALIPAY_APP_PRIVATE_KEY_PATH=keys/app-private-key.pem
|
||||
ALIPAY_PUBLIC_KEY_PATH=keys/alipay-public-key.pem
|
||||
# 证书模式:分别填写应用公钥证书、支付宝公钥证书、支付宝根证书的绝对路径
|
||||
ALIPAY_APP_CERT_PATH=keys/appCertPublicKey.crt
|
||||
ALIPAY_ALIPAY_PUBLIC_CERT_PATH=keys/alipayCertPublicKey_RSA2.crt
|
||||
ALIPAY_ROOT_CERT_PATH=keys/alipayRootCert.crt
|
||||
ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do
|
||||
ALIPAY_SIGN_TYPE=RSA2
|
||||
ALIPAY_CHARSET=utf-8
|
||||
ALIPAY_FORMAT=json
|
||||
ALIPAY_NOTIFY_URL=https://your-domain.example.com/alipay_callback
|
||||
ALIPAY_TRANSFER_API=alipay.fund.trans.uni.transfer
|
||||
ALIPAY_TRANSFER_QUERY_API=alipay.fund.trans.common.query
|
||||
ALIPAY_TRANSFER_SCENE_NAME=佣金报酬
|
||||
ALIPAY_HTTP_TIMEOUT_SECONDS=30
|
||||
ALIPAY_RECONCILE_INTERVAL_SECONDS=30
|
||||
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/alipay_provider.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/alipay_provider.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/alipay_provider.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/alipay_provider.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/config.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/database.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/llm.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/llm.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/llm.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/llm.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/models.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/models.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/schemas.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/schemas.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/screenshot_storage.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/screenshot_storage.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/screenshot_storage.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/screenshot_storage.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/security.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/security.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/security.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/security.cpython-314.pyc
Normal file
Binary file not shown.
476
backend/app/alipay_provider.py
Normal file
476
backend/app/alipay_provider.py
Normal file
@ -0,0 +1,476 @@
|
||||
"""Small adapter around Alipay's official Python SDK.
|
||||
|
||||
The SDK is imported lazily so local development and the disabled/simulation
|
||||
mode do not require credentials or network access.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlipayTransferError(RuntimeError):
|
||||
def __init__(self, message: str, code: str = "", ambiguous: bool = False):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.ambiguous = ambiguous
|
||||
|
||||
|
||||
def validate_configuration() -> None:
|
||||
missing = [
|
||||
label for label, value in (
|
||||
("ALIPAY_APP_ID", settings.alipay_app_id),
|
||||
("ALIPAY_PAYER_ACCOUNT", settings.alipay_payer_account),
|
||||
("ALIPAY_APP_PRIVATE_KEY_PATH", settings.alipay_app_private_key_path),
|
||||
("ALIPAY_PUBLIC_KEY_PATH 或 ALIPAY_ALIPAY_PUBLIC_CERT_PATH", settings.alipay_public_key_path or settings.alipay_alipay_public_cert_path),
|
||||
("ALIPAY_TRANSFER_SCENE_NAME", settings.alipay_transfer_scene_name),
|
||||
) if not str(value or "").strip()
|
||||
]
|
||||
if missing:
|
||||
raise AlipayTransferError(f"支付宝配置不完整:{', '.join(missing)}")
|
||||
if settings.alipay_notify_url and not settings.alipay_notify_url.lower().startswith("https://"):
|
||||
raise AlipayTransferError("ALIPAY_NOTIFY_URL 必须使用公网 HTTPS 地址")
|
||||
_sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
|
||||
if _certificate_mode_enabled():
|
||||
app_cert = _load_certificate(settings.alipay_app_cert_path, "应用公钥证书")
|
||||
alipay_cert = _load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书")
|
||||
root_certs = _load_certificate_bundle(settings.alipay_root_cert_path, "支付宝根证书")
|
||||
_certificate_serial(app_cert)
|
||||
_certificate_serial(alipay_cert)
|
||||
if not root_certs:
|
||||
raise AlipayTransferError("支付宝根证书文件中没有可识别的证书")
|
||||
_root_certificate_serial(root_certs)
|
||||
private = _sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
|
||||
if not _same_rsa_key(private, _certificate_public_key(app_cert)):
|
||||
raise AlipayTransferError("应用公钥证书与应用私钥不匹配")
|
||||
else:
|
||||
_sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
|
||||
logger.info(
|
||||
"Alipay provider configuration validated: scene=%s certificate_mode=%s transfer_scene_report_schema=佣金报酬说明",
|
||||
settings.alipay_transfer_scene_name,
|
||||
_certificate_mode_enabled(),
|
||||
)
|
||||
|
||||
|
||||
def _certificate_mode_enabled() -> bool:
|
||||
return any((settings.alipay_app_cert_path, settings.alipay_alipay_public_cert_path, settings.alipay_root_cert_path))
|
||||
|
||||
|
||||
def _load_certificate(path_value: str, label: str):
|
||||
try:
|
||||
from cryptography import x509
|
||||
except ImportError as exc:
|
||||
raise AlipayTransferError("证书模式依赖 cryptography,请安装后重启服务") from exc
|
||||
if not path_value or not Path(path_value).is_file():
|
||||
raise AlipayTransferError(f"{label}文件不存在,请检查对应 .env 绝对路径")
|
||||
try:
|
||||
return x509.load_pem_x509_certificate(Path(path_value).read_bytes())
|
||||
except Exception as exc:
|
||||
raise AlipayTransferError(f"{label}格式无效,应为 PEM 编码的 X.509 .crt 文件") from exc
|
||||
|
||||
|
||||
def _load_certificate_bundle(path_value: str, label: str):
|
||||
try:
|
||||
from cryptography import x509
|
||||
except ImportError as exc:
|
||||
raise AlipayTransferError("证书模式依赖 cryptography,请安装后重启服务") from exc
|
||||
if not path_value or not Path(path_value).is_file():
|
||||
raise AlipayTransferError(f"{label}文件不存在,请检查对应 .env 绝对路径")
|
||||
content = Path(path_value).read_bytes()
|
||||
blocks = re.findall(rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", content, re.DOTALL)
|
||||
certificates = []
|
||||
parse_errors = []
|
||||
for block in blocks:
|
||||
try:
|
||||
certificates.append(x509.load_pem_x509_certificate(block))
|
||||
except (TypeError, ValueError) as exc:
|
||||
# Alipay's root bundle contains RSA, SM2 and ECC certificates.
|
||||
# Some cryptography/OpenSSL builds cannot decode the SM2
|
||||
# certificate even though the RSA certificates needed for RSA2
|
||||
# are valid. Parse each PEM independently so one unsupported
|
||||
# non-RSA certificate does not prevent the service from starting.
|
||||
parse_errors.append(exc)
|
||||
if not certificates:
|
||||
cause = parse_errors[0] if parse_errors else None
|
||||
raise AlipayTransferError(
|
||||
f"{label}格式无效,应为包含有效 RSA 证书的 PEM X.509 证书集合"
|
||||
) from cause
|
||||
return certificates
|
||||
|
||||
|
||||
def _certificate_serial(certificate) -> str:
|
||||
issuer = certificate.issuer.rfc4514_string()
|
||||
# Match AntCertificationUtil.getCertSN in Alipay's official Java SDK:
|
||||
# MD5(issuer X500 name + the certificate serial number in *decimal*).
|
||||
# Using hexadecimal here makes app_cert_sn/alipay_root_cert_sn invalid.
|
||||
serial = str(certificate.serial_number)
|
||||
return hashlib.md5(f"{issuer}{serial}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _root_certificate_serial(certificates) -> str:
|
||||
# Alipay's root bundle can contain RSA, SM2 and ECC certificates. The
|
||||
# OpenAPI root certificate SN is defined from the RSA certificates only;
|
||||
# including every certificate produces isv.invalid-alipay-root-cert-sn.
|
||||
serials = [
|
||||
_certificate_serial(certificate)
|
||||
for certificate in certificates
|
||||
# This is the exact RSA signature OID prefix check used by Alipay's
|
||||
# AntCertificationUtil.getRootCertSN implementation.
|
||||
if certificate.signature_algorithm_oid.dotted_string.startswith("1.2.840.113549.1.1")
|
||||
]
|
||||
if not serials:
|
||||
raise AlipayTransferError("支付宝根证书中没有可用于计算序列号的 RSA 证书")
|
||||
return "_".join(serials)
|
||||
|
||||
|
||||
def _certificate_public_key(certificate) -> str:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
key = certificate.public_key()
|
||||
if not isinstance(key, rsa.RSAPublicKey):
|
||||
raise AlipayTransferError("支付宝证书必须包含 RSA 公钥")
|
||||
return key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
# alipay-sdk-python's verify_with_rsa uses
|
||||
# rsa.PublicKey.load_pkcs1_openssl_pem(), which expects an X.509
|
||||
# SubjectPublicKeyInfo (BEGIN PUBLIC KEY), not PKCS#1's
|
||||
# BEGIN RSA PUBLIC KEY structure.
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def _same_rsa_key(private_key: str, public_key: str) -> bool:
|
||||
from Crypto.PublicKey import RSA
|
||||
private = RSA.import_key(private_key)
|
||||
public = RSA.import_key(public_key)
|
||||
private_public = private.publickey()
|
||||
return (private_public.n, private_public.e) == (public.n, public.e)
|
||||
|
||||
|
||||
def _signed_response_content(response_value, public_key: str, charset: str = "utf-8") -> str:
|
||||
"""Verify a certificate-mode response and return its API response object.
|
||||
|
||||
Old alipay-sdk-python releases include the top-level ``alipay_cert_sn``
|
||||
field in the bytes they verify. Alipay signs only the inner ``*_response``
|
||||
JSON object, so otherwise a valid code=10000 response is rejected.
|
||||
"""
|
||||
from alipay.aop.api.util.SignatureUtils import verify_with_rsa
|
||||
|
||||
response_text = response_value.decode(charset) if isinstance(response_value, bytes) else str(response_value)
|
||||
marker = re.search(r'"[^"\\]+_response"\s*:\s*', response_text)
|
||||
if not marker:
|
||||
raise AlipayTransferError("支付宝返回内容缺少业务响应对象", ambiguous=True)
|
||||
start = response_text.find("{", marker.end())
|
||||
if start < 0:
|
||||
raise AlipayTransferError("支付宝返回业务响应格式无效", ambiguous=True)
|
||||
depth = 0
|
||||
quoted = False
|
||||
escaped = False
|
||||
end = -1
|
||||
for index in range(start, len(response_text)):
|
||||
char = response_text[index]
|
||||
if quoted:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
quoted = False
|
||||
continue
|
||||
if char == '"':
|
||||
quoted = True
|
||||
elif char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = index + 1
|
||||
break
|
||||
if end < 0:
|
||||
raise AlipayTransferError("支付宝返回业务响应不完整", ambiguous=True)
|
||||
try:
|
||||
envelope = json.loads(response_text)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AlipayTransferError("支付宝返回内容无法解析", ambiguous=True) from exc
|
||||
signature = str(envelope.get("sign") or "")
|
||||
response_content = response_text[start:end]
|
||||
if not signature or not verify_with_rsa(public_key, response_content.encode(charset), signature):
|
||||
raise AlipayTransferError("支付宝响应验签失败", "ALIPAY_RESPONSE_UNVERIFIED", ambiguous=True)
|
||||
return response_content
|
||||
|
||||
|
||||
def _read_key(path_value: str, label: str) -> str:
|
||||
if not path_value:
|
||||
raise AlipayTransferError(f"未配置{label}文件路径")
|
||||
path = Path(path_value)
|
||||
if not path.is_file():
|
||||
raise AlipayTransferError(f"{label}文件不存在:{path}")
|
||||
value = path.read_text(encoding="utf-8-sig").strip()
|
||||
if not value:
|
||||
raise AlipayTransferError(f"{label}文件为空")
|
||||
return value
|
||||
|
||||
|
||||
def _sdk_key(value: str, *, private: bool) -> str:
|
||||
"""Return a PKCS#1 PEM accepted by the SDK's rsa dependency."""
|
||||
try:
|
||||
from Crypto.PublicKey import RSA
|
||||
normalized = value.strip()
|
||||
if "-----BEGIN" not in normalized:
|
||||
marker = "PRIVATE KEY" if private else "PUBLIC KEY"
|
||||
normalized = f"-----BEGIN {marker}-----\n{normalized}\n-----END {marker}-----"
|
||||
key = RSA.import_key(normalized.encode("utf-8"))
|
||||
if private and not key.has_private():
|
||||
raise ValueError("not a private key")
|
||||
if private:
|
||||
return key.export_key(format="PEM", pkcs=1).decode("ascii")
|
||||
return key.publickey().export_key(format="PEM", pkcs=1).decode("ascii")
|
||||
except Exception as exc:
|
||||
raise AlipayTransferError("支付宝密钥格式无效,请确认上传的是完整 RSA2 密钥") from exc
|
||||
|
||||
|
||||
def _client():
|
||||
try:
|
||||
from alipay.aop.api.AlipayClientConfig import AlipayClientConfig
|
||||
from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient
|
||||
except ImportError as exc:
|
||||
raise AlipayTransferError("未安装支付宝 SDK,请安装 alipay-sdk-python") from exc
|
||||
config = AlipayClientConfig()
|
||||
config.server_url = settings.alipay_gateway
|
||||
config.app_id = settings.alipay_app_id
|
||||
config.app_private_key = _sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
|
||||
if _certificate_mode_enabled():
|
||||
app_cert = _load_certificate(settings.alipay_app_cert_path, "应用公钥证书")
|
||||
alipay_cert = _load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书")
|
||||
root_certs = _load_certificate_bundle(settings.alipay_root_cert_path, "支付宝根证书")
|
||||
config.alipay_public_key = _certificate_public_key(alipay_cert)
|
||||
app_cert_sn = _certificate_serial(app_cert)
|
||||
alipay_root_cert_sn = _root_certificate_serial(root_certs)
|
||||
|
||||
class CertificateAlipayClient(DefaultAlipayClient):
|
||||
def _DefaultAlipayClient__get_common_params(self, params):
|
||||
common = super()._DefaultAlipayClient__get_common_params(params)
|
||||
common["app_cert_sn"] = app_cert_sn
|
||||
common["alipay_root_cert_sn"] = alipay_root_cert_sn
|
||||
return common
|
||||
|
||||
def _DefaultAlipayClient__parse_response(self, response_value):
|
||||
response_text = response_value.decode(settings.alipay_charset) if isinstance(response_value, bytes) else str(response_value)
|
||||
if '"alipay_cert_sn"' in response_text:
|
||||
return _signed_response_content(
|
||||
response_text,
|
||||
config.alipay_public_key,
|
||||
settings.alipay_charset,
|
||||
)
|
||||
return super()._DefaultAlipayClient__parse_response(response_value)
|
||||
|
||||
client_class = CertificateAlipayClient
|
||||
else:
|
||||
config.alipay_public_key = _sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
|
||||
client_class = DefaultAlipayClient
|
||||
config.sign_type = settings.alipay_sign_type
|
||||
config.charset = settings.alipay_charset
|
||||
config.format = settings.alipay_format
|
||||
config.timeout = settings.alipay_http_timeout_seconds
|
||||
return client_class(alipay_client_config=config)
|
||||
|
||||
|
||||
def _parse_response(raw, response_cls):
|
||||
"""Parse the JSON string returned by DefaultAlipayClient.execute."""
|
||||
if isinstance(raw, response_cls):
|
||||
response = raw
|
||||
else:
|
||||
response = response_cls()
|
||||
try:
|
||||
response.parse_response_content(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
||||
except Exception as exc:
|
||||
raise AlipayTransferError("支付宝返回内容无法解析", ambiguous=True) from exc
|
||||
if response.code != "10000" or response.sub_code:
|
||||
raise AlipayTransferError(
|
||||
response.sub_msg or response.msg or "支付宝请求失败",
|
||||
response.sub_code or response.code or "ALIPAY_ERROR",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _provider_response_from_exception(exc: Exception) -> dict | None:
|
||||
message = str(exc)
|
||||
json_start = message.find("{")
|
||||
if json_start < 0:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(message[json_start:])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return next((
|
||||
value for key, value in payload.items()
|
||||
if key.endswith("_response") and isinstance(value, dict)
|
||||
), None)
|
||||
|
||||
|
||||
def _unverified_success_response(exc: Exception) -> bool:
|
||||
response = _provider_response_from_exception(exc)
|
||||
return bool(response and str(response.get("code")) == "10000" and not response.get("sub_code"))
|
||||
|
||||
|
||||
def _provider_error_from_exception(exc: Exception) -> AlipayTransferError | None:
|
||||
"""Recover Alipay's structured business error from old SDK exceptions.
|
||||
|
||||
alipay-sdk-python 3.x raises ResponseException before returning an error
|
||||
response when the envelope contains ``alipay_cert_sn``. The exception
|
||||
still embeds the complete JSON response, so preserve the provider's
|
||||
sub-code/message instead of treating a deterministic rejection as an
|
||||
ambiguous network failure.
|
||||
"""
|
||||
response = _provider_response_from_exception(exc)
|
||||
if not response or not response.get("code"):
|
||||
return None
|
||||
# A code=10000 envelope is not a provider rejection. The SDK may still
|
||||
# raise because the response signature could not be verified; in that
|
||||
# case the transfer result is uncertain and must remain pending.
|
||||
if str(response.get("code")) == "10000" and not response.get("sub_code"):
|
||||
return None
|
||||
return AlipayTransferError(
|
||||
response.get("sub_msg") or response.get("msg") or "支付宝请求失败",
|
||||
response.get("sub_code") or response.get("code") or "ALIPAY_ERROR",
|
||||
ambiguous=False,
|
||||
)
|
||||
|
||||
|
||||
def transfer(*, out_biz_no: str, amount_fen: int, account: str, payee_name: str, order_title: str) -> dict:
|
||||
from alipay.aop.api.domain.AlipayFundTransUniTransferModel import AlipayFundTransUniTransferModel
|
||||
from alipay.aop.api.domain.Participant import Participant
|
||||
from alipay.aop.api.domain.TransferSceneReportInfo import TransferSceneReportInfo
|
||||
from alipay.aop.api.request.AlipayFundTransUniTransferRequest import AlipayFundTransUniTransferRequest
|
||||
|
||||
model = AlipayFundTransUniTransferModel()
|
||||
model.out_biz_no = out_biz_no
|
||||
model.trans_amount = f"{Decimal(amount_fen) / Decimal(100):.2f}"
|
||||
model.product_code = "TRANS_ACCOUNT_NO_PWD"
|
||||
model.biz_scene = "DIRECT_TRANSFER"
|
||||
model.transfer_scene_name = settings.alipay_transfer_scene_name
|
||||
reward_description = TransferSceneReportInfo()
|
||||
reward_description.info_type = "佣金报酬说明"
|
||||
reward_description.info_content = order_title[:32]
|
||||
model.transfer_scene_report_infos = [reward_description]
|
||||
model.order_title = order_title[:128]
|
||||
payee = Participant()
|
||||
payee.identity = account
|
||||
payee.identity_type = "ALIPAY_LOGON_ID"
|
||||
payee.name = payee_name
|
||||
model.payee_info = payee
|
||||
# Keep a deploy-time diagnostic in the server log without exposing the
|
||||
# recipient account, name, private key, or certificate data. This makes
|
||||
# it possible to distinguish an old server build from an Alipay rejection.
|
||||
logger.info(
|
||||
"Alipay transfer request prepared: scene=%s info_type=%s info_content=%s amount=%s biz_scene=%s product_code=%s",
|
||||
model.transfer_scene_name,
|
||||
reward_description.info_type,
|
||||
reward_description.info_content,
|
||||
model.trans_amount,
|
||||
model.biz_scene,
|
||||
model.product_code,
|
||||
)
|
||||
try:
|
||||
request = AlipayFundTransUniTransferRequest(biz_model=model)
|
||||
if settings.alipay_notify_url:
|
||||
request.notify_url = settings.alipay_notify_url
|
||||
raw = _client().execute(request)
|
||||
from alipay.aop.api.response.AlipayFundTransUniTransferResponse import AlipayFundTransUniTransferResponse
|
||||
response = _parse_response(raw, AlipayFundTransUniTransferResponse)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, AlipayTransferError):
|
||||
raise
|
||||
if _unverified_success_response(exc):
|
||||
logger.error(
|
||||
"Alipay returned a success-code envelope, but the SDK could not verify/parse it; transfer outcome is unconfirmed"
|
||||
)
|
||||
raise AlipayTransferError(
|
||||
"支付宝返回成功格式响应,但本地未能验证响应;转账结果未确认,请查询支付宝流水,勿重复发起",
|
||||
"ALIPAY_RESPONSE_UNVERIFIED",
|
||||
ambiguous=True,
|
||||
) from exc
|
||||
provider_error = _provider_error_from_exception(exc)
|
||||
if provider_error:
|
||||
logger.error(
|
||||
"Alipay transfer rejected: code=%s message=%s",
|
||||
provider_error.code,
|
||||
provider_error,
|
||||
)
|
||||
raise provider_error from exc
|
||||
logger.exception("Alipay transfer request failed without a structured provider response")
|
||||
raise AlipayTransferError(f"支付宝转账请求失败:{exc}", ambiguous=True) from exc
|
||||
return {
|
||||
"out_biz_no": response.out_biz_no or out_biz_no,
|
||||
"trade_no": response.pay_fund_order_id or response.order_id or "",
|
||||
"status": response.status or "UNKNOWN",
|
||||
}
|
||||
|
||||
|
||||
def query(*, out_biz_no: str) -> dict:
|
||||
from alipay.aop.api.domain.AlipayFundTransCommonQueryModel import AlipayFundTransCommonQueryModel
|
||||
from alipay.aop.api.request.AlipayFundTransCommonQueryRequest import AlipayFundTransCommonQueryRequest
|
||||
|
||||
model = AlipayFundTransCommonQueryModel()
|
||||
model.out_biz_no = out_biz_no
|
||||
model.product_code = "TRANS_ACCOUNT_NO_PWD"
|
||||
model.biz_scene = "DIRECT_TRANSFER"
|
||||
request = AlipayFundTransCommonQueryRequest(biz_model=model)
|
||||
try:
|
||||
raw = _client().execute(request)
|
||||
from alipay.aop.api.response.AlipayFundTransCommonQueryResponse import AlipayFundTransCommonQueryResponse
|
||||
response = _parse_response(raw, AlipayFundTransCommonQueryResponse)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, AlipayTransferError):
|
||||
raise
|
||||
if _unverified_success_response(exc):
|
||||
raise AlipayTransferError(
|
||||
"支付宝查询返回成功格式响应,但本地未能验证响应;请稍后再次查询",
|
||||
"ALIPAY_RESPONSE_UNVERIFIED",
|
||||
ambiguous=True,
|
||||
) from exc
|
||||
provider_error = _provider_error_from_exception(exc)
|
||||
if provider_error:
|
||||
raise provider_error from exc
|
||||
raise AlipayTransferError(f"支付宝查询请求失败:{exc}") from exc
|
||||
return {
|
||||
"out_biz_no": response.out_biz_no or out_biz_no,
|
||||
"trade_no": response.pay_fund_order_id or response.order_id or "",
|
||||
"status": response.status or response.sub_status or "",
|
||||
"fail_reason": response.fail_reason or response.sub_order_fail_reason or response.sub_msg or "",
|
||||
}
|
||||
|
||||
|
||||
def verify_notification(params: dict[str, str]) -> bool:
|
||||
from alipay.aop.api.util.SignatureUtils import verify_with_rsa
|
||||
|
||||
signature = params.get("sign")
|
||||
if not signature or params.get("sign_type", "RSA2").upper() != "RSA2":
|
||||
return False
|
||||
if settings.alipay_app_id and params.get("app_id") not in {None, settings.alipay_app_id}:
|
||||
return False
|
||||
content = "&".join(
|
||||
f"{key}={params[key]}" for key in sorted(params)
|
||||
if key not in {"sign", "sign_type"}
|
||||
)
|
||||
try:
|
||||
alipay_public_key = (
|
||||
_certificate_public_key(_load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书"))
|
||||
if _certificate_mode_enabled()
|
||||
else _sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
|
||||
)
|
||||
return verify_with_rsa(
|
||||
alipay_public_key,
|
||||
content.encode(settings.alipay_charset),
|
||||
signature,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
@ -4,13 +4,27 @@ from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
PROJECT_DIR = Path(__file__).resolve().parents[2]
|
||||
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
|
||||
def _resolve_backend_path(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
path = Path(value.strip())
|
||||
return str(path if path.is_absolute() else BASE_DIR / path)
|
||||
|
||||
|
||||
class Settings:
|
||||
app_name = "问大家运营管理后台"
|
||||
database_url = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'data' / 'app.db'}")
|
||||
_screenshot_storage_value = Path(os.getenv("SCREENSHOT_STORAGE_DIR", "data/uploads/screenshots"))
|
||||
screenshot_storage_dir = (
|
||||
_screenshot_storage_value
|
||||
if _screenshot_storage_value.is_absolute()
|
||||
else BASE_DIR / _screenshot_storage_value
|
||||
)
|
||||
secret_key = os.getenv("SECRET_KEY", "change-this-secret-key-in-production")
|
||||
cookie_name = "admin_session"
|
||||
session_hours = int(os.getenv("SESSION_HOURS", "12"))
|
||||
@ -27,13 +41,45 @@ class Settings:
|
||||
llm_timeout_seconds = int(os.getenv("LLM_TIMEOUT_SECONDS", "90"))
|
||||
# 开发环境返回验证码,接入短信服务后请关闭并在发送函数中对接供应商。
|
||||
sms_dev_mode = os.getenv("SMS_DEV_MODE", "true").lower() == "true"
|
||||
# DingTalk workbench OAuth settings.
|
||||
# DingTalk workbench SSO/OAuth settings.
|
||||
dingtalk_corp_id = os.getenv("DINGTALK_CORP_ID", "")
|
||||
dingtalk_agent_id = os.getenv("DINGTALK_AGENT_ID", "")
|
||||
dingtalk_client_id = os.getenv("DINGTALK_CLIENT_ID", "")
|
||||
dingtalk_client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "")
|
||||
dingtalk_redirect_uri = os.getenv("DINGTALK_REDIRECT_URI", "http://localhost:8000/api/admin/auth/dingtalk/callback")
|
||||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||||
dingtalk_allowed_union_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_UNION_IDS", "").split(",") if item.strip()}
|
||||
# New DingTalk users may enter only when they belong to one of these
|
||||
# business divisions (including child departments). Existing active
|
||||
# admin_users are grandfathered and continue to work.
|
||||
dingtalk_allowed_dept_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_DEPT_IDS", "").split(",") if item.strip()}
|
||||
dingtalk_allowed_dept_names = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_DEPT_NAMES", "战狼事业部,雪狼事业部").split(",") if item.strip()}
|
||||
dingtalk_super_admin_union_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_UNION_IDS", "").split(",") if item.strip()}
|
||||
# Members of these departments receive the same access as super admins.
|
||||
# IDs are preferred; names are supported as a readable fallback.
|
||||
dingtalk_super_admin_dept_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_IDS", "").split(",") if item.strip()}
|
||||
dingtalk_super_admin_dept_names = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_NAMES", "AI指挥部").split(",") if item.strip()}
|
||||
# Alipay transfer settings. Keep live transfer disabled until credentials and
|
||||
# the application's transfer permission have been verified in the sandbox.
|
||||
alipay_enabled = os.getenv("ALIPAY_ENABLED", "false").lower() == "true"
|
||||
alipay_app_id = os.getenv("ALIPAY_APP_ID", "")
|
||||
alipay_payer_account = os.getenv("ALIPAY_PAYER_ACCOUNT", "")
|
||||
alipay_app_private_key_path = _resolve_backend_path(os.getenv("ALIPAY_APP_PRIVATE_KEY_PATH", ""))
|
||||
alipay_public_key_path = _resolve_backend_path(os.getenv("ALIPAY_PUBLIC_KEY_PATH", ""))
|
||||
# Certificate-mode signing files from Alipay Open Platform.
|
||||
alipay_app_cert_path = _resolve_backend_path(os.getenv("ALIPAY_APP_CERT_PATH", ""))
|
||||
alipay_alipay_public_cert_path = _resolve_backend_path(os.getenv("ALIPAY_ALIPAY_PUBLIC_CERT_PATH", ""))
|
||||
alipay_root_cert_path = _resolve_backend_path(os.getenv("ALIPAY_ROOT_CERT_PATH", ""))
|
||||
alipay_gateway = os.getenv("ALIPAY_GATEWAY", "https://openapi.alipay.com/gateway.do")
|
||||
alipay_sign_type = os.getenv("ALIPAY_SIGN_TYPE", "RSA2")
|
||||
alipay_charset = os.getenv("ALIPAY_CHARSET", "utf-8")
|
||||
alipay_format = os.getenv("ALIPAY_FORMAT", "json")
|
||||
alipay_notify_url = os.getenv("ALIPAY_NOTIFY_URL", "")
|
||||
alipay_transfer_api = os.getenv("ALIPAY_TRANSFER_API", "alipay.fund.trans.uni.transfer")
|
||||
alipay_transfer_query_api = os.getenv("ALIPAY_TRANSFER_QUERY_API", "alipay.fund.trans.common.query")
|
||||
alipay_transfer_scene_name = os.getenv("ALIPAY_TRANSFER_SCENE_NAME", "").strip()
|
||||
alipay_http_timeout_seconds = int(os.getenv("ALIPAY_HTTP_TIMEOUT_SECONDS", "30"))
|
||||
alipay_reconcile_interval_seconds = max(10, int(os.getenv("ALIPAY_RECONCILE_INTERVAL_SECONDS", "30")))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@ -23,5 +23,13 @@ def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
# A request may exit through an exception or an early return before a
|
||||
# route explicitly commits. Roll back first so any checked-out
|
||||
# connection is returned to QueuePool in a clean state, then close the
|
||||
# Session itself. Session.close() also rolls back, but doing it here
|
||||
# makes the lifecycle guarantee explicit and observable in tests.
|
||||
try:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@ -1,19 +1,39 @@
|
||||
import json
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from .config import settings
|
||||
from .schemas import QAGenerateRequest
|
||||
from .schemas import QABulkAnswerRequest, QAGenerateRequest
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _natural_question(value: object) -> str:
|
||||
"""Keep generated questions conversational and free of forced punctuation."""
|
||||
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
return re.sub(r"[??。.!!]+$", "", text).rstrip()
|
||||
|
||||
|
||||
def _natural_answer(value: object) -> str:
|
||||
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
text = re.sub(r"^(?:回答|答复|参考回答)\s*[::]\s*", "", text).strip()
|
||||
while text and (unicodedata.category(text[-1]).startswith("P") or text[-1] in "~~"):
|
||||
text = text[:-1].rstrip()
|
||||
return text
|
||||
|
||||
|
||||
def _prompt(payload: QAGenerateRequest) -> str:
|
||||
style = "带真实体验的轻度种草语气" if payload.seed_style else "客观自然,不刻意种草"
|
||||
return f"""你是电商问大家内容策划。请根据商品条件生成真实、具体、互不重复的消费者提问和参考回答。
|
||||
@ -21,16 +41,18 @@ def _prompt(payload: QAGenerateRequest) -> str:
|
||||
商品品类:{payload.category or '未提供'}
|
||||
商品功效:{payload.benefits or '未提供'}
|
||||
商品卖点:{payload.selling_points or '未提供'}
|
||||
语言风格:{payload.language_style}
|
||||
目标人群:{payload.target_group}
|
||||
使用场景:{payload.usage_scene}
|
||||
购买渠道:{payload.purchase_channel}
|
||||
语言风格:{payload.language_style or '客观自然'}
|
||||
目标人群:{payload.target_group or '普通消费者'}
|
||||
使用场景:{payload.usage_scene or '日常使用'}
|
||||
购买渠道:{payload.purchase_channel or '电商平台'}
|
||||
回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'}
|
||||
回答语气:{style}
|
||||
真实问答参考:{payload.reference_qa or '未提供'}
|
||||
|
||||
请生成 {payload.question_count} 个问题,每个问题生成 {payload.answers_per_question} 个不同角度的参考回答。
|
||||
约束:问题尽量不超过20字,回答尽量不超过50字;不虚构成分、检测、认证和医疗功效;表达像真实买家;不要重复商品名称作为开头。
|
||||
提问口吻:像买家随手输入,简短、口语化,允许省略主语;不要每句都用“请问、是否、怎么样、如何、能不能”这类标准句式;句式和长短要有变化;问题末尾不要加问号或其他标点。
|
||||
回答口吻:像普通买家在手机上简短回复,可自然使用“我用着、感觉、还行、挺、不会”等日常表达;不要写成客服说明、产品说明书、广告文案或整齐统一的完整句;不同回答的语气和长度要有变化;回答末尾一律不要加任何标点符号。
|
||||
约束:问题尽量不超过20字,回答尽量不超过50字;不虚构成分、检测、认证、医疗功效或未提供的使用事实;不要重复商品名称作为开头。
|
||||
如果提供了真实问答参考,请优先学习其中的提问角度、真实语气和回答结构,但不要照抄原文,不要把参考中的其他商品信息误套到当前商品。
|
||||
只返回严格 JSON,不要 Markdown 代码块或额外解释,格式必须是:
|
||||
{{"items":[{{"question":"问题文本","answers":["回答1","回答2"]}}]}}"""
|
||||
@ -57,15 +79,17 @@ def _parse_content(content: str, expected_answers: int) -> list[dict]:
|
||||
answers = raw.get("answers", [])
|
||||
if isinstance(answers, str):
|
||||
answers = [answers]
|
||||
answers = [str(answer).strip() for answer in answers if str(answer).strip()][:expected_answers]
|
||||
answers = [cleaned for answer in answers if (cleaned := _natural_answer(answer))][:expected_answers]
|
||||
if answers:
|
||||
items.append({"id": f"Q{index + 1:03d}", "question": str(raw["question"]).strip(), "answers": answers, "source": "AI生成", "status": "待采纳", "accepted": False})
|
||||
question = _natural_question(raw["question"])
|
||||
if question:
|
||||
items.append({"id": f"Q{index + 1:03d}", "question": question, "answers": answers, "source": "AI生成", "status": "待采纳", "accepted": False})
|
||||
if not items:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型未返回有效问答内容")
|
||||
return items
|
||||
|
||||
|
||||
def generate_qa(payload: QAGenerateRequest) -> list[dict]:
|
||||
def _generate_qa_batch(payload: QAGenerateRequest) -> list[dict]:
|
||||
if not settings.llm_api_key:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY")
|
||||
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
|
||||
@ -88,22 +112,218 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
|
||||
return _parse_content(content, payload.answers_per_question)
|
||||
|
||||
|
||||
def generate_answer_for_question(product_title: str, question: str) -> str:
|
||||
"""Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP."""
|
||||
def generate_qa(payload: QAGenerateRequest) -> list[dict]:
|
||||
"""Generate the requested amount in small batches so large requests are not truncated."""
|
||||
target = payload.question_count
|
||||
items: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
attempts = 0
|
||||
max_attempts = max((target + 19) // 20 + 2, 3)
|
||||
while len(items) < target and attempts < max_attempts:
|
||||
batch_size = min(20, target - len(items))
|
||||
batch_payload = payload.model_copy(update={"question_count": batch_size})
|
||||
for item in _generate_qa_batch(batch_payload):
|
||||
key = item["question"].strip()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
items.append(item)
|
||||
if len(items) >= target:
|
||||
break
|
||||
attempts += 1
|
||||
if len(items) < target:
|
||||
logger.warning("LLM returned %s/%s unique QA items", len(items), target)
|
||||
for index, item in enumerate(items[:target], start=1):
|
||||
item["id"] = f"Q{index:03d}"
|
||||
return items[:target]
|
||||
|
||||
|
||||
def _bulk_answers_prompt(payload: QABulkAnswerRequest, questions: list[str]) -> str:
|
||||
style = "带真实体验的轻度种草语气" if payload.seed_style else "客观自然,不刻意种草"
|
||||
question_lines = "\n".join(f"{index}. {question}" for index, question in enumerate(questions, start=1))
|
||||
return f"""你是电商问大家回答助手。请严格按照给定问题逐条生成参考回答,不要新增、删除、合并或改写问题。
|
||||
商品名称:{payload.product_name or '未提供'}
|
||||
商品品类:{payload.category or '未提供'}
|
||||
商品功效:{payload.benefits or '未提供'}
|
||||
商品卖点:{payload.selling_points or '未提供'}
|
||||
语言风格:{payload.language_style or '客观自然'}
|
||||
目标人群:{payload.target_group or '普通消费者'}
|
||||
使用场景:{payload.usage_scene or '日常使用'}
|
||||
购买渠道:{payload.purchase_channel or '电商平台'}
|
||||
回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'}
|
||||
回答语气:{style}
|
||||
|
||||
问题列表:
|
||||
{question_lines}
|
||||
|
||||
每个问题生成 {payload.answers_per_question} 条不同角度的回答。回答要像普通买家在手机上随手回复,简短口语化,可自然使用“我用着、感觉、还行、挺、不会”等日常表达;避免客服腔、说明书语气、广告词和整齐统一的句式。不同回答的开头、长短和语气要有变化。回答末尾一律不要加任何标点符号。回答尽量不超过50字,不虚构成分、检测、认证、医疗功效或未提供的使用事实。
|
||||
只返回严格 JSON,不要 Markdown 或解释。必须按问题序号原顺序返回,格式:
|
||||
{{"items":[{{"index":1,"answers":["回答1","回答2"]}}]}}"""
|
||||
|
||||
|
||||
def _generate_bulk_answers_batch(payload: QABulkAnswerRequest, questions: list[str]) -> list[list[str]]:
|
||||
if not settings.llm_api_key:
|
||||
return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。"
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY")
|
||||
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
|
||||
prompt = f"商品:{product_title}\n问题:{question}\n请生成一条50字以内、自然真实、不夸大功效的淘宝问大家回答。只输出回答正文。"
|
||||
request_body = {
|
||||
"model": settings.llm_model,
|
||||
"temperature": 0.75,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你只输出合法 JSON,并严格保持输入问题的数量和顺序。"},
|
||||
{"role": "user", "content": _bulk_answers_prompt(payload, questions)},
|
||||
],
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(request_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
content = str(result["choices"][0]["message"]["content"]).strip()
|
||||
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.I | re.S).strip()
|
||||
parsed = json.loads(cleaned)
|
||||
raw_items = parsed.get("items") if isinstance(parsed, dict) else parsed
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError("items is not a list")
|
||||
by_index: dict[int, list[str]] = {}
|
||||
for raw in raw_items:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
try:
|
||||
index = int(raw.get("index"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
answers = raw.get("answers") or []
|
||||
if isinstance(answers, str):
|
||||
answers = [answers]
|
||||
clean_answers = [cleaned for answer in answers if (cleaned := _natural_answer(answer))]
|
||||
if len(clean_answers) >= payload.answers_per_question:
|
||||
by_index[index] = clean_answers[:payload.answers_per_question]
|
||||
if any(index not in by_index for index in range(1, len(questions) + 1)):
|
||||
raise ValueError("answers are incomplete")
|
||||
return [by_index[index] for index in range(1, len(questions) + 1)]
|
||||
except urllib.error.HTTPError as exc:
|
||||
logger.error("LLM bulk-answer HTTP error: status=%s url=%s", exc.code, url)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"大模型服务返回错误(HTTP {exc.code})") from exc
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError, TypeError, ValueError) as exc:
|
||||
logger.exception("LLM bulk-answer generation failed")
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="批量回答生成不完整,请重试") from exc
|
||||
|
||||
|
||||
def generate_answers_batch(payload: QABulkAnswerRequest, questions: list[str]) -> list[list[str]]:
|
||||
"""Generate one bounded batch for the persistent background job worker."""
|
||||
return _generate_bulk_answers_batch(payload, questions)
|
||||
|
||||
|
||||
def generate_answers_for_questions(payload: QABulkAnswerRequest, questions: list[str]) -> list[dict]:
|
||||
"""Generate answers for exact imported questions without rewriting them."""
|
||||
items: list[dict] = []
|
||||
# Keep each response small enough that the provider can return every
|
||||
# requested answer without truncating the JSON payload.
|
||||
batch_size = max(1, min(10, 30 // payload.answers_per_question))
|
||||
for offset in range(0, len(questions), batch_size):
|
||||
batch = questions[offset:offset + batch_size]
|
||||
answer_groups = _generate_bulk_answers_batch(payload, batch)
|
||||
for question, answers in zip(batch, answer_groups):
|
||||
items.append({
|
||||
"id": f"Q{len(items) + 1:03d}",
|
||||
"question": question,
|
||||
"answers": answers,
|
||||
"source": "批量导入(AI回答)",
|
||||
"status": "待采纳",
|
||||
"accepted": False,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _questions_prompt(payload: dict, count: int, reference_qa: str = "") -> str:
|
||||
return f"""你是电商问大家提问策划。请根据商品信息生成 {count} 个真实、具体、互不重复的消费者提问。
|
||||
商品名称:{payload.get('product_name') or '未提供'}
|
||||
商品品类:{payload.get('category') or '未提供'}
|
||||
商品功效:{payload.get('benefits') or '未提供'}
|
||||
商品卖点:{payload.get('selling_points') or '未提供'}
|
||||
语言风格:{payload.get('language_style') or '客观自然'}
|
||||
目标人群:{payload.get('target_group') or '普通消费者'}
|
||||
使用场景:{payload.get('usage_scene') or '日常使用'}
|
||||
购买渠道:{payload.get('purchase_channel') or '电商平台'}
|
||||
真实问答参考:{reference_qa or '未提供'}
|
||||
提问必须像买家随手输入:简短、口语化,允许省略主语;不要每句都用“请问、是否、怎么样、如何、能不能”这类标准句式;句式和长短要有变化;末尾不要加问号或其他标点。尽量不超过30字,不要虚构具体参数、功效或使用事实。
|
||||
只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。"""
|
||||
|
||||
|
||||
def _generate_questions_batch(payload: dict, count: int, reference_qa: str = "") -> list[str]:
|
||||
"""Generate only consumer questions for the ask-task workflow."""
|
||||
if not settings.llm_api_key:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,无法生成真实提问")
|
||||
prompt = _questions_prompt(payload, count, reference_qa)
|
||||
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
|
||||
body = {"model": settings.llm_model, "temperature": 0.85, "messages": [{"role": "system", "content": "你只输出合法 JSON。"}, {"role": "user", "content": prompt}]}
|
||||
request = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", str(content).strip(), flags=re.I | re.S).strip()
|
||||
parsed = json.loads(cleaned)
|
||||
questions = parsed.get("questions") if isinstance(parsed, dict) else parsed
|
||||
if not isinstance(questions, list):
|
||||
raise ValueError("questions is not a list")
|
||||
output = []
|
||||
for value in questions:
|
||||
text = _natural_question(value)
|
||||
if text and text not in output:
|
||||
output.append(text[:200])
|
||||
if not output:
|
||||
raise ValueError("empty questions")
|
||||
return output[:count]
|
||||
except urllib.error.HTTPError as exc:
|
||||
logger.error("LLM question generation HTTP error: status=%s url=%s", exc.code, url)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"大模型服务返回错误(HTTP {exc.code})") from exc
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError, TypeError, ValueError) as exc:
|
||||
logger.exception("LLM question generation failed")
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型提问生成失败,请稍后重试") from exc
|
||||
|
||||
|
||||
def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]:
|
||||
"""Generate questions in batches and merge them until the requested count is reached."""
|
||||
target = int(count)
|
||||
output: list[str] = []
|
||||
seen: set[str] = set()
|
||||
attempts = 0
|
||||
max_attempts = max((target + 19) // 20 + 2, 3)
|
||||
while len(output) < target and attempts < max_attempts:
|
||||
batch = _generate_questions_batch(payload, min(20, target - len(output)), reference_qa)
|
||||
for question in batch:
|
||||
if question not in seen:
|
||||
seen.add(question)
|
||||
output.append(question)
|
||||
if len(output) >= target:
|
||||
break
|
||||
attempts += 1
|
||||
if len(output) < target:
|
||||
logger.warning("LLM returned %s/%s unique questions", len(output), target)
|
||||
return output[:target]
|
||||
|
||||
|
||||
def generate_answer_for_question(product_title: str, question: str) -> str:
|
||||
"""Generate one concise answer for an OCR-extracted question."""
|
||||
if not settings.llm_api_key:
|
||||
return "我用着还行,建议先少量试试,适合自己再继续用"
|
||||
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
|
||||
prompt = f"商品:{product_title}\n问题:{question}\n请生成一条50字以内的淘宝问大家回答。像普通买家在手机上随手回复,简短口语化,避免客服腔、说明书语气、广告词和过于完整规范的句式;不要虚构未提供的使用事实或夸大功效;回答末尾一律不要加任何标点符号。只输出回答正文。"
|
||||
request_body = {"model": settings.llm_model, "temperature": 0.7, "messages": [{"role": "system", "content": "你是电商问大家回答助手。"}, {"role": "user", "content": prompt}]}
|
||||
request = urllib.request.Request(url, data=json.dumps(request_body).encode("utf-8"), headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
answer = str(result["choices"][0]["message"]["content"]).strip()
|
||||
answer = _natural_answer(result["choices"][0]["message"]["content"])
|
||||
return answer[:300]
|
||||
except Exception:
|
||||
logger.exception("Answer generation failed for simulated OCR question")
|
||||
return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。"
|
||||
logger.exception("Answer generation failed for OCR question")
|
||||
return "我用着还行,建议先少量试试,适合自己再继续用"
|
||||
|
||||
|
||||
def _parse_ocr_content(content: str) -> list[dict]:
|
||||
@ -143,31 +363,54 @@ def _parse_ocr_content(content: str) -> list[dict]:
|
||||
return items[:100]
|
||||
|
||||
|
||||
def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]:
|
||||
"""Extract consumer questions from one or more Taobao Q&A screenshots.
|
||||
def _compress_ocr_image(image_data: bytes, content_type: str) -> tuple[bytes, str]:
|
||||
"""Normalize screenshots and keep each vision request reasonably small."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(image_data)) as source:
|
||||
image = ImageOps.exif_transpose(source)
|
||||
image.thumbnail((2200, 2200), Image.Resampling.LANCZOS)
|
||||
if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
|
||||
rgba = image.convert("RGBA")
|
||||
background = Image.new("RGB", rgba.size, "white")
|
||||
background.paste(rgba, mask=rgba.getchannel("A"))
|
||||
image = background
|
||||
else:
|
||||
image = image.convert("RGB")
|
||||
|
||||
The configured OpenAI-compatible model must support image input. The
|
||||
caller keeps a deterministic fallback when no vision model/API key is
|
||||
configured, so uploading screenshots never blocks the task workflow.
|
||||
"""
|
||||
if not settings.llm_api_key or not images:
|
||||
return []
|
||||
content = [{
|
||||
for quality in (86, 76, 66):
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="JPEG", quality=quality, optimize=True)
|
||||
compressed = output.getvalue()
|
||||
if len(compressed) <= 1_500_000:
|
||||
return compressed, "image/jpeg"
|
||||
|
||||
# Very long/high-detail screenshots get one final size reduction.
|
||||
image.thumbnail((1700, 1700), Image.Resampling.LANCZOS)
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="JPEG", quality=72, optimize=True)
|
||||
return output.getvalue(), "image/jpeg"
|
||||
except Exception:
|
||||
logger.warning("Could not compress OCR image; using original payload", exc_info=True)
|
||||
return image_data, content_type
|
||||
|
||||
|
||||
def _extract_questions_from_image(image_data: bytes, content_type: str) -> list[dict]:
|
||||
encoded = base64.b64encode(image_data).decode("ascii")
|
||||
content = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"请识别这些淘宝‘问答/待回答’页面截图中的消费者提问。只提取真正的问题句子,"
|
||||
"请识别这张淘宝‘问答/待回答’页面截图中的所有消费者提问。只提取真正的问题句子,"
|
||||
"不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。"
|
||||
"多张截图合并去重。只返回严格 JSON:"
|
||||
"只返回严格 JSON:"
|
||||
'{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}'
|
||||
),
|
||||
}]
|
||||
for index, (image_data, content_type) in enumerate(images, start=1):
|
||||
encoded = base64.b64encode(image_data).decode("ascii")
|
||||
content.append({"type": "text", "text": f"截图 {index}"})
|
||||
content.append({
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{content_type};base64,{encoded}"},
|
||||
})
|
||||
},
|
||||
]
|
||||
request_body = {
|
||||
"model": settings.llm_vision_model,
|
||||
"temperature": 0,
|
||||
@ -183,11 +426,57 @@ def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
return _parse_ocr_content(content)
|
||||
return _parse_ocr_content(result["choices"][0]["message"]["content"])
|
||||
|
||||
|
||||
def _recognize_one_image(index: int, image: tuple[bytes, str]) -> tuple[int, list[dict]]:
|
||||
image_data, content_type = _compress_ocr_image(*image)
|
||||
for attempt in range(2):
|
||||
try:
|
||||
items = _extract_questions_from_image(image_data, content_type)
|
||||
if items:
|
||||
return index, items
|
||||
raise ValueError("vision model returned no valid questions")
|
||||
except Exception:
|
||||
logger.exception("Question OCR failed")
|
||||
logger.warning(
|
||||
"Question OCR failed for image %s on attempt %s/2",
|
||||
index + 1,
|
||||
attempt + 1,
|
||||
exc_info=True,
|
||||
)
|
||||
if attempt == 0:
|
||||
time.sleep(0.5)
|
||||
return index, []
|
||||
|
||||
|
||||
def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]:
|
||||
"""Extract consumer questions from one or more Taobao Q&A screenshots.
|
||||
|
||||
The configured OpenAI-compatible model must support image input. Each
|
||||
screenshot is compressed and recognized independently. Failed image
|
||||
requests are retried once, then successful results are merged and deduped.
|
||||
"""
|
||||
if not settings.llm_api_key or not images:
|
||||
return []
|
||||
recognized: dict[int, list[dict]] = {}
|
||||
with ThreadPoolExecutor(max_workers=min(3, len(images))) as executor:
|
||||
futures = {
|
||||
executor.submit(_recognize_one_image, index, image): index
|
||||
for index, image in enumerate(images)
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
index, items = future.result()
|
||||
recognized[index] = items
|
||||
|
||||
merged = []
|
||||
seen = set()
|
||||
for index in range(len(images)):
|
||||
for item in recognized.get(index, []):
|
||||
key = re.sub(r"[\s,。!?、,.!?]+", "", item["question"]).lower()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append({**item, "source_index": index})
|
||||
return merged[:100]
|
||||
|
||||
4767
backend/app/main.py
4767
backend/app/main.py
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,100 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.mysql import MEDIUMBLOB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class RefundPaymentAccount(Base):
|
||||
__tablename__ = "refund_payment_accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
alipay_account: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
balance_fen: Mapped[int] = mapped_column(Integer, default=0)
|
||||
single_limit_fen: Mapped[int] = mapped_column(Integer)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class RefundAccountAdjustment(Base):
|
||||
__tablename__ = "refund_account_adjustments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
account_id: Mapped[int] = mapped_column(ForeignKey("refund_payment_accounts.id"), index=True)
|
||||
amount_fen: Mapped[int] = mapped_column(Integer)
|
||||
balance_after_fen: Mapped[int] = mapped_column(Integer)
|
||||
adjustment_type: Mapped[str] = mapped_column(String(32), default="资金调整")
|
||||
note: Mapped[str] = mapped_column(String(255), default="人工调整资金总额")
|
||||
operator_name: Mapped[str] = mapped_column(String(64), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class RefundRecord(Base):
|
||||
"""Immutable confirmation snapshot; deliberately independent of task deletion."""
|
||||
__tablename__ = "refund_records"
|
||||
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_refund_record_target"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
target_kind: Mapped[str] = mapped_column(String(32))
|
||||
target_id: Mapped[int] = mapped_column(Integer)
|
||||
owner_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
shop_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
ecommerce_platform: Mapped[str] = mapped_column(String(64), default="")
|
||||
product_id: Mapped[str] = mapped_column(String(128), default="")
|
||||
order_no: Mapped[str] = mapped_column(String(64), index=True)
|
||||
payee_name: Mapped[str] = mapped_column(String(64), default="")
|
||||
payee_account: Mapped[str] = mapped_column(String(128), default="")
|
||||
amount_fen: Mapped[int] = mapped_column(Integer)
|
||||
payer_account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
payer_account: Mapped[str] = mapped_column(String(128), default="")
|
||||
operator_name: Mapped[str] = mapped_column(String(64))
|
||||
alipay_trade_no: Mapped[str] = mapped_column(String(128), default="")
|
||||
transferred_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class AlipayRefundOrder(Base):
|
||||
"""Idempotency and provider status record for a real Alipay transfer."""
|
||||
__tablename__ = "alipay_transfer_attempts"
|
||||
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_alipay_transfer_attempt_target"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
target_kind: Mapped[str] = mapped_column(String(32), index=True)
|
||||
target_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
out_biz_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
amount_fen: Mapped[int] = mapped_column(Integer)
|
||||
order_no: Mapped[str] = mapped_column(String(64), default="")
|
||||
shop_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
ecommerce_platform: Mapped[str] = mapped_column(String(64), default="")
|
||||
product_id: Mapped[str] = mapped_column(String(128), default="")
|
||||
owner_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
account: Mapped[str] = mapped_column(String(128))
|
||||
payee_name: Mapped[str] = mapped_column(String(64))
|
||||
payer_account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
payer_account: Mapped[str] = mapped_column(String(128), default="")
|
||||
created_by_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
operator_name: Mapped[str] = mapped_column(String(64), default="")
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
|
||||
alipay_trade_no: Mapped[str] = mapped_column(String(128), default="")
|
||||
error_code: Mapped[str] = mapped_column(String(128), default="")
|
||||
error_message: Mapped[str] = mapped_column(String(500), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class RefundRecipient(Base):
|
||||
__tablename__ = "refund_recipients"
|
||||
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_refund_recipient_target"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
target_kind: Mapped[str] = mapped_column(String(32))
|
||||
target_id: Mapped[int] = mapped_column(Integer)
|
||||
account: Mapped[str] = mapped_column(String(128))
|
||||
payee_name: Mapped[str] = mapped_column(String(64))
|
||||
amount_fen: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
@ -14,12 +103,28 @@ class AdminUser(Base):
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
display_name: Mapped[str] = mapped_column(String(64))
|
||||
role_code: Mapped[str] = mapped_column(String(32), default="operator", index=True)
|
||||
refund_payment_account_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
dingtalk_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
dingtalk_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
dingtalk_corp_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
dingtalk_dept_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
dingtalk_dept_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
dingtalk_dept_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
dingtalk_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
is_dept_manager: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
# Stable public entry point for answer-task submissions created by this
|
||||
# operator. The token is intentionally separate from the admin session
|
||||
# cookie and is safe to embed in a long-lived URL.
|
||||
answer_public_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True, nullable=True)
|
||||
last_answer_shop_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
last_answer_ecommerce_platform: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
login_logs: Mapped[list["AdminLoginLog"]] = relationship(back_populates="user")
|
||||
ask_tasks: Mapped[list["AskTask"]] = relationship(back_populates="owner")
|
||||
|
||||
|
||||
class AdminLoginLog(Base):
|
||||
@ -36,6 +141,19 @@ class AdminLoginLog(Base):
|
||||
user: Mapped[AdminUser | None] = relationship(back_populates="login_logs")
|
||||
|
||||
|
||||
class AdminUsageEntry(Base):
|
||||
"""One deduplicated entry into the operations console."""
|
||||
|
||||
__tablename__ = "admin_usage_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
username: Mapped[str] = mapped_column(String(64), index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(64), index=True)
|
||||
department_name: Mapped[str] = mapped_column(String(255), default="", index=True)
|
||||
entered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class QALibraryEntry(Base):
|
||||
__tablename__ = "qa_library_entries"
|
||||
|
||||
@ -52,12 +170,61 @@ class QALibraryEntry(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class QABulkGenerationJob(Base):
|
||||
"""Persistent progress for a bulk AI-answer generation request."""
|
||||
|
||||
__tablename__ = "qa_bulk_generation_jobs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
public_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
# Keep this as an indexed ownership value without a database FK so the
|
||||
# table remains compatible with existing MySQL installations whose admin
|
||||
# primary key may use a different integer width.
|
||||
owner_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
payload_json: Mapped[str] = mapped_column(Text)
|
||||
answers_per_question: Mapped[int] = mapped_column(Integer)
|
||||
status: Mapped[str] = mapped_column(String(32), default="queued", index=True)
|
||||
total_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
completed_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
failed_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
items: Mapped[list["QABulkGenerationItem"]] = relationship(
|
||||
back_populates="job",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
order_by="QABulkGenerationItem.sequence_no",
|
||||
)
|
||||
|
||||
|
||||
class QABulkGenerationItem(Base):
|
||||
__tablename__ = "qa_bulk_generation_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("job_id", "sequence_no", name="uq_qa_bulk_generation_item_sequence"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(ForeignKey("qa_bulk_generation_jobs.id"), index=True)
|
||||
sequence_no: Mapped[int] = mapped_column(Integer)
|
||||
question_text: Mapped[str] = mapped_column(Text)
|
||||
answers_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
job: Mapped[QABulkGenerationJob] = relationship(back_populates="items")
|
||||
|
||||
|
||||
class AnswerUser(Base):
|
||||
__tablename__ = "answer_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
public_id: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True)
|
||||
taobao_id: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
|
||||
auth_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_login_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
@ -72,12 +239,21 @@ class AnswerSubmission(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True)
|
||||
owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
|
||||
# Store name is entered by the operator when this answer task is
|
||||
# dispatched. It is kept on the submission because one operator may
|
||||
# handle multiple stores and the same user can submit through different
|
||||
# operator links.
|
||||
shop_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
ecommerce_platform: Mapped[str] = mapped_column(String(64), default="")
|
||||
product_id: Mapped[str] = mapped_column(String(128), default="")
|
||||
# 用户上传受邀截图后立即进入运营筛选队列。
|
||||
status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user: Mapped[AnswerUser] = relationship(back_populates="submissions", lazy="selectin")
|
||||
owner: Mapped[AdminUser | None] = relationship()
|
||||
questions: Mapped[list["AnswerRecognizedQuestion"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin")
|
||||
screenshots: Mapped[list["AnswerScreenshot"]] = relationship(back_populates="submission", cascade="all, delete-orphan")
|
||||
review_decisions: Mapped[list["AnswerReviewDecision"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin")
|
||||
@ -91,7 +267,8 @@ class AnswerScreenshot(Base):
|
||||
stage: Mapped[str] = mapped_column(String(20), index=True) # invited/completion
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str] = mapped_column(String(64))
|
||||
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
|
||||
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
|
||||
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
submission: Mapped[AnswerSubmission] = relationship(back_populates="screenshots")
|
||||
@ -106,6 +283,8 @@ class AnswerRecognizedQuestion(Base):
|
||||
sequence_no: Mapped[int] = mapped_column(Integer)
|
||||
product_title: Mapped[str] = mapped_column(String(255))
|
||||
product_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True)
|
||||
operator_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True)
|
||||
operator_image_screenshot_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
question_text: Mapped[str] = mapped_column(Text)
|
||||
answer_text: Mapped[str] = mapped_column(Text, default="")
|
||||
answer_status: Mapped[str] = mapped_column(String(32), default="已生成")
|
||||
@ -134,7 +313,20 @@ class AskTask(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||
public_token: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
|
||||
owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
|
||||
product_link: Mapped[str] = mapped_column(String(1000))
|
||||
# QR images are capped at 5 MB by the API. MEDIUMBLOB keeps fresh MySQL
|
||||
# installs above the 64 KB BLOB limit while the generic type remains
|
||||
# compatible with SQLite development databases.
|
||||
product_qr_image_data: Mapped[bytes | None] = mapped_column(
|
||||
LargeBinary().with_variant(MEDIUMBLOB, "mysql"), nullable=True
|
||||
)
|
||||
product_qr_image_content_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
product_qr_image_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
shop_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
ecommerce_platform: Mapped[str] = mapped_column(String(64), default="")
|
||||
product_id: Mapped[str] = mapped_column(String(128), default="")
|
||||
product_name: Mapped[str] = mapped_column(String(255))
|
||||
category: Mapped[str] = mapped_column(String(100), default="")
|
||||
benefits: Mapped[str] = mapped_column(Text, default="")
|
||||
@ -143,12 +335,14 @@ class AskTask(Base):
|
||||
target_group: Mapped[str] = mapped_column(String(100), default="女性")
|
||||
usage_scene: Mapped[str] = mapped_column(String(100), default="居家")
|
||||
purchase_channel: Mapped[str] = mapped_column(String(100), default="电商平台")
|
||||
claim_questions_per_user: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(32), default="草稿", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
questions: Mapped[list["AskQuestion"]] = relationship(back_populates="task", cascade="all, delete-orphan", lazy="selectin")
|
||||
assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="task", cascade="all, delete-orphan")
|
||||
owner: Mapped[AdminUser | None] = relationship(back_populates="ask_tasks")
|
||||
|
||||
|
||||
class AskQuestion(Base):
|
||||
@ -158,6 +352,9 @@ class AskQuestion(Base):
|
||||
task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True)
|
||||
sequence_no: Mapped[int] = mapped_column(Integer)
|
||||
question_text: Mapped[str] = mapped_column(Text)
|
||||
# Keep the origin so the draft UI can distinguish AI-generated questions
|
||||
# from questions added manually or imported from the Q&A library.
|
||||
source: Mapped[str] = mapped_column(String(32), default="AI生成", nullable=False)
|
||||
selected: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@ -173,6 +370,10 @@ class AskAssignment(Base):
|
||||
task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="待选择", index=True)
|
||||
# The current reservation window is independent from the assignment's
|
||||
# lifetime. One assignment can retain several submitted batches while a
|
||||
# later, unsubmitted selection expires and is released safely.
|
||||
reservation_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@ -215,7 +416,8 @@ class AskAssignmentBatchScreenshot(Base):
|
||||
stage: Mapped[str] = mapped_column(String(20), index=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str] = mapped_column(String(64))
|
||||
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
|
||||
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
|
||||
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
batch: Mapped[AskAssignmentBatch] = relationship(back_populates="screenshots")
|
||||
@ -281,7 +483,8 @@ class AskAssignmentScreenshot(Base):
|
||||
stage: Mapped[str] = mapped_column(String(20), index=True) # question/payment
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str] = mapped_column(String(64))
|
||||
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
|
||||
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
|
||||
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
assignment: Mapped[AskAssignment] = relationship(back_populates="screenshots")
|
||||
|
||||
@ -1,4 +1,37 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import field_validator
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class RefundPaymentAccountSave(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
alipay_account: str = Field(min_length=1, max_length=128)
|
||||
balance: Decimal = Field(ge=0, le=9999999.99, decimal_places=2, allow_inf_nan=False)
|
||||
single_limit: Decimal = Field(gt=0, le=9999999.99, decimal_places=2, allow_inf_nan=False)
|
||||
is_active: bool = True
|
||||
|
||||
@field_validator("name", "alipay_account", mode="before")
|
||||
@classmethod
|
||||
def strip_text(cls, value):
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
class RefundFundAdjustment(BaseModel):
|
||||
adjustment_type: str = Field(pattern=r"^(追加资金|资金调整)$")
|
||||
amount: Decimal = Field(ge=-9999999.99, le=9999999.99, decimal_places=2, allow_inf_nan=False)
|
||||
note: str = Field(default="", max_length=255)
|
||||
|
||||
@field_validator("note", mode="before")
|
||||
@classmethod
|
||||
def strip_note(cls, value):
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
@field_validator("amount")
|
||||
@classmethod
|
||||
def reject_zero_amount(cls, value):
|
||||
if value == 0:
|
||||
raise ValueError("调整金额不能为 0")
|
||||
return value
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@ -6,6 +39,10 @@ class LoginRequest(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class PaymentAccountAuthorization(BaseModel):
|
||||
account_id: int | None = Field(..., gt=0, strict=True)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
@ -16,13 +53,22 @@ class AdminUserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
display_name: str = Field(min_length=1, max_length=64)
|
||||
role_code: str = Field(default="operator", pattern=r"^(super_admin|operator)$")
|
||||
role_code: str = Field(default="operator", pattern=r"^(super_admin|dept_manager|operator)$")
|
||||
|
||||
|
||||
class AdminDingTalkUserLookup(BaseModel):
|
||||
identifier: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class AdminDingTalkUserCreate(BaseModel):
|
||||
identifier: str = Field(min_length=1, max_length=128)
|
||||
role_code: str | None = Field(default=None, pattern=r"^(super_admin|dept_manager|operator)$")
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
role_code: str | None = Field(default=None, pattern=r"^(super_admin|operator)$")
|
||||
role_code: str | None = Field(default=None, pattern=r"^(super_admin|dept_manager|operator)$")
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@ -35,13 +81,28 @@ class QAGenerateRequest(BaseModel):
|
||||
target_group: str = Field(default="女性", max_length=100)
|
||||
usage_scene: str = Field(default="居家", max_length=100)
|
||||
purchase_channel: str = Field(default="电商平台", max_length=100)
|
||||
question_count: int = Field(default=5, ge=1, le=20)
|
||||
question_count: int = Field(default=5, ge=1)
|
||||
answers_per_question: int = Field(default=2, ge=1, le=50)
|
||||
answer_requirement: str = Field(default="", max_length=1000)
|
||||
reference_qa: str = Field(default="", max_length=20000)
|
||||
seed_style: bool = True
|
||||
|
||||
|
||||
class QABulkAnswerRequest(BaseModel):
|
||||
product_name: str = Field(default="", max_length=200)
|
||||
category: str = Field(default="", max_length=100)
|
||||
benefits: str = Field(default="", max_length=1000)
|
||||
selling_points: str = Field(default="", max_length=2000)
|
||||
language_style: str = Field(default="口语(亲切随性)", max_length=40)
|
||||
target_group: str = Field(default="女性", max_length=100)
|
||||
usage_scene: str = Field(default="居家", max_length=100)
|
||||
purchase_channel: str = Field(default="电商平台", max_length=100)
|
||||
answers_per_question: int = Field(default=2, ge=1, le=20)
|
||||
answer_requirement: str = Field(default="", max_length=1000)
|
||||
seed_style: bool = True
|
||||
questions: list[str] = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
99
backend/app/screenshot_storage.py
Normal file
99
backend/app/screenshot_storage.py
Normal file
@ -0,0 +1,99 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Response
|
||||
from .config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_extension(filename: str, content_type: str) -> str:
|
||||
extension = Path(filename or "").suffix.lower()
|
||||
if extension in {".jpg", ".jpeg", ".png", ".webp"}:
|
||||
return extension
|
||||
guessed = mimetypes.guess_extension(content_type or "") or ".bin"
|
||||
return ".jpg" if guessed == ".jpe" else guessed
|
||||
|
||||
|
||||
def _absolute_path(relative_path: str) -> Path:
|
||||
root = settings.screenshot_storage_dir.resolve()
|
||||
candidate = (root / relative_path).resolve()
|
||||
if root != candidate and root not in candidate.parents:
|
||||
raise ValueError("非法截图文件路径")
|
||||
return candidate
|
||||
|
||||
|
||||
def store_screenshot(data: bytes, filename: str, content_type: str, category: str) -> str:
|
||||
if not data:
|
||||
raise ValueError("截图内容不能为空")
|
||||
now_path = Path(category) / uuid.uuid4().hex[:2]
|
||||
relative_path = now_path / f"{uuid.uuid4().hex}{_safe_extension(filename, content_type)}"
|
||||
target = _absolute_path(relative_path.as_posix())
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(target.suffix + ".tmp")
|
||||
temporary.write_bytes(data)
|
||||
if temporary.stat().st_size != len(data):
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise OSError("截图写入不完整")
|
||||
temporary.replace(target)
|
||||
return relative_path.as_posix()
|
||||
|
||||
|
||||
def screenshot_fields(data: bytes, filename: str, content_type: str, category: str) -> dict:
|
||||
try:
|
||||
file_path = store_screenshot(data, filename, content_type, category)
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.exception(
|
||||
"Screenshot file write failed: category=%s filename=%s storage_dir=%s",
|
||||
category,
|
||||
filename,
|
||||
settings.screenshot_storage_dir,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="图片存储目录不可用,请联系管理员检查目录配置和写入权限",
|
||||
) from exc
|
||||
return {
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"file_path": file_path,
|
||||
"image_data": None,
|
||||
}
|
||||
|
||||
|
||||
def screenshot_bytes(image) -> bytes:
|
||||
if image.file_path:
|
||||
try:
|
||||
path = _absolute_path(image.file_path)
|
||||
if path.is_file():
|
||||
return path.read_bytes()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if image.image_data:
|
||||
return bytes(image.image_data)
|
||||
raise HTTPException(status_code=404, detail="截图文件不存在")
|
||||
|
||||
|
||||
def screenshot_response(image) -> Response:
|
||||
return Response(
|
||||
content=screenshot_bytes(image),
|
||||
media_type=image.content_type or "application/octet-stream",
|
||||
headers={"Cache-Control": "private, max-age=600"},
|
||||
)
|
||||
|
||||
|
||||
def screenshot_data_url(image) -> str:
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(screenshot_bytes(image)).decode()
|
||||
return f"data:{image.content_type};base64,{encoded}"
|
||||
|
||||
|
||||
def file_digest(relative_path: str) -> tuple[int, str]:
|
||||
data = _absolute_path(relative_path).read_bytes()
|
||||
return len(data), hashlib.sha256(data).hexdigest()
|
||||
|
||||
0
backend/data/app.db
Normal file
0
backend/data/app.db
Normal file
@ -5,3 +5,8 @@ pymysql>=1.1,<2.0
|
||||
python-multipart>=0.0.20,<1.0
|
||||
httpx2>=2.12,<3.0
|
||||
python-dotenv>=1.0,<2.0
|
||||
alipay-sdk-python>=3.7,<4.0
|
||||
pycryptodome>=3.20,<5.0
|
||||
cryptography>=43,<47
|
||||
openpyxl>=3.1,<4.0
|
||||
pillow>=11.0,<13.0
|
||||
|
||||
@ -7,16 +7,72 @@ CREATE TABLE IF NOT EXISTS admin_users (
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(64) NOT NULL,
|
||||
role_code VARCHAR(32) NOT NULL DEFAULT 'operator',
|
||||
refund_payment_account_id INTEGER NULL,
|
||||
answer_public_token VARCHAR(96) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_admin_users_username (username),
|
||||
UNIQUE KEY uk_admin_users_answer_public_token (answer_public_token),
|
||||
KEY idx_admin_users_role (role_code),
|
||||
KEY idx_admin_users_status (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refund_recipients (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
target_kind VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
account VARCHAR(128) NOT NULL,
|
||||
payee_name VARCHAR(64) NOT NULL,
|
||||
amount_fen INTEGER NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_refund_recipient_target (target_kind, target_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alipay_refund_orders (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
target_kind VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
amount_fen INT NOT NULL,
|
||||
account VARCHAR(128) NOT NULL,
|
||||
payee_name VARCHAR(64) NOT NULL,
|
||||
created_by_id BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_alipay_refund_target (target_kind, target_id),
|
||||
KEY idx_alipay_refund_orders_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alipay_transfer_attempts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
target_kind VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
out_biz_no VARCHAR(64) NOT NULL,
|
||||
amount_fen INT NOT NULL,
|
||||
order_no VARCHAR(64) NOT NULL DEFAULT '',
|
||||
shop_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
owner_id BIGINT NULL,
|
||||
account VARCHAR(128) NOT NULL,
|
||||
payee_name VARCHAR(64) NOT NULL,
|
||||
payer_account VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_by_id BIGINT NOT NULL,
|
||||
operator_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||
alipay_trade_no VARCHAR(128) NOT NULL DEFAULT '',
|
||||
error_code VARCHAR(128) NOT NULL DEFAULT '',
|
||||
error_message VARCHAR(500) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_alipay_transfer_attempt_out_biz_no (out_biz_no),
|
||||
UNIQUE KEY uq_alipay_transfer_attempt_target (target_kind, target_id),
|
||||
KEY idx_alipay_transfer_attempt_target (target_kind, target_id),
|
||||
KEY idx_alipay_transfer_attempt_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_login_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NULL,
|
||||
@ -31,3 +87,86 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
|
||||
KEY idx_admin_login_logs_created (created_at),
|
||||
CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_usage_entries (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(64) NOT NULL,
|
||||
department_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_admin_usage_user_time (user_id, entered_at),
|
||||
KEY idx_admin_usage_entered_at (entered_at),
|
||||
KEY idx_admin_usage_department (department_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。
|
||||
-- 新环境建表时请将 owner_id、shop_name 一并加入 ask_tasks;已有环境由应用启动时自动补列。
|
||||
-- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qa_bulk_generation_jobs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
public_id VARCHAR(64) NOT NULL,
|
||||
owner_id BIGINT NULL,
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
answers_per_question INT NOT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'queued',
|
||||
total_count INT NOT NULL DEFAULT 0,
|
||||
completed_count INT NOT NULL DEFAULT 0,
|
||||
failed_count INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_qa_bulk_generation_jobs_public_id (public_id),
|
||||
KEY idx_qa_bulk_generation_jobs_owner (owner_id),
|
||||
KEY idx_qa_bulk_generation_jobs_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refund_records (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
target_kind VARCHAR(32) NOT NULL,
|
||||
target_id INT NOT NULL,
|
||||
owner_id INT NULL,
|
||||
shop_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
order_no VARCHAR(64) NOT NULL,
|
||||
payee_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
payee_account VARCHAR(128) NOT NULL DEFAULT '',
|
||||
amount_fen INT NOT NULL,
|
||||
payer_account VARCHAR(128) NOT NULL DEFAULT '',
|
||||
operator_name VARCHAR(64) NOT NULL,
|
||||
alipay_trade_no VARCHAR(128) NOT NULL DEFAULT '',
|
||||
transferred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_refund_record_target (target_kind, target_id),
|
||||
KEY ix_refund_records_owner_id (owner_id),
|
||||
KEY ix_refund_records_order_no (order_no),
|
||||
KEY ix_refund_records_transferred_at (transferred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refund_payment_accounts (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
alipay_account VARCHAR(128) NOT NULL UNIQUE,
|
||||
balance_fen INT NOT NULL DEFAULT 0,
|
||||
single_limit_fen INT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qa_bulk_generation_items (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
job_id BIGINT UNSIGNED NOT NULL,
|
||||
sequence_no INT NOT NULL,
|
||||
question_text TEXT NOT NULL,
|
||||
answers_json LONGTEXT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
error_message VARCHAR(500) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_qa_bulk_generation_items_sequence (job_id, sequence_no),
|
||||
KEY idx_qa_bulk_generation_items_status (job_id, status),
|
||||
CONSTRAINT fk_qa_bulk_generation_items_job FOREIGN KEY (job_id) REFERENCES qa_bulk_generation_jobs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
89
backend/scripts/migrate_screenshots_to_files.py
Normal file
89
backend/scripts/migrate_screenshots_to_files.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""Move legacy screenshot BLOBs to server files.
|
||||
|
||||
Run from the project root with::
|
||||
|
||||
python backend/scripts/migrate_screenshots_to_files.py
|
||||
|
||||
Each row is committed only after the written file matches the source byte
|
||||
length and SHA-256 digest. After verification, the database BLOB is cleared so
|
||||
screenshots exist only in the configured server storage directory.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_ROOT = PROJECT_ROOT / "backend"
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from sqlalchemy import inspect, or_, select, text # noqa: E402
|
||||
|
||||
from app.config import settings # noqa: E402
|
||||
from app.database import SessionLocal, engine # noqa: E402
|
||||
from app.models import AnswerScreenshot, AskAssignmentBatchScreenshot, AskAssignmentScreenshot # noqa: E402
|
||||
from app.screenshot_storage import file_digest, store_screenshot # noqa: E402
|
||||
|
||||
|
||||
TARGETS = (
|
||||
(AnswerScreenshot, "answer_screenshots", "answers"),
|
||||
(AskAssignmentBatchScreenshot, "ask_assignment_batch_screenshots", "ask-batches"),
|
||||
(AskAssignmentScreenshot, "ask_assignment_screenshots", "ask-assignments"),
|
||||
)
|
||||
def ensure_columns() -> None:
|
||||
for _model, table, _category in TARGETS:
|
||||
columns = {column["name"]: column for column in inspect(engine).get_columns(table)}
|
||||
if "file_path" not in columns:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"ALTER TABLE {table} ADD COLUMN file_path VARCHAR(1000) NULL"))
|
||||
if engine.dialect.name == "mysql" and not columns["image_data"].get("nullable", False):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"ALTER TABLE {table} MODIFY COLUMN image_data MEDIUMBLOB NULL"))
|
||||
|
||||
|
||||
def migrate() -> None:
|
||||
ensure_columns()
|
||||
settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
migrated_rows = 0
|
||||
migrated_bytes = 0
|
||||
for model, table, category in TARGETS:
|
||||
table_rows = 0
|
||||
table_bytes = 0
|
||||
while True:
|
||||
with SessionLocal() as db:
|
||||
image = db.scalar(
|
||||
select(model)
|
||||
.where(
|
||||
model.image_data.is_not(None),
|
||||
or_(model.file_path.is_(None), model.file_path == ""),
|
||||
)
|
||||
.order_by(model.id)
|
||||
.limit(1)
|
||||
)
|
||||
if image is None:
|
||||
break
|
||||
source = bytes(image.image_data)
|
||||
source_digest = hashlib.sha256(source).hexdigest()
|
||||
relative_path = store_screenshot(source, image.filename, image.content_type, category)
|
||||
stored_size, stored_digest = file_digest(relative_path)
|
||||
if stored_size != len(source) or stored_digest != source_digest:
|
||||
raise RuntimeError(f"{table}#{image.id} 文件校验失败")
|
||||
image.file_path = relative_path
|
||||
image.image_data = None
|
||||
db.commit()
|
||||
table_rows += 1
|
||||
table_bytes += len(source)
|
||||
if table_rows % 100 == 0:
|
||||
print(f"{table}: migrated {table_rows} rows", flush=True)
|
||||
migrated_rows += table_rows
|
||||
migrated_bytes += table_bytes
|
||||
print(f"{table}: complete rows={table_rows} bytes={table_bytes}", flush=True)
|
||||
print(f"TOTAL rows={migrated_rows} bytes={migrated_bytes}", flush=True)
|
||||
print(f"FILES {settings.screenshot_storage_dir.resolve()}", flush=True)
|
||||
print("DATABASE_COPIES CLEARED", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
BIN
backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc
Normal file
BIN
backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc
Normal file
Binary file not shown.
BIN
backend/tests/__pycache__/conftest.cpython-313.pyc
Normal file
BIN
backend/tests/__pycache__/conftest.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/tests/__pycache__/conftest.cpython-314-pytest-8.4.1.pyc
Normal file
BIN
backend/tests/__pycache__/conftest.cpython-314-pytest-8.4.1.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc
Normal file
BIN
backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc
Normal file
Binary file not shown.
BIN
backend/tests/__pycache__/test_auth.cpython-313.pyc
Normal file
BIN
backend/tests/__pycache__/test_auth.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/tests/__pycache__/test_llm_ocr.cpython-313.pyc
Normal file
BIN
backend/tests/__pycache__/test_llm_ocr.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
18
backend/tests/conftest.py
Normal file
18
backend/tests/conftest.py
Normal file
@ -0,0 +1,18 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Never let the integration tests inherit a development or production MySQL
|
||||
# URL. The application is imported during test collection, so the isolated
|
||||
# database must be selected before any test module imports app.main.
|
||||
_test_dir = Path(tempfile.mkdtemp(prefix="wendajia-tests-"))
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{(_test_dir / 'app.db').as_posix()}"
|
||||
os.environ["SCREENSHOT_STORAGE_DIR"] = str(_test_dir / "screenshots")
|
||||
os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "admin123456"
|
||||
os.environ["OPERATOR_USERNAME"] = "operator"
|
||||
os.environ["OPERATOR_PASSWORD"] = "operator123456"
|
||||
# Integration tests must never call the real payout API or inherit the
|
||||
# developer machine's real payer-account restriction.
|
||||
os.environ["ALIPAY_ENABLED"] = "false"
|
||||
61
backend/tests/test_alipay_callback.py
Normal file
61
backend/tests/test_alipay_callback.py
Normal file
@ -0,0 +1,61 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import alipay_provider
|
||||
|
||||
|
||||
def test_alipay_callback_reconciles_verified_success(monkeypatch):
|
||||
monkeypatch.setattr(alipay_provider, "verify_notification", lambda params: params.get("sign") == "valid")
|
||||
out_biz_no = f"RF{uuid4().hex[:20]}"
|
||||
with TestClient(main.app) as client:
|
||||
with main.SessionLocal() as db:
|
||||
user = db.scalar(main.select(main.AdminUser).where(main.AdminUser.username == "operator"))
|
||||
answer_user = main.AnswerUser(
|
||||
public_id=f"U{uuid4().hex[:20]}", phone=f"1{uuid4().int % 10**10:010d}",
|
||||
auth_token=uuid4().hex,
|
||||
)
|
||||
db.add(answer_user)
|
||||
db.flush()
|
||||
submission = main.AnswerSubmission(
|
||||
submission_no=f"ANS{uuid4().hex[:20]}", user_id=answer_user.id,
|
||||
owner_id=user.id, shop_name="回调测试店", status="已通过",
|
||||
)
|
||||
db.add(submission)
|
||||
db.flush()
|
||||
order = main.AlipayRefundOrder(
|
||||
target_kind="answer_submission", target_id=submission.id,
|
||||
out_biz_no=out_biz_no, amount_fen=300, account="buyer@example.com",
|
||||
payee_name="测试收款人", created_by_id=user.id, owner_id=user.id,
|
||||
order_no=submission.submission_no, shop_name=submission.shop_name,
|
||||
operator_name=user.display_name, payer_account="公司账户", status="pending",
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
order_id = order.id
|
||||
submission_id = submission.id
|
||||
response = client.post("/alipay_callback", data={
|
||||
"sign": "valid", "out_biz_no": out_biz_no, "status": "SUCCESS",
|
||||
"order_id": "ALI-CALLBACK-001",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.text == "success"
|
||||
with main.SessionLocal() as db:
|
||||
order = db.get(main.AlipayRefundOrder, order_id)
|
||||
assert order.status == "success"
|
||||
assert order.alipay_trade_no == "ALI-CALLBACK-001"
|
||||
assert db.get(main.AnswerSubmission, submission_id).status == "已完成"
|
||||
refund = db.scalar(main.select(main.RefundRecord).where(
|
||||
main.RefundRecord.target_kind == "answer_submission",
|
||||
main.RefundRecord.target_id == submission_id,
|
||||
))
|
||||
assert refund.alipay_trade_no == "ALI-CALLBACK-001"
|
||||
|
||||
|
||||
def test_alipay_callback_rejects_invalid_signature(monkeypatch):
|
||||
monkeypatch.setattr(alipay_provider, "verify_notification", lambda _params: False)
|
||||
with TestClient(main.app) as client:
|
||||
response = client.post("/alipay_callback", data={"sign": "bad", "out_biz_no": "RF-BAD"})
|
||||
assert response.status_code == 400
|
||||
assert response.text == "failure"
|
||||
267
backend/tests/test_alipay_provider.py
Normal file
267
backend/tests/test_alipay_provider.py
Normal file
@ -0,0 +1,267 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hashlib import md5
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app import alipay_provider
|
||||
from app.main import _migrate_alipay_transfer_attempts
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def execute(self, _request):
|
||||
return json.dumps(self.payload, ensure_ascii=False)
|
||||
|
||||
|
||||
class CapturingClient(FakeClient):
|
||||
def execute(self, request):
|
||||
self.params = request.get_params()
|
||||
return super().execute(request)
|
||||
|
||||
|
||||
def test_transfer_parses_official_sdk_json_response(monkeypatch):
|
||||
client = CapturingClient({
|
||||
"code": "10000",
|
||||
"msg": "Success",
|
||||
"out_biz_no": "RF001",
|
||||
"order_id": "ALI001",
|
||||
"status": "SUCCESS",
|
||||
})
|
||||
monkeypatch.setattr(alipay_provider, "_client", lambda: client)
|
||||
monkeypatch.setattr(alipay_provider.settings, "alipay_transfer_scene_name", "佣金报酬")
|
||||
result = alipay_provider.transfer(
|
||||
out_biz_no="RF001", amount_fen=300, account="buyer@example.com",
|
||||
payee_name="测试收款人", order_title="测试返款",
|
||||
)
|
||||
assert result == {"out_biz_no": "RF001", "trade_no": "ALI001", "status": "SUCCESS"}
|
||||
biz_content = json.loads(client.params["biz_content"])
|
||||
assert biz_content["transfer_scene_name"] == "佣金报酬"
|
||||
assert biz_content["transfer_scene_report_infos"] == [
|
||||
{"info_content": "测试返款", "info_type": "佣金报酬说明"},
|
||||
]
|
||||
|
||||
|
||||
def test_transfer_preserves_provider_business_error(monkeypatch):
|
||||
monkeypatch.setattr(alipay_provider, "_client", lambda: FakeClient({
|
||||
"code": "40004",
|
||||
"msg": "Business Failed",
|
||||
"sub_code": "PAYEE_NOT_EXIST",
|
||||
"sub_msg": "收款账户不存在",
|
||||
}))
|
||||
with pytest.raises(alipay_provider.AlipayTransferError) as error:
|
||||
alipay_provider.transfer(
|
||||
out_biz_no="RF002", amount_fen=300, account="missing@example.com",
|
||||
payee_name="测试收款人", order_title="测试返款",
|
||||
)
|
||||
assert error.value.code == "PAYEE_NOT_EXIST"
|
||||
assert error.value.ambiguous is False
|
||||
|
||||
|
||||
def test_old_sdk_error_envelope_is_recovered_from_response_exception():
|
||||
raw = (
|
||||
'[request-id]response shape maybe illegal. '
|
||||
'{"alipay_fund_trans_common_query_response":{'
|
||||
'"code":"40006","msg":"Insufficient Permissions",'
|
||||
'"sub_code":"isv.illegal-client-ip",'
|
||||
'"sub_msg":"当前访问IP不在开放平台设置"},'
|
||||
'"alipay_cert_sn":"abc","sign":"signature"}'
|
||||
)
|
||||
|
||||
error = alipay_provider._provider_error_from_exception(RuntimeError(raw))
|
||||
|
||||
assert error is not None
|
||||
assert error.code == "isv.illegal-client-ip"
|
||||
assert str(error) == "当前访问IP不在开放平台设置"
|
||||
assert error.ambiguous is False
|
||||
|
||||
|
||||
def test_success_code_envelope_is_not_misreported_as_provider_rejection():
|
||||
raw = (
|
||||
'[request-id]response sign verify failed. '
|
||||
'{"alipay_fund_trans_uni_transfer_response":{'
|
||||
'"code":"10000","msg":"Success","out_biz_no":"RF004",'
|
||||
'"order_id":"ALI004","status":"SUCCESS"},"sign":"signature"}'
|
||||
)
|
||||
|
||||
assert alipay_provider._provider_error_from_exception(RuntimeError(raw)) is None
|
||||
assert alipay_provider._unverified_success_response(RuntimeError(raw)) is True
|
||||
|
||||
|
||||
def test_transfer_keeps_unverified_success_envelope_ambiguous(monkeypatch):
|
||||
class RaisingClient:
|
||||
def execute(self, _request):
|
||||
raise RuntimeError(
|
||||
'[request-id]response sign verify failed. '
|
||||
'{"alipay_fund_trans_uni_transfer_response":{'
|
||||
'"code":"10000","msg":"Success","out_biz_no":"RF005",'
|
||||
'"order_id":"ALI005","status":"SUCCESS"},"sign":"signature"}'
|
||||
)
|
||||
|
||||
monkeypatch.setattr(alipay_provider, "_client", lambda: RaisingClient())
|
||||
monkeypatch.setattr(alipay_provider.settings, "alipay_transfer_scene_name", "佣金报酬")
|
||||
|
||||
with pytest.raises(alipay_provider.AlipayTransferError) as error:
|
||||
alipay_provider.transfer(
|
||||
out_biz_no="RF005", amount_fen=300, account="buyer@example.com",
|
||||
payee_name="测试收款人", order_title="测试返款",
|
||||
)
|
||||
|
||||
assert error.value.code == "ALIPAY_RESPONSE_UNVERIFIED"
|
||||
assert error.value.ambiguous is True
|
||||
assert "勿重复发起" in str(error.value)
|
||||
|
||||
|
||||
def test_certificate_response_verifies_only_inner_response_object(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_verify(_public_key, content, signature):
|
||||
captured["content"] = content
|
||||
captured["signature"] = signature
|
||||
return True
|
||||
|
||||
from alipay.aop.api.util import SignatureUtils
|
||||
monkeypatch.setattr(SignatureUtils, "verify_with_rsa", fake_verify)
|
||||
envelope = json.dumps({
|
||||
"alipay_fund_trans_common_query_response": {
|
||||
"code": "10000", "msg": "Success", "status": "SUCCESS",
|
||||
"pay_fund_order_id": "ALI006",
|
||||
},
|
||||
"alipay_cert_sn": "cert-sn",
|
||||
"sign": "signed-value",
|
||||
}, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
content = alipay_provider._signed_response_content(envelope, "public-key")
|
||||
|
||||
assert json.loads(content)["pay_fund_order_id"] == "ALI006"
|
||||
assert b"alipay_cert_sn" not in captured["content"]
|
||||
assert captured["signature"] == "signed-value"
|
||||
|
||||
|
||||
def test_query_parses_processing_status(monkeypatch):
|
||||
client = CapturingClient({
|
||||
"code": "10000",
|
||||
"msg": "Success",
|
||||
"out_biz_no": "RF003",
|
||||
"order_id": "ALI003",
|
||||
"status": "DEALING",
|
||||
})
|
||||
monkeypatch.setattr(alipay_provider, "_client", lambda: client)
|
||||
result = alipay_provider.query(out_biz_no="RF003")
|
||||
assert result["status"] == "DEALING"
|
||||
assert result["trade_no"] == "ALI003"
|
||||
biz_content = json.loads(client.params["biz_content"])
|
||||
assert biz_content["biz_scene"] == "DIRECT_TRANSFER"
|
||||
assert biz_content["product_code"] == "TRANS_ACCOUNT_NO_PWD"
|
||||
|
||||
|
||||
def test_legacy_transfer_table_is_migrated_without_losing_rows():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("""
|
||||
CREATE TABLE alipay_transfer_attempts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
target_kind VARCHAR(32) NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
amount_fen INTEGER NOT NULL,
|
||||
account VARCHAR(128) NOT NULL,
|
||||
payee_name VARCHAR(64) NOT NULL,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME
|
||||
)
|
||||
"""))
|
||||
connection.execute(text("""
|
||||
INSERT INTO alipay_transfer_attempts
|
||||
(id, target_kind, target_id, amount_fen, account, payee_name, created_by_id)
|
||||
VALUES (1, 'answer_submission', 42, 100, 'buyer@example.com', '测试收款人', 7)
|
||||
"""))
|
||||
|
||||
_migrate_alipay_transfer_attempts(engine)
|
||||
columns = {column["name"] for column in inspect(engine).get_columns("alipay_transfer_attempts")}
|
||||
assert {"order_no", "shop_name", "payer_account", "operator_name", "error_message", "out_biz_no"} <= columns
|
||||
with engine.connect() as connection:
|
||||
row = connection.execute(text("SELECT * FROM alipay_transfer_attempts WHERE id = 1")).mappings().one()
|
||||
assert row["amount_fen"] == 100
|
||||
assert row["out_biz_no"].startswith("LEGACY-1-")
|
||||
assert row["status"] == "failed"
|
||||
assert "旧版本记录" in row["error_message"]
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_certificate_serials_use_issuer_and_decimal_serial_number():
|
||||
class FakeName:
|
||||
def rfc4514_string(self):
|
||||
return "CN=Alipay,O=Example,C=CN"
|
||||
|
||||
class FakeCertificate:
|
||||
issuer = FakeName()
|
||||
serial_number = 0xA12B
|
||||
|
||||
class SignatureAlgorithmOid:
|
||||
dotted_string = "1.2.840.113549.1.1.11"
|
||||
|
||||
signature_algorithm_oid = SignatureAlgorithmOid()
|
||||
|
||||
expected = md5(b"CN=Alipay,O=Example,C=CN41259").hexdigest()
|
||||
assert alipay_provider._certificate_serial(FakeCertificate()) == expected
|
||||
assert alipay_provider._root_certificate_serial([FakeCertificate(), FakeCertificate()]) == f"{expected}_{expected}"
|
||||
|
||||
|
||||
def test_root_certificate_serial_ignores_non_rsa_certificates():
|
||||
class FakeName:
|
||||
def rfc4514_string(self):
|
||||
return "CN=Alipay,O=Example,C=CN"
|
||||
|
||||
class FakeCertificate:
|
||||
issuer = FakeName()
|
||||
serial_number = 0xA12B
|
||||
|
||||
def __init__(self, oid):
|
||||
self.signature_algorithm_oid = type("Oid", (), {"dotted_string": oid})()
|
||||
|
||||
rsa = FakeCertificate("1.2.840.113549.1.1.11")
|
||||
sm2 = FakeCertificate("1.2.156.10197.1.501")
|
||||
ecc = FakeCertificate("1.2.840.10045.4.3.3")
|
||||
expected = md5(b"CN=Alipay,O=Example,C=CN41259").hexdigest()
|
||||
|
||||
assert alipay_provider._root_certificate_serial([sm2, rsa, ecc]) == expected
|
||||
|
||||
|
||||
def test_certificate_public_key_uses_sdk_compatible_subject_public_key_info():
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa as crypto_rsa
|
||||
import rsa as sdk_rsa
|
||||
|
||||
private_key = crypto_rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
class FakeCertificate:
|
||||
def public_key(self):
|
||||
return private_key.public_key()
|
||||
|
||||
public_key = alipay_provider._certificate_public_key(FakeCertificate())
|
||||
|
||||
assert public_key.startswith("-----BEGIN PUBLIC KEY-----")
|
||||
parsed = sdk_rsa.PublicKey.load_pkcs1_openssl_pem(public_key)
|
||||
assert parsed.n == private_key.public_key().public_numbers().n
|
||||
|
||||
|
||||
def test_certificate_bundle_keeps_valid_certificates_when_one_block_is_unsupported(monkeypatch, tmp_path):
|
||||
bundle = tmp_path / "alipayRootCert.crt"
|
||||
bundle.write_bytes(
|
||||
b"-----BEGIN CERTIFICATE-----\nBAD\n-----END CERTIFICATE-----\n"
|
||||
b"-----BEGIN CERTIFICATE-----\nGOOD\n-----END CERTIFICATE-----\n"
|
||||
)
|
||||
valid_certificate = object()
|
||||
|
||||
class FakeX509:
|
||||
@staticmethod
|
||||
def load_pem_x509_certificate(block):
|
||||
if b"BAD" in block:
|
||||
raise ValueError("unsupported SM2 certificate")
|
||||
return valid_certificate
|
||||
|
||||
monkeypatch.setitem(__import__("sys").modules, "cryptography.x509", FakeX509)
|
||||
|
||||
assert alipay_provider._load_certificate_bundle(str(bundle), "支付宝根证书") == [valid_certificate]
|
||||
File diff suppressed because it is too large
Load Diff
76
backend/tests/test_llm_ocr.py
Normal file
76
backend/tests/test_llm_ocr.py
Normal file
@ -0,0 +1,76 @@
|
||||
from collections import Counter
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app import llm
|
||||
|
||||
|
||||
def test_generated_qa_is_normalized_to_conversational_text():
|
||||
content = '{"items":[{"question":" 洗完会不会很干?? ","answers":["回答:我用着还行。”", "感觉不会,挺清爽的!"]}]}'
|
||||
|
||||
result = llm._parse_content(content, expected_answers=2)
|
||||
|
||||
assert result[0]["question"] == "洗完会不会很干"
|
||||
assert result[0]["answers"] == ["我用着还行", "感觉不会,挺清爽的"]
|
||||
|
||||
|
||||
def test_generation_prompts_request_varied_buyer_language_without_question_marks():
|
||||
qa_prompt = llm._prompt(llm.QAGenerateRequest(product_name="洗面奶"))
|
||||
task_prompt = llm._questions_prompt({"product_name": "洗面奶"}, 5)
|
||||
task_prompt_terms = (
|
||||
"像买家随手输入",
|
||||
"末尾不要加问号",
|
||||
"不要每句都用",
|
||||
)
|
||||
|
||||
assert all(term in qa_prompt for term in task_prompt_terms)
|
||||
assert all(term in task_prompt for term in task_prompt_terms)
|
||||
assert "不要写成客服说明" in qa_prompt
|
||||
|
||||
|
||||
def test_multi_image_ocr_retries_per_image_and_deduplicates(monkeypatch):
|
||||
attempts = Counter()
|
||||
|
||||
def recognize(image_data, content_type):
|
||||
attempts[image_data] += 1
|
||||
if image_data == b"first" and attempts[image_data] == 1:
|
||||
raise TimeoutError("temporary timeout")
|
||||
if image_data == b"first":
|
||||
return [
|
||||
{"product_title": "商品A", "question": "共同问题?"},
|
||||
{"product_title": "商品A", "question": "第一张的问题?"},
|
||||
]
|
||||
return [
|
||||
{"product_title": "商品A", "question": "共同问题?"},
|
||||
{"product_title": "商品A", "question": "第二张的问题?"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(llm.settings, "llm_api_key", "test-key")
|
||||
monkeypatch.setattr(llm, "_compress_ocr_image", lambda data, content_type: (data, content_type))
|
||||
monkeypatch.setattr(llm, "_extract_questions_from_image", recognize)
|
||||
monkeypatch.setattr(llm.time, "sleep", lambda seconds: None)
|
||||
|
||||
result = llm.extract_questions_from_images([
|
||||
(b"first", "image/png"),
|
||||
(b"second", "image/png"),
|
||||
])
|
||||
|
||||
assert attempts == Counter({b"first": 2, b"second": 1})
|
||||
assert [item["question"] for item in result] == ["共同问题?", "第一张的问题?", "第二张的问题?"]
|
||||
assert [item["source_index"] for item in result] == [0, 0, 1]
|
||||
|
||||
|
||||
def test_ocr_image_compression_limits_dimensions_and_converts_to_jpeg():
|
||||
image = Image.new("RGBA", (3200, 2600), (20, 100, 220, 180))
|
||||
source = BytesIO()
|
||||
image.save(source, format="PNG")
|
||||
|
||||
compressed, content_type = llm._compress_ocr_image(source.getvalue(), "image/png")
|
||||
|
||||
with Image.open(BytesIO(compressed)) as result:
|
||||
assert max(result.size) <= 2200
|
||||
assert result.mode == "RGB"
|
||||
assert result.format == "JPEG"
|
||||
assert content_type == "image/jpeg"
|
||||
assert len(compressed) < len(source.getvalue())
|
||||
86
backend/tests/test_payment_account_authorization.py
Normal file
86
backend/tests/test_payment_account_authorization.py
Normal file
@ -0,0 +1,86 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import main
|
||||
from app.models import AdminUser, RefundPaymentAccount, RefundRecord
|
||||
from test_refund_amounts import setup
|
||||
|
||||
|
||||
def test_operator_cannot_grant_or_use_another_account(setup):
|
||||
db, client, owner, _, task = setup
|
||||
other = RefundPaymentAccount(name="其他账户", alipay_account="other@example.com", single_limit_fen=50000)
|
||||
db.add(other)
|
||||
db.commit()
|
||||
url = f"/api/admin/users/{owner.id}/payment-account"
|
||||
assert client.get("/api/admin/users").status_code == 403
|
||||
assert client.patch(url, json={"account_id": other.id}).status_code == 403
|
||||
available = client.get("/api/admin/refund-payment-accounts/available").json()["data"]
|
||||
assert [row["id"] for row in available] == [owner.refund_payment_account_id]
|
||||
assert client.post(f"/api/admin/task-board/{task.id}/confirm-refund", data={"payer_account_id": other.id}).status_code == 403
|
||||
assert task.status == "已通过"
|
||||
assert db.scalar(select(RefundRecord.id)) is None
|
||||
|
||||
|
||||
def test_manager_department_scope_and_revocation(setup):
|
||||
db, client, owner, _, _ = setup
|
||||
owner.dingtalk_dept_id = 10
|
||||
manager = AdminUser(username="manager", display_name="主管", password_hash="x", role_code="dept_manager", dingtalk_dept_id=10)
|
||||
child = AdminUser(username="child", display_name="下级部门", password_hash="x", dingtalk_dept_id=11, dingtalk_dept_path=",10,11,")
|
||||
outsider = AdminUser(username="outsider", display_name="其他部门", password_hash="x", dingtalk_dept_id=110, dingtalk_dept_path=",110,")
|
||||
db.add_all([manager, child, outsider])
|
||||
db.commit()
|
||||
account_id = owner.refund_payment_account_id
|
||||
main.app.dependency_overrides[main.current_user] = lambda: manager
|
||||
assert "users" in main.public_user(manager)["permissions"]
|
||||
rows = client.get("/api/admin/users").json()["data"]
|
||||
assert {row["id"] for row in rows} == {owner.id, child.id}
|
||||
assert client.get(f"/api/admin/users/{owner.id}").status_code == 200
|
||||
assert client.get(f"/api/admin/users/{outsider.id}").status_code == 403
|
||||
assert client.patch(f"/api/admin/users/{owner.id}", json={"display_name": "本部门成员"}).status_code == 200
|
||||
assert client.patch(f"/api/admin/users/{owner.id}", json={"role_code": "dept_manager"}).status_code == 403
|
||||
created = client.post("/api/admin/users", json={
|
||||
"username": "manager-created", "password": "password123", "display_name": "主管新增", "role_code": "operator",
|
||||
})
|
||||
assert created.status_code == 201
|
||||
created_id = created.json()["data"]["user"]["id"]
|
||||
created_user = db.get(AdminUser, created_id)
|
||||
assert str(created_user.dingtalk_dept_id) == str(manager.dingtalk_dept_id)
|
||||
assert client.delete(f"/api/admin/users/{created_id}").status_code == 200
|
||||
assert client.delete(f"/api/admin/users/{outsider.id}").status_code == 403
|
||||
for target in (owner, child):
|
||||
assert client.patch(f"/api/admin/users/{target.id}/payment-account", json={"account_id": account_id}).status_code == 200
|
||||
for target in (outsider, manager):
|
||||
assert client.patch(f"/api/admin/users/{target.id}/payment-account", json={"account_id": account_id}).status_code == 403
|
||||
assert client.patch(f"/api/admin/users/{owner.id}", json={"role_code": "super_admin"}).status_code == 403
|
||||
assert client.patch(f"/api/admin/users/{owner.id}/payment-account", json={"account_id": None}).status_code == 200
|
||||
main.app.dependency_overrides[main.current_user] = lambda: owner
|
||||
assert client.get("/api/admin/refund-payment-accounts/available").json()["data"] == []
|
||||
manager.dingtalk_dept_id = None
|
||||
db.commit()
|
||||
main.app.dependency_overrides[main.current_user] = lambda: manager
|
||||
assert client.get("/api/admin/users").json()["data"] == []
|
||||
assert client.patch(f"/api/admin/users/{owner.id}/payment-account", json={"account_id": account_id}).status_code == 403
|
||||
|
||||
|
||||
def test_admin_grant_validation_and_account_deletion(setup):
|
||||
db, client, owner, _, task = setup
|
||||
admin = AdminUser(username="admin-test", display_name="管理员", password_hash="x", role_code="super_admin")
|
||||
disabled = RefundPaymentAccount(name="停用", alipay_account="off@example.com", is_active=False, single_limit_fen=50000)
|
||||
db.add_all([admin, disabled])
|
||||
db.commit()
|
||||
main.app.dependency_overrides[main.current_user] = lambda: admin
|
||||
url = f"/api/admin/users/{owner.id}/payment-account"
|
||||
for account_id in (disabled.id, 99999):
|
||||
assert client.patch(url, json={"account_id": account_id}).status_code == 400
|
||||
for payload in ({}, {"account_id": "1"}, {"account_id": True}, {"account_id": 0}):
|
||||
assert client.patch(url, json=payload).status_code == 422
|
||||
account_id = owner.refund_payment_account_id
|
||||
assert client.patch(url, json={"account_id": None}).status_code == 200
|
||||
main.app.dependency_overrides[main.current_user] = lambda: owner
|
||||
assert client.post(f"/api/admin/task-board/{task.id}/confirm-refund", data={"payer_account_id": account_id}).status_code == 403
|
||||
main.app.dependency_overrides[main.current_user] = lambda: admin
|
||||
assert client.patch(url, json={"account_id": account_id}).status_code == 200
|
||||
rows = client.get("/api/admin/users").json()["data"]
|
||||
assert next(row for row in rows if row["id"] == owner.id)["refund_payment_account_name"] == "公司账户"
|
||||
assert client.delete(f"/api/admin/refund-payment-accounts/{account_id}").status_code == 200
|
||||
db.refresh(owner)
|
||||
assert owner.refund_payment_account_id is None
|
||||
118
backend/tests/test_refund_amounts.py
Normal file
118
backend/tests/test_refund_amounts.py
Normal file
@ -0,0 +1,118 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app import main
|
||||
from app.database import Base, get_db
|
||||
from app.models import (
|
||||
AdminUser, AnswerUser, AnswerSubmission, AnswerRecognizedQuestion,
|
||||
AnswerScreenshot, AskTask, AskQuestion, AskAssignment,
|
||||
AskAssignmentQuestion, AskAssignmentBatch, RefundRecipient, RefundPaymentAccount,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(monkeypatch):
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
owner = AdminUser(username="owner", display_name="运营", password_hash="unused", answer_public_token="owner-link")
|
||||
person = AnswerUser(public_id="buyer", phone="13800000000", taobao_id="buyer", auth_token=main.PUBLIC_USER_SESSION_PREFIX + "buyer-token")
|
||||
db.add_all([owner, person, RefundPaymentAccount(name="公司账户", alipay_account="payer@example.com", balance_fen=10000, single_limit_fen=50000)])
|
||||
db.flush()
|
||||
owner.refund_payment_account_id = db.scalar(select(RefundPaymentAccount.id))
|
||||
record = AnswerSubmission(submission_no="ANS-test", user_id=person.id, owner_id=owner.id, status="已通过")
|
||||
db.add(record)
|
||||
db.flush()
|
||||
for i, selected in enumerate([True, True, False]):
|
||||
db.add(AnswerRecognizedQuestion(submission_id=record.id, sequence_no=i + 1, product_title="商品", question_text=f"问题{i}", selected=selected))
|
||||
db.add(RefundRecipient(target_kind="answer_submission", target_id=record.id, account="buyer@example.com", payee_name="张三"))
|
||||
db.commit()
|
||||
main.app.dependency_overrides[get_db] = lambda: db
|
||||
main.app.dependency_overrides[main.current_user] = lambda: owner
|
||||
# Exercise uploads without touching production files or image conversion.
|
||||
monkeypatch.setattr(main, "screenshot_fields", lambda data, filename, content_type, folder: dict(image_data=data, filename=filename, content_type=content_type))
|
||||
with TestClient(main.app, base_url="https://testserver") as client:
|
||||
yield db, client, owner, person, record
|
||||
main.app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_amount_default_override_reset_and_scope(setup):
|
||||
db, client, owner, _, record = setup
|
||||
data = main._task_board_data(record, include_images=True)
|
||||
assert data["refund_amount_fen"] == 100
|
||||
assert data["alipay_account"] == "buyer@example.com"
|
||||
assert data["payee_name"] == "张三"
|
||||
payload = dict(target_kind="answer_submission", target_id=record.id, amount="2.35")
|
||||
response = client.patch("/api/admin/task-board/refund-amount", json=payload)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["data"]["refund_amount_fen"] == 235
|
||||
db.expire_all()
|
||||
assert main._refund_recipient_data(record, "answer_submission")["refund_amount_fen"] == 235
|
||||
response = client.patch("/api/admin/task-board/refund-amount", json={**payload, "amount": None})
|
||||
assert response.json()["data"]["refund_amount_fen"] == 100
|
||||
stranger = AdminUser(username="stranger", display_name="其他运营", password_hash="unused")
|
||||
db.add(stranger)
|
||||
db.commit()
|
||||
main.app.dependency_overrides[main.current_user] = lambda: stranger
|
||||
assert client.patch("/api/admin/task-board/refund-amount", json=payload).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount", ["0", "-1", "0.001", "NaN", "Infinity", "1000000", "abc"])
|
||||
def test_reject_invalid_amount(setup, amount):
|
||||
_, client, _, _, record = setup
|
||||
response = client.patch("/api/admin/task-board/refund-amount", json=dict(target_kind="answer_submission", target_id=record.id, amount=amount))
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_confirm_requires_account_without_proof_and_saves_final_amount(setup):
|
||||
db, client, _, person, record = setup
|
||||
url = f"/api/admin/task-board/{record.id}/confirm-refund"
|
||||
assert client.post(url, data={"refund_amount": "1.25"}).status_code == 422
|
||||
db.rollback()
|
||||
payer_id = db.scalar(select(RefundPaymentAccount.id))
|
||||
response = client.post(url, data={"refund_amount": "1.25", "payer_account_id": payer_id})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["data"]["refund_amount_fen"] == 125
|
||||
assert record.status == "已完成"
|
||||
assert client.post(url, data={"refund_amount": "2", "payer_account_id": payer_id}).status_code == 409
|
||||
assert client.patch("/api/admin/task-board/refund-amount", json=dict(target_kind="answer_submission", target_id=record.id, amount="2")).status_code == 409
|
||||
|
||||
|
||||
def test_answer_completion_without_collection_qr(setup):
|
||||
db, client, _, person, record = setup
|
||||
record.status = "待用户完成"
|
||||
db.commit()
|
||||
response = client.post(f"/api/answer/me/submissions/{record.id}/completion", data=dict(token=person.auth_token, owner_token="owner-link", alipay_account="new@example.com", payee_name="李四"), files={"files": ("task.png", b"proof", "image/png")})
|
||||
assert response.status_code == 200, response.text
|
||||
db.expire_all()
|
||||
assert record.status == "待审核"
|
||||
assert [image.stage for image in record.screenshots] == ["completion"]
|
||||
assert main._refund_recipient_data(record, "answer_submission")["payee_name"] == "李四"
|
||||
|
||||
|
||||
def test_ask_completion_without_collection_qr_and_batch_amount(setup):
|
||||
db, client, owner, person, _ = setup
|
||||
task = AskTask(task_no="TASK-test", product_link="https://example.com", product_name="商品", owner_id=owner.id, status="已发布")
|
||||
db.add(task)
|
||||
db.flush()
|
||||
assignment = AskAssignment(task_id=task.id, user_id=person.id, status="进行中")
|
||||
db.add(assignment)
|
||||
db.flush()
|
||||
for i in range(2):
|
||||
question = AskQuestion(task_id=task.id, sequence_no=i + 1, question_text=f"问题{i}")
|
||||
db.add(question)
|
||||
db.flush()
|
||||
db.add(AskAssignmentQuestion(assignment_id=assignment.id, question_id=question.id, selected=True))
|
||||
db.commit()
|
||||
response = client.post("/api/ask/tasks/TASK-test/submission", data=dict(token=person.auth_token, alipay_account="ask@example.com", payee_name="王五"), files={"files": ("task.png", b"proof", "image/png")})
|
||||
assert response.status_code == 200, response.text
|
||||
batch = db.scalar(select(AskAssignmentBatch).where(AskAssignmentBatch.assignment_id == assignment.id))
|
||||
assert [image.stage for image in batch.screenshots] == ["question"]
|
||||
data = main._refund_recipient_data(batch, "ask_batch")
|
||||
assert data["refund_amount_fen"] == 100
|
||||
assert data["alipay_account"] == "ask@example.com"
|
||||
assert main._refund_recipient_data(assignment, "ask_assignment")["refund_default_amount_fen"] == 100
|
||||
193
backend/tests/test_refund_payment_accounts.py
Normal file
193
backend/tests/test_refund_payment_accounts.py
Normal file
@ -0,0 +1,193 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app import main
|
||||
from app.database import Base, get_db
|
||||
from app.models import AdminUser, AlipayRefundOrder, RefundPaymentAccount, RefundRecord
|
||||
|
||||
|
||||
ENDPOINT = "/api/admin/refund-payment-accounts"
|
||||
PAYLOAD = dict(name="公司付款账户", alipay_account="payer@example.com", balance="5000.01", single_limit="500.50", is_active=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
admin = AdminUser(username="payer-admin", display_name="管理员", password_hash="unused", role_code="super_admin")
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
main.app.dependency_overrides[get_db] = lambda: db
|
||||
main.app.dependency_overrides[main.current_user] = lambda: admin
|
||||
try:
|
||||
with TestClient(main.app, base_url="https://testserver") as client:
|
||||
yield db, client, admin
|
||||
finally:
|
||||
main.app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_create_edit_and_persist_cents(setup):
|
||||
db, client, _ = setup
|
||||
response = client.post(ENDPOINT, json={**PAYLOAD, "name": " 公司付款账户 "})
|
||||
assert response.status_code == 201, response.text
|
||||
record = response.json()["data"]
|
||||
assert record["name"] == "公司付款账户"
|
||||
assert record["balance_fen"] == 500001
|
||||
assert record["single_limit_fen"] == 50050
|
||||
response = client.patch(f"{ENDPOINT}/{record['id']}", json={**PAYLOAD, "balance": "0.00", "is_active": False})
|
||||
assert response.status_code == 200, response.text
|
||||
db.expire_all()
|
||||
saved = db.get(RefundPaymentAccount, record["id"])
|
||||
assert saved.balance_fen == 500001
|
||||
assert not saved.is_active
|
||||
assert saved.updated_at is not None
|
||||
assert client.get(ENDPOINT).json()["data"]["items"][0]["is_active"] is False
|
||||
|
||||
|
||||
def test_append_and_adjust_funds_with_ledger(setup):
|
||||
db, client, _ = setup
|
||||
account = client.post(ENDPOINT, json=PAYLOAD).json()["data"]
|
||||
response = client.post(f"{ENDPOINT}/{account['id']}/funds", json={
|
||||
"adjustment_type": "追加资金", "amount": "1000.00", "note": "追加预算",
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["data"]["balance_fen"] == 600001
|
||||
response = client.post(f"{ENDPOINT}/{account['id']}/funds", json={
|
||||
"adjustment_type": "资金调整", "amount": "-10.01", "note": "修正录入",
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["data"]["balance_fen"] == 599000
|
||||
db.expire_all()
|
||||
assert db.get(RefundPaymentAccount, account["id"]).balance_fen == 599000
|
||||
labels = {item["type_label"] for item in client.get(f"{ENDPOINT}/{account['id']}/ledger").json()["data"]["items"]}
|
||||
assert {"初始资金", "追加资金", "资金调整"}.issubset(labels)
|
||||
|
||||
|
||||
def test_fund_adjustment_validation_and_committed_floor(setup):
|
||||
db, client, admin = setup
|
||||
account = client.post(ENDPOINT, json=PAYLOAD).json()["data"]
|
||||
assert client.post(f"{ENDPOINT}/{account['id']}/funds", json={
|
||||
"adjustment_type": "追加资金", "amount": "-1.00", "note": "",
|
||||
}).status_code == 400
|
||||
assert client.post(f"{ENDPOINT}/{account['id']}/funds", json={
|
||||
"adjustment_type": "资金调整", "amount": "1.00", "note": "",
|
||||
}).status_code == 400
|
||||
assert client.post(f"{ENDPOINT}/{account['id']}/funds", json={
|
||||
"adjustment_type": "资金调整", "amount": "0.00", "note": "无效调整",
|
||||
}).status_code == 422
|
||||
db.add(RefundRecord(
|
||||
target_kind="answer_submission", target_id=991001, owner_id=admin.id, order_no="ANS-FLOOR-1",
|
||||
payee_name="测试用户", payee_account="floor@example.com", amount_fen=400000,
|
||||
payer_account_id=account["id"], payer_account=account["name"], operator_name=admin.display_name,
|
||||
alipay_trade_no="TRADE-FLOOR-1",
|
||||
))
|
||||
db.commit()
|
||||
response = client.post(f"{ENDPOINT}/{account['id']}/funds", json={
|
||||
"adjustment_type": "资金调整", "amount": "-1000.02", "note": "过度调减",
|
||||
})
|
||||
assert response.status_code == 400
|
||||
assert "不能低于" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_account_usage_summary_and_ledger(setup):
|
||||
db, client, admin = setup
|
||||
account = client.post(ENDPOINT, json=PAYLOAD).json()["data"]
|
||||
db.add(RefundRecord(
|
||||
target_kind="answer_submission", target_id=900001, owner_id=admin.id,
|
||||
shop_name="测试店铺", order_no="ANS-USAGE-1", payee_name="测试用户",
|
||||
payee_account="user@example.com", amount_fen=12345,
|
||||
payer_account_id=account["id"], payer_account=account["name"],
|
||||
operator_name=admin.display_name, alipay_trade_no="TRADE-USAGE-1",
|
||||
))
|
||||
db.add(AlipayRefundOrder(
|
||||
target_kind="answer_submission", target_id=900002, out_biz_no="RF-USAGE-PENDING",
|
||||
amount_fen=5000, order_no="ANS-USAGE-2", account="user2@example.com",
|
||||
payee_name="测试用户2", payer_account_id=account["id"], payer_account=account["name"],
|
||||
created_by_id=admin.id, operator_name=admin.display_name, status="pending",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
item = client.get(ENDPOINT).json()["data"]["items"][0]
|
||||
assert item["used_fen"] == 12345
|
||||
assert item["processing_fen"] == 5000
|
||||
assert item["available_fen"] == 482656
|
||||
ledger = client.get(f"{ENDPOINT}/{account['id']}/ledger")
|
||||
assert ledger.status_code == 200
|
||||
ledger_data = ledger.json()["data"]
|
||||
assert ledger_data["total"] == 3
|
||||
assert ledger_data["page"] == 1
|
||||
assert ledger_data["page_size"] == 10
|
||||
types = {row["type"] for row in ledger_data["items"]}
|
||||
assert {"adjustment", "refund", "processing"}.issubset(types)
|
||||
first_page = client.get(f"{ENDPOINT}/{account['id']}/ledger", params={"page": 1, "page_size": 2}).json()["data"]
|
||||
second_page = client.get(f"{ENDPOINT}/{account['id']}/ledger", params={"page": 2, "page_size": 2}).json()["data"]
|
||||
assert first_page["total_pages"] == 2
|
||||
assert len(first_page["items"]) == 2
|
||||
assert len(second_page["items"]) == 1
|
||||
|
||||
|
||||
def test_duplicate_and_failed_edit_preserve_accounts(setup):
|
||||
db, client, _ = setup
|
||||
first = client.post(ENDPOINT, json=PAYLOAD).json()["data"]
|
||||
second = client.post(ENDPOINT, json={**PAYLOAD, "alipay_account": "second@example.com"}).json()["data"]
|
||||
assert client.post(ENDPOINT, json=PAYLOAD).status_code == 409
|
||||
assert client.patch(f"{ENDPOINT}/{second['id']}", json=PAYLOAD).status_code == 409
|
||||
db.expire_all()
|
||||
assert len(db.scalars(select(RefundPaymentAccount)).all()) == 2
|
||||
assert db.get(RefundPaymentAccount, second["id"]).alipay_account == "second@example.com"
|
||||
assert db.get(RefundPaymentAccount, first["id"]).balance_fen == 500001
|
||||
|
||||
|
||||
def test_search_pagination_and_literal_wildcard(setup):
|
||||
_, client, _ = setup
|
||||
for index in range(3):
|
||||
assert client.post(ENDPOINT, json={**PAYLOAD, "name": f"付款{index}", "alipay_account": f"payer{index}@example.com"}).status_code == 201
|
||||
data = client.get(ENDPOINT, params={"page": 2, "page_size": 2}).json()["data"]
|
||||
assert data["total"] == 3
|
||||
assert len(data["items"]) == 1
|
||||
data = client.get(ENDPOINT, params={"q": "payer1"}).json()["data"]
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["name"] == "付款1"
|
||||
assert client.get(ENDPOINT, params={"q": "%"}).json()["data"]["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"name": " "}, {"alipay_account": " "}, {"balance": "-1"},
|
||||
{"balance": "1.001"}, {"single_limit": "0"}, {"single_limit": "10000000"},
|
||||
{"balance": "NaN"}, {"single_limit": "Infinity"},
|
||||
])
|
||||
def test_reject_invalid_account(setup, changes):
|
||||
_, client, _ = setup
|
||||
response = client.post(ENDPOINT, json={**PAYLOAD, **changes})
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
def test_permissions_and_missing_account(setup):
|
||||
db, client, admin = setup
|
||||
assert client.patch(f"{ENDPOINT}/999", json=PAYLOAD).status_code == 404
|
||||
assert client.post(f"{ENDPOINT}/999/funds", json={"adjustment_type": "追加资金", "amount": "1.00", "note": ""}).status_code == 404
|
||||
admin.role_code = "operator"
|
||||
db.commit()
|
||||
assert client.get(ENDPOINT).status_code == 403
|
||||
assert client.post(ENDPOINT, json=PAYLOAD).status_code == 403
|
||||
assert client.patch(f"{ENDPOINT}/999", json=PAYLOAD).status_code == 403
|
||||
assert client.post(f"{ENDPOINT}/999/funds", json={"adjustment_type": "追加资金", "amount": "1.00", "note": ""}).status_code == 403
|
||||
assert client.delete(f"{ENDPOINT}/999").status_code == 403
|
||||
|
||||
|
||||
def test_delete_account_and_preserve_other_accounts(setup):
|
||||
db, client, _ = setup
|
||||
first = client.post(ENDPOINT, json=PAYLOAD).json()["data"]
|
||||
other = client.post(ENDPOINT, json={**PAYLOAD, "alipay_account": "other@example.com"}).json()["data"]
|
||||
response = client.delete(f"{ENDPOINT}/{first['id']}")
|
||||
assert response.status_code == 200, response.text
|
||||
db.expire_all()
|
||||
assert db.get(RefundPaymentAccount, first["id"]) is None
|
||||
assert db.get(RefundPaymentAccount, other["id"]) is not None
|
||||
assert client.get(ENDPOINT).json()["data"]["total"] == 1
|
||||
assert client.delete(f"{ENDPOINT}/{first['id']}").status_code == 404
|
||||
145
backend/tests/test_refund_records.py
Normal file
145
backend/tests/test_refund_records.py
Normal file
@ -0,0 +1,145 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from test_refund_amounts import setup
|
||||
from app import main
|
||||
from app.models import (AdminUser, AnswerScreenshot, RefundRecord, RefundRecipient, RefundPaymentAccount,
|
||||
AskTask, AskQuestion, AskAssignment, AskAssignmentQuestion,
|
||||
AskAssignmentBatch, AskAssignmentScreenshot, AskAssignmentBatchScreenshot)
|
||||
|
||||
|
||||
def test_answer_confirmation_snapshot_survives_changes_and_deletion(setup):
|
||||
db, client, owner, _, task = setup
|
||||
task.shop_name = "测试店铺"
|
||||
db.commit()
|
||||
url = f"/api/admin/task-board/{task.id}/confirm-refund"
|
||||
payer_id = db.scalar(select(RefundPaymentAccount.id))
|
||||
response = client.post(url, data={"refund_amount": "2.35", "payer_account_id": payer_id, "payer_account": "伪造账户", "alipay_trade_no": "ALI123"})
|
||||
assert response.status_code == 200, response.text
|
||||
assert client.post(url, data={"payer_account_id": payer_id}).status_code == 409
|
||||
rows = client.get("/api/admin/refund-records").json()["data"]
|
||||
assert rows["total"] == 1 and rows["total_amount_fen"] == 235
|
||||
row = rows["items"][0]
|
||||
assert (row["payee_name"], row["payee_account"], row["operator_name"], row["payer_account"], row["alipay_trade_no"]) == ("张三", "buyer@example.com", owner.display_name, "公司账户", "")
|
||||
recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == "answer_submission"))
|
||||
recipient.payee_name = "修改后姓名"
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
assert client.get("/api/admin/refund-records").json()["data"]["items"][0] == row
|
||||
|
||||
|
||||
def test_refund_snapshot_is_idempotent_when_callback_wins_race(setup):
|
||||
db, _, owner, _, task = setup
|
||||
payer = db.scalar(select(RefundPaymentAccount))
|
||||
|
||||
first = main._snapshot_refund(
|
||||
db, task, "answer_submission", owner, payer.name, "ALI-RACE-001"
|
||||
)
|
||||
db.commit()
|
||||
second = main._snapshot_refund(
|
||||
db, task, "answer_submission", owner, payer.name, "ALI-RACE-001"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert second.id == first.id
|
||||
assert db.scalars(select(RefundRecord)).all() == [first]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batched", [True, False])
|
||||
def test_ask_confirmation_creates_record(setup, batched):
|
||||
db, client, owner, person, _ = setup
|
||||
task = AskTask(
|
||||
task_no="TASK-refund", product_link="https://example.com", product_name="商品",
|
||||
shop_name="提问店铺", ecommerce_platform="京东", product_id="687154817423861300",
|
||||
owner_id=owner.id, status="已发布",
|
||||
)
|
||||
db.add(task)
|
||||
db.flush()
|
||||
assignment = AskAssignment(task_id=task.id, user_id=person.id, status="已通过")
|
||||
question = AskQuestion(task_id=task.id, sequence_no=1, question_text="问题")
|
||||
db.add_all([assignment, question])
|
||||
db.flush()
|
||||
db.add(AskAssignmentQuestion(assignment_id=assignment.id, question_id=question.id, selected=True))
|
||||
if batched:
|
||||
record = AskAssignmentBatch(assignment_id=assignment.id, status="approved", selected_question_ids=str(question.id))
|
||||
db.add(record)
|
||||
db.flush()
|
||||
kind, route = "ask_batch", "ask-batches"
|
||||
else:
|
||||
record = assignment
|
||||
kind, route = "ask_assignment", "ask-assignments"
|
||||
db.add(RefundRecipient(target_kind=kind, target_id=record.id, account="ask@example.com", payee_name="提问人"))
|
||||
db.commit()
|
||||
detail = client.get(f"/api/admin/task-board/{route}/{record.id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["data"]["ecommerce_platform"] == "京东"
|
||||
assert detail.json()["data"]["product_id"] == "687154817423861300"
|
||||
url = f"/api/admin/task-board/{route}/{record.id}/confirm-refund"
|
||||
payer_id = db.scalar(select(RefundPaymentAccount.id))
|
||||
owner = db.get(AdminUser, task.owner_id)
|
||||
owner.refund_payment_account_id = None
|
||||
db.commit()
|
||||
assert client.post(url, data={"payer_account_id": payer_id}).status_code == 403
|
||||
owner.refund_payment_account_id = payer_id
|
||||
db.commit()
|
||||
response = client.post(url, data={"payer_account_id": payer_id})
|
||||
assert response.status_code == 200, response.text
|
||||
assert client.post(url, data={"payer_account_id": payer_id}).status_code == 409
|
||||
row = client.get("/api/admin/refund-records").json()["data"]["items"][0]
|
||||
assert row["amount_fen"] == 50
|
||||
assert row["payee_name"] == "提问人" and row["order_no"] == "TASK-refund"
|
||||
assert row["ecommerce_platform"] == "京东"
|
||||
assert row["product_id"] == "687154817423861300"
|
||||
|
||||
|
||||
def test_available_accounts_and_disabled_account_rejection(setup):
|
||||
db, client, _, _, task = setup
|
||||
disabled = RefundPaymentAccount(name="停用账户", alipay_account="disabled@example.com", single_limit_fen=50000, is_active=False)
|
||||
db.add(disabled)
|
||||
db.commit()
|
||||
accounts = client.get("/api/admin/refund-payment-accounts/available")
|
||||
assert accounts.status_code == 200
|
||||
assert [item["name"] for item in accounts.json()["data"]] == ["公司账户"]
|
||||
assert client.get("/api/admin/refund-payment-accounts").status_code == 403
|
||||
url = f"/api/admin/task-board/{task.id}/confirm-refund"
|
||||
for account_id in (disabled.id, 99999):
|
||||
response = client.post(url, data={"payer_account_id": account_id})
|
||||
assert response.status_code == 400, response.text
|
||||
assert task.status == "已通过"
|
||||
assert db.scalar(select(RefundRecord.id)) is None
|
||||
|
||||
|
||||
def test_scope_date_range_pagination_and_export(setup):
|
||||
db, client, owner, _, _ = setup
|
||||
owner.dingtalk_dept_id = 10
|
||||
colleague = AdminUser(username="colleague", display_name="同事", password_hash="x", dingtalk_dept_id=11, dingtalk_dept_path=",10,11,")
|
||||
stranger = AdminUser(username="stranger", display_name="外部门", password_hash="x", dingtalk_dept_id=20)
|
||||
db.add_all([colleague, stranger])
|
||||
db.flush()
|
||||
for i in range(14):
|
||||
db.add(RefundRecord(target_kind="answer_submission", target_id=100 + i, owner_id=owner.id if i < 12 else colleague.id if i == 12 else stranger.id,
|
||||
order_no=f"TASK-{i}", amount_fen=50, operator_name="运营", shop_name="=unsafe",
|
||||
transferred_at=datetime(2026, 10, 21, 16, 0) if i < 13 else datetime(2026, 10, 22, 16, 0)))
|
||||
db.commit()
|
||||
query = "start_date=2026-10-22&end_date=2026-10-22"
|
||||
page = client.get(f"/api/admin/refund-records?{query}&page=2").json()["data"]
|
||||
assert len(page["items"]) == 2 and page["total"] == 12 and page["total_amount_fen"] == 600
|
||||
exported = client.get(f"/api/admin/refund-records/export?{query}")
|
||||
rows = list(csv.reader(io.StringIO(exported.content.decode("utf-8-sig"))))
|
||||
assert len(rows) == 14 and rows[-1][7] == "6.00"
|
||||
assert rows[1][0] == "'=unsafe" and rows[1][3] == "2026-10-22 00:00:00"
|
||||
owner.role_code = "dept_manager"
|
||||
db.commit()
|
||||
assert client.get(f"/api/admin/refund-records?{query}").json()["data"]["total"] == 13
|
||||
owner.role_code = "super_admin"
|
||||
db.commit()
|
||||
assert client.get("/api/admin/refund-records").json()["data"]["total"] == 14
|
||||
assert client.get(f"/api/admin/refund-records?{query}&q=TASK-13").json()["data"]["total"] == 0
|
||||
assert client.get("/api/admin/refund-records?q=TASK-13").json()["data"]["total"] == 1
|
||||
for path in ["/api/admin/refund-records", "/api/admin/refund-records/export"]:
|
||||
assert client.get(path + "?start_date=wrong").status_code == 400
|
||||
assert client.get(path + "?start_date=2026-10-23&end_date=2026-10-22").status_code == 400
|
||||
44
backend/tests/test_screenshot_storage.py
Normal file
44
backend/tests/test_screenshot_storage.py
Normal file
@ -0,0 +1,44 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app import screenshot_storage
|
||||
|
||||
|
||||
def test_new_screenshot_is_stored_only_as_server_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(screenshot_storage.settings, "screenshot_storage_dir", tmp_path)
|
||||
|
||||
fields = screenshot_storage.screenshot_fields(
|
||||
b"image-content", "proof.png", "image/png", "answers"
|
||||
)
|
||||
|
||||
assert fields["image_data"] is None
|
||||
assert (tmp_path / fields["file_path"]).read_bytes() == b"image-content"
|
||||
|
||||
|
||||
def test_missing_file_falls_back_to_database_copy(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(screenshot_storage.settings, "screenshot_storage_dir", tmp_path)
|
||||
image = SimpleNamespace(
|
||||
file_path="answers/missing.png",
|
||||
image_data=b"database-backup",
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
assert screenshot_storage.screenshot_bytes(image) == b"database-backup"
|
||||
|
||||
|
||||
def test_file_write_failure_returns_clear_service_error(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(screenshot_storage.settings, "screenshot_storage_dir", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
screenshot_storage,
|
||||
"store_screenshot",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("read-only")),
|
||||
)
|
||||
|
||||
try:
|
||||
screenshot_storage.screenshot_fields(
|
||||
b"image-content", "proof.png", "image/png", "answers"
|
||||
)
|
||||
except screenshot_storage.HTTPException as exc:
|
||||
assert exc.status_code == 503
|
||||
assert "图片存储目录不可用" in exc.detail
|
||||
else:
|
||||
raise AssertionError("expected screenshot storage error")
|
||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
Binary file not shown.
@ -1 +1,13 @@
|
||||
<div id="root"></div><script type="module" src="/src/main.jsx"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>问大家</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script data-dingtalk-sdk="true" src="https://g.alicdn.com/dingding/dingtalk-jsapi/2.10.3/dingtalk.open.js"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@ -9,13 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-react": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.146.0",
|
||||
@ -679,6 +679,12 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
|
||||
@ -3,7 +3,18 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {"dev": "vite", "build": "vite build", "preview": "vite preview"},
|
||||
"dependencies": {"@vitejs/plugin-react": "latest", "vite": "latest", "typescript": "latest", "react": "latest", "react-dom": "latest", "lucide-react": "latest"},
|
||||
"devDependencies": {}
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-react": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,56 +1,62 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
import { Check, ClipboardCheck, Clock3, Copy, Eye, FileCheck2, History, ImagePlus, LogOut, MessageCircle, ReceiptText, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from './taobaoId.js'
|
||||
import { formatDateTime } from './time.js'
|
||||
|
||||
const SESSION_KEY = 'answer_user_session'
|
||||
const STATUS_TEXT = { '待筛选': '截图审核中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
||||
const maskPhone = (phone = '') => phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||||
const SESSION_KEY = 'answer_user_taobao_session_v2'
|
||||
const LEGACY_SESSION_KEYS = ['answer_user_taobao_session', 'answer_user_session']
|
||||
const STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
||||
const accountName = session => session?.taobao_id || session?.account || session?.user_id || ''
|
||||
const formatTime = formatDateTime
|
||||
const fileKey = file => `${file.name}-${file.size}-${file.lastModified}`
|
||||
|
||||
function Top({ session, latest, logout }) {
|
||||
return <>
|
||||
<header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div><span>ⓘ 规则说明</span></header>
|
||||
<section className="answer-v2-user"><div><b>淘宝问答承接</b><span>● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}</span></div><p>用户ID:{session.user_id} · {maskPhone(session.phone)}{latest?.status === '待用户完成' && <> 派发任务编号:<strong>{latest.submission_no}</strong></>}</p><button title="退出当前用户" onClick={logout}><LogOut size={15}/></button></section>
|
||||
<section className="answer-v2-user"><div><b>淘宝问答承接</b><span>● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}</span></div><p>淘宝ID:{accountName(session)}{latest?.status === '待用户完成' && <> 派发任务编号:<strong>{latest.submission_no}</strong></>}</p><button title="退出当前用户" onClick={logout}><LogOut size={15}/></button></section>
|
||||
</>
|
||||
}
|
||||
|
||||
function Progress({ status }) {
|
||||
const active = !status ? 1 : ['待筛选', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4
|
||||
const active = !status ? 1 : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4
|
||||
const labels = ['被邀截图', '任务派发', '完成任务', '终审确认']
|
||||
return <div className="answer-v2-progress">{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return <div key={label} className={step === active ? 'active' : done ? 'done' : ''}><b>{done ? <Check size={15}/> : step}</b><span>{label}</span></div> })}</div>
|
||||
}
|
||||
|
||||
function Login({ api, onLogin }) {
|
||||
const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(''); const [devCode, setDevCode] = useState('')
|
||||
useEffect(() => { if (!cooldown) return undefined; const timer = window.setInterval(() => setCooldown(value => Math.max(0, value - 1)), 1000); return () => window.clearInterval(timer) }, [cooldown])
|
||||
async function sendCode() {
|
||||
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
|
||||
setSending(true); setError('')
|
||||
try { const result = await api('/api/answer/auth/send-code', { method: 'POST', body: JSON.stringify({ phone }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') }
|
||||
catch (err) { setError(err.message) } finally { setSending(false) }
|
||||
}
|
||||
function Login({ api, onLogin, linkToken }) {
|
||||
const [taobaoId, setTaobaoId] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState('')
|
||||
async function submit(event) {
|
||||
event.preventDefault(); setError('')
|
||||
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
|
||||
if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码')
|
||||
const account = normalizeTaobaoId(taobaoId)
|
||||
if (!isValidTaobaoId(account)) return setError(TAOBAO_ID_HINT)
|
||||
setBusy(true)
|
||||
try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
|
||||
try { const result = await api('/api/answer/auth/taobao-login', { method: 'POST', body: JSON.stringify({ taobao_id: account, owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
|
||||
catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>手机号登录</h1><p>使用短信验证码安全登录</p><label>手机号码<input value={phone} onChange={event => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} inputMode="numeric" placeholder="请输入 11 位手机号码" autoFocus/></label><label>短信验证码<div className="answer-v2-code-row"><input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? '发送中…' : '获取验证码'}</button></div></label>{devCode && <small className="answer-v2-dev-code">开发环境验证码:{devCode}</small>}{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
|
||||
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>淘宝账号登录</h1><p>输入淘宝ID即可进入任务中心</p><label>淘宝ID<input value={taobaoId} onChange={event => setTaobaoId(event.target.value.slice(0, 64))} maxLength={64} inputMode="text" lang="zh-CN" autoCapitalize="none" autoCorrect="off" placeholder="例如:tb1008611 或 淘宝用户" autoFocus/><small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID;支持中文、字母和数字</small></label>{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
|
||||
}
|
||||
|
||||
function UploadStage({ api, session, onUploaded, setError }) {
|
||||
function UploadStage({ api, session, onUploaded, setError, linkToken }) {
|
||||
const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const inputRef = useRef(null)
|
||||
const previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files])
|
||||
useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews])
|
||||
function addFiles(list) { const images = Array.from(list).filter(file => /^image\/(jpeg|png|webp)$/.test(file.type)); setFiles(current => [...current, ...images].filter((file, index, all) => all.findIndex(row => fileKey(row) === fileKey(file)) === index).slice(0, 10)) }
|
||||
function addFiles(list) {
|
||||
const images = Array.from(list).filter(file => /^image\/(jpeg|png|webp)$/.test(file.type))
|
||||
if (images.some(file => file.size > 10 * 1024 * 1024)) setError('单张图片不能超过10MB')
|
||||
const valid = images.filter(file => file.size <= 10 * 1024 * 1024)
|
||||
setFiles(current => {
|
||||
const next = [...current, ...valid].filter((file, index, all) => all.findIndex(row => fileKey(row) === fileKey(file)) === index)
|
||||
if (next.length > 2) setError('一次最多上传2张截图')
|
||||
return next.slice(0, 2)
|
||||
})
|
||||
}
|
||||
async function submit() {
|
||||
if (!files.length) return setError('请至少上传一张被邀请回答截图')
|
||||
setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file))
|
||||
if (linkToken) body.append('owner_token', linkToken)
|
||||
try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
return <><section className="answer-v2-hero purple"><small>✨ 任务认领与核验</small><h1>上传被邀请回答截图</h1><p>提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。</p></section><section className="answer-v2-card upload-card"><header><div><h2>上传被邀请回答截图 <em>截图 · 必传</em></h2><p>用于确认您的账号可回答指定问题(支持上传多张)。</p></div><b>{files.length}/10</b></header><div className="answer-v2-upload-grid">{previews.map(({ file, url }) => <figure key={fileKey(file)}><img src={url} alt={file.name}/><figcaption>{file.name}</figcaption><button onClick={() => setFiles(current => current.filter(row => fileKey(row) !== fileKey(file)))}>删除</button></figure>)}{files.length < 10 && <button type="button" className="answer-v2-add" onClick={() => inputRef.current?.click()}><ImagePlus size={28}/><strong>+ 上传被邀截图</strong><small>支持 JPG、PNG、WEBP</small></button>}</div><input ref={inputRef} hidden type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => addFiles(event.target.files)}/><p className="answer-v2-photo-note">▧ 请确保凭证清晰、信息完整、无遮挡</p></section><button className="answer-v2-primary wide" disabled={busy || !files.length} onClick={submit}>{busy ? '正在提交…' : '提交截图并进入审核'}</button></>
|
||||
return <><section className="answer-v2-hero purple"><small>✨ 任务认领与核验</small><h1>上传被邀请回答截图</h1><p>提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。</p></section><section className="answer-v2-card upload-card"><header><div><h2>上传被邀请回答截图 <em>截图 · 必传</em></h2><p>一次最多上传2张。为了提高问题识别的准确率,请每张图片少于十个问题。</p></div><b>{files.length}/2</b></header><div className="answer-v2-upload-grid">{previews.map(({ file, url }) => <figure key={fileKey(file)}><img src={url} alt={file.name}/><figcaption>{file.name}</figcaption><button onClick={() => setFiles(current => current.filter(row => fileKey(row) !== fileKey(file)))}>删除</button></figure>)}{files.length < 2 && <button type="button" className="answer-v2-add" onClick={() => inputRef.current?.click()}><ImagePlus size={28}/><strong>+ 上传被邀截图</strong><small>支持 JPG、PNG、WEBP</small></button>}</div><input ref={inputRef} hidden type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => addFiles(event.target.files)}/><p className="answer-v2-photo-note">▧ 请确保凭证清晰、信息完整、无遮挡;单张不超过10MB</p></section><button className="answer-v2-primary wide" disabled={busy || !files.length} onClick={submit}>{busy ? '正在提交…' : '提交截图并进入审核'}</button></>
|
||||
}
|
||||
|
||||
function MaterialCard({ latest, completion = false }) {
|
||||
@ -62,10 +68,11 @@ function ReviewStage({ latest, reload, busy, startNew }) {
|
||||
return <><section className="answer-v2-card status-card"><div className="status-title"><span><Clock3 size={24}/></span><div><h1>已进入任务派发审核 <em>截图审核中</em></h1><p>截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。</p></div></div><div className="status-meta"><span>提交时间<b>{formatTime(latest.created_at)}</b></span><span>当前状态<b>等待运营审核</b></span></div><footer><small>页面每 10 秒自动检查最新审核结果</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer></section><MaterialCard latest={latest}/><section className="answer-v2-wait"><ShieldCheck size={17}/><div><strong>等待运营确认并派发问题</strong><p>截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。</p></div></section><section className="answer-v2-new-task answer-v2-new-task-light"><strong>还可以继续提交新的任务</strong><p>当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。</p><button onClick={startNew}>+ 开始新的任务</button></section></>
|
||||
}
|
||||
|
||||
function AssignmentStage({ api, session, latest, reload, setError }) {
|
||||
function AssignmentStage({ api, session, latest, reload, setError, linkToken }) {
|
||||
const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}`
|
||||
const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } })
|
||||
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false)
|
||||
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false)
|
||||
const [alipayAccount, setAlipayAccount] = useState(''); const [payeeName, setPayeeName] = useState('')
|
||||
const completed = selected.filter(question => done.includes(question.id)).length
|
||||
useEffect(() => {
|
||||
if (latest.status !== '未通过' || !latest.latest_review) return
|
||||
@ -76,14 +83,17 @@ function AssignmentStage({ api, session, latest, reload, setError }) {
|
||||
function mark(id) { saveDone(done.includes(id) ? done.filter(value => value !== id) : [...done, id]) }
|
||||
async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } }
|
||||
async function complete(event) {
|
||||
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交')
|
||||
const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true)
|
||||
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图')
|
||||
if (!alipayAccount.trim() || !payeeName.trim()) return setError('请填写支付宝账号和收款人姓名')
|
||||
const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); setBusy(true)
|
||||
body.append('alipay_account', alipayAccount.trim()); body.append('payee_name', payeeName.trim())
|
||||
try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传(截图与收款码)</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small><div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><p>请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
|
||||
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传任务截图并填写收款信息。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small>{question.operator_images?.length > 0 && <div className="answer-question-operator-image"><span>运营指定图片(请在回答本题时添加)</span><div>{question.operator_images.map((image, imageIndex) => <img key={image.id || image.data_url || image} src={image.data_url || image.url || image} alt={`运营指定回答图片${imageIndex + 1}`}/>)}</div></div>}<div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><label>支付宝账号(必填)<input required maxLength={128} value={alipayAccount} onChange={event => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" /></label><label>收款人姓名(必填)<input required maxLength={64} value={payeeName} onChange={event => setPayeeName(event.target.value)} placeholder="请输入支付宝实名姓名" /></label><p>请上传已发布回答的截图,并填写准确的支付宝账号及收款人姓名。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
|
||||
}
|
||||
|
||||
function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
||||
const paid = latest.status === '已完成'
|
||||
const passed = ['已通过', '已完成'].includes(latest.status)
|
||||
const rejected = latest.status === '未通过'
|
||||
const cancelled = latest.status === '已撤销'
|
||||
@ -107,8 +117,8 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
||||
</>
|
||||
return <>
|
||||
<section className={`answer-v2-card status-card ${passed ? 'passed' : cancelled ? 'rejected' : ''}`}>
|
||||
<div className="status-title"><span>{passed ? <Check size={25}/> : cancelled ? '!' : <Clock3 size={24}/>}</span><div><h1>{passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} <em>{STATUS_TEXT[latest.status]}</em></h1><p>{passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}</p></div></div>
|
||||
{passed && <div className="answer-v2-payment-wait"><strong>审核已完成,等待打款</strong><span>款项将按运营安排发放,请留意收款账户。</span></div>}
|
||||
<div className="status-title"><span>{passed ? <Check size={25}/> : cancelled ? '!' : <Clock3 size={24}/>}</span><div><h1>{paid ? '已返款' : passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} <em>{STATUS_TEXT[latest.status]}</em></h1><p>{paid ? '运营已完成本次任务返款,请留意您的收款账户。' : passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}</p></div></div>
|
||||
{passed && <div className={`answer-v2-payment-wait ${paid ? 'paid' : ''}`}><strong>{paid ? '返款已完成' : '审核已完成,等待打款'}</strong><span>{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}</span></div>}
|
||||
<footer><small>提交编号:{latest.submission_no}</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer>
|
||||
</section>
|
||||
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>}
|
||||
@ -116,28 +126,43 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
||||
</>
|
||||
}
|
||||
|
||||
function HistoryPage({ records, close, onContinue }) {
|
||||
const canContinue = status => ['待筛选', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
|
||||
return <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src}/> })}</div>{canContinue(record.status) && <button className="answer-v2-history-continue" onClick={() => onContinue(record)}>↗ 继续此任务</button>}</article>)}</section>
|
||||
function HistoryPage({ records, close, onOpen, openingId }) {
|
||||
const canContinue = status => ['待筛选', '识别失败', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
|
||||
const actionText = record => canContinue(record.status) ? '继续此任务' : '查看任务详情'
|
||||
return <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src} alt="被邀请回答截图"/> })}</div><button className="answer-v2-history-continue" onClick={() => onOpen(record)} disabled={openingId === record.id}>{record.status === '已完成' ? <ReceiptText size={14}/> : <Eye size={14}/>} {openingId === record.id ? '正在读取…' : actionText(record)}</button></article>)}</section>
|
||||
}
|
||||
|
||||
export default function AnswerUserPortalV2({ api }) {
|
||||
const [session, setSession] = useState(() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') } catch { return null } })
|
||||
const [records, setRecords] = useState([]); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [retrying, setRetrying] = useState(false); const [newTaskMode, setNewTaskMode] = useState(false); const [activeRecordId, setActiveRecordId] = useState(null); const [optimisticStatuses, setOptimisticStatuses] = useState({})
|
||||
const imageLoadIds = useRef(new Set())
|
||||
export default function AnswerUserPortalV2({ api, linkToken = '' }) {
|
||||
const [session, setSession] = useState(() => { try { LEGACY_SESSION_KEYS.forEach(key => localStorage.removeItem(key)); return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') } catch { return null } })
|
||||
const [records, setRecords] = useState([]); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [retrying, setRetrying] = useState(false); const [newTaskMode, setNewTaskMode] = useState(false); const [activeRecordId, setActiveRecordId] = useState(null); const [optimisticStatuses, setOptimisticStatuses] = useState({}); const [openingId, setOpeningId] = useState(null)
|
||||
const imageLoadPromises = useRef(new Map())
|
||||
const loadVersion = useRef(0)
|
||||
async function load(silent = false) {
|
||||
if (!session) return
|
||||
const version = ++loadVersion.current
|
||||
if (!silent) setBusy(true)
|
||||
try {
|
||||
const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false`)
|
||||
const ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : ''
|
||||
const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false${ownerQuery}`)
|
||||
if (version !== loadVersion.current) return
|
||||
setRecords(current => result.data.map(record => {
|
||||
const previous = current.find(item => item.id === record.id)
|
||||
return previous ? {
|
||||
...previous,
|
||||
...record,
|
||||
// The polling endpoint is intentionally lightweight and returns
|
||||
// null image fields. Preserve images already loaded from the detail
|
||||
// endpoint so polling/clicking never makes task instructions vanish.
|
||||
questions: record.questions.map(question => {
|
||||
const oldQuestion = previous.questions?.find(item => item.id === question.id)
|
||||
return {
|
||||
...oldQuestion,
|
||||
...question,
|
||||
product_image: question.product_image || oldQuestion?.product_image || null,
|
||||
source_image: question.source_image || oldQuestion?.source_image || null,
|
||||
operator_images: question.operator_images?.length ? question.operator_images : (oldQuestion?.operator_images || []),
|
||||
}
|
||||
}),
|
||||
invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots,
|
||||
completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_screenshots,
|
||||
payment_screenshots: record.payment_screenshots?.length ? record.payment_screenshots : previous.payment_screenshots,
|
||||
@ -152,6 +177,7 @@ export default function AnswerUserPortalV2({ api }) {
|
||||
})
|
||||
setError('')
|
||||
} catch (err) {
|
||||
if (err.message.includes('登录已失效')) { logout(); return }
|
||||
if (version === loadVersion.current) setError(err.message)
|
||||
} finally {
|
||||
if (version === loadVersion.current) { setLoaded(true); if (!silent) setBusy(false) }
|
||||
@ -160,16 +186,40 @@ export default function AnswerUserPortalV2({ api }) {
|
||||
useEffect(() => { if (!session) return undefined; load(); const timer = window.setInterval(() => load(true), 10000); return () => window.clearInterval(timer) }, [session?.token])
|
||||
useEffect(() => { if (historyOpen) setError('') }, [historyOpen])
|
||||
async function loadSubmissionImages(record) {
|
||||
if (!record?.id || imageLoadIds.current.has(record.id)) return
|
||||
imageLoadIds.current.add(record.id)
|
||||
if (!record?.id) return
|
||||
const loadKey = `${record.id}:${record.status}`
|
||||
if (record._imagesLoadedFor === record.status) return record
|
||||
const pending = imageLoadPromises.current.get(loadKey)
|
||||
if (pending) return pending
|
||||
const request = (async () => {
|
||||
try {
|
||||
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}`)
|
||||
setRecords(current => current.map(item => item.id === record.id ? { ...item, ...result.data, _imagesLoaded: true } : item))
|
||||
} catch (err) { imageLoadIds.current.delete(record.id); setError(err.message) }
|
||||
const ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : ''
|
||||
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}${ownerQuery}`)
|
||||
const detailed = { ...result.data, _imagesLoadedFor: result.data.status }
|
||||
setRecords(current => current.map(item => item.id === record.id ? { ...item, ...detailed } : item))
|
||||
return detailed
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
return null
|
||||
} finally {
|
||||
imageLoadPromises.current.delete(loadKey)
|
||||
}
|
||||
})()
|
||||
imageLoadPromises.current.set(loadKey, request)
|
||||
return request
|
||||
}
|
||||
async function openHistoryRecord(record) {
|
||||
if (openingId !== null) return
|
||||
setOpeningId(record.id); setError('')
|
||||
const detailed = await loadSubmissionImages(record)
|
||||
setOpeningId(null)
|
||||
if (!detailed) return
|
||||
setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
const latestRecord = records.find(record => record.id === activeRecordId) || records[0]
|
||||
const latest = latestRecord && optimisticStatuses[latestRecord.id] ? { ...latestRecord, status: optimisticStatuses[latestRecord.id] } : latestRecord
|
||||
const logout = () => { localStorage.removeItem(SESSION_KEY); setSession(null); setRecords([]); setLoaded(false); setOptimisticStatuses({}) }
|
||||
const logout = () => { localStorage.removeItem(SESSION_KEY); LEGACY_SESSION_KEYS.forEach(key => localStorage.removeItem(key)); setSession(null); setRecords([]); setLoaded(false); setOptimisticStatuses({}) }
|
||||
function applySubmittedRecord(submitted) {
|
||||
if (!submitted?.id) return
|
||||
setOptimisticStatuses(current => ({ ...current, [submitted.id]: submitted.status }))
|
||||
@ -185,11 +235,12 @@ export default function AnswerUserPortalV2({ api }) {
|
||||
useEffect(() => {
|
||||
if (!visibleLatest || historyOpen || newTaskMode) return
|
||||
loadSubmissionImages(visibleLatest)
|
||||
}, [visibleLatest?.id, historyOpen, newTaskMode])
|
||||
}, [visibleLatest?.id, visibleLatest?.status, historyOpen, newTaskMode])
|
||||
useEffect(() => {
|
||||
if (!historyOpen) return
|
||||
records.slice(0, 20).forEach(record => loadSubmissionImages(record))
|
||||
records.slice(0, 20).forEach(record => { loadSubmissionImages(record) })
|
||||
}, [historyOpen, records.length])
|
||||
if (!session) return <Login api={api} onLogin={setSession}/>
|
||||
return <main className="answer-v2-shell"><section className="answer-v2-app"><Top session={session} latest={visibleLatest} logout={logout}/><div className="answer-v2-tabs"><button className={!historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(false)}><ClipboardCheck size={15}/>任务认领</button><button className={historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(true)}><History size={15}/>任务状态 <small>{visibleLatest ? STATUS_TEXT[visibleLatest.status] || visibleLatest.status : '待上传'}</small></button></div>{!historyOpen && <Progress status={visibleLatest?.status}/>}<div className="answer-v2-body">{error && <div className="answer-v2-error">{error}</div>}{!loaded ? <div className="answer-v2-card answer-v2-loading">正在读取任务状态…</div> : historyOpen ? <HistoryPage records={records} close={() => setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? <UploadStage api={api} session={session} onUploaded={async () => { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? <ReviewStage latest={visibleLatest} reload={load} busy={busy} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? <AssignmentStage api={api} session={session} latest={visibleLatest} reload={async submitted => { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : <FinalStage latest={visibleLatest} reload={load} busy={busy} reopen={() => setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}</div><footer className="answer-v2-footer"><button onClick={() => setHistoryOpen(true)}>上传历史({records.length})</button>{visibleLatest && <button className="primary" onClick={() => load()}><RefreshCw size={14}/>刷新状态</button>}</footer></section></main>
|
||||
if (!session) return <Login api={api} onLogin={setSession} linkToken={linkToken}/>
|
||||
return <main className="answer-v2-shell"><section className="answer-v2-app"><Top session={session} latest={visibleLatest} logout={logout}/><div className="answer-v2-tabs"><button className={!historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(false)}><ClipboardCheck size={15}/>任务认领</button><button className={historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(true)}><History size={15}/>任务状态 <small>{visibleLatest ? STATUS_TEXT[visibleLatest.status] || visibleLatest.status : '待上传'}</small></button></div>{!historyOpen && <Progress status={visibleLatest?.status}/>}<div className="answer-v2-body">{error && <div className="answer-v2-error">{error}</div>}{!loaded ? <div className="answer-v2-card answer-v2-loading">正在读取任务状态…</div> : historyOpen ? <HistoryPage records={records} close={() => setHistoryOpen(false)} onOpen={openHistoryRecord} openingId={openingId}/> : !visibleLatest ? <UploadStage api={api} session={session} linkToken={linkToken} onUploaded={async () => { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(visibleLatest.status) ? <ReviewStage latest={visibleLatest} reload={load} busy={busy} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? <AssignmentStage api={api} session={session} latest={visibleLatest} linkToken={linkToken} reload={async submitted => { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : <FinalStage latest={visibleLatest} reload={load} busy={busy} reopen={() => setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}</div><footer className="answer-v2-footer"><button onClick={() => setHistoryOpen(true)}>上传历史({records.length})</button>{visibleLatest && <button className="primary" onClick={() => load()}><RefreshCw size={14}/>刷新状态</button>}</footer></section></main>
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ import {
|
||||
Check,
|
||||
ClipboardCheck,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
ImagePlus,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
@ -12,53 +11,94 @@ import {
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from "./taobaoId.js";
|
||||
import { formatDateTime } from "./time.js";
|
||||
|
||||
const SESSION_KEY = "ask_user_session";
|
||||
const SESSION_KEY = "ask_user_taobao_session_v2";
|
||||
const LEGACY_SESSION_KEYS = ["ask_user_taobao_session", "ask_user_session"];
|
||||
const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
|
||||
const COMPLETED_TASK_MESSAGE = "本次提问任务已全部完成,可查看历史状态";
|
||||
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
|
||||
const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`;
|
||||
const maskPhone = (phone = "") =>
|
||||
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
|
||||
const accountName = (session) =>
|
||||
session?.taobao_id || session?.account || session?.user_id || "";
|
||||
|
||||
function getTaskNo() {
|
||||
const segments = window.location.pathname.split("/").filter(Boolean);
|
||||
return segments[1] || "latest";
|
||||
}
|
||||
|
||||
function PhoneLogin({ api, onLogin }) {
|
||||
const [phone, setPhone] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [devCode, setDevCode] = useState("");
|
||||
function isInvalidTaskError(message = "") {
|
||||
return message.includes("提问任务链接无效") || message.includes("提问任务不存在");
|
||||
}
|
||||
|
||||
function isExpiredAssignmentError(message = "") {
|
||||
return message.includes("提问任务已失效") || message.includes("任务已失效");
|
||||
}
|
||||
|
||||
function formatCountdown(seconds) {
|
||||
const safe = Math.max(0, Number(seconds) || 0);
|
||||
const minutes = Math.floor(safe / 60).toString().padStart(2, "0");
|
||||
const remainder = Math.floor(safe % 60).toString().padStart(2, "0");
|
||||
return `${minutes}:${remainder}`;
|
||||
}
|
||||
|
||||
function parseServerDate(value) {
|
||||
if (!value) return null;
|
||||
const text = String(value);
|
||||
// API datetime values are stored as UTC but older responses omit the
|
||||
// timezone suffix. Explicitly mark those values as UTC before parsing in
|
||||
// the browser, otherwise China Standard Time makes them appear eight hours
|
||||
// older and the countdown expires immediately.
|
||||
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(text) ? text : `${text}Z`);
|
||||
}
|
||||
|
||||
function AssignmentCountdown({ expiresAt, onExpired }) {
|
||||
const [remaining, setRemaining] = useState(() =>
|
||||
expiresAt ? Math.max(0, (parseServerDate(expiresAt)?.getTime() - Date.now()) / 1000) : 0,
|
||||
);
|
||||
const expiredRef = useRef(false);
|
||||
const onExpiredRef = useRef(onExpired);
|
||||
useEffect(() => {
|
||||
if (!cooldown) return undefined;
|
||||
const timer = window.setInterval(() => setCooldown((value) => Math.max(0, value - 1)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [cooldown]);
|
||||
async function sendCode() {
|
||||
if (!/^1\d{10}$/.test(phone)) return setError("请输入正确的 11 位手机号码");
|
||||
setSending(true); setError("");
|
||||
try {
|
||||
const result = await api("/api/ask/auth/send-code", { method: "POST", body: JSON.stringify({ phone }) });
|
||||
setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || "");
|
||||
} catch (requestError) { setError(requestError.message); } finally { setSending(false); }
|
||||
onExpiredRef.current = onExpired;
|
||||
}, [onExpired]);
|
||||
useEffect(() => {
|
||||
if (!expiresAt) return undefined;
|
||||
expiredRef.current = false;
|
||||
const update = () => {
|
||||
const next = Math.max(0, (parseServerDate(expiresAt)?.getTime() - Date.now()) / 1000);
|
||||
setRemaining(next);
|
||||
if (next <= 0 && !expiredRef.current) {
|
||||
expiredRef.current = true;
|
||||
onExpiredRef.current?.();
|
||||
}
|
||||
};
|
||||
update();
|
||||
const timer = window.setInterval(update, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [expiresAt]);
|
||||
if (!expiresAt) return null;
|
||||
return <span className={`ask-assignment-countdown ${remaining <= 10 ? "urgent" : ""}`}>剩余 {formatCountdown(remaining)}</span>;
|
||||
}
|
||||
|
||||
function TaobaoLogin({ api, onLogin }) {
|
||||
const [taobaoId, setTaobaoId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
setError("请输入正确的 11 位手机号码");
|
||||
const account = normalizeTaobaoId(taobaoId);
|
||||
if (!isValidTaobaoId(account)) {
|
||||
setError(TAOBAO_ID_HINT);
|
||||
return;
|
||||
}
|
||||
if (!/^\d{6}$/.test(code)) { setError("请输入6位验证码"); return; }
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api("/api/ask/auth/phone-login", {
|
||||
const result = await api("/api/ask/auth/taobao-login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ phone, code }),
|
||||
body: JSON.stringify({ taobao_id: account }),
|
||||
});
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(result.data));
|
||||
onLogin(result.data);
|
||||
@ -83,23 +123,23 @@ function PhoneLogin({ api, onLogin }) {
|
||||
<span className="ask-login-icon">
|
||||
<MessageCircle size={28} />
|
||||
</span>
|
||||
<h1>手机号登录</h1>
|
||||
<p>使用短信验证码安全登录</p>
|
||||
<h1>淘宝账号登录</h1>
|
||||
<p>输入淘宝ID即可领取提问任务</p>
|
||||
<label>
|
||||
手机号码
|
||||
淘宝ID
|
||||
<input
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
maxLength="11"
|
||||
value={phone}
|
||||
onChange={(event) =>
|
||||
setPhone(event.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder="请输入 11 位手机号码"
|
||||
maxLength="64"
|
||||
value={taobaoId}
|
||||
onChange={(event) => setTaobaoId(event.target.value.slice(0, 64))}
|
||||
inputMode="text"
|
||||
lang="zh-CN"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
placeholder="例如:tb1008611 或 淘宝用户"
|
||||
/>
|
||||
<small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID;支持中文、字母和数字</small>
|
||||
</label>
|
||||
<label>短信验证码<div className="ask-code-row"><input inputMode="numeric" maxLength="6" value={code} onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))} placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? "发送中…" : "获取验证码"}</button></div></label>
|
||||
{devCode && <small className="ask-dev-code">开发环境验证码:{devCode}</small>}
|
||||
{error && <div className="ask-user-error">{error}</div>}
|
||||
<button disabled={busy}>
|
||||
{busy ? "登录中…" : "登录并领取提问任务"}
|
||||
@ -129,7 +169,7 @@ function TaskExhausted({ session, onLogout }) {
|
||||
<h1>本次任务已被领取完</h1>
|
||||
<p>当前链接中的问题已全部分配给其他用户,请关注下一个任务!</p>
|
||||
<small>
|
||||
当前账号:{session.user_id} · {maskPhone(session.phone)}
|
||||
当前淘宝ID:{accountName(session)}
|
||||
</small>
|
||||
<button type="button" onClick={onLogout}>
|
||||
更换登录账号
|
||||
@ -140,6 +180,71 @@ function TaskExhausted({ session, onLogout }) {
|
||||
);
|
||||
}
|
||||
|
||||
function InvalidTask({ message, onReload }) {
|
||||
return (
|
||||
<main className="ask-user-shell">
|
||||
<section className="ask-user-app ask-invalid-app">
|
||||
<header className="ask-user-topbar">
|
||||
<span><ShieldCheck size={14} /> 淘宝问答运营官方承接通道</span>
|
||||
<em>官方认证</em>
|
||||
</header>
|
||||
<section className="ask-invalid-card">
|
||||
<span className="ask-invalid-icon"><ShieldCheck size={34} /></span>
|
||||
<em>任务链接不可用</em>
|
||||
<h1>暂时无法读取任务</h1>
|
||||
<p>{message || INVALID_TASK_MESSAGE}。请向运营确认是否已成功发布,并重新打开最新任务链接。</p>
|
||||
<button type="button" onClick={onReload}>重新读取任务</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function CompletedTask({ session, onOpenHistory, busy, error }) {
|
||||
return (
|
||||
<main className="ask-user-shell">
|
||||
<section className="ask-user-app ask-invalid-app">
|
||||
<header className="ask-user-topbar">
|
||||
<span><ShieldCheck size={14} /> 淘宝问答运营官方承接通道</span>
|
||||
<em>官方认证</em>
|
||||
</header>
|
||||
<section className="ask-invalid-card ask-completed-card">
|
||||
<span className="ask-invalid-icon"><PackageCheck size={34} /></span>
|
||||
<em>任务已完成</em>
|
||||
<h1>本次提问任务已全部完成</h1>
|
||||
<p>当前任务的所有问题均已完成,您可以前往历史状态查看自己提交过的任务和审核进度。</p>
|
||||
<small>当前淘宝ID:{accountName(session)}</small>
|
||||
{error && <div className="ask-user-error">{error}</div>}
|
||||
<button type="button" onClick={onOpenHistory} disabled={busy}>
|
||||
{busy ? "正在读取历史…" : "查看历史状态"}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpiredAssignment({ session, onReload }) {
|
||||
return (
|
||||
<main className="ask-user-shell">
|
||||
<section className="ask-user-app ask-invalid-app">
|
||||
<header className="ask-user-topbar">
|
||||
<span><ShieldCheck size={14} /> 淘宝问答运营官方承接通道</span>
|
||||
<em>官方认证</em>
|
||||
</header>
|
||||
<section className="ask-invalid-card ask-expired-card">
|
||||
<span className="ask-invalid-icon"><PackageCheck size={34} /></span>
|
||||
<em>任务已失效</em>
|
||||
<h1>本次任务已超时</h1>
|
||||
<p>当前分配的问题已自动释放,请点击刷新重新分配任务。</p>
|
||||
<small>当前淘宝ID:{accountName(session)}</small>
|
||||
<button type="button" onClick={onReload}>刷新重新分配</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
session,
|
||||
assignment,
|
||||
@ -162,9 +267,9 @@ function Header({
|
||||
<div className="ask-user-avatar">问</div>
|
||||
<div>
|
||||
<strong>问大家提问用户</strong>
|
||||
<i>手机已绑定</i>
|
||||
<i>淘宝账号已登录</i>
|
||||
<small>
|
||||
ID:{session.user_id} · {maskPhone(session.phone)}
|
||||
淘宝ID:{accountName(session)}
|
||||
</small>
|
||||
</div>
|
||||
<button title="退出登录" onClick={onLogout}>
|
||||
@ -197,6 +302,9 @@ function SelectQuestions({
|
||||
setSelected,
|
||||
onConfirm,
|
||||
busy,
|
||||
onExpired,
|
||||
expired = false,
|
||||
onRefresh,
|
||||
}) {
|
||||
const isSubmitted = ["待审核", "已通过"].includes(assignment.status);
|
||||
const isUsed = (item) => item.used || (isSubmitted && item.selected);
|
||||
@ -207,7 +315,7 @@ function SelectQuestions({
|
||||
selected.includes(item.id),
|
||||
);
|
||||
function toggle(id) {
|
||||
if (isUsed(assignment.questions.find((item) => item.id === id))) return;
|
||||
if (expired || isUsed(assignment.questions.find((item) => item.id === id))) return;
|
||||
setSelected((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((value) => value !== id)
|
||||
@ -257,6 +365,7 @@ function SelectQuestions({
|
||||
请选择提问内容(共 {assignment.questions.length} 题)
|
||||
</strong>
|
||||
<button
|
||||
disabled={expired}
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
allSelected
|
||||
@ -278,7 +387,7 @@ function SelectQuestions({
|
||||
<button
|
||||
key={item.id}
|
||||
className={`${active ? "active" : ""} ${isUsed(item) ? "used" : ""}`}
|
||||
disabled={isUsed(item)}
|
||||
disabled={expired || isUsed(item)}
|
||||
onClick={() => toggle(item.id)}
|
||||
>
|
||||
<i>{active && <Check size={16} />}</i>
|
||||
@ -289,12 +398,18 @@ function SelectQuestions({
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{expired && (
|
||||
<div className="ask-assignment-expired-notice" role="alert">
|
||||
领取超时,问题已自动释放,请点击“刷新重新分配”获取新任务。
|
||||
</div>
|
||||
)}
|
||||
<footer className="ask-user-sticky">
|
||||
<span>
|
||||
已选择 <b>{selected.length}</b> / {assignment.questions.length} 题
|
||||
{!expired && <AssignmentCountdown expiresAt={assignment.expires_at} onExpired={onExpired} />}
|
||||
</span>
|
||||
<button disabled={!selected.length || busy} onClick={onConfirm}>
|
||||
{busy ? "确认中…" : "确认"}
|
||||
<button disabled={busy || (!expired && !selected.length)} onClick={expired ? onRefresh : onConfirm}>
|
||||
{busy ? "处理中…" : expired ? "刷新重新分配" : "确认"}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
@ -308,18 +423,18 @@ function ConfirmStage({
|
||||
taskNo,
|
||||
onSubmitted,
|
||||
setError,
|
||||
onExpired,
|
||||
}) {
|
||||
const selectedQuestions = assignment.questions.filter(
|
||||
(item) => item.selected,
|
||||
);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [paymentFile, setPaymentFile] = useState(null);
|
||||
const [payeeName, setPayeeName] = useState("");
|
||||
const [alipayAccount, setAlipayAccount] = useState("");
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
const paymentInputRef = useRef(null);
|
||||
const previews = useMemo(
|
||||
() => files.map((file) => ({ file, url: URL.createObjectURL(file) })),
|
||||
[files],
|
||||
@ -357,8 +472,8 @@ function ConfirmStage({
|
||||
const body = new FormData();
|
||||
body.append("token", session.token);
|
||||
files.forEach((file) => body.append("files", file));
|
||||
body.append("payment_file", paymentFile);
|
||||
body.append("payee_name", payeeName);
|
||||
body.append("alipay_account", alipayAccount.trim());
|
||||
try {
|
||||
const result = await api(
|
||||
`/api/ask/tasks/${encodeURIComponent(taskNo)}/submission`,
|
||||
@ -370,7 +485,9 @@ function ConfirmStage({
|
||||
setReviewOpen(false);
|
||||
onSubmitted(result.data);
|
||||
} catch (requestError) {
|
||||
setError(requestError.message);
|
||||
setReviewOpen(false);
|
||||
if (isExpiredAssignmentError(requestError.message)) onExpired();
|
||||
else setError(requestError.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -393,20 +510,27 @@ function ConfirmStage({
|
||||
<h2>{assignment.product_name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{(assignment.product_link || assignment.product_qr) && (
|
||||
<div className="ask-product-access">
|
||||
{assignment.product_link && (
|
||||
<footer>
|
||||
<code>{assignment.product_link}</code>
|
||||
<code title={assignment.product_link}>{assignment.product_link}</code>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigator.clipboard?.writeText(assignment.product_link)
|
||||
}
|
||||
onClick={() => navigator.clipboard?.writeText(assignment.product_link)}
|
||||
>
|
||||
<Copy size={13} />
|
||||
复制链接
|
||||
复制淘口令
|
||||
</button>
|
||||
<a href={assignment.product_link} target="_blank" rel="noreferrer">
|
||||
去淘宝提问 <ExternalLink size={13} />
|
||||
</a>
|
||||
</footer>
|
||||
)}
|
||||
{assignment.product_qr?.data_url && (
|
||||
<figure className="ask-product-qr-card">
|
||||
<img src={assignment.product_qr.data_url} alt="商品二维码" />
|
||||
<figcaption>商品二维码</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="ask-selected-card">
|
||||
<header>
|
||||
@ -471,45 +595,27 @@ function ConfirmStage({
|
||||
onChange={(event) => addFiles(event.target.files)}
|
||||
/>
|
||||
</section>
|
||||
<div className="ask-upload-expiry-note">
|
||||
<ShieldCheck size={14} />
|
||||
<span>请在有效期内完成上传并提交<AssignmentCountdown expiresAt={assignment.expires_at} onExpired={onExpired} /></span>
|
||||
</div>
|
||||
<section className="ask-upload-card ask-payment-card">
|
||||
<header>
|
||||
<div>
|
||||
<b>2</b>
|
||||
<strong>上传收款码</strong>
|
||||
<small>审核通过后将按原路径结算至此账户</small>
|
||||
<strong>填写收款信息</strong>
|
||||
<small>请填写准确的支付宝账号及实名姓名</small>
|
||||
</div>
|
||||
<span>{paymentFile ? "已上传" : "未上传"}</span>
|
||||
</header>
|
||||
{paymentFile ? (
|
||||
<div className="ask-payment-file">
|
||||
<span>{paymentFile.name}</span>
|
||||
<button onClick={() => paymentInputRef.current?.click()}>
|
||||
重新上传
|
||||
</button>
|
||||
<button className="delete" onClick={() => setPaymentFile(null)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ask-payment-empty"
|
||||
onClick={() => paymentInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus size={22} />
|
||||
上传收款码
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={paymentInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={(event) => setPaymentFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<label>
|
||||
收款人真实姓名(选填,用于核验一致性)
|
||||
支付宝账号(必填)
|
||||
<input value={alipayAccount} maxLength={128} onChange={(event) => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" />
|
||||
</label>
|
||||
<label>
|
||||
收款人真实姓名(必填)
|
||||
<input
|
||||
value={payeeName}
|
||||
maxLength={64}
|
||||
onChange={(event) => setPayeeName(event.target.value)}
|
||||
placeholder="请输入收款人姓名"
|
||||
/>
|
||||
@ -517,7 +623,7 @@ function ConfirmStage({
|
||||
</section>
|
||||
<button
|
||||
className="ask-submit-review"
|
||||
disabled={!files.length || !paymentFile}
|
||||
disabled={!files.length || !alipayAccount.trim() || !payeeName.trim()}
|
||||
onClick={() => setReviewOpen(true)}
|
||||
>
|
||||
下一步:核对并提交审核
|
||||
@ -548,8 +654,8 @@ function ConfirmStage({
|
||||
<b>已上传({files.length}张)</b>
|
||||
</p>
|
||||
<p>
|
||||
<span>收款码</span>
|
||||
<b>已上传</b>
|
||||
<span>支付宝收款信息</span>
|
||||
<b>{payeeName} / {alipayAccount}</b>
|
||||
</p>
|
||||
</div>
|
||||
<aside>
|
||||
@ -574,18 +680,23 @@ function ConfirmStage({
|
||||
|
||||
function AuditStatus({ assignment, reload, busy, onBackHome }) {
|
||||
const pending = assignment.status === "待审核";
|
||||
const passed = assignment.status === "已通过";
|
||||
const paid = assignment.status === "已完成";
|
||||
const passed = ["已通过", "已完成"].includes(assignment.status);
|
||||
const review = assignment.latest_review;
|
||||
return (
|
||||
<div className="ask-audit-body">
|
||||
<section
|
||||
className={`ask-audit-card ${passed ? "passed" : assignment.status === "未通过" ? "rejected" : ""}`}
|
||||
>
|
||||
<span>{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}</span>
|
||||
<span>
|
||||
{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}
|
||||
</span>
|
||||
<em>{assignment.status}</em>
|
||||
<h1>
|
||||
{pending
|
||||
? "任务正在审核,请耐心等待。"
|
||||
: paid
|
||||
? "返款已完成"
|
||||
: passed
|
||||
? "运营审核已通过"
|
||||
: "运营审核未通过"}
|
||||
@ -593,6 +704,8 @@ function AuditStatus({ assignment, reload, busy, onBackHome }) {
|
||||
<p>
|
||||
{pending
|
||||
? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。"
|
||||
: paid
|
||||
? "运营已完成返款,以下为返款支付凭证,请留意您的收款账户。"
|
||||
: passed
|
||||
? "您提交的提问凭证已经核验通过,本次任务审核完成。"
|
||||
: "请根据运营反馈修改截图后重新提交审核。"}
|
||||
@ -608,9 +721,7 @@ function AuditStatus({ assignment, reload, busy, onBackHome }) {
|
||||
</dd>
|
||||
<dt>提交时间:</dt>
|
||||
<dd>
|
||||
{new Date(assignment.updated_at).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
})}
|
||||
{formatDateTime(assignment.updated_at)}
|
||||
</dd>
|
||||
<dt>锁定状态:</dt>
|
||||
<dd>{pending ? "已锁定不可撤销" : assignment.status}</dd>
|
||||
@ -643,14 +754,14 @@ function TaskStatusPage({ history, onView }) {
|
||||
["待选择", "进行中", "待审核"].includes(item.status),
|
||||
).length;
|
||||
const completed = history.filter((item) =>
|
||||
["已通过", "未通过"].includes(item.status),
|
||||
["已通过", "已完成", "未通过"].includes(item.status),
|
||||
).length;
|
||||
const filtered = history.filter(
|
||||
(item) =>
|
||||
filter === "all" ||
|
||||
(filter === "progress"
|
||||
? ["待选择", "进行中", "待审核"].includes(item.status)
|
||||
: ["已通过", "未通过"].includes(item.status)),
|
||||
: ["已通过", "已完成", "未通过"].includes(item.status)),
|
||||
);
|
||||
return (
|
||||
<div className="ask-status-page">
|
||||
@ -682,7 +793,7 @@ function TaskStatusPage({ history, onView }) {
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<article
|
||||
className={`ask-history-item ${item.status === "待审核" ? "pending" : item.status === "已通过" ? "done" : ""}`}
|
||||
className={`ask-history-item ${item.status === "待审核" ? "pending" : ["已通过", "已完成"].includes(item.status) ? "done" : ""}`}
|
||||
key={item.assignment_id}
|
||||
>
|
||||
<header>
|
||||
@ -702,16 +813,14 @@ function TaskStatusPage({ history, onView }) {
|
||||
认领问题{" "}
|
||||
{item.questions.filter((question) => question.selected).length} 条
|
||||
· 更新时间{" "}
|
||||
{new Date(item.updated_at).toLocaleTimeString("zh-CN", {
|
||||
hour12: false,
|
||||
})}
|
||||
{formatDateTime(item.updated_at)}
|
||||
</p>
|
||||
<footer>
|
||||
<span>
|
||||
{item.status === "待审核"
|
||||
? "凭证已锁定,专员核验中"
|
||||
: item.status === "已通过"
|
||||
? "审核已通过"
|
||||
: ["已通过", "已完成"].includes(item.status)
|
||||
? item.status === "已完成" ? "任务已完成" : "审核已通过"
|
||||
: item.status === "未通过"
|
||||
? "需要重新提交"
|
||||
: "等待选择问题"}
|
||||
@ -729,6 +838,7 @@ export default function AskUserPortal({ api }) {
|
||||
const taskNo = getTaskNo();
|
||||
const [session, setSession] = useState(() => {
|
||||
try {
|
||||
LEGACY_SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
|
||||
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
@ -743,19 +853,32 @@ export default function AskUserPortal({ api }) {
|
||||
const [notice, setNotice] = useState("");
|
||||
const [claimEdit, setClaimEdit] = useState(false);
|
||||
const [exhausted, setExhausted] = useState(false);
|
||||
const [invalidTask, setInvalidTask] = useState("");
|
||||
const [completedTask, setCompletedTask] = useState(false);
|
||||
const [expiredAssignment, setExpiredAssignment] = useState(false);
|
||||
const [selectionExpired, setSelectionExpired] = useState(false);
|
||||
|
||||
async function loadAssignment(silent = false) {
|
||||
async function loadAssignment(silent = false, refresh = false) {
|
||||
if (!session) return;
|
||||
const previousAssignmentId = assignment?.assignment_id;
|
||||
if (!silent) setBusy(true);
|
||||
try {
|
||||
const result = await api(
|
||||
`/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}`,
|
||||
`/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}${refresh ? "&refresh=true" : ""}`,
|
||||
);
|
||||
setAssignment(result.data);
|
||||
setExhausted(false);
|
||||
setInvalidTask("");
|
||||
setCompletedTask(false);
|
||||
setExpiredAssignment(false);
|
||||
setSelectionExpired(false);
|
||||
// Keep the current view. In particular, polling a pending review must
|
||||
// not pull the user back to the audit screen after they chose to return
|
||||
// to the claim page.
|
||||
// Keep unsaved checkbox choices while a user is on the claim screen.
|
||||
// If an expired reservation is refreshed, the fresh allocation is
|
||||
// selected instead of restoring the stale checkbox choices.
|
||||
if (!silent || result.data.assignment_id !== previousAssignmentId || assignment?.status !== "待选择") {
|
||||
setSelected(
|
||||
result.data.questions
|
||||
// Questions already submitted for review are consumed and must not
|
||||
@ -763,12 +886,30 @@ export default function AskUserPortal({ api }) {
|
||||
.filter((item) => item.selected && !item.used)
|
||||
.map((item) => item.id),
|
||||
);
|
||||
}
|
||||
setError("");
|
||||
} catch (requestError) {
|
||||
if (requestError.message === EXHAUSTED_MESSAGE) {
|
||||
setAssignment(null);
|
||||
setExhausted(true);
|
||||
setInvalidTask("");
|
||||
setError("");
|
||||
} else if (requestError.message === COMPLETED_TASK_MESSAGE) {
|
||||
setAssignment(null);
|
||||
setCompletedTask(true);
|
||||
setInvalidTask("");
|
||||
setError("");
|
||||
} else if (isInvalidTaskError(requestError.message)) {
|
||||
setAssignment(null);
|
||||
setInvalidTask(requestError.message);
|
||||
setError("");
|
||||
} else if (isExpiredAssignmentError(requestError.message)) {
|
||||
setAssignment(null);
|
||||
setSelected([]);
|
||||
setExpiredAssignment(true);
|
||||
setError("");
|
||||
} else if (requestError.message.includes("登录已失效")) {
|
||||
logout();
|
||||
} else {
|
||||
setError(requestError.message);
|
||||
}
|
||||
@ -777,34 +918,90 @@ export default function AskUserPortal({ api }) {
|
||||
}
|
||||
}
|
||||
async function loadHistory() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api(
|
||||
`/api/ask/me/assignments?token=${encodeURIComponent(session.token)}`,
|
||||
);
|
||||
setHistory(result.data);
|
||||
setView("status");
|
||||
setError("");
|
||||
} catch (requestError) {
|
||||
setError(requestError.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
loadAssignment();
|
||||
}, [session?.token, taskNo]);
|
||||
useEffect(() => {
|
||||
if (assignment?.status !== "待审核") return undefined;
|
||||
const timer = window.setInterval(() => loadAssignment(true), 10000);
|
||||
if (!assignment || assignment.status !== "待审核") return undefined;
|
||||
const interval = 10000;
|
||||
const timer = window.setInterval(() => loadAssignment(true), interval);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [assignment?.status, session?.token, taskNo]);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
LEGACY_SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
|
||||
setSession(null);
|
||||
setAssignment(null);
|
||||
setExhausted(false);
|
||||
setInvalidTask("");
|
||||
setCompletedTask(false);
|
||||
setExpiredAssignment(false);
|
||||
setSelectionExpired(false);
|
||||
};
|
||||
if (!session) return <PhoneLogin api={api} onLogin={setSession} />;
|
||||
if (!session) return <TaobaoLogin api={api} onLogin={setSession} />;
|
||||
if (exhausted)
|
||||
return <TaskExhausted session={session} onLogout={logout} />;
|
||||
if (completedTask && view !== "status" && !assignment)
|
||||
return <CompletedTask session={session} onOpenHistory={loadHistory} busy={busy} error={error} />;
|
||||
if (invalidTask)
|
||||
return <InvalidTask message={invalidTask} onReload={() => loadAssignment()} />;
|
||||
if (expiredAssignment)
|
||||
return <ExpiredAssignment session={session} onReload={refreshAssignment} />;
|
||||
async function releaseAssignment() {
|
||||
try {
|
||||
await api(`/api/ask/tasks/${encodeURIComponent(taskNo)}/release`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token: session.token }),
|
||||
});
|
||||
} catch {
|
||||
// The server may already have released the reservation during its
|
||||
// expiry sweep; a subsequent refresh remains safe in that case.
|
||||
}
|
||||
}
|
||||
function expireSelection() {
|
||||
if (selectionExpired) return;
|
||||
void releaseAssignment();
|
||||
setSelectionExpired(true);
|
||||
setSelected([]);
|
||||
setError("");
|
||||
}
|
||||
function expireUpload() {
|
||||
if (expiredAssignment) return;
|
||||
void releaseAssignment();
|
||||
setExpiredAssignment(true);
|
||||
setAssignment(null);
|
||||
setSelected([]);
|
||||
setError("");
|
||||
}
|
||||
async function refreshAssignment() {
|
||||
await releaseAssignment();
|
||||
setSelectionExpired(false);
|
||||
setExpiredAssignment(false);
|
||||
setAssignment(null);
|
||||
await loadAssignment(false, true);
|
||||
}
|
||||
async function startNextSelection() {
|
||||
setView("claim");
|
||||
setSelected([]);
|
||||
setNotice("");
|
||||
await loadAssignment(false, true);
|
||||
setClaimEdit(true);
|
||||
}
|
||||
async function confirm() {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
@ -843,17 +1040,13 @@ export default function AskUserPortal({ api }) {
|
||||
view={view}
|
||||
onOpenHistory={loadHistory}
|
||||
onOpenClaim={() => {
|
||||
if (assignment && (assignment.remaining_count ?? 0) > 0 && ["已通过", "已完成"].includes(assignment.status)) {
|
||||
void startNextSelection();
|
||||
} else {
|
||||
setView("claim");
|
||||
// If a previous batch has completed and questions remain, opening
|
||||
// the claim tab should show the next selectable batch directly.
|
||||
setClaimEdit(
|
||||
Boolean(
|
||||
assignment &&
|
||||
(assignment.remaining_count ?? 0) > 0 &&
|
||||
assignment.status === "已通过",
|
||||
),
|
||||
);
|
||||
setClaimEdit(false);
|
||||
setNotice("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <div className="ask-user-error page">{error}</div>}
|
||||
@ -882,8 +1075,11 @@ export default function AskUserPortal({ api }) {
|
||||
setSelected={setSelected}
|
||||
onConfirm={confirm}
|
||||
busy={busy}
|
||||
onExpired={expireSelection}
|
||||
expired={selectionExpired}
|
||||
onRefresh={refreshAssignment}
|
||||
/>
|
||||
) : ["待审核", "已通过"].includes(assignment.status) ? (
|
||||
) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? (
|
||||
<AuditStatus
|
||||
assignment={assignment}
|
||||
reload={() => loadAssignment()}
|
||||
@ -901,11 +1097,7 @@ export default function AskUserPortal({ api }) {
|
||||
loadHistory();
|
||||
setNotice("本次任务您的认领额度已达上限,请关注下一次任务");
|
||||
} else {
|
||||
setView("claim");
|
||||
setClaimEdit(true);
|
||||
setNotice(
|
||||
`本次任务还可以继续选择 ${assignment.questions.length - selectedCount} 个问题`,
|
||||
);
|
||||
void startNextSelection();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -917,6 +1109,7 @@ export default function AskUserPortal({ api }) {
|
||||
taskNo={taskNo}
|
||||
onSubmitted={setAssignment}
|
||||
setError={setError}
|
||||
onExpired={expireUpload}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -1,11 +1,51 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export default function CreateAnswerTasksPageV2({ api }) {
|
||||
export default function CreateAnswerTasksPageV2({ api, user, answerDraftCount = 0 }) {
|
||||
const [records, setRecords] = useState([])
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [busy, setBusy] = useState('')
|
||||
const [detailBusy, setDetailBusy] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [rejecting, setRejecting] = useState(null)
|
||||
const [publishing, setPublishing] = useState(null)
|
||||
const [publishForm, setPublishForm] = useState({ ecommerce_platform: '', shop_name: '', product_id: '' })
|
||||
const [answerLink, setAnswerLink] = useState('')
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
const editingAnswersRef = useRef(new Set())
|
||||
const shopMemoryKey = `answer_last_shop_name_${user?.id || 'current'}`
|
||||
const platformMemoryKey = `answer_last_ecommerce_platform_${user?.id || 'current'}`
|
||||
const lastShopNameRef = useRef(window.localStorage.getItem(shopMemoryKey) || '')
|
||||
const lastPlatformRef = useRef(window.localStorage.getItem(platformMemoryKey) || '')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api('/api/admin/auth/answer-link').then(result => {
|
||||
if (!active) return
|
||||
if (result.data?.path) setAnswerLink(new URL(result.data.path, window.location.origin).toString())
|
||||
const remembered = String(result.data?.last_shop_name || '').trim()
|
||||
if (remembered) {
|
||||
lastShopNameRef.current = remembered
|
||||
window.localStorage.setItem(shopMemoryKey, remembered)
|
||||
}
|
||||
const rememberedPlatform = String(result.data?.last_ecommerce_platform || '').trim()
|
||||
if (rememberedPlatform) {
|
||||
lastPlatformRef.current = rememberedPlatform
|
||||
window.localStorage.setItem(platformMemoryKey, rememberedPlatform)
|
||||
}
|
||||
}).catch(() => {})
|
||||
return () => { active = false }
|
||||
}, [api, user?.id])
|
||||
|
||||
async function copyAnswerLink() {
|
||||
if (!answerLink) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(answerLink)
|
||||
setLinkCopied(true)
|
||||
window.setTimeout(() => setLinkCopied(false), 1600)
|
||||
} catch {
|
||||
setError('复制失败,请手动复制专属链接')
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@ -18,8 +58,14 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
...record,
|
||||
questions: record.questions.map(question => ({
|
||||
...question,
|
||||
// Keep an in-progress textarea edit while the 5-second polling
|
||||
// request is returning the previous server value.
|
||||
answer: editingAnswersRef.current.has(question.id)
|
||||
? (previousQuestions.get(question.id)?.answer ?? question.answer)
|
||||
: question.answer,
|
||||
product_image: question.product_image || previousQuestions.get(question.id)?.product_image || null,
|
||||
source_image: question.source_image || previousQuestions.get(question.id)?.source_image || null,
|
||||
operator_images: question.operator_images?.length ? question.operator_images : (previousQuestions.get(question.id)?.operator_images || []),
|
||||
})),
|
||||
}
|
||||
}))
|
||||
@ -48,7 +94,19 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
setDetailBusy(record.id)
|
||||
try {
|
||||
const result = await api(`/api/admin/answer-task-drafts/${record.id}`)
|
||||
setRecords(current => current.map(item => item.id === record.id ? result.data : item))
|
||||
setRecords(current => current.map(item => {
|
||||
if (item.id !== record.id) return item
|
||||
const currentQuestions = new Map(item.questions.map(question => [question.id, question]))
|
||||
return {
|
||||
...result.data,
|
||||
questions: result.data.questions.map(question => ({
|
||||
...question,
|
||||
answer: editingAnswersRef.current.has(question.id)
|
||||
? (currentQuestions.get(question.id)?.answer ?? question.answer)
|
||||
: question.answer,
|
||||
})),
|
||||
}
|
||||
}))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@ -79,8 +137,10 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
method: 'PATCH', body: JSON.stringify(patch),
|
||||
})
|
||||
setQuestionLocal(question.id, { answer: result.data.answer, selected: result.data.selected })
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'answer')) editingAnswersRef.current.delete(question.id)
|
||||
} catch (err) {
|
||||
setQuestionLocal(question.id, previous)
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'answer')) editingAnswersRef.current.delete(question.id)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
@ -101,17 +161,114 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(record) {
|
||||
function openPublish(record) {
|
||||
if (!record.questions.some(question => question.selected)) {
|
||||
setError(`请先在用户 ${record.user_id} 下选择至少一个问题`)
|
||||
setExpanded(current => ({ ...current, [record.id]: true }))
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setPublishing(record)
|
||||
setPublishForm({
|
||||
ecommerce_platform: record.ecommerce_platform || lastPlatformRef.current || '',
|
||||
shop_name: record.shop_name || lastShopNameRef.current || '',
|
||||
product_id: '',
|
||||
})
|
||||
}
|
||||
|
||||
function updatePublishField(field, value) {
|
||||
setPublishForm(current => ({ ...current, [field]: value }))
|
||||
const remembered = value.trim()
|
||||
if (!remembered) return
|
||||
if (field === 'shop_name') {
|
||||
lastShopNameRef.current = remembered
|
||||
window.localStorage.setItem(shopMemoryKey, remembered)
|
||||
} else if (field === 'ecommerce_platform') {
|
||||
lastPlatformRef.current = remembered
|
||||
window.localStorage.setItem(platformMemoryKey, remembered)
|
||||
}
|
||||
}
|
||||
|
||||
function closePublish() {
|
||||
if (busy) return
|
||||
setPublishing(null)
|
||||
setPublishForm(current => ({ ...current, product_id: '' }))
|
||||
setError('')
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const record = publishing
|
||||
if (!record) return
|
||||
const ecommercePlatform = publishForm.ecommerce_platform.trim()
|
||||
const shopName = publishForm.shop_name.trim()
|
||||
const productId = publishForm.product_id.trim()
|
||||
if (!ecommercePlatform) return setError('请填写电商平台')
|
||||
if (!shopName) return setError('请填写店铺名称')
|
||||
if (!productId) return setError('请填写商品ID')
|
||||
setBusy(record.id)
|
||||
setError('')
|
||||
try {
|
||||
await api(`/api/admin/answer-task-drafts/${record.id}/publish`, { method: 'POST' })
|
||||
await api(`/api/admin/answer-task-drafts/${record.id}/publish`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: productId }),
|
||||
})
|
||||
lastShopNameRef.current = shopName
|
||||
lastPlatformRef.current = ecommercePlatform
|
||||
window.localStorage.setItem(shopMemoryKey, shopName)
|
||||
window.localStorage.setItem(platformMemoryKey, ecommercePlatform)
|
||||
setPublishing(null)
|
||||
setPublishForm({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: '' })
|
||||
await load()
|
||||
window.dispatchEvent(new CustomEvent('answer-draft-count-changed'))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function reject(record) {
|
||||
if (!record || busy) return
|
||||
setBusy(record.id)
|
||||
setError('')
|
||||
try {
|
||||
await api(`/api/admin/answer-task-drafts/${record.id}/reject`, { method: 'POST' })
|
||||
setRecords(current => current.filter(item => item.id !== record.id))
|
||||
setRejecting(null)
|
||||
window.dispatchEvent(new CustomEvent('answer-draft-count-changed'))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function retryRecognition(record) {
|
||||
if (!record || busy) return
|
||||
setBusy(record.id)
|
||||
setError('')
|
||||
try {
|
||||
const result = await api(`/api/admin/answer-task-drafts/${record.id}/retry-recognition`, { method: 'POST' })
|
||||
setRecords(current => current.map(item => item.id === record.id ? result.data : item))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOperatorImage(question, event) {
|
||||
const files = Array.from(event.target.files || [])
|
||||
event.target.value = ''
|
||||
if (!files.length) return
|
||||
if (files.length > 3) return setError('每道问题最多上传3张运营指定图片')
|
||||
const body = new FormData()
|
||||
files.forEach(file => body.append('files', file))
|
||||
setBusy(question.id); setError('')
|
||||
try {
|
||||
const result = await api(`/api/admin/answer-task-drafts/questions/${question.id}/operator-image`, { method: 'POST', body })
|
||||
const updated = result.data.questions?.find(item => item.id === question.id)
|
||||
setQuestionLocal(question.id, { operator_images: updated?.operator_images || [] })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@ -122,9 +279,10 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
return <>
|
||||
<div className="create-page-heading">
|
||||
<div><h2>创建任务</h2><p>通过顶部切换创建「提问任务」或「回答任务」。</p></div>
|
||||
<div className="create-mode-tabs"><button onClick={() => window.dispatchEvent(new CustomEvent('switch-create-ask'))}>创建提问任务</button><button className="active">创建回答任务</button></div>
|
||||
<div className="create-mode-tabs"><button onClick={() => window.dispatchEvent(new CustomEvent('switch-create-ask'))}>创建提问任务</button><button className="active">创建回答任务{answerDraftCount > 0 && <span className="create-mode-badge">{answerDraftCount > 99 ? '99+' : answerDraftCount}</span>}</button></div>
|
||||
</div>
|
||||
<div className="create-guide"><b>ⓘ 回答截图审核</b><span>用户提交截图后会立即进入此审核队列。按「用户ID」分组核对识别的问题,勾选需要派发的问题,回答可重新生成或直接编辑,确认后生成任务。</span></div>
|
||||
{answerLink && <div className="answer-link-bar"><div><strong>我的回答任务专属链接</strong><span>长期固定,用户通过此链接提交的截图只会进入您的审核队列</span></div><input readOnly value={answerLink} onFocus={event => event.target.select()} /><button type="button" onClick={copyAnswerLink}>{linkCopied ? '已复制' : '复制链接'}</button></div>}
|
||||
{error && <div className="form-error create-task-error">{error}</div>}
|
||||
<div className="create-select-all">
|
||||
<label><input className="task-checkbox" type="checkbox" checked={editableQuestions.length > 0 && selectedCount === editableQuestions.length} disabled={!editableQuestions.length} onChange={event => toggleMany(editableQuestions, event.target.checked)}/><span>全选所有问题</span></label>
|
||||
@ -137,31 +295,55 @@ export default function CreateAnswerTasksPageV2({ api }) {
|
||||
const selected = record.questions.filter(question => question.selected).length
|
||||
const allSelected = record.questions.length > 0 && selected === record.questions.length
|
||||
const identifying = record.questions.length === 0 && ['待筛选', '待运营派发'].includes(record.status)
|
||||
const recognitionFailed = record.status === '识别失败' || record.recognition_failed
|
||||
const open = expanded[record.id]
|
||||
return <article className="answer-draft-group panel" key={record.id}>
|
||||
<header className="draft-group-header">
|
||||
<button className="draft-expand" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{open ? '⌄' : '›'}</button>
|
||||
<label className="draft-group-select"><input className="task-checkbox" type="checkbox" checked={allSelected} disabled={locked || identifying} onChange={event => toggleMany(record.questions.map(question => ({ ...question, locked })), event.target.checked)}/></label>
|
||||
<div className="draft-user-meta"><strong>{record.user_id}</strong><span>用户_{record.user_id.slice(1)}</span><code>{record.submission_no}</code><em>已选 {selected}/{record.questions.length} 题</em></div>
|
||||
<div className="draft-user-meta"><strong>淘宝ID:{record.user_id}</strong><code>{record.submission_no}</code><em>已选 {selected}/{record.questions.length} 题</em></div>
|
||||
{identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>}
|
||||
<button className="draft-fold" disabled={identifying || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button>
|
||||
<button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => publish(record)}>{locked ? '已派发' : identifying ? '识别中…' : '生成任务'}</button>
|
||||
{recognitionFailed && <span className="draft-recognition-failed" role="alert">识别失败,请重新识别</span>}
|
||||
<button className="draft-fold" disabled={identifying || recognitionFailed || detailBusy === record.id} onClick={() => toggleOpen(record)}>{detailBusy === record.id ? '正在加载图片…' : open ? '点击折叠' : '点击展开'}</button>
|
||||
<button className="primary-button" disabled={!!busy || locked || identifying} onClick={() => recognitionFailed ? retryRecognition(record) : openPublish(record)}>{busy === record.id && recognitionFailed ? '重新识别中…' : locked ? '已派发' : identifying ? '识别中…' : recognitionFailed ? '重新识别' : '生成任务'}</button>
|
||||
<button className="draft-reject-button" disabled={!!busy || locked || identifying} onClick={() => setRejecting(record)}>打回</button>
|
||||
</header>
|
||||
{open && record.questions.map(question => <section className={`answer-draft-question ${question.selected ? 'selected' : ''}`} key={question.id}>
|
||||
{open && record.questions.map(question => <React.Fragment key={question.id}><section className={`answer-draft-question ${question.selected ? 'selected' : ''}`}>
|
||||
<label className="question-check"><input className="task-checkbox" type="checkbox" checked={question.selected} disabled={locked} onChange={event => updateQuestion(question, { selected: event.target.checked })}/></label>
|
||||
<div className="answer-draft-content">
|
||||
<div className="draft-question-title"><h3>Q{question.sequence_no}:{question.question}</h3></div>
|
||||
<div className="answer-product-line">
|
||||
{question.product_image && <img src={question.product_image} alt="商品主图"/>}
|
||||
<span><b>{question.product_title}</b><small>来源:模拟OCR识别</small></span>
|
||||
<span><b>{question.product_title}</b><small>来源:图片OCR识别</small></span>
|
||||
{question.source_image && <div className="source-image-wrap"><small>识图原始截图:</small><img className="source-shot" src={question.source_image} alt="识别原始截图"/></div>}
|
||||
</div>
|
||||
<textarea value={question.answer} disabled={locked} onChange={event => setQuestionLocal(question.id, { answer: event.target.value })} onBlur={event => updateQuestion({ ...question, answer: event.target.value }, { answer: event.target.value })}/>
|
||||
<textarea value={question.answer} disabled={locked} onChange={event => { editingAnswersRef.current.add(question.id); setQuestionLocal(question.id, { answer: event.target.value }) }} onBlur={event => updateQuestion({ ...question, answer: event.target.value }, { answer: event.target.value })}/>
|
||||
</div>
|
||||
<button className="draft-icon-button" title="重新生成回答" disabled={!!busy || locked} onClick={() => updateQuestion(question, { regenerate: true })}>↻</button>
|
||||
</section>)}
|
||||
</section><div className="operator-image-panel">
|
||||
<div><strong>运营指定回答图片(选填)</strong><small>用户端会看到这张图片,请用户在回答本题时一并发布。</small></div>
|
||||
<label className="operator-image-upload"><input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={event => uploadOperatorImage(question, event)} disabled={!!busy || locked}/>{question.operator_images?.length ? '重新上传指定图片(最多3张)' : '+ 上传指定图片(最多3张)'}</label>
|
||||
{!!question.operator_images?.length && <div className="operator-image-list">{question.operator_images.map((image, index) => <figure key={image.id || image.data_url}><img src={image.data_url || image.url || image} alt="运营指定图片"/><figcaption>指定图片 {index + 1}</figcaption></figure>)}</div>}
|
||||
</div></React.Fragment>)}
|
||||
</article>
|
||||
})}
|
||||
</div>
|
||||
{publishing && <div className="manual-modal-backdrop" onMouseDown={event => event.target === event.currentTarget && closePublish()}>
|
||||
<form className="manual-modal answer-publish-dialog" onSubmit={event => { event.preventDefault(); publish() }}>
|
||||
<div className="manual-modal-header"><div><h3>生成回答任务</h3><p>确认后将派发用户 {publishing.user_id} 已勾选的问题</p></div><button type="button" disabled={!!busy} onClick={closePublish}>×</button></div>
|
||||
<label>电商平台 *<input autoFocus required value={publishForm.ecommerce_platform} onChange={event => updatePublishField('ecommerce_platform', event.target.value)} placeholder="请输入电商平台,如淘宝" maxLength={64}/></label>
|
||||
<label>店铺名称 *<input required value={publishForm.shop_name} onChange={event => updatePublishField('shop_name', event.target.value)} placeholder="请输入店铺名称" maxLength={255}/></label>
|
||||
<label>商品ID *<input required value={publishForm.product_id} onChange={event => updatePublishField('product_id', event.target.value)} placeholder="请输入商品ID" maxLength={128}/></label>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<div className="manual-modal-actions"><button type="button" disabled={!!busy} onClick={closePublish}>取消</button><button type="submit" className="primary-button" disabled={!!busy}>{busy ? '生成中…' : '确认生成'}</button></div>
|
||||
</form>
|
||||
</div>}
|
||||
{rejecting && <div className="manual-modal-backdrop" onMouseDown={event => event.target === event.currentTarget && setRejecting(null)}>
|
||||
<section className="manual-modal draft-reject-modal" role="dialog" aria-modal="true" aria-labelledby="draft-reject-title">
|
||||
<div className="manual-modal-header"><h3 id="draft-reject-title">确认打回</h3><button type="button" onClick={() => setRejecting(null)}>×</button></div>
|
||||
<p>确认打回用户 {rejecting.user_id} 的这条回答截图吗?打回后将直接从生成任务列表删除。</p>
|
||||
<div className="manual-modal-actions"><button type="button" onClick={() => setRejecting(null)}>取消</button><button type="button" className="danger-solid-button" disabled={!!busy} onClick={() => reject(rejecting)}>{busy ? '处理中…' : '确认打回'}</button></div>
|
||||
</section>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
|
||||
@ -1,57 +1,361 @@
|
||||
import React, { useState } from "react";
|
||||
import { Check, Link2, Plus, Sparkles } from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Check, Copy, FileUp, Link2, Plus, Sparkles, X } from "lucide-react";
|
||||
import jsQR from "jsqr";
|
||||
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
|
||||
import { clearPageCache, readPageCache, writePageCache } from "./pageCache.js";
|
||||
|
||||
const blankForm = {
|
||||
shop_name: "",
|
||||
ecommerce_platform: "",
|
||||
product_id: "",
|
||||
product_link: "",
|
||||
product_qr_image: "",
|
||||
product_qr_image_name: "",
|
||||
product_name: "",
|
||||
category: "",
|
||||
benefits: "",
|
||||
selling_points: "",
|
||||
language_style: "口语(亲切随性)",
|
||||
target_group: "女性",
|
||||
usage_scene: "居家",
|
||||
purchase_channel: "电商平台",
|
||||
question_count: 5,
|
||||
language_style: "",
|
||||
target_group: "",
|
||||
usage_scene: "",
|
||||
purchase_channel: "",
|
||||
question_count: "",
|
||||
claim_questions_per_user: "5",
|
||||
};
|
||||
|
||||
async function readReferenceFile(file) {
|
||||
const ADDED_QUESTION_SOURCES = new Set(["自主添加", "问答库", "批量导入"]);
|
||||
|
||||
function questionKey(value) {
|
||||
return String(value || "").trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function dedupeQuestions(items = []) {
|
||||
const seen = new Set();
|
||||
return items.filter((item) => {
|
||||
const key = questionKey(item?.question);
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isAddedQuestion(item) {
|
||||
return ADDED_QUESTION_SOURCES.has(item?.source) || String(item?.id || "").startsWith("new-");
|
||||
}
|
||||
|
||||
function withoutTransientProductFields(value = {}) {
|
||||
const { product_qr: _discardedProductQr, ...rest } = value;
|
||||
return {
|
||||
...rest,
|
||||
product_id: "",
|
||||
product_link: "",
|
||||
product_qr_image: "",
|
||||
product_qr_image_name: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function readReferenceFile(file, requestApi) {
|
||||
if (!file) return "";
|
||||
if (/\.xlsx$/i.test(file.name)) {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const result = await requestApi("/api/admin/reference-qa/parse", { method: "POST", body });
|
||||
return result.data.text || "";
|
||||
}
|
||||
return (await file.text()).slice(0, 20000);
|
||||
}
|
||||
|
||||
export default function CreateAskTasksPage({ api }) {
|
||||
const [form, setForm] = useState(blankForm);
|
||||
const [task, setTask] = useState(null);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed, answerDraftCount = 0 }) {
|
||||
const cached = readPageCache("create-ask-task", {});
|
||||
const [form, setForm] = useState(() => {
|
||||
// Product identifiers and QR/token data are one-time publish inputs. Never
|
||||
// restore them from an older page cache; only shop and platform may carry
|
||||
// over to the next task.
|
||||
const restored = withoutTransientProductFields({ ...blankForm, ...(cached.form || {}) });
|
||||
if (!String(restored.claim_questions_per_user ?? "").trim()) {
|
||||
restored.claim_questions_per_user = "5";
|
||||
}
|
||||
return restored;
|
||||
});
|
||||
// Only a task explicitly marked as a draft can be resumed. Older cached
|
||||
// payloads did not include `status`, so treating them as editable could
|
||||
// resend a published task id and leave the operator stuck on this page.
|
||||
const resumableCachedTask = cached.task?.status === "草稿" ? cached.task : null;
|
||||
const [task, setTask] = useState(resumableCachedTask);
|
||||
const [questions, setQuestions] = useState(() =>
|
||||
resumableCachedTask ? dedupeQuestions(cached.questions || []) : [],
|
||||
);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [resolvingProduct, setResolvingProduct] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [referenceFile, setReferenceFile] = useState(null);
|
||||
const referenceInputRef = useRef(null);
|
||||
const productQrInputRef = useRef(null);
|
||||
const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
|
||||
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
|
||||
const [addDialog, setAddDialog] = useState(null);
|
||||
const [publishOpen, setPublishOpen] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const productQrPasteRef = useRef(null);
|
||||
const processingLibrarySelection = useRef(false);
|
||||
const consumedLibrarySelectionKey = useRef("");
|
||||
useEffect(() => {
|
||||
writePageCache("create-ask-task", {
|
||||
form: withoutTransientProductFields(form),
|
||||
task: task ? withoutTransientProductFields(task) : task,
|
||||
questions,
|
||||
referenceQa,
|
||||
referenceFileName,
|
||||
});
|
||||
}, [form, task, questions, referenceQa, referenceFileName]);
|
||||
useEffect(() => {
|
||||
if (!librarySelection.length) {
|
||||
consumedLibrarySelectionKey.current = "";
|
||||
return;
|
||||
}
|
||||
const selectionKey = librarySelection
|
||||
.map((item) => `${item.id}:${questionKey(item.question)}`)
|
||||
.join("|");
|
||||
if (consumedLibrarySelectionKey.current === selectionKey || processingLibrarySelection.current) return;
|
||||
// Mark before the async requests start. This also makes the effect safe
|
||||
// under React StrictMode, which intentionally re-runs effects in dev.
|
||||
consumedLibrarySelectionKey.current = selectionKey;
|
||||
processingLibrarySelection.current = true;
|
||||
void addLibraryQuestions(librarySelection).finally(() => {
|
||||
processingLibrarySelection.current = false;
|
||||
onLibrarySelectionConsumed?.();
|
||||
});
|
||||
}, [librarySelection, onLibrarySelectionConsumed]);
|
||||
const update = (key, value) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
async function resolveProduct(sourceValue) {
|
||||
const source = String(sourceValue ?? form.product_link).trim();
|
||||
if (!source) return;
|
||||
setResolvingProduct(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api("/api/admin/product-metadata/resolve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source }),
|
||||
});
|
||||
const metadata = result.data || {};
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
product_link: source,
|
||||
ecommerce_platform: metadata.ecommerce_platform || current.ecommerce_platform,
|
||||
product_id: metadata.product_id || current.product_id,
|
||||
shop_name: metadata.shop_name || current.shop_name,
|
||||
product_name: current.product_name || metadata.product_name || "",
|
||||
}));
|
||||
if (!metadata.ecommerce_platform && !metadata.product_id && !metadata.shop_name) {
|
||||
setError("暂未识别出商品信息,请检查淘口令/二维码或手工填写");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "商品信息识别失败");
|
||||
} finally {
|
||||
setResolvingProduct(false);
|
||||
}
|
||||
}
|
||||
async function decodeProductQr(file) {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
bitmap.close?.();
|
||||
const rawValue = String(jsQR(image.data, image.width, image.height)?.data || "").trim();
|
||||
if (rawValue) await resolveProduct(rawValue);
|
||||
} catch {
|
||||
// Keep the uploaded QR for the task even when this browser cannot decode it.
|
||||
}
|
||||
}
|
||||
async function selectReferenceFile(file) {
|
||||
setReferenceFile(file);
|
||||
setReferenceFileName(file?.name || "");
|
||||
setError("");
|
||||
if (!file) {
|
||||
setReferenceQa("");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setReferenceQa(await readReferenceFile(file, api));
|
||||
} catch (err) {
|
||||
setError(err.message || "参考文件读取失败");
|
||||
}
|
||||
}
|
||||
function clearReferenceFile() {
|
||||
setReferenceFile(null);
|
||||
setReferenceFileName("");
|
||||
setReferenceQa("");
|
||||
if (referenceInputRef.current) referenceInputRef.current.value = "";
|
||||
}
|
||||
|
||||
function selectProductQr(file) {
|
||||
setError("");
|
||||
if (!file) return;
|
||||
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
if (!allowed.has(file.type)) {
|
||||
setError("商品二维码仅支持 JPG、PNG 或 WEBP 图片");
|
||||
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError("商品二维码图片不能超过 5MB");
|
||||
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
product_qr_image: String(reader.result || ""),
|
||||
product_qr_image_name: file.name,
|
||||
}));
|
||||
void decodeProductQr(file);
|
||||
};
|
||||
reader.onerror = () => setError("商品二维码图片读取失败,请重试");
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function clearProductQr() {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
product_qr_image: "",
|
||||
product_qr_image_name: "",
|
||||
}));
|
||||
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||
}
|
||||
|
||||
function clearTransientProductInputs() {
|
||||
setForm((current) => withoutTransientProductFields(current));
|
||||
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||
}
|
||||
|
||||
function closePublishDialog() {
|
||||
if (busy) return;
|
||||
clearTransientProductInputs();
|
||||
setPublishOpen(false);
|
||||
setError("");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!publishOpen) return undefined;
|
||||
const pasteProductQr = (event) => {
|
||||
const imageItem = Array.from(event.clipboardData?.items || []).find((item) =>
|
||||
item.kind === "file" && item.type.startsWith("image/"),
|
||||
);
|
||||
if (!imageItem) return;
|
||||
const blob = imageItem.getAsFile?.();
|
||||
if (!blob) {
|
||||
setError("读取剪贴板图片失败,请重新复制后再试");
|
||||
return;
|
||||
}
|
||||
const extension = blob.type === "image/jpeg" ? "jpg" : blob.type.split("/")[1] || "png";
|
||||
const pastedFile = new File(
|
||||
[blob],
|
||||
`product-qr-${Date.now()}.${extension}`,
|
||||
{ type: blob.type, lastModified: Date.now() },
|
||||
);
|
||||
event.preventDefault();
|
||||
selectProductQr(pastedFile);
|
||||
};
|
||||
window.addEventListener("paste", pasteProductQr);
|
||||
const focusTimer = window.setTimeout(() => productQrPasteRef.current?.focus(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
window.removeEventListener("paste", pasteProductQr);
|
||||
};
|
||||
}, [publishOpen]);
|
||||
|
||||
function clearAll() {
|
||||
if (busy) return;
|
||||
clearPageCache("create-ask-task");
|
||||
setForm({ ...blankForm });
|
||||
setTask(null);
|
||||
setQuestions([]);
|
||||
setEditing(null);
|
||||
setError("");
|
||||
setReferenceFile(null);
|
||||
setReferenceFileName("");
|
||||
setReferenceQa("");
|
||||
setAddDialog(null);
|
||||
setPublishOpen(false);
|
||||
if (referenceInputRef.current) referenceInputRef.current.value = "";
|
||||
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const intentError = analyzeProductIntent([
|
||||
form.product_name,
|
||||
form.category,
|
||||
form.benefits,
|
||||
form.selling_points,
|
||||
]);
|
||||
if (intentError) {
|
||||
setError(intentError);
|
||||
return;
|
||||
}
|
||||
const optionsError = validateGenerationOptions({ ...form, claim_questions_per_user: "" }, false);
|
||||
if (optionsError) {
|
||||
setError(optionsError);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const reference_qa = await readReferenceFile(referenceFile);
|
||||
// A stale browser cache may contain a task that was already published
|
||||
// before the page received the publish response. Never send that id to
|
||||
// the draft-generation endpoint; start a fresh draft instead.
|
||||
const draftTaskId = task?.status === "草稿" ? task.id : null;
|
||||
if (task && !draftTaskId) {
|
||||
setTask(null);
|
||||
setQuestions([]);
|
||||
}
|
||||
const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
|
||||
setReferenceQa(reference_qa);
|
||||
const pendingQuestions = dedupeQuestions(questions.filter((item) => String(item.id).startsWith("new-")).map((item) => ({ question: item.question, source: item.source || "自主添加" })).filter((item) => item.question));
|
||||
const result = await api("/api/admin/ask-tasks/generate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
question_count: Number(form.question_count),
|
||||
...(draftTaskId ? { task_id: draftTaskId } : {}),
|
||||
reference_qa,
|
||||
}),
|
||||
});
|
||||
setTask(result.data);
|
||||
setQuestions(
|
||||
result.data.selected_questions.map((item) => ({
|
||||
...item,
|
||||
selected: true,
|
||||
})),
|
||||
);
|
||||
const generatedQuestions = result.data.selected_questions.map((item) => ({ ...item, selected: true }));
|
||||
if (!draftTaskId && pendingQuestions.length) {
|
||||
const importedQuestions = pendingQuestions.filter((item) => item.source === "批量导入");
|
||||
const individualQuestions = pendingQuestions.filter((item) => item.source !== "批量导入");
|
||||
if (importedQuestions.length) {
|
||||
const imported = await api(`/api/admin/ask-tasks/${result.data.id}/questions/bulk`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ questions: importedQuestions.map((item) => item.question) }),
|
||||
});
|
||||
generatedQuestions.push(...imported.data.items);
|
||||
}
|
||||
const added = [];
|
||||
for (const item of individualQuestions) {
|
||||
added.push(await api(`/api/admin/ask-tasks/${result.data.id}/questions`, { method: "POST", body: JSON.stringify({ question: item.question, source: item.source }) }));
|
||||
}
|
||||
generatedQuestions.push(...added.map((item, index) => ({ ...item.data, source: individualQuestions[index].source })));
|
||||
}
|
||||
const priorSourceByQuestion = new Map(questions.map((item) => [item.question, item.source]));
|
||||
setQuestions(dedupeQuestions(generatedQuestions.map((item) => ({ ...item, source: item.source || priorSourceByQuestion.get(item.question) || "AI生成" }))));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
if (String(err.message || "").includes("已发布任务不能重新生成")) {
|
||||
clearPageCache("create-ask-task");
|
||||
setTask(null);
|
||||
setQuestions([]);
|
||||
setError("这条任务已经发布,已为你清空旧任务;请再次点击“生成提问”创建新的测试任务。");
|
||||
} else {
|
||||
setError(err.message || "生成失败,请检查参考文件和大模型配置后重试");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -86,33 +390,170 @@ export default function CreateAskTasksPage({ api }) {
|
||||
body: JSON.stringify({ selected }),
|
||||
});
|
||||
}
|
||||
async function addQuestion() {
|
||||
const value = window.prompt("请输入提问内容");
|
||||
if (!value?.trim()) return;
|
||||
if (!task)
|
||||
return setQuestions((current) => [
|
||||
...current,
|
||||
{ id: `new-${Date.now()}`, question: value.trim(), selected: true },
|
||||
]);
|
||||
function addQuestion() {
|
||||
setAddDialog("choose");
|
||||
}
|
||||
|
||||
async function removeQuestion(index) {
|
||||
const question = questions[index];
|
||||
if (!question) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api(`/api/admin/ask-tasks/${task.id}/questions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ question: value.trim() }),
|
||||
});
|
||||
setQuestions((current) => [...current, result.data]);
|
||||
if (task && !String(question.id).startsWith("new-")) {
|
||||
await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, { method: "DELETE" });
|
||||
}
|
||||
setQuestions((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
setEditing((current) => current === index ? null : current > index ? current - 1 : current);
|
||||
} catch (err) {
|
||||
setError(err.message || "删除提问失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAddedQuestions() {
|
||||
const addedQuestions = questions.filter(isAddedQuestion);
|
||||
if (!addedQuestions.length) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (task) {
|
||||
for (const question of addedQuestions) {
|
||||
if (!String(question.id).startsWith("new-")) {
|
||||
await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
}
|
||||
setQuestions((current) => current.filter((question) => !isAddedQuestion(question)));
|
||||
setEditing(null);
|
||||
} catch (err) {
|
||||
setError(err.message || "清除新增提问失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function addManualQuestion(value) {
|
||||
const text = value.trim();
|
||||
if (!text) return;
|
||||
if (!task) {
|
||||
setQuestions((current) => dedupeQuestions([...current, { id: `new-${Date.now()}`, question: text, selected: true, source: "自主添加" }]));
|
||||
setAddDialog(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api(`/api/admin/ask-tasks/${task.id}/questions`, { method: "POST", body: JSON.stringify({ question: text, source: "自主添加" }) });
|
||||
setQuestions((current) => dedupeQuestions([...current, { ...result.data, source: result.data.source || "自主添加" }]));
|
||||
setAddDialog(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
async function publish() {
|
||||
if (!task) return setError("请先生成提问");
|
||||
function openLibrary() {
|
||||
setAddDialog(null);
|
||||
window.dispatchEvent(new CustomEvent("open-qa-library-picker"));
|
||||
}
|
||||
async function addLibraryQuestions(items = []) {
|
||||
const existingTexts = new Set(questions.map((item) => questionKey(item.question)));
|
||||
const seenSelection = new Set();
|
||||
const selected = items.filter((item) => {
|
||||
const text = questionKey(item.question);
|
||||
if (!text || existingTexts.has(text) || seenSelection.has(text)) return false;
|
||||
seenSelection.add(text);
|
||||
return true;
|
||||
});
|
||||
if (!selected.length) return;
|
||||
try {
|
||||
if (!task) {
|
||||
setQuestions((current) => dedupeQuestions([...current, ...selected.map((item, index) => ({ id: `new-library-${item.id}-${Date.now()}-${index}`, question: item.question, selected: true, source: "问答库" }))]));
|
||||
} else {
|
||||
const added = [];
|
||||
for (const item of selected) {
|
||||
added.push(await api(`/api/admin/ask-tasks/${task.id}/questions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ question: item.question, source: "问答库" }),
|
||||
}));
|
||||
}
|
||||
setQuestions((current) => dedupeQuestions([...current, ...added.map((result) => ({ ...result.data, source: result.data.source || "问答库" }))]));
|
||||
}
|
||||
setAddDialog(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
async function addImportedQuestions(values = []) {
|
||||
const existingTexts = new Set(questions.map((item) => questionKey(item.question)));
|
||||
const selected = dedupeQuestions(
|
||||
values
|
||||
.map((value) => ({ question: String(value || "").trim() }))
|
||||
.filter((item) => item.question && !existingTexts.has(questionKey(item.question))),
|
||||
);
|
||||
if (!selected.length) throw new Error("文件中的问题都已存在,无需重复导入");
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api(`/api/admin/ask-tasks/${task.id}/publish`, {
|
||||
if (!task) {
|
||||
const importedAt = Date.now();
|
||||
setQuestions((current) => dedupeQuestions([
|
||||
...current,
|
||||
...selected.map((item, index) => ({
|
||||
id: `new-import-${importedAt}-${index}`,
|
||||
question: item.question,
|
||||
selected: true,
|
||||
source: "批量导入",
|
||||
})),
|
||||
]));
|
||||
} else {
|
||||
const result = await api(`/api/admin/ask-tasks/${task.id}/questions/bulk`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ questions: selected.map((item) => item.question) }),
|
||||
});
|
||||
setTask(result.data);
|
||||
setQuestions((current) => dedupeQuestions([
|
||||
...current,
|
||||
...result.data.items.map((item) => ({ ...item, source: item.source || "批量导入" })),
|
||||
]));
|
||||
}
|
||||
setAddDialog(null);
|
||||
} catch (err) {
|
||||
setError(err.message || "批量导入失败");
|
||||
throw err;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function publish() {
|
||||
const checkedQuestions = questions.filter((item) => item.selected && item.question.trim());
|
||||
const selectedQuestions = checkedQuestions.length ? checkedQuestions : questions.filter((item) => item.question.trim());
|
||||
if (!selectedQuestions.length) return setError("请至少添加一个提问");
|
||||
if (!form.shop_name.trim()) return setError("请填写店铺名称");
|
||||
if (!form.ecommerce_platform.trim()) return setError("请填写电商平台");
|
||||
if (!form.product_id.trim()) return setError("请填写商品ID");
|
||||
if (!form.product_link.trim() && !form.product_qr_image) return setError("请填写商品淘口令或上传商品二维码");
|
||||
const claimLimit = Number(form.claim_questions_per_user);
|
||||
if (!Number.isInteger(claimLimit) || claimLimit < 2 || claimLimit > 20) return setError("每个用户可领取的问题数量必须填写 2-20 的整数");
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api("/api/admin/ask-tasks/publish", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
claim_questions_per_user: claimLimit,
|
||||
...(task?.status === "草稿" ? { task_id: task.id } : {}),
|
||||
questions: selectedQuestions.map((item) => ({
|
||||
id: item.id,
|
||||
question: item.question.trim(),
|
||||
source: item.source || "自主添加",
|
||||
})),
|
||||
}),
|
||||
});
|
||||
// A successful response from this endpoint is authoritative even when
|
||||
// an older backend build omits the raw workflow state from its payload.
|
||||
// Keep the state explicit so the success/link view renders on the first
|
||||
// click instead of waiting for a second request to return 409.
|
||||
setTask({ ...result.data, status: result.data.status || "已发布" });
|
||||
clearTransientProductInputs();
|
||||
setPublishOpen(false);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@ -120,7 +561,29 @@ export default function CreateAskTasksPage({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (task?.status === "已发布")
|
||||
async function copyTaskLink() {
|
||||
const taskLink = `${window.location.origin}/ask/${task.public_token}`;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(taskLink);
|
||||
} else {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = taskLink;
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
setLinkCopied(true);
|
||||
window.setTimeout(() => setLinkCopied(false), 1600);
|
||||
} catch {
|
||||
setError("复制失败,请手动选择链接复制");
|
||||
}
|
||||
}
|
||||
|
||||
if (task?.status === "已发布" || task?.status_label === "已发布")
|
||||
return (
|
||||
<>
|
||||
<div className="create-page-heading">
|
||||
@ -137,15 +600,25 @@ export default function CreateAskTasksPage({ api }) {
|
||||
<p>
|
||||
任务ID:<strong>{task.task_id}</strong>
|
||||
</p>
|
||||
<div className="ask-created-summary">
|
||||
<span>店铺名称<b>{task.shop_name || form.shop_name || "--"}</b></span>
|
||||
<span>商品名称<b>{task.product_name}</b></span>
|
||||
</div>
|
||||
<div className="ask-link-box">
|
||||
<span>
|
||||
<Link2 size={14} />
|
||||
提问任务链接
|
||||
</span>
|
||||
<div className="ask-link-value">
|
||||
<code>
|
||||
{window.location.origin}/ask/{task.task_id}
|
||||
{window.location.origin}/ask/{task.public_token}
|
||||
</code>
|
||||
<small>当前链接为任务预留入口,后续接入提问用户页面。</small>
|
||||
<button type="button" onClick={copyTaskLink}>
|
||||
{linkCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{linkCopied ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
</div>
|
||||
<small>该随机链接用于用户领取任务,请勿通过任务ID拼接访问。</small>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
@ -159,9 +632,12 @@ export default function CreateAskTasksPage({ api }) {
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
clearPageCache("create-ask-task");
|
||||
setForm(blankForm);
|
||||
setQuestions([]);
|
||||
setTask(null);
|
||||
clearReferenceFile();
|
||||
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
继续创建新任务
|
||||
@ -186,6 +662,9 @@ export default function CreateAskTasksPage({ api }) {
|
||||
}
|
||||
>
|
||||
创建回答任务
|
||||
{answerDraftCount > 0 && (
|
||||
<span className="create-mode-badge">{answerDraftCount > 99 ? "99+" : answerDraftCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -199,16 +678,9 @@ export default function CreateAskTasksPage({ api }) {
|
||||
<section className="ask-config">
|
||||
<div className="panel-title">
|
||||
<Sparkles size={17} />
|
||||
生成条件配置 <small>* 商品链接为必填项</small>
|
||||
生成条件配置 <small>以下内容仅用于 AI 生成提问,发布信息将在发布时填写</small>
|
||||
<button type="button" className="ask-clear-all" disabled={busy} onClick={clearAll}>一键清空</button>
|
||||
</div>
|
||||
<label>
|
||||
商品链接 *
|
||||
<input
|
||||
value={form.product_link}
|
||||
onChange={(event) => update("product_link", event.target.value)}
|
||||
placeholder="粘贴商品链接,例如:https://item.jd.com/100028981.html"
|
||||
/>
|
||||
</label>
|
||||
<h4>商品信息</h4>
|
||||
<label>
|
||||
商品名称
|
||||
@ -246,10 +718,21 @@ export default function CreateAskTasksPage({ api }) {
|
||||
</label>
|
||||
<label className="reference-qa-upload">
|
||||
真实问答参考(选填)
|
||||
<input type="file" accept=".txt,.csv,.json,.md,text/plain,text/csv,application/json,text/markdown" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
|
||||
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown,生成提问时会参考其中的问题角度"}</small>
|
||||
<input ref={referenceInputRef} type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => selectReferenceFile(event.target.files?.[0] || null)} />
|
||||
<small>{referenceFileName ? `已选择:${referenceFileName}` : "支持 TXT、CSV、JSON、Markdown、XLSX,生成提问时会参考其中的问题角度"}</small>
|
||||
{referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
|
||||
</label>
|
||||
<h4>生成偏好与数量</h4>
|
||||
<label>
|
||||
提问生成条数 *
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
value={form.question_count}
|
||||
onChange={(event) => update("question_count", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="two-col">
|
||||
<label>
|
||||
语言风格
|
||||
@ -259,6 +742,7 @@ export default function CreateAskTasksPage({ api }) {
|
||||
update("language_style", event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">请选择语言风格</option>
|
||||
<option>口语(亲切随性)</option>
|
||||
<option>专业</option>
|
||||
<option>幽默</option>
|
||||
@ -270,21 +754,13 @@ export default function CreateAskTasksPage({ api }) {
|
||||
value={form.target_group}
|
||||
onChange={(event) => update("target_group", event.target.value)}
|
||||
>
|
||||
<option value="">请选择目标人群</option>
|
||||
<option>女性</option>
|
||||
<option>男性</option>
|
||||
<option>学生</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
提问生成条数
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.question_count}
|
||||
onChange={(event) => update("question_count", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="two-col">
|
||||
<label>
|
||||
使用场景
|
||||
@ -292,6 +768,7 @@ export default function CreateAskTasksPage({ api }) {
|
||||
value={form.usage_scene}
|
||||
onChange={(event) => update("usage_scene", event.target.value)}
|
||||
>
|
||||
<option value="">请选择使用场景</option>
|
||||
<option>居家</option>
|
||||
<option>旅行</option>
|
||||
<option>通勤</option>
|
||||
@ -305,6 +782,7 @@ export default function CreateAskTasksPage({ api }) {
|
||||
update("purchase_channel", event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">请选择购买渠道</option>
|
||||
<option>电商平台</option>
|
||||
<option>大型商超</option>
|
||||
<option>药房</option>
|
||||
@ -323,6 +801,7 @@ export default function CreateAskTasksPage({ api }) {
|
||||
</section>
|
||||
<section className="ask-results">
|
||||
<div className="ask-toolbar">
|
||||
<div className="ask-toolbar-selection">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -342,20 +821,30 @@ export default function CreateAskTasksPage({ api }) {
|
||||
全选 ({questions.filter((item) => item.selected).length}/
|
||||
{questions.length})
|
||||
</label>
|
||||
<button className="ghost-button" onClick={addQuestion}>
|
||||
{questions.some(isAddedQuestion) && (
|
||||
<button type="button" className="ask-clear-added" disabled={busy} onClick={clearAddedQuestions}>
|
||||
一键清除新增({questions.filter(isAddedQuestion).length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ask-toolbar-actions">
|
||||
<button type="button" className="ghost-button" onClick={addQuestion}>
|
||||
<Plus size={14} />
|
||||
添加提问
|
||||
</button>
|
||||
<button type="button" className="ghost-button" disabled={busy} onClick={generate}>
|
||||
↻ 重新生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={
|
||||
!questions.some((item) => item.selected) || !task || busy
|
||||
}
|
||||
onClick={publish}
|
||||
disabled={!questions.length || busy}
|
||||
onClick={() => { setError(""); setPublishOpen(true); }}
|
||||
>
|
||||
发布任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{questions.length === 0 ? (
|
||||
<div className="ask-empty">
|
||||
点击左侧「生成提问」,AI 生成的提问将展示在这里,可逐条勾选。
|
||||
@ -386,7 +875,11 @@ export default function CreateAskTasksPage({ api }) {
|
||||
) : (
|
||||
<strong>{question.question}</strong>
|
||||
)}
|
||||
<button type="button" className="ask-question-remove" disabled={busy} onClick={() => removeQuestion(index)}>
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditing(editing === index ? null : index)
|
||||
}
|
||||
@ -394,13 +887,161 @@ export default function CreateAskTasksPage({ api }) {
|
||||
{editing === index ? "完成" : "编辑"}
|
||||
</button>
|
||||
</header>
|
||||
<small>来源:AI生成 · 可编辑后发布</small>
|
||||
<small>来源:{question.source || "AI生成"} · 可编辑后发布</small>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{addDialog === "choose" && (
|
||||
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setAddDialog(null)}>
|
||||
<section className="manual-modal ask-add-dialog" role="dialog" aria-modal="true" aria-labelledby="ask-add-title">
|
||||
<div className="manual-modal-header"><h3 id="ask-add-title">添加提问</h3><button type="button" onClick={() => setAddDialog(null)}><X size={18} /></button></div>
|
||||
<p>选择添加方式,新增提问不会覆盖已生成的问题。</p>
|
||||
<div className="ask-add-options">
|
||||
<button type="button" onClick={() => setAddDialog("manual")}>自主添加<span>手动输入问题</span></button>
|
||||
<button type="button" onClick={openLibrary}>问答库添加<span>进入问答库搜索并勾选</span></button>
|
||||
<button type="button" onClick={() => setAddDialog("import")}>批量导入<span>从本地文件导入问题</span></button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{addDialog === "manual" && <ManualAskQuestionModal onClose={() => setAddDialog(null)} onAdd={addManualQuestion} />}
|
||||
{addDialog === "import" && <ImportAskQuestionsModal api={api} onClose={() => setAddDialog(null)} onImport={addImportedQuestions} />}
|
||||
{publishOpen && (
|
||||
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && closePublishDialog()}>
|
||||
<form className="manual-modal ask-publish-dialog" onSubmit={(event) => { event.preventDefault(); publish(); }}>
|
||||
<div className="manual-modal-header">
|
||||
<div><h3>发布提问任务</h3><p>本次将发布 {questions.some((item) => item.selected) ? questions.filter((item) => item.selected).length : questions.length} 条问题</p></div>
|
||||
<button type="button" disabled={busy} onClick={closePublishDialog}><X size={18} /></button>
|
||||
</div>
|
||||
<label>
|
||||
店铺名称 *
|
||||
<input autoFocus value={form.shop_name} onChange={(event) => update("shop_name", event.target.value)} placeholder="请输入店铺名称" maxLength={255} />
|
||||
</label>
|
||||
<div className="ask-product-meta-grid">
|
||||
<label>电商平台 *<input required value={form.ecommerce_platform} onChange={(event) => update("ecommerce_platform", event.target.value)} placeholder="识别后自动填写,如淘宝" maxLength={64} /></label>
|
||||
<label>商品ID *<input required value={form.product_id} onChange={(event) => update("product_id", event.target.value)} placeholder="识别后自动填写" maxLength={128} /></label>
|
||||
</div>
|
||||
<div className="ask-product-token-field">
|
||||
<label>
|
||||
商品淘口令/二维码 *
|
||||
<div className="ask-product-resolve-row">
|
||||
<input value={form.product_link} onChange={(event) => update("product_link", event.target.value)} onBlur={() => resolveProduct()} placeholder="请输入商品淘口令或商品链接" />
|
||||
<button type="button" disabled={resolvingProduct || !form.product_link.trim()} onClick={() => resolveProduct()}>{resolvingProduct ? "识别中…" : "识别商品信息"}</button>
|
||||
</div>
|
||||
</label>
|
||||
<input ref={productQrInputRef} hidden type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => selectProductQr(event.target.files?.[0] || null)} />
|
||||
{form.product_qr_image ? (
|
||||
<div className="ask-product-qr-preview">
|
||||
<img src={form.product_qr_image} alt="商品二维码预览" />
|
||||
<div><strong>{form.product_qr_image_name || "商品二维码"}</strong><small>已上传商品二维码,可粘贴图片替换</small><button type="button" className="ghost-button" onClick={clearProductQr}>移除二维码</button></div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
ref={productQrPasteRef}
|
||||
type="button"
|
||||
className="ask-product-qr-upload"
|
||||
onClick={() => productQrInputRef.current?.click()}
|
||||
aria-label="粘贴或选择商品二维码"
|
||||
>
|
||||
<span>+ 粘贴或上传商品二维码</span>
|
||||
<small>复制图片后按 Ctrl+V 粘贴,也可选择 JPG、PNG、WEBP,单张不超过 5MB</small>
|
||||
</button>
|
||||
)}
|
||||
<small className="ask-product-token-hint">淘口令和二维码至少填写一种,也可以两者都填写;复制二维码图片后可直接按 Ctrl+V 粘贴</small>
|
||||
</div>
|
||||
<label>
|
||||
每个用户可领取问题数 *
|
||||
<input type="number" min="2" max="20" step="1" value={form.claim_questions_per_user} onChange={(event) => update("claim_questions_per_user", event.target.value)} placeholder="请输入 2-20" />
|
||||
<small>每名用户在该链接中最多可领取 2-20 条问题,默认 5 条</small>
|
||||
</label>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<div className="manual-modal-actions"><button type="button" disabled={busy} onClick={closePublishDialog}>取消</button><button type="submit" className="primary-button" disabled={busy}>{busy ? "发布中…" : "确认发布"}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualAskQuestionModal({ onClose, onAdd }) {
|
||||
const [value, setValue] = useState("");
|
||||
return <div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}><form className="manual-modal ask-manual-dialog" onSubmit={(event) => { event.preventDefault(); onAdd(value); }}><div className="manual-modal-header"><h3>自主添加提问</h3><button type="button" onClick={onClose}><X size={18} /></button></div><label>问题内容<textarea autoFocus value={value} onChange={(event) => setValue(event.target.value)} placeholder="请输入要加入任务的问题" maxLength={500} /></label><div className="manual-modal-actions"><button type="button" onClick={onClose}>取消</button><button type="submit" className="primary-button" disabled={!value.trim()}>添加问题</button></div></form></div>;
|
||||
}
|
||||
|
||||
function ImportAskQuestionsModal({ api, onClose, onImport }) {
|
||||
const inputRef = useRef(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function selectFile(nextFile) {
|
||||
setFile(nextFile || null);
|
||||
setQuestions([]);
|
||||
setError("");
|
||||
if (!nextFile) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", nextFile);
|
||||
const result = await api("/api/admin/ask-questions/import", { method: "POST", body });
|
||||
setQuestions(result.data.questions || []);
|
||||
} catch (err) {
|
||||
setError(err.message || "文件解析失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
if (!questions.length || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await onImport(questions);
|
||||
} catch (err) {
|
||||
setError(err.message || "批量导入失败");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && !busy && onClose()}>
|
||||
<form className="manual-modal ask-import-dialog" onSubmit={submit}>
|
||||
<div className="manual-modal-header">
|
||||
<h3>批量导入提问</h3>
|
||||
<button type="button" disabled={busy} onClick={onClose}><X size={18} /></button>
|
||||
</div>
|
||||
<p>上传本地问题文件,识别后将追加到当前待发布列表。</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
onChange={(event) => selectFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<button type="button" className="ask-import-picker" disabled={busy} onClick={() => inputRef.current?.click()}>
|
||||
<FileUp size={22} />
|
||||
<span>{file ? file.name : "选择本地文件"}<small>支持 TXT、CSV、JSON、Markdown、XLSX,最大 5MB</small></span>
|
||||
</button>
|
||||
<small className="ask-import-rule">TXT、Markdown 每行一个问题;表格文件优先读取“问题”列。</small>
|
||||
{busy && !questions.length && <div className="ask-import-status">正在解析文件…</div>}
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
{questions.length > 0 && (
|
||||
<div className="ask-import-preview">
|
||||
<header><strong>识别到 {questions.length} 条问题</strong><span>导入时自动排除重复项</span></header>
|
||||
<ol>
|
||||
{questions.slice(0, 8).map((question, index) => <li key={`${question}-${index}`}>{question}</li>)}
|
||||
</ol>
|
||||
{questions.length > 8 && <small>还有 {questions.length - 8} 条未展开显示</small>}
|
||||
</div>
|
||||
)}
|
||||
<div className="manual-modal-actions">
|
||||
<button type="button" disabled={busy} onClick={onClose}>取消</button>
|
||||
<button type="submit" className="primary-button" disabled={!questions.length || busy}>{busy ? "导入中…" : `导入 ${questions.length || 0} 条问题`}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
229
frontend/src/RefundManagementPage.jsx
Normal file
229
frontend/src/RefundManagementPage.jsx
Normal file
@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Plus, Search, X, Pencil, Trash2, History } from "lucide-react";
|
||||
import { formatDateTime } from "./time.js";
|
||||
|
||||
const endpoint = "/api/admin/refund-payment-accounts";
|
||||
const money = (fen) => (fen / 100).toFixed(2);
|
||||
const emptyForm = () => ({ name: "", alipay_account: "", balance: "0.00", single_limit: "500.00", is_active: true });
|
||||
|
||||
export default function RefundManagementPage({ api }) {
|
||||
const [data, setData] = useState({ items: [], total: 0, summary: {} });
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [ledger, setLedger] = useState(null);
|
||||
const [ledgerLoading, setLedgerLoading] = useState(false);
|
||||
const [ledgerPage, setLedgerPage] = useState(1);
|
||||
const [ledgerPageSize, setLedgerPageSize] = useState(10);
|
||||
const [funding, setFunding] = useState(null);
|
||||
const [fundAmount, setFundAmount] = useState("");
|
||||
const [fundNote, setFundNote] = useState("");
|
||||
const [fundSaving, setFundSaving] = useState(false);
|
||||
const [fundError, setFundError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await api(`${endpoint}?${new URLSearchParams({ q: query, page, page_size: pageSize })}`);
|
||||
if (!cancelled) setData(result.data);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}, 250);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, [api, query, page, pageSize, reload]);
|
||||
|
||||
function open(account) {
|
||||
setEditing(account || {});
|
||||
setForm(account ? { name: account.name, alipay_account: account.alipay_account,
|
||||
balance: money(account.balance_fen), single_limit: money(account.single_limit_fen), is_active: account.is_active } : emptyForm());
|
||||
setSaveError("");
|
||||
}
|
||||
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
try {
|
||||
if (!form.name.trim() || !form.alipay_account.trim()) throw new Error("请填写账户名称和支付宝账户");
|
||||
const moneyFields = editing.id ? [["single_limit", "单次付款限额"]] : [["balance", "初始资金"], ["single_limit", "单次付款限额"]];
|
||||
for (const [key, label] of moneyFields) {
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(form[key]) || Number(form[key]) > 9999999.99 ||
|
||||
(key === "single_limit" && Number(form[key]) <= 0)) {
|
||||
throw new Error(`${label}需填写有效金额,最多两位小数,最高 9999999.99 元${key === "single_limit" ? ",且必须大于 0" : ""}`);
|
||||
}
|
||||
}
|
||||
await api(editing.id ? `${endpoint}/${editing.id}` : endpoint, {
|
||||
method: editing.id ? "PATCH" : "POST", body: JSON.stringify(form),
|
||||
});
|
||||
setEditing(null);
|
||||
setPage(1);
|
||||
setReload((n) => n + 1);
|
||||
} catch (err) {
|
||||
setSaveError(typeof err.message === "string" ? err.message : "保存失败,请检查填写内容");
|
||||
} finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function remove(account) {
|
||||
if (deleting !== null || !window.confirm(`确定删除付款账户“${account.name}”(${account.alipay_account})吗?删除后无法恢复。`)) return;
|
||||
setDeleting(account.id);
|
||||
setDeleteError("");
|
||||
try {
|
||||
await api(`${endpoint}/${account.id}`, { method: "DELETE" });
|
||||
setPage(1);
|
||||
setReload((n) => n + 1);
|
||||
} catch (err) {
|
||||
setDeleteError(err.message || "删除失败,请重试");
|
||||
} finally { setDeleting(null); }
|
||||
}
|
||||
|
||||
async function loadLedger(account, targetPage = 1, targetPageSize = ledgerPageSize) {
|
||||
setLedger({ account, items: [] });
|
||||
setLedgerLoading(true);
|
||||
try {
|
||||
const result = await api(endpoint + "/" + account.id + "/ledger?" + new URLSearchParams({ page: targetPage, page_size: targetPageSize }));
|
||||
setLedger(result.data);
|
||||
setLedgerPage(result.data.page);
|
||||
setLedgerPageSize(result.data.page_size);
|
||||
} catch (err) {
|
||||
setLedger({ account, items: [], error: err.message });
|
||||
} finally {
|
||||
setLedgerLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLedger(account) {
|
||||
setLedgerPage(1);
|
||||
await loadLedger(account, 1, ledgerPageSize);
|
||||
}
|
||||
|
||||
function openFunding(account, type) {
|
||||
setFunding({ account, type });
|
||||
setFundAmount("");
|
||||
setFundNote("");
|
||||
setFundError("");
|
||||
}
|
||||
|
||||
async function saveFunds(event) {
|
||||
event.preventDefault();
|
||||
if (fundSaving) return;
|
||||
setFundSaving(true);
|
||||
setFundError("");
|
||||
try {
|
||||
const amountPattern = funding.type === "追加资金" ? /^\d+(\.\d{1,2})?$/ : /^-?\d+(\.\d{1,2})?$/;
|
||||
const amount = Number(fundAmount);
|
||||
if (!amountPattern.test(fundAmount) || !Number.isFinite(amount) || amount === 0 || Math.abs(amount) > 9999999.99 ||
|
||||
(funding.type === "追加资金" && amount <= 0)) {
|
||||
throw new Error(funding.type === "追加资金" ? "追加金额必须大于 0,最多两位小数" : "请输入非 0 的调整金额,减少资金请填写负数");
|
||||
}
|
||||
if (funding.type === "资金调整" && !fundNote.trim()) throw new Error("资金调整必须填写原因");
|
||||
await api(`${endpoint}/${funding.account.id}/funds`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ adjustment_type: funding.type, amount: fundAmount, note: fundNote.trim() }),
|
||||
});
|
||||
setFunding(null);
|
||||
setReload((n) => n + 1);
|
||||
} catch (err) {
|
||||
setFundError(err.message || "资金操作失败,请重试");
|
||||
} finally {
|
||||
setFundSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const pages = Math.max(1, Math.ceil(data.total / pageSize));
|
||||
return <>
|
||||
<div className="page-heading"><div><h2>返款管理</h2><p>管理返款使用的支付宝付款账户。</p></div></div>
|
||||
<section className="panel refund-management">
|
||||
<div className="refund-fund-summary">
|
||||
<article><span>累计入账</span><strong>¥{money(data.summary?.balance_fen || 0)}</strong><small>初始资金与后续追加合计</small></article>
|
||||
<article><span>累计已使用</span><strong>¥{money(data.summary?.used_fen || 0)}</strong><small>已确认成功的返款</small></article>
|
||||
<article><span>处理中</span><strong>¥{money(data.summary?.processing_fen || 0)}</strong><small>结果未明确,暂时冻结</small></article>
|
||||
<article className="available"><span>可用余额</span><strong>¥{money(data.summary?.available_fen || 0)}</strong><small>累计入账减已使用和冻结</small></article>
|
||||
</div>
|
||||
<div className="refund-management-notice">页面显示的是系统账面资金,不代表支付宝实际余额。返款成功后自动计入已使用,处理中订单会冻结对应金额。</div>
|
||||
<div className="refund-management-toolbar">
|
||||
<label className="refund-account-search"><Search size={16} /><input aria-label="搜索付款账户" placeholder="搜索账户名称或支付宝账户" value={query} maxLength={128} onChange={(e) => { setQuery(e.target.value); setPage(1); }} /></label>
|
||||
<button className="primary-button" onClick={() => open(null)}><Plus size={16} />新增付款账户</button>
|
||||
</div>
|
||||
{error ? <div className="form-error" role="alert">{error}<button onClick={() => setReload((n) => n + 1)}>重试</button></div> : null}
|
||||
{deleteError && <div className="form-error" role="alert">{deleteError}</div>}
|
||||
<div className="refund-account-table-wrap" aria-busy={loading}>
|
||||
<table className="refund-account-table"><thead><tr>
|
||||
<th>类型</th><th>账户名称</th><th>支付宝账户</th><th>累计入账</th><th>已使用</th><th>处理中</th><th>可用余额</th><th>单次限额</th><th>状态</th><th>操作</th>
|
||||
</tr></thead><tbody>
|
||||
{loading ? <tr><td colSpan={10} className="empty-state">加载中…</td></tr> : error ? null : data.items.length ? data.items.map((account) => <tr key={account.id}>
|
||||
<td><span className="refund-account-tag">支付宝</span></td><td>{account.name}</td><td>{account.alipay_account}</td>
|
||||
<td>¥{money(account.balance_fen)}</td><td>¥{money(account.used_fen)}</td><td>¥{money(account.processing_fen)}</td>
|
||||
<td className="refund-account-balance">¥{money(account.available_fen)}</td><td>¥{money(account.single_limit_fen)}</td>
|
||||
<td><span className={`refund-account-tag ${account.is_active ? "enabled" : "disabled"}`}>{account.is_active ? "启用" : "禁用"}</span></td>
|
||||
<td><div className="refund-account-actions"><button className="refund-account-edit refund-account-add-funds" onClick={() => openFunding(account, "追加资金")}><Plus size={14} />追加资金</button><button className="refund-account-edit" onClick={() => openFunding(account, "资金调整")}><Pencil size={14} />资金调整</button><button className="refund-account-edit" onClick={() => openLedger(account)}><History size={14} />流水</button><button className="refund-account-edit" disabled={deleting !== null} onClick={() => open(account)}><Pencil size={14} />编辑</button><button className="refund-account-edit refund-account-delete" disabled={deleting !== null} onClick={() => remove(account)}><Trash2 size={14} />{deleting === account.id ? "删除中…" : "删除"}</button></div></td>
|
||||
</tr>) : <tr><td colSpan={10} className="empty-state">{query ? "未找到匹配账户" : "暂无付款账户,请点击新增付款账户"}</td></tr>}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div className="refund-account-pagination">
|
||||
<span>共 {data.total} 条记录</span><button disabled={loading || page <= 1} onClick={() => setPage((n) => n - 1)}>上一页</button>
|
||||
<span>{page} / {pages}</span><button disabled={loading || page >= pages} onClick={() => setPage((n) => n + 1)}>下一页</button>
|
||||
<select aria-label="每页条数" value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}>{[10, 20, 50].map((n) => <option key={n} value={n}>{n} 条/页</option>)}</select>
|
||||
</div>
|
||||
</section>
|
||||
{editing && <div className="refund-account-overlay"><section className="refund-account-modal" role="dialog" aria-modal="true" aria-labelledby="refund-account-title">
|
||||
<header><h2 id="refund-account-title">{editing.id ? "编辑付款账户" : "新增付款账户"}</h2><button aria-label="关闭" disabled={saving} onClick={() => setEditing(null)}><X size={20} /></button></header>
|
||||
<form onSubmit={save}>
|
||||
<label>账户类型<input value="支付宝" disabled /></label>
|
||||
<label>账户名称<input required maxLength={128} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="例如:公司名称或账户别名" /></label>
|
||||
<label>支付宝账户<input required maxLength={128} value={form.alipay_account} onChange={(e) => setForm({ ...form, alipay_account: e.target.value })} placeholder="支付宝手机号或邮箱" /></label>
|
||||
<div className="refund-account-money-fields">
|
||||
{editing.id ? <label>累计入账(不可直接修改)<input disabled value={money(editing.balance_fen)} /></label> : <label>初始资金(元)<input required type="number" min="0" max="9999999.99" step="0.01" value={form.balance} onChange={(e) => setForm({ ...form, balance: e.target.value })} /></label>}
|
||||
<label>单次付款限额(元)<input required type="number" min="0.01" max="9999999.99" step="0.01" value={form.single_limit} onChange={(e) => setForm({ ...form, single_limit: e.target.value })} /></label>
|
||||
</div>
|
||||
<label>状态<select value={String(form.is_active)} onChange={(e) => setForm({ ...form, is_active: e.target.value === "true" })}><option value="true">启用</option><option value="false">禁用</option></select></label>
|
||||
<p className="refund-account-hint">{editing.id ? "账户资金请使用“追加资金”或“资金调整”,每次变化都会记录流水。" : "初始资金保存后不能在编辑账户中直接修改,后续通过追加资金或资金调整维护。"}</p>
|
||||
{saveError && <div className="form-error" role="alert">{saveError}</div>}
|
||||
<footer><button type="button" disabled={saving} onClick={() => setEditing(null)}>取消</button><button className="primary-button" disabled={saving}>{saving ? "保存中…" : "保存"}</button></footer>
|
||||
</form>
|
||||
</section></div>}
|
||||
{ledger && <div className="refund-account-overlay"><section className="refund-ledger-modal" role="dialog" aria-modal="true">
|
||||
<header><div><h2>{ledger.account?.name || "付款账户"}资金流水</h2><p>资金调整、成功返款和处理中冻结记录</p></div><button aria-label="关闭" onClick={() => setLedger(null)}><X size={20} /></button></header>
|
||||
{ledgerLoading ? <div className="empty-state">加载中…</div> : ledger.error ? <div className="form-error">{ledger.error}</div> : <>
|
||||
<div className="refund-ledger-summary"><span>累计入账<b>¥{money(ledger.account.balance_fen)}</b></span><span>已使用<b>¥{money(ledger.account.used_fen)}</b></span><span>处理中<b>¥{money(ledger.account.processing_fen)}</b></span><span>可用余额<b>¥{money(ledger.account.available_fen)}</b></span></div>
|
||||
<div className="refund-ledger-list">{ledger.items.length ? ledger.items.map((item) => <article key={item.id}>
|
||||
<div><strong>{item.type_label}</strong><small>{item.order_no || item.note || "--"}</small></div>
|
||||
<div><b className={item.amount_fen >= 0 ? "income" : "expense"}>{item.amount_fen >= 0 ? "+" : "-"}¥{money(Math.abs(item.amount_fen))}</b><small>{formatDateTime(item.created_at)} · {item.operator_name || "系统"}</small></div>
|
||||
</article>) : <div className="empty-state">暂无资金流水</div>}</div>
|
||||
<div className="refund-ledger-pagination">
|
||||
<span>共 {ledger.total || 0} 条</span>
|
||||
<button disabled={ledgerLoading || ledgerPage <= 1} onClick={() => loadLedger(ledger.account, ledgerPage - 1, ledgerPageSize)}>上一页</button>
|
||||
<strong>{ledgerPage} / {ledger.total_pages || 1}</strong>
|
||||
<button disabled={ledgerLoading || ledgerPage >= (ledger.total_pages || 1)} onClick={() => loadLedger(ledger.account, ledgerPage + 1, ledgerPageSize)}>下一页</button>
|
||||
<select value={ledgerPageSize} onChange={(event) => loadLedger(ledger.account, 1, Number(event.target.value))}>{[5, 10, 20, 50].map((size) => <option key={size} value={size}>{size} 条/页</option>)}</select>
|
||||
</div>
|
||||
</>}
|
||||
</section></div>}
|
||||
{funding && <div className="refund-account-overlay"><section className="refund-account-modal refund-fund-modal" role="dialog" aria-modal="true">
|
||||
<header><div><h2>{funding.type}</h2><p>{funding.account.name}</p></div><button aria-label="关闭" disabled={fundSaving} onClick={() => setFunding(null)}><X size={20} /></button></header>
|
||||
<div className="refund-fund-current"><span>当前累计入账<b>¥{money(funding.account.balance_fen)}</b></span><span>当前可用余额<b>¥{money(funding.account.available_fen)}</b></span></div>
|
||||
<form onSubmit={saveFunds}>
|
||||
<label>{funding.type === "追加资金" ? "追加金额(元)" : "调整金额(元)"}<input autoFocus required type="number" min={funding.type === "追加资金" ? "0.01" : undefined} max="9999999.99" step="0.01" value={fundAmount} onChange={(e) => setFundAmount(e.target.value)} placeholder={funding.type === "追加资金" ? "例如:5000.00" : "增加填正数,减少填负数"} /></label>
|
||||
<label>{funding.type === "资金调整" ? "调整原因(必填)" : "备注(选填)"}<input required={funding.type === "资金调整"} maxLength={255} value={fundNote} onChange={(e) => setFundNote(e.target.value)} placeholder={funding.type === "资金调整" ? "例如:修正录入错误" : "例如:本月追加预算"} /></label>
|
||||
<p className="refund-account-hint">{funding.type === "追加资金" ? "仅允许增加资金,提交后会立即计入累计入账并生成流水。" : "允许增加或减少,但调整后的累计入账不能低于已使用与处理中金额之和。"}</p>
|
||||
{fundError && <div className="form-error" role="alert">{fundError}</div>}
|
||||
<footer><button type="button" disabled={fundSaving} onClick={() => setFunding(null)}>取消</button><button className="primary-button" disabled={fundSaving}>{fundSaving ? "提交中…" : "确认提交"}</button></footer>
|
||||
</form>
|
||||
</section></div>}
|
||||
</>;
|
||||
}
|
||||
105
frontend/src/RefundRecordsPage.jsx
Normal file
105
frontend/src/RefundRecordsPage.jsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Download, Search } from "lucide-react";
|
||||
import { formatDateTime } from "./time.js";
|
||||
|
||||
const endpoint = "/api/admin/refund-records";
|
||||
const money = (fen) => `¥${(fen / 100).toFixed(2)}`;
|
||||
|
||||
export default function RefundRecordsPage({ api }) {
|
||||
const [filters, setFilters] = useState({ q: "", start_date: "", end_date: "" });
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [data, setData] = useState({ items: [], total: 0, total_amount_fen: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const invalidDates = filters.start_date && filters.end_date && filters.start_date > filters.end_date;
|
||||
const filterParams = new URLSearchParams(filters).toString();
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
if (invalidDates) throw new Error("开始日期不能晚于结束日期");
|
||||
const result = await api(`${endpoint}?${filterParams}&page=${page}&page_size=${pageSize}`);
|
||||
if (!cancelled) {
|
||||
const lastPage = Math.max(1, Math.ceil(result.data.total / pageSize));
|
||||
if (page > lastPage) setPage(lastPage);
|
||||
else setData(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}, 250);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, [api, filterParams, page, pageSize, invalidDates]);
|
||||
function changeFilter(key, value) {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPage(1);
|
||||
}
|
||||
async function exportRecords() {
|
||||
setExporting(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE || ""}${endpoint}/export?${filterParams}`, { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.detail || `导出失败(HTTP ${response.status})`);
|
||||
}
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `返款记录_${filters.start_date || "全部"}_${filters.end_date || "至今"}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setExporting(false); }
|
||||
}
|
||||
const pages = Math.max(1, Math.ceil(data.total / pageSize));
|
||||
return <>
|
||||
<div className="page-heading"><h1>返款记录</h1><p>查看已确认返款的记录,支持按时间筛选及导出。</p></div>
|
||||
<section className="panel refund-records">
|
||||
<div className="refund-records-toolbar">
|
||||
<label className="refund-account-search"><Search size={16} /><input aria-label="搜索订单号" placeholder="输入订单号查找返款记录" value={filters.q} onChange={(e) => changeFilter("q", e.target.value)} /></label>
|
||||
<label>开始日期<input type="date" value={filters.start_date} onChange={(e) => changeFilter("start_date", e.target.value)} /></label>
|
||||
<label>结束日期<input type="date" value={filters.end_date} onChange={(e) => changeFilter("end_date", e.target.value)} /></label>
|
||||
<button onClick={() => { setFilters({ q: "", start_date: "", end_date: "" }); setPage(1); }}>清空筛选</button>
|
||||
<button className="primary-button" disabled={exporting || !!invalidDates} onClick={exportRecords}><Download size={16} />{exporting ? "正在导出…" : "导出返款记录"}</button>
|
||||
</div>
|
||||
<p className="refund-records-hint">时间按确认返款时间(北京时间)记录;订单号暂用任务编号。导出包含筛选范围内全部记录。</p>
|
||||
{error && <p role="alert" className="refund-records-error">{error}</p>}
|
||||
<div className="refund-account-table-wrap" aria-busy={loading}>
|
||||
<table className="refund-account-table refund-records-table">
|
||||
<colgroup>
|
||||
<col className="refund-col-platform" />
|
||||
<col className="refund-col-shop" />
|
||||
<col className="refund-col-product" />
|
||||
<col className="refund-col-time" />
|
||||
<col className="refund-col-order" />
|
||||
<col className="refund-col-payee" />
|
||||
<col className="refund-col-account" />
|
||||
<col className="refund-col-amount" />
|
||||
<col className="refund-col-payer" />
|
||||
<col className="refund-col-operator" />
|
||||
<col className="refund-col-trade" />
|
||||
</colgroup>
|
||||
<thead><tr>{["电商平台", "店铺名称", "商品ID", "转账时间", "订单号", "收款人", "收款账号", "返款金额", "付款账户", "操作人", "支付宝流水"].map((title) => <th key={title}>{title}</th>)}</tr></thead>
|
||||
<tbody>{loading || error ? <tr><td colSpan={11}>{loading ? "加载中…" : "记录加载失败,请调整筛选或重新进入页面"}</td></tr> : data.items.length ? data.items.map((item) => <tr key={item.id}>
|
||||
<td className="refund-platform-cell">{item.ecommerce_platform || "—"}</td><td className="refund-shop-cell">{item.shop_name || "—"}</td><td className="refund-product-cell">{item.product_id || "—"}</td><td className="refund-time-cell">{formatDateTime(item.transferred_at)}</td><td className="refund-order-cell">{item.order_no}</td><td>{item.payee_name || "—"}</td><td className="refund-payee-account-cell">{item.payee_account || "—"}</td><td className="refund-account-balance">{money(item.amount_fen)}</td><td>{item.payer_account || "—"}</td><td>{item.operator_name || "—"}</td><td className="refund-trade-cell" title={item.alipay_trade_no || ""}>{item.alipay_trade_no || "—"}</td>
|
||||
</tr>) : <tr><td colSpan={11}>暂无返款记录</td></tr>}</tbody>
|
||||
{!loading && !error && <tfoot><tr><th colSpan={7}>合计(当前筛选全部记录)</th><td className="refund-account-balance">{money(data.total_amount_fen)}</td><td colSpan={3} /></tr></tfoot>}
|
||||
</table>
|
||||
</div>
|
||||
<div className="refund-account-pagination">
|
||||
<span>共 {error || loading ? "—" : data.total} 条记录</span>
|
||||
<button disabled={loading || page <= 1} onClick={() => setPage(page - 1)}>上一页</button><span>{page} / {pages}</span><button disabled={loading || page >= pages} onClick={() => setPage(page + 1)}>下一页</button>
|
||||
<select aria-label="每页记录数" value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}>{[10, 20, 50, 100].map((size) => <option key={size} value={size}>{size} 条/页</option>)}</select>
|
||||
</div>
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MessageCircleMore, RefreshCw, ShieldCheck, X } from "lucide-react";
|
||||
import { ChevronRight, LoaderCircle, MessageCircleMore, RefreshCw, X } from "lucide-react";
|
||||
import { formatDateTime } from "./time.js";
|
||||
|
||||
const REASONS = [
|
||||
"内容不清晰",
|
||||
@ -10,8 +11,7 @@ const REASONS = [
|
||||
"其他",
|
||||
];
|
||||
const STATUSES = ["全部", "待审核"];
|
||||
const formatTime = (value) =>
|
||||
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||||
const formatTime = formatDateTime;
|
||||
const productName = (record) =>
|
||||
[
|
||||
...new Set(
|
||||
@ -22,7 +22,80 @@ const StatusBadge = ({ status }) => (
|
||||
<em className={`review-v2-status ${status}`}>{status}</em>
|
||||
);
|
||||
|
||||
function ReviewDetail({ record, close, decide, busy }) {
|
||||
function RefundRecipientInfo({ record }) {
|
||||
return (
|
||||
<div className="refund-recipient-info">
|
||||
<strong>支付宝收款信息</strong>
|
||||
<span>
|
||||
支付宝账号
|
||||
<b>{record.alipay_account || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
<span>
|
||||
收款人姓名
|
||||
<b>{record.payee_name || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const reviewKind = (record) => record?.batch_id ? "batch" : "assignment";
|
||||
const reviewId = (record) => record?.batch_id || record?.assignment_id || record?.id;
|
||||
const reviewKey = (record) => `${reviewKind(record)}:${reviewId(record)}`;
|
||||
const reviewEndpoint = (record) => reviewKind(record) === "batch"
|
||||
? `/api/admin/ask-batch-reviews/${reviewId(record)}`
|
||||
: `/api/admin/ask-assignment-reviews/${reviewId(record)}`;
|
||||
|
||||
function DetailLoadingModal({ title }) {
|
||||
return (
|
||||
<div className="review-v2-backdrop review-detail-loading-backdrop">
|
||||
<section className="review-detail-loading" role="status" aria-live="polite">
|
||||
<LoaderCircle size={24} className="spin" />
|
||||
<strong>{title}</strong>
|
||||
<span>请求已收到,图片将在弹窗内按需加载…</span>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextReviewButton({ hasNext, onNext, busy, label = "下一个审核任务" }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="review-next-button"
|
||||
disabled={!hasNext || busy}
|
||||
onClick={onNext}
|
||||
>
|
||||
{busy ? "正在打开…" : hasNext ? label : "已是最后一个"}
|
||||
{hasNext && !busy && <ChevronRight size={15} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RefundPrompt({ onConfirm, onCancel, onNext, hasNext }) {
|
||||
return (
|
||||
<div className="review-v2-backdrop refund-prompt-backdrop">
|
||||
<section className="refund-prompt" role="dialog" aria-modal="true" aria-labelledby="refund-prompt-title">
|
||||
<div className="refund-prompt-icon">✓</div>
|
||||
<div className="refund-prompt-content">
|
||||
<h2 id="refund-prompt-title">审核已通过</h2>
|
||||
<p>是否前往任务看板的“待返款”模块进行返款?</p>
|
||||
</div>
|
||||
<div className="refund-prompt-actions">
|
||||
<button type="button" className="refund-prompt-cancel" onClick={onCancel}>暂不前往</button>
|
||||
{hasNext && (
|
||||
<button type="button" className="refund-prompt-next" onClick={onNext}>
|
||||
继续下一个审核
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="refund-prompt-confirm" onClick={onConfirm}>前往待返款</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }) {
|
||||
const [reason, setReason] = useState(REASONS[0]);
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const selected = record.questions.filter((q) => q.selected);
|
||||
@ -51,6 +124,7 @@ function ReviewDetail({ record, close, decide, busy }) {
|
||||
<span>商品:{productName(record)}</span>
|
||||
<span>提交时间:{formatTime(record.updated_at)}</span>
|
||||
</div>
|
||||
<RefundRecipientInfo record={record} />
|
||||
<h3>① 问答记录(发布问题 ↔ 用户回答)</h3>
|
||||
<div className="review-v2-qa">
|
||||
{selected.map((question, index) => (
|
||||
@ -74,37 +148,30 @@ function ReviewDetail({ record, close, decide, busy }) {
|
||||
<img
|
||||
src={image.data_url}
|
||||
alt={`任务截图${index + 1}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption>任务截图 {index + 1}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
{!!record.payment_screenshots?.length && <section>
|
||||
<h3>③ 返款二维码截图</h3>
|
||||
<div>
|
||||
{record.payment_screenshots?.length ? (
|
||||
record.payment_screenshots.map((image) => (
|
||||
{record.payment_screenshots.map((image) => (
|
||||
<figure key={image.id}>
|
||||
<img
|
||||
src={image.data_url}
|
||||
alt="收款码"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption>审核通过后用于后续人工处理</figcaption>
|
||||
</figure>
|
||||
))
|
||||
) : (
|
||||
<div className="review-v2-no-payment">未单独上传收款码</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="review-v2-assist">
|
||||
<ShieldCheck size={16} />
|
||||
<strong>系统智能助判</strong>
|
||||
<span>
|
||||
✓ 商品一致 ✓ 内容与要求匹配 ✓ 无重复提交 ✓ 无违规内容
|
||||
</span>
|
||||
</section>}
|
||||
</div>
|
||||
{record.latest_review && !pending && (
|
||||
<div
|
||||
@ -146,6 +213,7 @@ function ReviewDetail({ record, close, decide, busy }) {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy || busy} />
|
||||
<button
|
||||
className="reject"
|
||||
disabled={busy || (reason === "其他" && !customReason.trim())}
|
||||
@ -167,6 +235,7 @@ function ReviewDetail({ record, close, decide, busy }) {
|
||||
该任务已完成审核,当前状态:
|
||||
<StatusBadge status={record.status} />
|
||||
</span>
|
||||
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy} />
|
||||
<button onClick={close}>关闭</button>
|
||||
</footer>
|
||||
)}
|
||||
@ -175,7 +244,7 @@ function ReviewDetail({ record, close, decide, busy }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, nextBusy }) {
|
||||
const [reason, setReason] = useState("截图不清晰");
|
||||
const [reasonText, setReasonText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@ -183,8 +252,8 @@ function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
async function decide(approved) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(
|
||||
`/api/admin/ask-completion-reviews/${record.batch_id || record.assignment_id}/decision`,
|
||||
const result = await api(
|
||||
`${reviewEndpoint(record)}/decision`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@ -194,9 +263,12 @@ function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
}),
|
||||
},
|
||||
);
|
||||
close();
|
||||
await onChanged();
|
||||
window.dispatchEvent(new Event("review-count-changed"));
|
||||
onDecision({
|
||||
...record,
|
||||
...result.data,
|
||||
question_screenshots: record.question_screenshots || [],
|
||||
payment_screenshots: record.payment_screenshots || [],
|
||||
}, approved);
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
@ -225,8 +297,10 @@ function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
<code>{record.user_id}</code>
|
||||
<code>{record.task_id}</code>
|
||||
<span>商品:{record.product_name}</span>
|
||||
<span>创建人:{record.creator_name || "运营后台"}</span>
|
||||
<span>提交时间:{formatTime(record.updated_at)}</span>
|
||||
</div>
|
||||
<RefundRecipientInfo record={record} />
|
||||
<h3>① 用户认领并完成的提问</h3>
|
||||
<div className="review-v2-qa">
|
||||
{selected.map((item, index) => (
|
||||
@ -246,13 +320,15 @@ function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
<img
|
||||
src={image.data_url}
|
||||
alt={`提问截图${index + 1}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption>提问截图 {index + 1}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
{!!record.payment_screenshots?.length && <section>
|
||||
<h3>③ 用户上传的收款码</h3>
|
||||
<div>
|
||||
{record.payment_screenshots.map((image) => (
|
||||
@ -260,12 +336,14 @@ function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
<img
|
||||
src={image.data_url}
|
||||
alt="收款码"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption>收款码截图</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>}
|
||||
</div>
|
||||
{record.latest_review && record.status !== "待审核" && (
|
||||
<div
|
||||
@ -306,6 +384,7 @@ function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
placeholder="补充说明(选填)"
|
||||
/>
|
||||
</div>
|
||||
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy || busy} />
|
||||
<button
|
||||
className="reject"
|
||||
disabled={busy}
|
||||
@ -327,6 +406,7 @@ function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
当前审核状态:
|
||||
<StatusBadge status={record.status} />
|
||||
</span>
|
||||
<NextReviewButton hasNext={hasNext} onNext={onNext} busy={nextBusy} />
|
||||
<button onClick={close}>关闭</button>
|
||||
</footer>
|
||||
)}
|
||||
@ -335,29 +415,65 @@ function AskReviewDetail({ record, close, onChanged, api }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AskReviewQueue({ api }) {
|
||||
function AskReviewQueue({ api, onApproved }) {
|
||||
const [records, setRecords] = useState([]);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(null);
|
||||
const [nextRecord, setNextRecord] = useState(null);
|
||||
const detailCache = useRef(new Map());
|
||||
async function openDetail(record) {
|
||||
if (!record || detailLoading) return;
|
||||
const key = reviewKey(record);
|
||||
const index = records.findIndex((item) => reviewKey(item) === key);
|
||||
const nextCandidate = index >= 0 ? records[index + 1] || null : null;
|
||||
setNextRecord(nextCandidate);
|
||||
const cached = detailCache.current.get(key);
|
||||
if (cached) {
|
||||
setDetail(cached);
|
||||
return;
|
||||
}
|
||||
setDetailLoading(key);
|
||||
try {
|
||||
const result = await api(
|
||||
`/api/admin/ask-completion-reviews/${record.batch_id || record.assignment_id}`,
|
||||
reviewEndpoint(record),
|
||||
);
|
||||
detailCache.current.set(key, result.data);
|
||||
setDetail(result.data);
|
||||
if (nextCandidate && !detailCache.current.has(reviewKey(nextCandidate))) {
|
||||
void api(reviewEndpoint(nextCandidate))
|
||||
.then((nextResult) => detailCache.current.set(reviewKey(nextCandidate), nextResult.data))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
setDetailLoading(null);
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
async function load(showLoading = true) {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const result = await api("/api/admin/ask-completion-reviews");
|
||||
setRecords(result.data);
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
}
|
||||
function handleDecision(updated, approved) {
|
||||
const key = reviewKey(updated);
|
||||
detailCache.current.set(key, updated);
|
||||
setDetail(updated);
|
||||
setRecords((current) => current.filter((item) => reviewKey(item) !== key));
|
||||
window.dispatchEvent(new Event("review-count-changed"));
|
||||
void load(false);
|
||||
if (approved) {
|
||||
onApproved({
|
||||
hasNext: Boolean(nextRecord),
|
||||
onNext: () => openDetail(nextRecord),
|
||||
});
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
@ -386,9 +502,7 @@ function AskReviewQueue({ api }) {
|
||||
<code>{record.user_id}</code>
|
||||
<code>{record.task_id}</code>
|
||||
</b>
|
||||
<small>
|
||||
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
|
||||
</small>
|
||||
{(record.taobao_id || record.phone) !== record.user_id && <small>{record.taobao_id || record.phone}</small>}
|
||||
</span>
|
||||
<span>{record.product_name}</span>
|
||||
<span>{formatTime(record.updated_at)}</span>
|
||||
@ -402,20 +516,27 @@ function AskReviewQueue({ api }) {
|
||||
<StatusBadge status={record.status} />
|
||||
</span>
|
||||
<span>
|
||||
<button onClick={() => openDetail(record)}>
|
||||
{record.status === "待审核" ? "查看并审核" : "查看详情"}
|
||||
<button disabled={Boolean(detailLoading)} onClick={() => openDetail(record)}>
|
||||
{detailLoading === reviewKey(record)
|
||||
? "正在打开…"
|
||||
: record.status === "待审核" ? "查看并审核" : "查看详情"}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
{detailLoading && !detail && <DetailLoadingModal title="正在打开提问审核详情" />}
|
||||
{detail && (
|
||||
<AskReviewDetail
|
||||
key={reviewKey(detail)}
|
||||
record={detail}
|
||||
close={() => setDetail(null)}
|
||||
onChanged={load}
|
||||
api={api}
|
||||
onDecision={handleDecision}
|
||||
hasNext={Boolean(nextRecord)}
|
||||
nextBusy={Boolean(detailLoading)}
|
||||
onNext={() => openDetail(nextRecord)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -430,24 +551,42 @@ export default function ReviewCenterPage({ api }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(null);
|
||||
const [nextRecord, setNextRecord] = useState(null);
|
||||
const [refundPrompt, setRefundPrompt] = useState(null);
|
||||
const loadVersion = useRef(0);
|
||||
const detailCache = useRef(new Map());
|
||||
async function openAnswerDetail(record) {
|
||||
setDetailLoading(true);
|
||||
if (!record || detailLoading) return;
|
||||
const index = allRows.findIndex((item) => item.id === record.id);
|
||||
const nextCandidate = index >= 0 ? allRows[index + 1] || null : null;
|
||||
setNextRecord(nextCandidate);
|
||||
const cached = detailCache.current.get(record.id);
|
||||
if (cached) {
|
||||
setDetail(cached);
|
||||
return;
|
||||
}
|
||||
setDetailLoading(record.id);
|
||||
try {
|
||||
const result = await api(
|
||||
`/api/admin/answer-completion-reviews/${record.id}`,
|
||||
);
|
||||
detailCache.current.set(record.id, result.data);
|
||||
setDetail(result.data);
|
||||
if (nextCandidate && !detailCache.current.has(nextCandidate.id)) {
|
||||
void api(`/api/admin/answer-completion-reviews/${nextCandidate.id}`)
|
||||
.then((nextResult) => detailCache.current.set(nextCandidate.id, nextResult.data))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
window.alert(err.message);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
setDetailLoading(null);
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
async function load(showLoading = true) {
|
||||
const version = ++loadVersion.current;
|
||||
setLoading(true);
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const result = await api("/api/admin/answer-completion-reviews");
|
||||
if (version !== loadVersion.current) return;
|
||||
@ -456,17 +595,12 @@ export default function ReviewCenterPage({ api }) {
|
||||
} catch (err) {
|
||||
if (version === loadVersion.current) setError(err.message);
|
||||
} finally {
|
||||
if (version === loadVersion.current) setLoading(false);
|
||||
if (showLoading && version === loadVersion.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (queue !== "answer") return undefined;
|
||||
const timer = window.setInterval(() => load(), 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [queue]);
|
||||
const allRows = useMemo(
|
||||
() => records.filter((record) => record.status === "待审核"),
|
||||
[records],
|
||||
@ -485,7 +619,7 @@ export default function ReviewCenterPage({ api }) {
|
||||
async function decide(record, approved, reasonCode = "", reasonText = "") {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
|
||||
const result = await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
approved,
|
||||
@ -493,10 +627,25 @@ export default function ReviewCenterPage({ api }) {
|
||||
reason_text: reasonText,
|
||||
}),
|
||||
});
|
||||
setDetail(null);
|
||||
await load();
|
||||
const updated = {
|
||||
...record,
|
||||
...result.data,
|
||||
invited_screenshots: record.invited_screenshots || [],
|
||||
completion_screenshots: record.completion_screenshots || [],
|
||||
payment_screenshots: record.payment_screenshots || [],
|
||||
};
|
||||
detailCache.current.set(record.id, updated);
|
||||
setDetail(updated);
|
||||
setRecords((current) => current.filter((item) => item.id !== record.id));
|
||||
void load(false);
|
||||
window.dispatchEvent(new Event("review-count-changed"));
|
||||
setStatusFilter("待审核");
|
||||
if (approved) {
|
||||
setRefundPrompt({
|
||||
hasNext: Boolean(nextRecord),
|
||||
onNext: () => openAnswerDetail(nextRecord),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
window.alert(err.message);
|
||||
} finally {
|
||||
@ -553,7 +702,7 @@ export default function ReviewCenterPage({ api }) {
|
||||
</div>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
{queue === "ask" ? (
|
||||
<AskReviewQueue api={api} />
|
||||
<AskReviewQueue api={api} onApproved={(navigation) => setRefundPrompt(navigation)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="review-v2-status-filter">
|
||||
@ -590,9 +739,7 @@ export default function ReviewCenterPage({ api }) {
|
||||
<code>{record.user_id}</code>
|
||||
<code>{record.submission_no}</code>
|
||||
</b>
|
||||
<small>
|
||||
{record.phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")}
|
||||
</small>
|
||||
{(record.taobao_id || record.phone) !== record.user_id && <small>{record.taobao_id || record.phone}</small>}
|
||||
</span>
|
||||
<span>{productName(record)}</span>
|
||||
<span>{formatTime(record.updated_at)}</span>
|
||||
@ -606,8 +753,10 @@ export default function ReviewCenterPage({ api }) {
|
||||
<StatusBadge status={record.status} />
|
||||
</span>
|
||||
<span>
|
||||
<button onClick={() => openAnswerDetail(record)}>
|
||||
{record.status === "待审核"
|
||||
<button disabled={Boolean(detailLoading)} onClick={() => openAnswerDetail(record)}>
|
||||
{detailLoading === record.id
|
||||
? "正在打开…"
|
||||
: record.status === "待审核"
|
||||
? "查看并审核"
|
||||
: "查看任务列表"}
|
||||
</button>
|
||||
@ -620,10 +769,29 @@ export default function ReviewCenterPage({ api }) {
|
||||
)}
|
||||
{detail && (
|
||||
<ReviewDetail
|
||||
key={detail.id}
|
||||
record={detail}
|
||||
close={() => setDetail(null)}
|
||||
decide={decide}
|
||||
busy={busy}
|
||||
hasNext={Boolean(nextRecord)}
|
||||
nextBusy={Boolean(detailLoading)}
|
||||
onNext={() => openAnswerDetail(nextRecord)}
|
||||
/>
|
||||
)}
|
||||
{detailLoading && !detail && <DetailLoadingModal title="正在打开回答审核详情" />}
|
||||
{refundPrompt && (
|
||||
<RefundPrompt
|
||||
hasNext={refundPrompt.hasNext}
|
||||
onNext={() => {
|
||||
setRefundPrompt(null);
|
||||
refundPrompt.onNext();
|
||||
}}
|
||||
onCancel={() => setRefundPrompt(null)}
|
||||
onConfirm={() => {
|
||||
setRefundPrompt(null);
|
||||
window.dispatchEvent(new CustomEvent("open-task-board", { detail: { status: "待返款" } }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronRight, LoaderCircle, Search, X } from "lucide-react";
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
["all", "全部任务"],
|
||||
@ -11,40 +11,165 @@ const STATUS_FILTERS = [
|
||||
];
|
||||
const STATUS_ALIASES = {
|
||||
"待用户完成": "进行中",
|
||||
"已超时": "已超时",
|
||||
"未通过": "进行中",
|
||||
"已发布": "进行中",
|
||||
};
|
||||
|
||||
// Keep the last lightweight board response while operators switch pages.
|
||||
// Returning to the board renders immediately and refreshes silently.
|
||||
let taskBoardCache = null;
|
||||
// Cache only the first unfiltered page and isolate it by administrator.
|
||||
// This keeps navigation fast without ever showing another operator's rows.
|
||||
const taskBoardCache = new Map();
|
||||
export function clearTaskBoardCache() {
|
||||
taskBoardCache.clear();
|
||||
}
|
||||
|
||||
const formatTime = (value) =>
|
||||
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||||
const maskPhone = (phone = "") =>
|
||||
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
|
||||
const formatTime = (value) => {
|
||||
if (!value) return "--";
|
||||
const raw = String(value);
|
||||
// The API stores legacy UTC datetimes without an offset (datetime.utcnow()).
|
||||
// Treat those values as UTC before displaying them in the business timezone.
|
||||
const hasOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw);
|
||||
const date = new Date(hasOffset ? raw : `${raw}Z`);
|
||||
return Number.isNaN(date.getTime())
|
||||
? "--"
|
||||
: date.toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" });
|
||||
};
|
||||
const maskPhone = (account = "") => account;
|
||||
|
||||
function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
function TaskDetailLoading() {
|
||||
return (
|
||||
<div className="task-detail-backdrop task-detail-loading-backdrop">
|
||||
<section className="task-detail-loading" role="status" aria-live="polite">
|
||||
<LoaderCircle size={24} className="spin" />
|
||||
<strong>正在打开任务详情</strong>
|
||||
<span>请求已收到,凭证图片将在弹窗内按需加载…</span>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, onReactivate, onCancel, actionBusy, api, user, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const payerAccountStorageKey = `task_board_last_payer_account_${user?.id || user?.username || "current"}`;
|
||||
const rememberedPayerAccount = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(payerAccountStorageKey) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const [payerAccount, setPayerAccount] = useState(rememberedPayerAccount);
|
||||
const [paymentAccounts, setPaymentAccounts] = useState([]);
|
||||
const [accountsBusy, setAccountsBusy] = useState(false);
|
||||
const [refundError, setRefundError] = useState("");
|
||||
useEffect(() => { setPayerAccount(rememberedPayerAccount()); setRefundError(""); }, [task.board_key, payerAccountStorageKey]);
|
||||
const [amountBusy, setAmountBusy] = useState(false);
|
||||
const [amountError, setAmountError] = useState("");
|
||||
useEffect(() => {
|
||||
setAmount(((task.refund_amount_fen ?? (task.selected_questions?.length ?? 0) * 50) / 100).toFixed(2));
|
||||
setAmountError("");
|
||||
}, [task.board_key, task.refund_amount_fen]);
|
||||
const validAmount = /^\d+(\.\d{1,2})?$/.test(amount) && Number(amount) >= 0.1 && Number(amount) <= 999999.99;
|
||||
async function saveAmount(reset = false) {
|
||||
if (!reset && !validAmount) return setAmountError("支付宝转账金额最低0.10元,且最多两位小数");
|
||||
setAmountBusy(true);
|
||||
setAmountError("");
|
||||
try {
|
||||
await onSaveRefundAmount(task, reset ? null : amount);
|
||||
if (reset) setAmount((task.refund_default_amount_fen / 100).toFixed(2));
|
||||
} catch (error) {
|
||||
setAmountError(error.message);
|
||||
} finally {
|
||||
setAmountBusy(false);
|
||||
}
|
||||
}
|
||||
const [refundOpen, setRefundOpen] = useState(false);
|
||||
const [refundBusy, setRefundBusy] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const isAskTask =
|
||||
task.task_type === "ask" || task.task_type_label === "提问任务";
|
||||
const isAnswerTask =
|
||||
task.task_type === "answer" || task.task_type_label === "回答任务";
|
||||
const hasProductMetadata = isAskTask || isAnswerTask;
|
||||
const askTaskToken = task.public_token || task.task_id;
|
||||
const taskLink = isAskTask && askTaskToken
|
||||
? `${window.location.origin}/ask/${encodeURIComponent(askTaskToken)}`
|
||||
: "";
|
||||
const proofScreenshots = [
|
||||
...(task.question_screenshots || []),
|
||||
...(task.completion_screenshots || []),
|
||||
];
|
||||
const paymentScreenshots = task.payment_screenshots || [];
|
||||
useEffect(() => {
|
||||
if (!refundOpen) return undefined;
|
||||
let cancelled = false;
|
||||
setAccountsBusy(true);
|
||||
setRefundError("");
|
||||
setPaymentAccounts([]);
|
||||
api("/api/admin/refund-payment-accounts/available").then((result) => {
|
||||
if (cancelled) return;
|
||||
const accounts = result.data || [];
|
||||
const remembered = rememberedPayerAccount();
|
||||
const isAvailable = accounts.some((account) => String(account.id) === remembered);
|
||||
setPaymentAccounts(accounts);
|
||||
setPayerAccount(isAvailable ? remembered : "");
|
||||
if (remembered && !isAvailable) {
|
||||
try { window.localStorage.removeItem(payerAccountStorageKey); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
}).catch((error) => { if (!cancelled) setRefundError(error.message); })
|
||||
.finally(() => { if (!cancelled) setAccountsBusy(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [refundOpen, api, payerAccountStorageKey]);
|
||||
function selectPayerAccount(value) {
|
||||
setPayerAccount(value);
|
||||
setRefundError("");
|
||||
}
|
||||
async function confirmRefund() {
|
||||
if (!validAmount) return setRefundError("请输入大于0且最多两位小数的金额");
|
||||
if (!payerAccount) return setRefundError("请选择付款账户");
|
||||
setRefundBusy(true);
|
||||
setRefundError("");
|
||||
try {
|
||||
await onConfirmRefund(task);
|
||||
await onConfirmRefund(task, amount, payerAccount);
|
||||
try { window.localStorage.setItem(payerAccountStorageKey, payerAccount); } catch { /* storage is optional */ }
|
||||
setRefundOpen(false);
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
setRefundError(error.message);
|
||||
} finally {
|
||||
setRefundBusy(false);
|
||||
}
|
||||
}
|
||||
async function copyTaskLink() {
|
||||
if (!taskLink) return;
|
||||
let copied = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(taskLink);
|
||||
copied = true;
|
||||
}
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
if (!copied) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = taskLink;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
copied = document.execCommand("copy");
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
if (copied) {
|
||||
setLinkCopied(true);
|
||||
window.setTimeout(() => setLinkCopied(false), 1800);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="task-detail-backdrop"
|
||||
@ -62,7 +187,34 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
<X size={21} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="task-detail-meta">
|
||||
{taskLink && (
|
||||
<div className="task-detail-share">
|
||||
<span>提问链接</span>
|
||||
<input
|
||||
readOnly
|
||||
value={taskLink}
|
||||
aria-label="提问任务链接"
|
||||
onFocus={(event) => event.target.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={linkCopied ? "copied" : ""}
|
||||
onClick={copyTaskLink}
|
||||
>
|
||||
{linkCopied ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={`task-detail-meta${hasProductMetadata ? " product-task-meta" : task.shop_name ? " shop-task-meta" : ""}`}>
|
||||
{task.shop_name && <span>
|
||||
店铺名称<b>{task.shop_name || "--"}</b>
|
||||
</span>}
|
||||
{hasProductMetadata && <span>
|
||||
电商平台<b>{task.ecommerce_platform || "历史任务未填写"}</b>
|
||||
</span>}
|
||||
{hasProductMetadata && <span>
|
||||
商品ID<b>{task.product_id || "历史任务未填写"}</b>
|
||||
</span>}
|
||||
<span>
|
||||
商品名称<b>{task.product_name}</b>
|
||||
</span>
|
||||
@ -76,6 +228,28 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
创建时间<b>{formatTime(task.created_at)}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="refund-recipient-info task-detail-recipient-info">
|
||||
<strong>支付宝收款信息</strong>
|
||||
<span>
|
||||
支付宝账号
|
||||
<b>{task.alipay_account || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
<span>
|
||||
收款人姓名
|
||||
<b>{task.payee_name || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="task-detail-refund-amount">
|
||||
<label>返款金额(元)
|
||||
{!readOnly && task.refund_endpoint ? <input aria-label="返款金额" type="number" min="0.10" max="999999.99" step="0.01" value={amount} onChange={(event) => setAmount(event.target.value)} /> : <b>¥{((task.refund_amount_fen ?? 0) / 100).toFixed(2)}</b>}
|
||||
</label>
|
||||
<span>默认:{task.refund_default_amount_fen / 50 || 0} 条 × 0.50 元 = ¥{((task.refund_default_amount_fen || 0) / 100).toFixed(2)}{task.refund_amount_custom ? "(已自定义)" : ""}</span>
|
||||
{!readOnly && task.refund_endpoint && <>
|
||||
<button type="button" disabled={amountBusy || refundBusy} onClick={() => saveAmount()}>保存金额</button>
|
||||
<button type="button" disabled={amountBusy || refundBusy} onClick={() => saveAmount(true)}>恢复默认</button>
|
||||
</>}
|
||||
{amountError && <p role="alert">{amountError}</p>}
|
||||
</div>
|
||||
<h3>认领用户的问题与回答(共 {task.assigned_users.length} 位)</h3>
|
||||
<div className="task-detail-table">
|
||||
<div className="head">
|
||||
@ -87,24 +261,40 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
{task.selected_questions.length === 0 ? (
|
||||
<div className="task-detail-empty">暂无问题</div>
|
||||
) : (
|
||||
task.selected_questions.map((question, index) => (
|
||||
<div className="row" key={question.id}>
|
||||
<span>{index + 1}</span>
|
||||
<span>
|
||||
<div className="task-detail-table-body">
|
||||
{task.selected_questions.map((question, index) => (
|
||||
<React.Fragment key={question.id}>
|
||||
<span
|
||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||
style={{ gridColumn: 1, gridRow: index + 1 }}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||
style={{ gridColumn: 2, gridRow: index + 1 }}
|
||||
>
|
||||
<b>Q:{question.question}</b>
|
||||
<small>A:{question.answer}</small>
|
||||
</span>
|
||||
<span>
|
||||
{task.user_id}
|
||||
<small>{maskPhone(task.phone)}</small>
|
||||
<span
|
||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||
style={{ gridColumn: 3, gridRow: index + 1 }}
|
||||
>
|
||||
{question.user_id || "--"}
|
||||
{question.phone && question.phone !== question.user_id && <small>{maskPhone(question.phone)}</small>}
|
||||
</span>
|
||||
<span>
|
||||
<span
|
||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||
style={{ gridColumn: 4, gridRow: index + 1 }}
|
||||
>
|
||||
<em className={`board-status ${task.status_label}`}>
|
||||
{task.status_label}
|
||||
</em>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="task-detail-evidence">
|
||||
@ -117,7 +307,7 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
type="button"
|
||||
key={image.id}
|
||||
>
|
||||
<img src={image.data_url || image.url} alt={image.name || "完成截图"} />
|
||||
<img src={image.data_url || image.url} alt={image.name || "完成截图"} loading="lazy" decoding="async" />
|
||||
<span>点击查看大图</span>
|
||||
</button>
|
||||
))}
|
||||
@ -126,7 +316,7 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
<p>未上传完成截图</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="payment">
|
||||
{paymentScreenshots.length > 0 && <section className="payment">
|
||||
<h3>收款码</h3>
|
||||
{paymentScreenshots.length ? (
|
||||
<div>
|
||||
@ -135,56 +325,82 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
type="button"
|
||||
key={image.id}
|
||||
>
|
||||
<img src={image.data_url || image.url} alt={image.name || "收款码"} />
|
||||
<img src={image.data_url || image.url} alt={image.name || "收款码"} loading="lazy" decoding="async" />
|
||||
<span>点击核对收款码</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>用户未单独上传收款码</p>
|
||||
<p>历史收款码</p>
|
||||
)}
|
||||
</section>
|
||||
</section>}
|
||||
</div>
|
||||
<footer>
|
||||
{task.refund_endpoint && (
|
||||
{!readOnly && task.can_reactivate && onReactivate && (
|
||||
<button type="button" className="reactivate-task" disabled={actionBusy} onClick={() => onReactivate(task)}>
|
||||
{actionBusy ? "重新激活中…" : "重新激活"}
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && task.can_cancel && onCancel && (
|
||||
<button type="button" className="cancel-task" disabled={actionBusy} onClick={() => onCancel(task)}>
|
||||
{actionBusy ? "处理中…" : "撤销"}
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && task.refund_endpoint && (
|
||||
<button
|
||||
type="button"
|
||||
className="confirm-refund"
|
||||
onClick={() => setRefundOpen(true)}
|
||||
onClick={() => {
|
||||
setRefundError("");
|
||||
setRefundOpen(true);
|
||||
}}
|
||||
>
|
||||
确认返款
|
||||
</button>
|
||||
)}
|
||||
{(task.status_label === "待返款" || task.refund_completed) && (
|
||||
<button
|
||||
type="button"
|
||||
className="next-refund-task"
|
||||
disabled={!hasNextRefund || nextBusy}
|
||||
onClick={onNextRefund}
|
||||
>
|
||||
{nextBusy ? "正在打开…" : hasNextRefund ? "下一个待返款任务" : "已是最后一个"}
|
||||
{hasNextRefund && !nextBusy && <ChevronRight size={15} />}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={close}>关闭</button>
|
||||
</footer>
|
||||
{refundOpen && (
|
||||
<div
|
||||
className="refund-confirm-backdrop"
|
||||
onMouseDown={(event) =>
|
||||
event.target === event.currentTarget && setRefundOpen(false)
|
||||
!refundBusy && event.target === event.currentTarget && setRefundOpen(false)
|
||||
}
|
||||
>
|
||||
<section className="refund-confirm-modal">
|
||||
<span>✓</span>
|
||||
<h2>确认已经完成返款?</h2>
|
||||
<p>
|
||||
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
|
||||
</p>
|
||||
<h2>确认返款</h2>
|
||||
<p>收款人:{task.payee_name || "未填写"}<br />支付宝账号:{task.alipay_account || "未填写"}</p>
|
||||
<fieldset className="refund-record-fields" disabled={refundBusy}>
|
||||
<label>返款金额(元)
|
||||
<input type="number" min="0.10" max="999999.99" step="0.01" value={amount} onChange={(event) => setAmount(event.target.value)} />
|
||||
<small>默认按每条问答 0.50 元计算;支付宝单笔转账最低 0.10 元。</small>
|
||||
</label>
|
||||
<label>付款账户
|
||||
<select value={payerAccount} disabled={accountsBusy} onChange={(event) => selectPayerAccount(event.target.value)}>
|
||||
<option value="">{accountsBusy ? "正在加载账户…" : "请选择付款账户"}</option>
|
||||
{paymentAccounts.map((account) => <option key={account.id} value={account.id}>{account.name}({account.alipay_account})</option>)}
|
||||
</select>
|
||||
{!accountsBusy && paymentAccounts.length === 0 && <small>暂无可用付款账户,请联系主管或超级管理员检查账户授权及启用状态。</small>}
|
||||
</label>
|
||||
<label>支付宝流水<input readOnly value={task.alipay_trade_no || "确认后由支付宝返回"} /></label>
|
||||
</fieldset>
|
||||
<p className="refund-confirm-notice">确认后系统将按所选付款账户向收款支付宝账号发起转账;支付宝返回流水号后会同步到返款记录。若接口返回处理中,请先查询状态再重试,避免重复付款。</p>
|
||||
{refundError && <p role="alert" className="refund-confirm-error">{refundError}</p>}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={refundBusy}
|
||||
onClick={() => setRefundOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="confirm"
|
||||
disabled={refundBusy}
|
||||
onClick={confirmRefund}
|
||||
>
|
||||
{refundBusy ? "确认中…" : "确定返款完成"}
|
||||
<button type="button" disabled={refundBusy} onClick={() => setRefundOpen(false)}>取消</button>
|
||||
<button type="button" className="confirm" disabled={refundBusy || accountsBusy || !validAmount || !paymentAccounts.some((account) => String(account.id) === payerAccount)} onClick={confirmRefund}>
|
||||
{refundBusy ? "确认中…" : "确认返款"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@ -195,26 +411,54 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskBoardPage({ api }) {
|
||||
const [tasks, setTasks] = useState(() => taskBoardCache || []);
|
||||
export default function TaskBoardPage({ api, user }) {
|
||||
const accountCacheKey = String(user?.id || user?.username || "anonymous");
|
||||
const initialStatus = window.__taskBoardInitialStatus || "all";
|
||||
const initialCache = initialStatus === "all" ? taskBoardCache.get(accountCacheKey) : null;
|
||||
const [tasks, setTasks] = useState(() => initialCache?.items || []);
|
||||
const [type, setType] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [page, setPage] = useState(() => initialCache?.page || 1);
|
||||
const [pageSize, setPageSize] = useState(() => initialCache?.page_size || 20);
|
||||
const [total, setTotal] = useState(() => initialCache?.total || 0);
|
||||
const [totalPages, setTotalPages] = useState(() => initialCache?.total_pages || 1);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [expandedAskTasks, setExpandedAskTasks] = useState(() => new Set());
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(null);
|
||||
const [nextRefundTask, setNextRefundTask] = useState(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(() => taskBoardCache === null);
|
||||
const [loading, setLoading] = useState(() => !initialCache);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [batchBusy, setBatchBusy] = useState(false);
|
||||
const [batchConfirm, setBatchConfirm] = useState(null);
|
||||
async function load(showLoading = taskBoardCache === null) {
|
||||
const [completeConfirm, setCompleteConfirm] = useState(null);
|
||||
const detailCache = useRef(new Map());
|
||||
async function load(showLoading = !tasks.length) {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const result = await api("/api/admin/task-board");
|
||||
taskBoardCache = result.data;
|
||||
setTasks(result.data);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
task_type: type,
|
||||
status_filter: status,
|
||||
});
|
||||
if (keyword.trim()) params.set("keyword", keyword.trim());
|
||||
if (dateFrom) params.set("date_from", dateFrom);
|
||||
if (dateTo) params.set("date_to", dateTo);
|
||||
const result = await api(`/api/admin/task-board?${params.toString()}`);
|
||||
const payload = result.data;
|
||||
setTasks(payload.items);
|
||||
setPage(payload.page);
|
||||
setPageSize(payload.page_size);
|
||||
setTotal(payload.total);
|
||||
setTotalPages(payload.total_pages);
|
||||
if (payload.page === 1 && type === "all" && status === "all" && !keyword.trim() && !dateFrom && !dateTo) {
|
||||
taskBoardCache.set(accountCacheKey, payload);
|
||||
}
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@ -223,31 +467,24 @@ export default function TaskBoardPage({ api }) {
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
load(taskBoardCache === null);
|
||||
}, []);
|
||||
const timer = window.setTimeout(() => load(!tasks.length), keyword.trim() ? 250 : 0);
|
||||
window.__taskBoardInitialStatus = "";
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [accountCacheKey, page, pageSize, type, status, keyword, dateFrom, dateTo]);
|
||||
const rowKey = (task) => task.board_key || `${task.task_type}-${task.id}`;
|
||||
const detailCacheKey = (task) => task.detail_endpoint || rowKey(task);
|
||||
const isAskTask = (task) => task.task_type === "ask" || task.task_type_label === "提问任务";
|
||||
const childRows = (task) => (isAskTask(task) ? task.claimed_tasks || [] : []);
|
||||
// Keep filtering as a direct derivation so changing either filter always
|
||||
// immediately updates the table (without relying on a stale memoized list).
|
||||
const rows = tasks.filter((task) => {
|
||||
const taskType = String(task.task_type || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const taskTypeLabel = String(task.task_type_label || "").trim();
|
||||
const rows = tasks;
|
||||
const refundQueue = useMemo(
|
||||
() => tasks.flatMap((task) => [task, ...childRows(task)]).filter((task) => {
|
||||
const rawStatus = String(task.status_label || task.status || "").trim();
|
||||
const taskStatus = STATUS_ALIASES[rawStatus] || rawStatus;
|
||||
const typeMatch =
|
||||
type === "all" ||
|
||||
(type === "ask" &&
|
||||
(taskType === "ask" || taskTypeLabel === "提问任务")) ||
|
||||
(type === "answer" &&
|
||||
(taskType === "answer" || taskTypeLabel === "回答任务"));
|
||||
const statusMatch = status === "all" || taskStatus === status;
|
||||
const text = `${task.user_id || ""} ${task.task_id || ""} ${task.product_name || ""}`.toLowerCase();
|
||||
const createdDate = String(task.created_at || task.updated_at || "").slice(0, 10);
|
||||
const fromMatch = !dateFrom || (createdDate && createdDate >= dateFrom);
|
||||
const toMatch = !dateTo || (createdDate && createdDate <= dateTo);
|
||||
return typeMatch && statusMatch && text.includes(keyword.trim().toLowerCase()) && fromMatch && toMatch;
|
||||
});
|
||||
return (STATUS_ALIASES[rawStatus] || rawStatus) === "待返款" || Boolean(task.refund_endpoint);
|
||||
}),
|
||||
[tasks],
|
||||
);
|
||||
const visibleKeys = rows.map(rowKey);
|
||||
const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((key) => selectedIds.includes(key));
|
||||
function toggleSelected(task) {
|
||||
@ -266,21 +503,31 @@ export default function TaskBoardPage({ api }) {
|
||||
setDateTo("");
|
||||
setType("all");
|
||||
setStatus("all");
|
||||
setPage(1);
|
||||
}
|
||||
function toggleAskTask(task) {
|
||||
const key = rowKey(task);
|
||||
setExpandedAskTasks((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function exportRows() {
|
||||
if (!rows.length) {
|
||||
window.alert("当前没有可导出的任务");
|
||||
return;
|
||||
}
|
||||
const headers = ["用户ID", "任务ID", "商品信息", "类型", "Q&A数", "待审/完成", "状态", "创建人", "创建时间"];
|
||||
const headers = ["用户ID", "任务ID", "商品/店铺信息", "类型", "Q&A数", "完成", "状态", "创建人", "创建时间"];
|
||||
const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
|
||||
const lines = [headers, ...rows.map((task) => [
|
||||
task.user_id,
|
||||
task.task_id,
|
||||
task.product_name,
|
||||
[task.product_name, task.shop_name].filter(Boolean).join(" / "),
|
||||
task.task_type_label,
|
||||
task.qa_count,
|
||||
`${task.pending_review_count} / ${task.completed_count}`,
|
||||
task.completed_count,
|
||||
task.status_label,
|
||||
task.creator_name,
|
||||
formatTime(task.created_at),
|
||||
@ -328,19 +575,40 @@ export default function TaskBoardPage({ api }) {
|
||||
setBatchBusy(false);
|
||||
}
|
||||
}
|
||||
async function openDetail(task) {
|
||||
try {
|
||||
async function requestDetail(task) {
|
||||
if (task.detail_endpoint) {
|
||||
const result = await api(task.detail_endpoint);
|
||||
setDetail(result.data);
|
||||
} else if (task.task_type === "ask") {
|
||||
setDetail(task);
|
||||
} else {
|
||||
return result.data;
|
||||
}
|
||||
if (task.task_type === "ask") return task;
|
||||
const result = await api(`/api/admin/task-board/${task.id}`);
|
||||
setDetail(result.data);
|
||||
return result.data;
|
||||
}
|
||||
async function openDetail(task) {
|
||||
if (!task || detailLoading) return;
|
||||
const key = detailCacheKey(task);
|
||||
const refundIndex = refundQueue.findIndex((item) => rowKey(item) === rowKey(task));
|
||||
const nextCandidate = refundIndex >= 0 ? refundQueue[refundIndex + 1] || null : null;
|
||||
setNextRefundTask(nextCandidate);
|
||||
const cached = detailCache.current.get(key);
|
||||
if (cached) {
|
||||
setDetail(cached);
|
||||
return;
|
||||
}
|
||||
setDetailLoading(key);
|
||||
try {
|
||||
const loaded = await requestDetail(task);
|
||||
detailCache.current.set(key, loaded);
|
||||
setDetail(loaded);
|
||||
if (nextCandidate && !detailCache.current.has(detailCacheKey(nextCandidate))) {
|
||||
void requestDetail(nextCandidate)
|
||||
.then((nextDetail) => detailCache.current.set(detailCacheKey(nextCandidate), nextDetail))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
window.alert(err.message);
|
||||
} finally {
|
||||
setDetailLoading(null);
|
||||
}
|
||||
}
|
||||
async function cancel(task) {
|
||||
@ -366,8 +634,50 @@ export default function TaskBoardPage({ api }) {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function confirmRefund(task) {
|
||||
const result = await api(task.refund_endpoint, { method: "POST" });
|
||||
async function reactivate(task) {
|
||||
if (!window.confirm("确定重新激活任务 " + task.task_id + " 吗?重新激活后用户有 24 小时完成任务。")) return;
|
||||
setBusy(task.id);
|
||||
try {
|
||||
const result = await api(task.reactivate_endpoint || "/api/admin/task-board/" + task.id + "/reactivate", { method: "POST" });
|
||||
detailCache.current.set(detailCacheKey(task), result.data);
|
||||
setDetail(result.data);
|
||||
await load(false);
|
||||
} catch (err) {
|
||||
window.alert(err.message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function complete(task) {
|
||||
if (!completeConfirm || busy) return;
|
||||
setBusy(task.id);
|
||||
try {
|
||||
await api(`/api/admin/ask-tasks/${task.id}/complete`, { method: "POST" });
|
||||
setCompleteConfirm(null);
|
||||
setSelectedIds((current) => current.filter((key) => key !== rowKey(task)));
|
||||
await load();
|
||||
if (detail?.id === task.id) setDetail(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function saveRefundAmount(task, amount) {
|
||||
const result = await api("/api/admin/task-board/refund-amount", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ target_kind: task.record_kind, target_id: task.id, amount }),
|
||||
});
|
||||
const updated = { ...task, ...result.data };
|
||||
detailCache.current.set(detailCacheKey(task), updated);
|
||||
setDetail(updated);
|
||||
taskBoardCache.delete(accountCacheKey);
|
||||
}
|
||||
async function confirmRefund(task, amount, payerAccount) {
|
||||
const body = new FormData();
|
||||
body.append("refund_amount", amount);
|
||||
body.append("payer_account_id", payerAccount);
|
||||
const result = await api(task.refund_endpoint, { method: "POST", body });
|
||||
const updatedDetail = {
|
||||
...task,
|
||||
...result.data,
|
||||
@ -378,6 +688,7 @@ export default function TaskBoardPage({ api }) {
|
||||
payment_screenshots:
|
||||
task.payment_screenshots || result.data.payment_screenshots || [],
|
||||
};
|
||||
detailCache.current.set(detailCacheKey(task), updatedDetail);
|
||||
setDetail(updatedDetail);
|
||||
setTasks((current) => {
|
||||
const next = current.map((item) =>
|
||||
@ -385,9 +696,12 @@ export default function TaskBoardPage({ api }) {
|
||||
? { ...item, ...result.data }
|
||||
: item,
|
||||
);
|
||||
taskBoardCache = next;
|
||||
taskBoardCache.delete(accountCacheKey);
|
||||
return next;
|
||||
});
|
||||
// A claimed ask row is nested under its system task. Refresh the compact
|
||||
// tree as well so the child immediately leaves “待返款” after payment.
|
||||
await load(false);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@ -406,7 +720,7 @@ export default function TaskBoardPage({ api }) {
|
||||
type="button"
|
||||
className={type === value ? "active" : ""}
|
||||
key={value}
|
||||
onClick={() => setType(value)}
|
||||
onClick={() => { setType(value); setPage(1); }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@ -418,7 +732,7 @@ export default function TaskBoardPage({ api }) {
|
||||
type="button"
|
||||
className={status === value ? "active" : ""}
|
||||
key={value}
|
||||
onClick={() => setStatus(value)}
|
||||
onClick={() => { setStatus(value); setPage(1); }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@ -428,23 +742,23 @@ export default function TaskBoardPage({ api }) {
|
||||
<Search size={17} />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索用户ID或任务ID…"
|
||||
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
|
||||
placeholder="搜索用户ID、任务ID、店铺或商品…"
|
||||
/>
|
||||
</div>
|
||||
<div className="board-tools">
|
||||
<label>
|
||||
<span>开始日期</span>
|
||||
<input type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
|
||||
<input type="date" value={dateFrom} onChange={(event) => { setDateFrom(event.target.value); setPage(1); }} />
|
||||
</label>
|
||||
<label>
|
||||
<span>结束日期</span>
|
||||
<input type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
|
||||
<input type="date" value={dateTo} onChange={(event) => { setDateTo(event.target.value); setPage(1); }} />
|
||||
</label>
|
||||
<button type="button" onClick={clearFilters}>清空筛选</button>
|
||||
<button type="button" onClick={exportRows}>导出表格</button>
|
||||
<button type="button" className="danger" disabled={batchBusy} onClick={batchClear}>
|
||||
{batchBusy ? "清除中…" : `批量清除${selectedIds.length ? `(${selectedIds.length})` : ""}`}
|
||||
{batchBusy ? "删除中…" : `批量删除${selectedIds.length ? `(${selectedIds.length})` : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
@ -458,10 +772,10 @@ export default function TaskBoardPage({ api }) {
|
||||
</span>
|
||||
<span>用户ID</span>
|
||||
<span>任务ID</span>
|
||||
<span>商品信息</span>
|
||||
<span>商品/店铺信息</span>
|
||||
<span>类型</span>
|
||||
<span>Q&A数</span>
|
||||
<span>待审/完成</span>
|
||||
<span>完成</span>
|
||||
<span>状态</span>
|
||||
<span>创建人</span>
|
||||
<span>操作</span>
|
||||
@ -473,66 +787,114 @@ export default function TaskBoardPage({ api }) {
|
||||
暂无符合条件的任务
|
||||
</div>
|
||||
) : (
|
||||
rows.map((task) => (
|
||||
<div className="board-table-row" key={rowKey(task)}>
|
||||
rows.map((task) => {
|
||||
const key = rowKey(task);
|
||||
const children = childRows(task);
|
||||
const expanded = expandedAskTasks.has(key);
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<div className="board-table-row">
|
||||
<span className="board-select-cell">
|
||||
<input type="checkbox" checked={selectedIds.includes(rowKey(task))} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
|
||||
<input type="checkbox" checked={selectedIds.includes(key)} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
|
||||
</span>
|
||||
<span>
|
||||
<span className={`board-user-cell${!isAskTask(task) ? " board-answer-user" : ""}`}>
|
||||
<code>{task.user_id}</code>
|
||||
{!isAskTask(task) && task.phone && task.phone !== task.user_id && <small>{maskPhone(task.phone)}</small>}
|
||||
</span>
|
||||
<span><b>{task.task_id}</b></span>
|
||||
<span>
|
||||
<b>{task.task_id}</b>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{task.product_name}</strong>
|
||||
<small>{maskPhone(task.phone)}</small>
|
||||
</span>
|
||||
<span>
|
||||
<em className={`board-type ${task.task_type}`}>
|
||||
{task.task_type_label}
|
||||
</em>
|
||||
<strong>{task.product_name || "--"}</strong>
|
||||
<small>{task.shop_name || "--"}</small>
|
||||
</span>
|
||||
<span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
|
||||
<span>{task.qa_count}</span>
|
||||
<span>
|
||||
{task.pending_review_count} / {task.completed_count}
|
||||
</span>
|
||||
<span>
|
||||
<em className={`board-status ${task.status_label}`}>
|
||||
{task.status_label}
|
||||
</em>
|
||||
</span>
|
||||
<span>
|
||||
{task.creator_name}
|
||||
<small>{formatTime(task.created_at)}</small>
|
||||
</span>
|
||||
<span>{task.completed_count}</span>
|
||||
<span><em className={`board-status ${task.status_label}`}>{task.status_label}</em></span>
|
||||
<span>{task.creator_name}<small>{formatTime(task.created_at)}</small></span>
|
||||
<span className="board-actions">
|
||||
<button onClick={() => openDetail(task)}>详情</button>
|
||||
{task.can_cancel && (
|
||||
{isAskTask(task) ? (
|
||||
<button
|
||||
className="cancel"
|
||||
disabled={busy === task.id}
|
||||
onClick={() => cancel(task)}
|
||||
type="button"
|
||||
className="expand"
|
||||
disabled={children.length === 0}
|
||||
onClick={() => toggleAskTask(task)}
|
||||
aria-expanded={expanded}
|
||||
title={children.length === 0 ? "暂无认领明细" : undefined}
|
||||
>
|
||||
{expanded ? "收起" : "展开"}
|
||||
</button>
|
||||
) : (
|
||||
<button disabled={Boolean(detailLoading)} onClick={() => openDetail(task)}>
|
||||
{detailLoading === detailCacheKey(task) ? "打开中…" : "详情"}
|
||||
</button>
|
||||
)}
|
||||
{task.can_cancel && (
|
||||
<button className="cancel" disabled={busy === task.id} onClick={() => cancel(task)}>
|
||||
{busy === task.id ? "撤销中" : "撤销"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="delete"
|
||||
onClick={() => setBatchConfirm({ tasks: [task] })}
|
||||
>
|
||||
删除
|
||||
{task.can_reactivate && (
|
||||
<button className="reactivate" disabled={busy === task.id} onClick={() => reactivate(task)}>
|
||||
{busy === task.id ? "激活中" : "重新激活"}
|
||||
</button>
|
||||
)}
|
||||
{task.can_complete && task.task_type === "ask" && (
|
||||
<button className="complete" disabled={busy === task.id} onClick={() => setCompleteConfirm(task)}>完成任务</button>
|
||||
)}
|
||||
<button className="delete" onClick={() => setBatchConfirm({ tasks: [task] })}>删除</button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
{expanded && children.map((child) => (
|
||||
<div className="board-table-row board-child-row" key={rowKey(child)}>
|
||||
<span className="board-select-cell" />
|
||||
<span className="board-user-cell board-child-user"><i>↳</i><code>{child.user_id}</code>{child.phone && child.phone !== child.user_id && <small>{maskPhone(child.phone)}</small>}</span>
|
||||
<span>
|
||||
<b>{child.task_id}</b>
|
||||
{child.batch_label && <small>{child.batch_label}</small>}
|
||||
</span>
|
||||
<span><strong>{child.product_name || "--"}</strong><small>{child.shop_name || "--"}</small></span>
|
||||
<span><em className="board-type ask">提问任务</em></span>
|
||||
<span>{child.qa_count}</span>
|
||||
<span>{child.completed_count}</span>
|
||||
<span><em className={`board-status ${child.status_label}`}>{child.status_label}</em></span>
|
||||
<span>{child.creator_name}<small>{formatTime(child.created_at)}</small></span>
|
||||
<span className="board-actions"><button disabled={Boolean(detailLoading)} onClick={() => openDetail(child)}>{detailLoading === detailCacheKey(child) ? "打开中…" : "详情"}</button></span>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
<nav className="board-pagination" aria-label="任务看板分页">
|
||||
<span>共 {total} 条</span>
|
||||
<label>
|
||||
每页
|
||||
<select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
|
||||
{[10, 20, 50, 100].map((size) => <option key={size} value={size}>{size} 条</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))}>上一页</button>
|
||||
<strong>{page} / {totalPages}</strong>
|
||||
<button type="button" disabled={page >= totalPages || loading} onClick={() => setPage((value) => Math.min(totalPages, value + 1))}>下一页</button>
|
||||
</nav>
|
||||
{detailLoading && !detail && <TaskDetailLoading />}
|
||||
{detail && (
|
||||
<TaskDetail
|
||||
key={rowKey(detail)}
|
||||
task={detail}
|
||||
close={() => setDetail(null)}
|
||||
onConfirmRefund={confirmRefund}
|
||||
onSaveRefundAmount={saveRefundAmount}
|
||||
onReactivate={reactivate}
|
||||
onCancel={cancel}
|
||||
actionBusy={busy === detail.id}
|
||||
api={api}
|
||||
user={user}
|
||||
|
||||
hasNextRefund={Boolean(nextRefundTask)}
|
||||
nextBusy={Boolean(detailLoading)}
|
||||
onNextRefund={() => openDetail(nextRefundTask)}
|
||||
/>
|
||||
)}
|
||||
{batchConfirm && (
|
||||
@ -555,6 +917,19 @@ export default function TaskBoardPage({ api }) {
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{completeConfirm && (
|
||||
<div className="board-confirm-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setCompleteConfirm(null)}>
|
||||
<section className="board-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="complete-task-title">
|
||||
<div className="board-confirm-icon complete-icon">✓</div>
|
||||
<h2 id="complete-task-title">确认完成任务?</h2>
|
||||
<p>该提问任务当前已完成 {completeConfirm.completed_count || 0} / {completeConfirm.total_count || completeConfirm.qa_count || 0} 条。确认后将强制归档到“已完成”,用户将不能继续提交。</p>
|
||||
<div className="board-confirm-actions">
|
||||
<button type="button" onClick={() => setCompleteConfirm(null)} disabled={busy === completeConfirm.id}>取消</button>
|
||||
<button type="button" className="confirm-complete" onClick={() => complete(completeConfirm)} disabled={busy === completeConfirm.id}>{busy === completeConfirm.id ? "处理中…" : "确认完成"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
26
frontend/src/pageCache.js
Normal file
26
frontend/src/pageCache.js
Normal file
@ -0,0 +1,26 @@
|
||||
const CACHE_PREFIX = "wendajia-page-cache:";
|
||||
|
||||
export function readPageCache(key, fallback = null) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${CACHE_PREFIX}${key}`);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writePageCache(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(value));
|
||||
} catch {
|
||||
// 缓存空间不足或浏览器禁用存储时,不影响页面正常使用。
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPageCache(key) {
|
||||
try {
|
||||
window.localStorage.removeItem(`${CACHE_PREFIX}${key}`);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
46
frontend/src/productIntent.js
Normal file
46
frontend/src/productIntent.js
Normal file
@ -0,0 +1,46 @@
|
||||
const REPEATED_PATTERN = /^(.{1,3})\1+$/u;
|
||||
|
||||
/**
|
||||
* Lightweight intent guard used before calling the generation APIs. It keeps
|
||||
* obvious placeholder input (for example, "111" or "aaaa") from being sent
|
||||
* to the model while allowing normal Chinese/English product descriptions.
|
||||
*/
|
||||
export function analyzeProductIntent(values) {
|
||||
const text = values
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (!text) return "请至少填写一项商品信息";
|
||||
|
||||
const compact = text.replace(/[\s\p{P}\p{S}]+/gu, "");
|
||||
if (!compact) return "商品信息不能只有符号,请填写真实的商品内容";
|
||||
if (/^\d+$/u.test(compact)) {
|
||||
return "商品信息看起来像占位内容,请填写真实的商品名称、品类、功效或卖点";
|
||||
}
|
||||
if (REPEATED_PATTERN.test(compact)) {
|
||||
return "商品信息看起来是重复的占位内容,请补充真实的商品信息";
|
||||
}
|
||||
if (!/[\u4e00-\u9fffA-Za-z]/u.test(compact)) {
|
||||
return "请填写包含文字的真实商品信息";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function validateGenerationOptions(form, includeAnswers = true) {
|
||||
// 生成偏好均为选填,只有生成条数需要填写并进行数值校验。
|
||||
const required = [["question_count", "提问生成条数"]];
|
||||
const missing = required
|
||||
.filter(([key]) => !String(form[key] ?? "").trim())
|
||||
.map(([, label]) => label);
|
||||
if (missing.length) return `请填写${missing.join("、")}`;
|
||||
if (!Number.isInteger(Number(form.question_count)) || Number(form.question_count) < 1) {
|
||||
return "提问生成条数必须是大于 0 的整数";
|
||||
}
|
||||
if (String(form.claim_questions_per_user ?? "").trim()) {
|
||||
const claimCount = Number(form.claim_questions_per_user);
|
||||
if (!Number.isInteger(claimCount) || claimCount < 2 || claimCount > 20) {
|
||||
return "每个用户可认领的问题数量必须在 2-20 条之间";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
128
frontend/src/qrCode.js
Normal file
128
frontend/src/qrCode.js
Normal file
@ -0,0 +1,128 @@
|
||||
// Tiny self-contained QR encoder for the short-lived refund upload URL.
|
||||
// It intentionally supports QR version 4-L (up to 78 UTF-8 bytes), which is
|
||||
// enough for the local upload route and keeps the admin bundle dependency-free.
|
||||
const EXP = new Array(512);
|
||||
const LOG = new Array(256);
|
||||
let value = 1;
|
||||
for (let i = 0; i < 255; i += 1) {
|
||||
EXP[i] = value;
|
||||
LOG[value] = i;
|
||||
value <<= 1;
|
||||
if (value & 0x100) value ^= 0x11d;
|
||||
}
|
||||
for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255];
|
||||
|
||||
function multiply(a, b) {
|
||||
return a && b ? EXP[LOG[a] + LOG[b]] : 0;
|
||||
}
|
||||
|
||||
function reedSolomon(data, count) {
|
||||
let generator = [1];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const next = new Array(generator.length + 1).fill(0);
|
||||
generator.forEach((coefficient, index) => {
|
||||
next[index] ^= coefficient;
|
||||
next[index + 1] ^= multiply(coefficient, EXP[i]);
|
||||
});
|
||||
generator = next;
|
||||
}
|
||||
const result = new Array(count).fill(0);
|
||||
data.forEach((byte) => {
|
||||
const factor = byte ^ result[0];
|
||||
result.shift();
|
||||
result.push(0);
|
||||
for (let i = 0; i < count; i += 1) result[i] ^= multiply(generator[i + 1], factor);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function bchTypeInfo(data) {
|
||||
let value = data << 10;
|
||||
while (value && value.toString(2).length >= 11) {
|
||||
value ^= 0x537 << (value.toString(2).length - 11);
|
||||
}
|
||||
return ((data << 10) | value) ^ 0x5412;
|
||||
}
|
||||
|
||||
function setFinder(matrix, row, col) {
|
||||
for (let r = -1; r <= 7; r += 1) {
|
||||
for (let c = -1; c <= 7; c += 1) {
|
||||
if (row + r < 0 || row + r >= matrix.length || col + c < 0 || col + c >= matrix.length) continue;
|
||||
matrix[row + r][col + c] = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeMatrix(text) {
|
||||
const bytes = Array.from(new TextEncoder().encode(text));
|
||||
if (bytes.length > 78) throw new Error("二维码内容过长");
|
||||
const bits = [];
|
||||
const push = (value, length) => { for (let i = length - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1); };
|
||||
push(0x4, 4);
|
||||
push(bytes.length, 8);
|
||||
bytes.forEach((byte) => push(byte, 8));
|
||||
for (let i = 0; i < 4 && bits.length < 640; i += 1) bits.push(0);
|
||||
while (bits.length % 8) bits.push(0);
|
||||
const data = [];
|
||||
for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((sum, bit) => (sum << 1) | bit, 0));
|
||||
// Version 4-L has 100 total codewords: one block of 80 data bytes and
|
||||
// 20 Reed-Solomon correction bytes.
|
||||
while (data.length < 80) data.push(data.length % 2 ? 0x11 : 0xec);
|
||||
const codewords = data.concat(reedSolomon(data, 20));
|
||||
const size = 33;
|
||||
const matrix = Array.from({ length: size }, () => Array(size).fill(null));
|
||||
setFinder(matrix, 0, 0); setFinder(matrix, size - 7, 0); setFinder(matrix, 0, size - 7);
|
||||
for (let i = 8; i < size - 8; i += 1) {
|
||||
if (matrix[i][6] === null) matrix[i][6] = i % 2 === 0;
|
||||
if (matrix[6][i] === null) matrix[6][i] = i % 2 === 0;
|
||||
}
|
||||
// Version 4 has one alignment pattern at (26, 26).
|
||||
for (let r = -2; r <= 2; r += 1) for (let c = -2; c <= 2; c += 1) {
|
||||
if (matrix[26 + r][26 + c] === null) matrix[26 + r][26 + c] = Math.max(Math.abs(r), Math.abs(c)) !== 1;
|
||||
}
|
||||
let bitIndex = 0;
|
||||
const writeFormat = (format) => {
|
||||
for (let i = 0; i < 15; i += 1) {
|
||||
const dark = ((format >>> i) & 1) === 1;
|
||||
if (i < 6) matrix[i][8] = dark;
|
||||
else if (i < 8) matrix[i + 1][8] = dark;
|
||||
else matrix[size - 15 + i][8] = dark;
|
||||
if (i < 8) matrix[8][size - i - 1] = dark;
|
||||
else if (i < 9) matrix[8][15 - i] = dark;
|
||||
else matrix[8][15 - i - 1] = dark;
|
||||
}
|
||||
matrix[size - 8][8] = true;
|
||||
};
|
||||
// Reserve these cells before data placement. Otherwise the data walk
|
||||
// would overwrite them and the resulting code could not be scanned.
|
||||
writeFormat(0);
|
||||
let upward = true;
|
||||
for (let right = size - 1; right >= 1; right -= 2) {
|
||||
if (right === 6) right -= 1;
|
||||
for (let offset = 0; offset < size; offset += 1) {
|
||||
const row = upward ? size - 1 - offset : offset;
|
||||
for (const col of [right, right - 1]) {
|
||||
if (matrix[row][col] !== null) continue;
|
||||
const byte = codewords[Math.floor(bitIndex / 8)] || 0;
|
||||
const raw = ((byte >>> (7 - (bitIndex % 8))) & 1) === 1;
|
||||
matrix[row][col] = raw !== ((row + col) % 2 === 0); // mask 0
|
||||
bitIndex += 1;
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
writeFormat(bchTypeInfo(8)); // error correction L, mask 0
|
||||
return matrix;
|
||||
}
|
||||
|
||||
export function qrDataUrl(text, moduleSize = 5) {
|
||||
const matrix = makeMatrix(text);
|
||||
const quiet = 4;
|
||||
const dimension = (matrix.length + quiet * 2) * moduleSize;
|
||||
const paths = [];
|
||||
matrix.forEach((row, r) => row.forEach((dark, c) => {
|
||||
if (dark) paths.push(`M${(c + quiet) * moduleSize} ${(r + quiet) * moduleSize}h${moduleSize}v${moduleSize}h-${moduleSize}z`);
|
||||
}));
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dimension} ${dimension}" width="${dimension}" height="${dimension}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${paths.join(" ")}" fill="#0b1a35"/></svg>`;
|
||||
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`;
|
||||
}
|
||||
@ -219,6 +219,27 @@ button {
|
||||
color: #8296ae;
|
||||
font-size: 11px;
|
||||
}
|
||||
.reference-qa-upload input[type="file"] {
|
||||
color: transparent;
|
||||
}
|
||||
.reference-qa-upload input[type="file"]::file-selector-button {
|
||||
margin-right: 10px;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid #c7d8eb;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #1764e8;
|
||||
cursor: pointer;
|
||||
}
|
||||
.reference-qa-clear {
|
||||
width: max-content;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #f1a9b5;
|
||||
border-radius: 7px;
|
||||
background: #fff3f5;
|
||||
color: #cc3150;
|
||||
font-size: 11px;
|
||||
}
|
||||
.login-hint {
|
||||
margin-top: 24px;
|
||||
color: #9aabc1;
|
||||
@ -292,6 +313,7 @@ button {
|
||||
color: #c5d2e8;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
|
||||
11
frontend/src/taobaoId.js
Normal file
11
frontend/src/taobaoId.js
Normal file
@ -0,0 +1,11 @@
|
||||
export const TAOBAO_ID_HINT = "请输入2-64位淘宝ID,支持中文、英文字母、数字、下划线、点和短横线";
|
||||
|
||||
const TAOBAO_ID_PATTERN = /^[\p{Script=Han}A-Za-z0-9_.-]{2,64}$/u;
|
||||
|
||||
export function normalizeTaobaoId(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
export function isValidTaobaoId(value) {
|
||||
return TAOBAO_ID_PATTERN.test(normalizeTaobaoId(value));
|
||||
}
|
||||
13
frontend/src/time.js
Normal file
13
frontend/src/time.js
Normal file
@ -0,0 +1,13 @@
|
||||
// Backend datetime values are stored as UTC. Older records omit the timezone suffix.
|
||||
export function formatDateTime(value, options = {}) {
|
||||
if (!value) return "--";
|
||||
const raw = String(value);
|
||||
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw) ? raw : `${raw}Z`;
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user