Compare commits

..

36 Commits
main ... dev

Author SHA1 Message Date
db_huang
5a45708d67 解冻之前被冻结的一分钱,修改返款记录布局 2026-11-05 15:39:42 +08:00
db_huang
bd7a578252 修复新字段不同步任务详情的bug,新增回答任务消息图标 2026-11-05 13:59:09 +08:00
db_huang
561a34f108 返款记录新增平台、商品ID字段,修改返款管理界面,新加资金统计、使用情况功能 2026-11-05 11:12:24 +08:00
db_huang
7f851593fb 修复主管点击成员管理没有反应的BUG 2026-11-04 16:49:15 +08:00
db_huang
00239b0431 修复支付是出现500报错的BUG,添加认领人ID,添加使用统计最近上线时间 2026-11-04 11:09:59 +08:00
db_huang
858fc132ef 修复文件存储路径错误的BUG 2026-11-04 10:21:58 +08:00
db_huang
9a4dd3ed0f 删除遗留上传凭证字段,加入生成问答导出 2026-11-03 11:55:37 +08:00
db_huang
a18f75f735 修改支付状态bug 2026-11-02 11:45:55 +08:00
db_huang
d888d9231f 修改支付完成后支付状态不同步的bug 2026-11-02 09:48:31 +08:00
db_huang
a25b774521 测试转账通过,最后调整 2026-10-31 18:12:11 +08:00
db_huang
b552ac56b0 添加测试日志 2026-10-31 17:49:15 +08:00
db_huang
2b01e0adec 修改支付宝佣金配置场景 2026-10-31 17:34:45 +08:00
db_huang
29ae53e593 修改佣金报酬场景 2026-10-31 17:11:17 +08:00
db_huang
fa3517afbb 转账请求新增 transfer_scene_report_infos 2026-10-30 16:15:42 +08:00
db_huang
8f07b6bcf1 添加支付场景 2026-10-30 15:58:12 +08:00
db_huang
e70866c4b6 修修改支付宝证书逐张解析 2026-10-29 10:47:18 +08:00
db_huang
5f9264fbb2 修改返款系统,添加支付宝付款管理,添加支付宝配置证书 2026-10-29 09:40:31 +08:00
db_huang
ccae5a3a3c 新加支付板块,修改数据库位置 2026-10-17 18:22:39 +08:00
db_huang
61039682e9 添加AI指挥部全员为超管,添加使用统计功能 2026-10-07 11:22:53 +08:00
db_huang
0b83663f77 修复问答助手生成回答报错的bug 2026-09-22 17:57:35 +08:00
db_huang
ee0f7d0a68 任务看板搜索功能报错BUG修复,已删除任务不在审核 2026-09-21 18:23:42 +08:00
db_huang
86a1a33693 商品二维码上传改为黏贴上传 2026-09-18 18:08:02 +08:00
db_huang
68b59d2eab 审核页面图片改为预览,[200~1. 任务不领取直接释放不等待一分钟 2026-09-17 17:37:50 +08:00
db_huang
3ab8b52593 修改登录时淘宝ID的输入合法性,问答助手可批量导入问题生成回答, 2026-09-17 14:30:10 +08:00
db_huang
3f15dd0ea3 修改批量上传后不能发布的BUG,优化发布时填写信息,认领上限改成20 2026-09-17 10:51:00 +08:00
db_huang
7f7311a47c 修复历史返款不可查看的BUG,修复问题认领对应逻辑问题 2026-09-16 14:32:31 +08:00
db_huang
73ac3e8163 修复卡顿bug,新增问题批量导入,释放机制调整 2026-09-16 10:15:20 +08:00
66aca010f1 返款凭证上传修复,新增回答任务专属链接 2026-09-10 14:23:31 +08:00
3f1da0e159 优化bug,返款扫码上传,新加填写店铺名,上传商品二维码 2026-09-10 10:03:37 +08:00
0b79c6812c 修改权限隔离,优化bug 2026-09-08 10:35:25 +08:00
598a2cbc29 整理钉钉原生免登流程 2026-09-05 18:18:48 +08:00
551c68c456 修改钉钉工作台免登环境识别 2026-09-05 17:47:31 +08:00
5b10096bbf 修改钉钉免登 2026-09-05 17:33:21 +08:00
970dc8bfee 取消运营后台注册功能 2026-09-05 15:43:54 +08:00
20ca5384fc 适配手机端,优化组件,完善权限隔离 2026-09-05 15:17:53 +08:00
cf83de291c 修复bug,调整页面布局,优化移动端页面 2026-09-03 18:20:25 +08:00
84 changed files with 14776 additions and 1353 deletions

View File

@ -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
View File

@ -1,6 +1,5 @@
.env
__pycache__/
*.py[cod]
.pytest_cache/
backend/.env
frontend/node_modules/
frontend/dist/
backend/data/uploads/
backend/keys/

View File

@ -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
View 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

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.

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.

View 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

View File

@ -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()

View File

@ -24,4 +24,12 @@ def get_db():
try:
yield db
finally:
db.close()
# 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()

View File

@ -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 = [{
"type": "text",
"text": (
"请识别这些淘宝‘问答/待回答’页面截图中的消费者提问。只提取真正的问题句子,"
"不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。"
"多张截图合并去重。只返回严格 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({
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:"
'{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}'
),
},
{
"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)
except Exception:
logger.exception("Question OCR failed")
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
result = json.loads(response.read().decode("utf-8"))
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.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]

File diff suppressed because it is too large Load Diff

View File

@ -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")

View File

@ -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

View 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
View File

View 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

View File

@ -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;

View 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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

18
backend/tests/conftest.py Normal file
View 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"

View 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"

View 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

View 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())

View 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

View 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

View 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

View 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

View 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")

Binary file not shown.

Binary file not shown.

View File

@ -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>

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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)
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) }
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 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>
}

View File

@ -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);
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);
}, [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); }
}
}, [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>
<footer>
<code>{assignment.product_link}</code>
<button
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_link || assignment.product_qr) && (
<div className="ask-product-access">
{assignment.product_link && (
<footer>
<code title={assignment.product_link}>{assignment.product_link}</code>
<button
onClick={() => navigator.clipboard?.writeText(assignment.product_link)}
>
<Copy size={13} />
复制淘口令
</button>
</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,26 +680,33 @@ 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
? "任务正在审核,请耐心等待。"
: passed
: paid
? "返款已完成"
: passed
? "运营审核已通过"
: "运营审核未通过"}
</h1>
<p>
{pending
? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。"
: passed
: paid
? "运营已完成返款,以下为返款支付凭证,请留意您的收款账户。"
: passed
? "您提交的提问凭证已经核验通过,本次任务审核完成。"
: "请根据运营反馈修改截图后重新提交审核。"}
</p>
@ -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,32 +853,63 @@ 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);
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.
setSelected(
result.data.questions
// Questions already submitted for review are consumed and must not
// be counted as a new selection when the user returns to claim more.
.filter((item) => item.selected && !item.used)
.map((item) => item.id),
);
// 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
// be counted as a new selection when the user returns to claim more.
.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={() => {
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 === "已通过",
),
);
setNotice("");
if (assignment && (assignment.remaining_count ?? 0) > 0 && ["已通过", "已完成"].includes(assignment.status)) {
void startNextSelection();
} else {
setView("claim");
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>

View File

@ -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>}
</>
}

View File

@ -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) }),
});
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 || "自主添加",
})),
}),
});
setTask(result.data);
// 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>
<code>
{window.location.origin}/ask/{task.task_id}
</code>
<small>当前链接为任务预留入口,后续接入提问用户页面。</small>
<div className="ask-link-value">
<code>
{window.location.origin}/ask/{task.public_token}
</code>
<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,38 +801,49 @@ export default function CreateAskTasksPage({ api }) {
</section>
<section className="ask-results">
<div className="ask-toolbar">
<label>
<input
type="checkbox"
checked={
questions.length > 0 &&
questions.every((item) => item.selected)
}
onChange={(event) =>
setQuestions((current) =>
current.map((item) => ({
...item,
selected: event.target.checked,
})),
)
}
/>{" "}
全选 ({questions.filter((item) => item.selected).length}/
{questions.length})
</label>
<button className="ghost-button" onClick={addQuestion}>
<Plus size={14} />
添加提问
</button>
<button
className="primary-button"
disabled={
!questions.some((item) => item.selected) || !task || busy
}
onClick={publish}
>
发布任务
</button>
<div className="ask-toolbar-selection">
<label>
<input
type="checkbox"
checked={
questions.length > 0 &&
questions.every((item) => item.selected)
}
onChange={(event) =>
setQuestions((current) =>
current.map((item) => ({
...item,
selected: event.target.checked,
})),
)
}
/>{" "}
全选 ({questions.filter((item) => item.selected).length}/
{questions.length})
</label>
{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.length || busy}
onClick={() => { setError(""); setPublishOpen(true); }}
>
发布任务
</button>
</div>
</div>
{questions.length === 0 ? (
<div className="ask-empty">
@ -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>
);
}

View 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>}
</>;
}

View 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>
</>;
}

View File

@ -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: "待返款" } }));
}}
/>
)}
</>

View File

@ -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>
<b>Q:{question.question}</b>
<small>A:{question.answer}</small>
</span>
<span>
{task.user_id}
<small>{maskPhone(task.phone)}</small>
</span>
<span>
<em className={`board-status ${task.status_label}`}>
{task.status_label}
</em>
</span>
</div>
))
<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
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
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 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;
});
const rows = tasks;
const refundQueue = useMemo(
() => tasks.flatMap((task) => [task, ...childRows(task)]).filter((task) => {
const rawStatus = String(task.status_label || task.status || "").trim();
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 requestDetail(task) {
if (task.detail_endpoint) {
const result = await api(task.detail_endpoint);
return result.data;
}
if (task.task_type === "ask") return task;
const result = await api(`/api/admin/task-board/${task.id}`);
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 {
if (task.detail_endpoint) {
const result = await api(task.detail_endpoint);
setDetail(result.data);
} else if (task.task_type === "ask") {
setDetail(task);
} else {
const result = await api(`/api/admin/task-board/${task.id}`);
setDetail(result.data);
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)}>
<span className="board-select-cell">
<input type="checkbox" checked={selectedIds.includes(rowKey(task))} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
</span>
<span>
<code>{task.user_id}</code>
</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>
</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 className="board-actions">
<button onClick={() => openDetail(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] })}
>
删除
</button>
</span>
</div>
))
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(key)} onChange={() => toggleSelected(task)} aria-label={`选择任务 ${task.task_id}`} />
</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>
<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.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">
{isAskTask(task) ? (
<button
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>
)}
{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
View 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.
}
}

View 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
View 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)))}`;
}

View File

@ -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
View 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
View 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,
});
}