返款记录新增平台、商品ID字段,修改返款管理界面,新加资金统计、使用情况功能

This commit is contained in:
db_huang 2026-11-05 11:12:24 +08:00
parent 7f851593fb
commit 561a34f108
24 changed files with 1100 additions and 120 deletions

View File

@ -1,7 +1,7 @@
from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation
from .models import RefundRecipient, RefundPaymentAccount, RefundRecord
from .schemas import RefundPaymentAccountSave, PaymentAccountAuthorization
from .models import RefundAccountAdjustment, RefundRecipient, RefundPaymentAccount, RefundRecord
from .schemas import RefundFundAdjustment, RefundPaymentAccountSave, PaymentAccountAuthorization
from sqlalchemy.exc import IntegrityError
import base64
@ -52,6 +52,7 @@ app.add_middleware(
# confirms a selection, the upload screen has a separate thirty-minute window.
ASK_SELECTION_TIMEOUT = timedelta(minutes=1)
ASK_UPLOAD_TIMEOUT = timedelta(minutes=30)
ANSWER_TASK_TIMEOUT = timedelta(hours=24)
# Bumping this prefix invalidates every previously issued public-user token.
# Only Taobao-ID login can issue a token accepted by the current user portals.
PUBLIC_USER_SESSION_PREFIX = "tb2_"
@ -88,6 +89,24 @@ def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -
return len(expired)
def _expire_answer_tasks(db: Session, submission_id: int | None = None) -> int:
"""Mark dispatched answer tasks as expired after a strict 24-hour window."""
now = datetime.utcnow()
statement = select(AnswerSubmission).where(
AnswerSubmission.status == "待用户完成",
AnswerSubmission.updated_at < now - ANSWER_TASK_TIMEOUT,
)
if submission_id is not None:
statement = statement.where(AnswerSubmission.id == submission_id)
expired = db.scalars(statement).unique().all()
for record in expired:
record.status = "已超时"
record.updated_at = now
if expired:
db.flush()
return len(expired)
def _ask_assignment_expired(assignment: AskAssignment | None, now: datetime | None = None) -> bool:
if not assignment or assignment.status not in {"待选择", "进行中"}:
return False
@ -171,6 +190,101 @@ def _product_token_error(value: object, qr_image: object = None) -> str | None:
return None
_COMMERCE_PLATFORMS = (
(("taobao.com", "tmall.com", "tb.cn", "e.tb.cn"), "淘宝"),
(("jd.com", "3.cn"), "京东"),
(("pinduoduo.com", "yangkeduo.com"), "拼多多"),
(("douyin.com", "iesdouyin.com"), "抖音电商"),
(("vip.com",), "唯品会"),
)
def _commerce_platform(hostname: str, text_value: str = "") -> str:
host = str(hostname or "").lower().strip(".")
for domains, label in _COMMERCE_PLATFORMS:
if any(host == domain or host.endswith("." + domain) for domain in domains):
return label
compact = str(text_value or "")
for marker, label in (("【淘宝】", "淘宝"), ("京东", "京东"), ("拼多多", "拼多多"), ("抖音", "抖音电商")):
if marker in compact:
return label
return ""
def _extract_product_metadata(source: object, fetched_url: str = "", fetched_text: str = "") -> dict:
raw = str(source or "").strip()
combined = "\n".join(value for value in (raw, fetched_url, fetched_text) if value)
urls = re.findall(r"https?://[^\s<>\"',。]+", combined, flags=re.IGNORECASE)
parsed_urls = [urllib.parse.urlparse(url.replace("&amp;", "&")) for url in urls]
platform = next(
(_commerce_platform(parsed.hostname or "", combined) for parsed in parsed_urls if _commerce_platform(parsed.hostname or "", combined)),
"",
) or _commerce_platform("", combined)
product_id = ""
for pattern in (
r"(?:itemIds?|item_id|itemId)[=:\"'\s]+(\d{6,})",
r"[?&]id=(\d{6,})",
r"/item/(\d{6,})",
r"/product/(\d{6,})",
):
match = re.search(pattern, combined, flags=re.IGNORECASE)
if match:
product_id = match.group(1)
break
shop_name = ""
for pattern in (
r'["\'](?:shopName|shop_name|sellerNick|seller_name)["\']\s*[:=]\s*["\']([^"\']{1,255})',
r"(?:shopName|shop_name|sellerNick|seller_name)=([^&\"'\s]{1,255})",
r"店铺(?:名称)?[::]\s*([^\n,,]{1,255})",
):
match = re.search(pattern, combined, flags=re.IGNORECASE)
if match:
shop_name = urllib.parse.unquote(match.group(1)).strip()
break
title_match = re.search(r"「([^」]{1,255})」", raw)
return {
"ecommerce_platform": platform,
"product_id": product_id,
"shop_name": shop_name,
"product_name": title_match.group(1).strip() if title_match else "",
"resolved_url": fetched_url or (urls[0] if urls else ""),
}
def _resolve_product_metadata(source: object) -> dict:
raw = str(source or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="请粘贴淘口令、商品链接或先上传商品二维码")
initial = _extract_product_metadata(raw)
url_match = re.search(r"https?://[^\s<>\"',。]+", raw, flags=re.IGNORECASE)
if not url_match:
return initial
source_url = url_match.group(0)
parsed = urllib.parse.urlparse(source_url)
if parsed.scheme not in {"http", "https"} or not _commerce_platform(parsed.hostname or "", raw):
return initial
class CommerceRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
target = urllib.parse.urlparse(newurl)
if target.scheme not in {"http", "https"} or not _commerce_platform(target.hostname or "", raw):
raise urllib.error.URLError("商品短链跳转到了不受支持的地址")
return super().redirect_request(req, fp, code, msg, headers, newurl)
request = urllib.request.Request(
source_url,
headers={"User-Agent": "Mozilla/5.0 (compatible; WendajiaProductResolver/1.0)"},
)
try:
with urllib.request.build_opener(CommerceRedirectHandler()).open(request, timeout=10) as response:
final_url = response.geturl()
body = response.read(1024 * 1024).decode(
response.headers.get_content_charset() or "utf-8", errors="ignore"
)
except Exception:
return initial
return _extract_product_metadata(raw, final_url, body)
def _decode_product_qr_image(value: object, filename: object = "") -> tuple[bytes | None, str | None, str | None]:
"""Decode the small data URL sent by the create-task form."""
if value is None:
@ -330,6 +444,10 @@ def startup():
if "amount_fen" not in {column["name"] for column in inspect(engine).get_columns("refund_recipients")}:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE refund_recipients ADD COLUMN amount_fen INTEGER NULL"))
adjustment_columns = {column["name"] for column in inspect(engine).get_columns("refund_account_adjustments")}
if "adjustment_type" not in adjustment_columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE refund_account_adjustments ADD COLUMN adjustment_type VARCHAR(32) NOT NULL DEFAULT '资金调整'"))
settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True)
for screenshot_table in ("answer_screenshots", "ask_assignment_batch_screenshots", "ask_assignment_screenshots"):
screenshot_column_defs = {column["name"]: column for column in inspect(engine).get_columns(screenshot_table)}
@ -348,6 +466,14 @@ def startup():
if "shop_name" not in columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''"))
for column_name, column_type in {
"ecommerce_platform": "VARCHAR(64) NOT NULL DEFAULT ''",
"product_id": "VARCHAR(128) NOT NULL DEFAULT ''",
}.items():
if column_name not in columns:
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE ask_tasks ADD COLUMN {column_name} {column_type}"))
columns.add(column_name)
if "owner_id" not in columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL"))
@ -391,6 +517,7 @@ def startup():
"is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0",
"answer_public_token": "VARCHAR(96) NULL",
"last_answer_shop_name": "VARCHAR(255) NULL",
"last_answer_ecommerce_platform": "VARCHAR(64) NULL",
}
for column_name, column_type in admin_user_migrations.items():
if column_name not in admin_user_columns:
@ -403,6 +530,22 @@ def startup():
if "shop_name" not in answer_submission_columns:
with engine.begin() as connection:
connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''"))
for column_name, column_type in {
"ecommerce_platform": "VARCHAR(64) NOT NULL DEFAULT ''",
"product_id": "VARCHAR(128) NOT NULL DEFAULT ''",
}.items():
if column_name not in answer_submission_columns:
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE answer_submissions ADD COLUMN {column_name} {column_type}"))
refund_record_columns = {column["name"] for column in inspect(engine).get_columns("refund_records")}
for column_name, column_type in {
"ecommerce_platform": "VARCHAR(64) NOT NULL DEFAULT ''",
"product_id": "VARCHAR(128) NOT NULL DEFAULT ''",
"payer_account_id": "INTEGER NULL",
}.items():
if column_name not in refund_record_columns:
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE refund_records ADD COLUMN {column_name} {column_type}"))
answer_user_columns = {column["name"] for column in inspect(engine).get_columns("answer_users")}
if "taobao_id" not in answer_user_columns:
with engine.begin() as connection:
@ -493,7 +636,10 @@ def _migrate_alipay_transfer_attempts(bind):
"out_biz_no": "VARCHAR(64) NULL",
"order_no": "VARCHAR(64) NOT NULL DEFAULT ''",
"shop_name": "VARCHAR(255) NOT NULL DEFAULT ''",
"ecommerce_platform": "VARCHAR(64) NOT NULL DEFAULT ''",
"product_id": "VARCHAR(128) NOT NULL DEFAULT ''",
"owner_id": "BIGINT NULL",
"payer_account_id": "BIGINT NULL",
"payer_account": "VARCHAR(128) NOT NULL DEFAULT ''",
"operator_name": "VARCHAR(64) NOT NULL DEFAULT ''",
"created_by_id": "BIGINT NOT NULL DEFAULT 0",
@ -571,6 +717,11 @@ def require_user_manager(user: AdminUser = Depends(current_user)) -> AdminUser:
return user
@app.post("/api/admin/product-metadata/resolve")
def resolve_product_metadata(payload: dict, _: AdminUser = Depends(current_user)):
return {"success": True, "data": _resolve_product_metadata(payload.get("source"))}
@app.get("/api/health")
def health():
return {"success": True, "message": "ok"}
@ -1450,6 +1601,8 @@ def _submission_data(
"phone": _public_user_account(record.user),
**(_refund_recipient_data(record, "answer_submission") if include_images else {"alipay_account": "", "payee_name": ""}),
"shop_name": record.shop_name or "",
"ecommerce_platform": record.ecommerce_platform or "",
"product_id": record.product_id or "",
"status": record.status,
"recognition_failed": record.status == "识别失败" or _has_legacy_fake_ocr_questions(record),
"created_at": record.created_at.isoformat(),
@ -1487,6 +1640,8 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag
"taobao_id": record.user.taobao_id,
"phone": _public_user_account(record.user),
"shop_name": record.shop_name or "",
"ecommerce_platform": record.ecommerce_platform or "",
"product_id": record.product_id or "",
"status": record.status,
"created_at": record.created_at.isoformat(),
"updated_at": record.updated_at.isoformat(),
@ -1505,6 +1660,7 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag
"已通过": "待返款",
"已完成": "已完成",
"未通过": "进行中",
"已超时": "已超时",
"已撤销": "已撤销",
}
# "待审核" and "待返款" mean the user has submitted evidence, not that
@ -1519,7 +1675,9 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag
"creator_name": record.owner.display_name if record.owner else "运营后台",
"qa_count": len(selected), "claimed_count": 1, "submitted_count": 1 if record.status in {"待审核", "已通过", "已完成"} else 0,
"pending_review_count": 1 if record.status == "待审核" else 0, "completed_count": completed_count, "total_count": len(selected),
"can_cancel": record.status in {"待用户完成", "待审核", "未通过"},
"can_cancel": record.status in {"待用户完成", "待审核", "未通过", "已超时"},
"can_reactivate": record.status == "已超时",
"reactivate_endpoint": f"/api/admin/task-board/{record.id}/reactivate" if record.status == "已超时" else None,
"can_delete": True,
"delete_endpoint": f"/api/admin/task-board/{record.id}",
"refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None,
@ -1582,6 +1740,8 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict:
"task_type_label": "提问任务",
"product_name": task.product_name,
"shop_name": task.shop_name,
"ecommerce_platform": task.ecommerce_platform,
"product_id": task.product_id,
"product_link": task.product_link,
"status": assignment.status,
"status_label": status_label,
@ -1673,6 +1833,8 @@ def _ask_batch_child_data(batch: AskAssignmentBatch) -> dict:
"task_type_label": "提问任务",
"product_name": task.product_name,
"shop_name": task.shop_name,
"ecommerce_platform": task.ecommerce_platform,
"product_id": task.product_id,
"product_link": task.product_link,
"status": batch.status,
"status_label": status_label,
@ -1779,6 +1941,8 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
"task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
"public_token": task.public_token or task.task_no,
"shop_name": task.shop_name,
"ecommerce_platform": task.ecommerce_platform,
"product_id": task.product_id,
"product_name": task.product_name, "product_link": task.product_link,
# Keep the persisted task state alongside the display label. The
# create-task page uses this value to switch to the publish-success
@ -1966,6 +2130,7 @@ def admin_answer_link(user: AdminUser = Depends(current_user), db: Session = Dep
"token": stored_user.answer_public_token,
"path": f"/answer/{stored_user.answer_public_token}",
"last_shop_name": stored_user.last_answer_shop_name or "",
"last_ecommerce_platform": stored_user.last_answer_ecommerce_platform or "",
}}
@ -2439,6 +2604,8 @@ def ask_assignment_review_decision(assignment_id: int, payload: dict, user: Admi
def answer_my_submissions(token: str, owner_token: str, include_images: bool = False, db: Session = Depends(get_db)):
user = _answer_user_from_token(token, db)
owner = _answer_link_owner(owner_token, db)
if _expire_answer_tasks(db):
db.commit()
statement = select(AnswerSubmission).where(
AnswerSubmission.user_id == user.id,
AnswerSubmission.owner_id == owner.id,
@ -2451,6 +2618,8 @@ def answer_my_submissions(token: str, owner_token: str, include_images: bool = F
def answer_submission_detail(submission_id: int, token: str, owner_token: str, db: Session = Depends(get_db)):
user = _answer_user_from_token(token, db)
owner = _answer_link_owner(owner_token, db)
if _expire_answer_tasks(db, submission_id):
db.commit()
record = db.get(AnswerSubmission, submission_id)
if not record or record.user_id != user.id or record.owner_id != owner.id:
raise HTTPException(status_code=404, detail="任务不存在")
@ -2573,6 +2742,8 @@ async def answer_create_submission(
@app.get("/api/admin/answer-task-drafts")
def admin_answer_task_drafts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if _expire_answer_tasks(db):
db.commit()
records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "识别失败", "待运营派发", "已派发", "待用户完成"])).where(_answer_owner_clause(user)).order_by(AnswerSubmission.created_at.desc())).unique().all()
return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]}
@ -2674,6 +2845,12 @@ def reject_answer_task_draft(submission_id: int, user: AdminUser = Depends(curre
@app.post("/api/admin/ask-tasks/generate")
def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
shop_name = str(payload.get("shop_name", "")).strip()
ecommerce_platform = str(payload.get("ecommerce_platform", "")).strip()
if len(ecommerce_platform) > 64:
raise HTTPException(status_code=400, detail="电商平台不能超过64个字符")
product_id = str(payload.get("product_id", "")).strip()
if len(product_id) > 128:
raise HTTPException(status_code=400, detail="商品ID不能超过128个字符")
product_link = str(payload.get("product_link", "")).strip()
product_qr_data, product_qr_content_type, product_qr_name = _decode_product_qr_image(
payload.get("product_qr_image"), payload.get("product_qr_image_name")
@ -2735,7 +2912,16 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
if task.status != "草稿":
raise HTTPException(status_code=409, detail="已发布任务不能重新生成")
_dedupe_draft_ask_questions(task, db)
# Regeneration is a full replacement. The resulting draft must contain
# exactly the requested number of questions, regardless of how the old
# draft questions were added.
for existing_question in list(task.questions):
db.delete(existing_question)
db.flush()
db.expire(task, ["questions"])
task.shop_name = shop_name
task.ecommerce_platform = ecommerce_platform
task.product_id = product_id
task.product_link = product_link
task.product_qr_image_data = product_qr_data
task.product_qr_image_content_type = product_qr_content_type
@ -2750,7 +2936,7 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
task.purchase_channel = str(payload.get("purchase_channel", ""))
task.claim_questions_per_user = claim_limit
else:
task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name=shop_name, product_link=product_link, product_qr_image_data=product_qr_data, product_qr_image_content_type=product_qr_content_type, product_qr_image_name=product_qr_name, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿")
task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name=shop_name, ecommerce_platform=ecommerce_platform, product_id=product_id, product_link=product_link, product_qr_image_data=product_qr_data, product_qr_image_content_type=product_qr_content_type, product_qr_image_name=product_qr_name, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿")
db.add(task); db.flush()
next_sequence = max((question.sequence_no for question in task.questions), default=0) + 1
for index, question in enumerate(generated):
@ -2861,12 +3047,22 @@ def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depend
return {"success": True, "data": {"id": question_id}}
def _ask_publish_fields(payload: dict) -> tuple[str, str, bytes | None, str | None, str | None, int]:
def _ask_publish_fields(payload: dict) -> tuple[str, str, str, str, bytes | None, str | None, str | None, int]:
shop_name = str(payload.get("shop_name", "")).strip()
if not shop_name:
raise HTTPException(status_code=400, detail="请填写店铺名称")
if len(shop_name) > 255:
raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符")
ecommerce_platform = str(payload.get("ecommerce_platform", "")).strip()
if not ecommerce_platform:
raise HTTPException(status_code=400, detail="请填写电商平台")
if len(ecommerce_platform) > 64:
raise HTTPException(status_code=400, detail="电商平台不能超过64个字符")
product_id = str(payload.get("product_id", "")).strip()
if not product_id:
raise HTTPException(status_code=400, detail="请填写商品ID")
if len(product_id) > 128:
raise HTTPException(status_code=400, detail="商品ID不能超过128个字符")
product_link = str(payload.get("product_link", "")).strip()
qr_data, qr_content_type, qr_name = _decode_product_qr_image(
payload.get("product_qr_image"), payload.get("product_qr_image_name")
@ -2883,12 +3079,14 @@ def _ask_publish_fields(payload: dict) -> tuple[str, str, bytes | None, str | No
raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须是 2-20 的整数")
if claim_limit < 2 or claim_limit > 20:
raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须在 2-20 条之间")
return shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit
return shop_name, ecommerce_platform, product_id, product_link, qr_data, qr_content_type, qr_name, claim_limit
def _apply_ask_publish_fields(task: AskTask, payload: dict) -> None:
shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit = _ask_publish_fields(payload)
shop_name, ecommerce_platform, product_id, product_link, qr_data, qr_content_type, qr_name, claim_limit = _ask_publish_fields(payload)
task.shop_name = shop_name
task.ecommerce_platform = ecommerce_platform
task.product_id = product_id
task.product_link = product_link
task.product_qr_image_data = qr_data
task.product_qr_image_content_type = qr_content_type
@ -2935,6 +3133,8 @@ def create_and_publish_ask_task(payload: dict, user: AdminUser = Depends(current
task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}",
owner_id=user.id,
shop_name="",
ecommerce_platform="",
product_id="",
product_link="",
product_name=str(payload.get("product_name", "")).strip() or "待补充商品",
category=str(payload.get("category", "")),
@ -3004,6 +3204,10 @@ def publish_ask_task(task_id: int, payload: dict = Body(default={}), user: Admin
raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}")
if not task.shop_name:
raise HTTPException(status_code=400, detail="请填写店铺名称")
if not task.ecommerce_platform:
raise HTTPException(status_code=400, detail="请填写电商平台")
if not task.product_id:
raise HTTPException(status_code=400, detail="请填写商品ID")
if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
if not task.public_token:
task.public_token = _new_ask_public_token(db)
@ -3076,13 +3280,28 @@ def publish_answer_task(submission_id: int, payload: dict = Body(default={}), us
raise HTTPException(status_code=400, detail="派发前必须填写店铺名称")
if len(shop_name) > 255:
raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符")
ecommerce_platform = str(payload.get("ecommerce_platform", "")).strip()
if not ecommerce_platform:
raise HTTPException(status_code=400, detail="派发前必须填写电商平台")
if len(ecommerce_platform) > 64:
raise HTTPException(status_code=400, detail="电商平台不能超过64个字符")
product_id = str(payload.get("product_id", "")).strip()
if not product_id:
raise HTTPException(status_code=400, detail="派发前必须填写商品ID")
if len(product_id) > 128:
raise HTTPException(status_code=400, detail="商品ID不能超过128个字符")
record.shop_name = shop_name
record.ecommerce_platform = ecommerce_platform
record.product_id = product_id
if record.owner_id is None:
record.owner_id = user.id
stored_user = db.get(AdminUser, user.id)
if stored_user:
stored_user.last_answer_shop_name = shop_name
record.status = "待用户完成"; db.commit()
stored_user.last_answer_ecommerce_platform = ecommerce_platform
record.status = "待用户完成"
record.updated_at = datetime.utcnow()
db.commit()
return {"success": True, "data": _submission_data(record, include_images=False)}
@ -3100,9 +3319,11 @@ def admin_task_board(
):
if _release_expired_ask_assignments(db):
db.commit()
if _expire_answer_tasks(db):
db.commit()
page = max(page, 1)
page_size = min(max(page_size, 10), 100)
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已超时", "已撤销"]
answer_candidate = (
select(
literal("answer").label("record_kind"),
@ -3435,10 +3656,13 @@ def _snapshot_refund(db, record, kind, user, payer_account="", alipay_trade_no="
snapshot = RefundRecord(
target_kind=kind, target_id=record.id, owner_id=task.owner_id,
shop_name=task.shop_name or "",
ecommerce_platform=getattr(task, "ecommerce_platform", "") or "",
product_id=getattr(task, "product_id", "") or "",
order_no=record.submission_no if kind == "answer_submission" else task.task_no,
payee_name=identity["payee_name"], payee_account=identity["alipay_account"],
amount_fen=identity["refund_amount_fen"], operator_name=user.display_name,
payer_account=payer_account.strip(), alipay_trade_no=alipay_trade_no.strip(),
payer_account_id=getattr(payer_account, "id", None),
payer_account=getattr(payer_account, "name", payer_account).strip(), alipay_trade_no=alipay_trade_no.strip(),
)
db.add(snapshot)
return snapshot
@ -3469,10 +3693,13 @@ def _complete_alipay_refund_order(db: Session, order: AlipayRefundOrder, trade_n
target_id=order.target_id,
owner_id=order.owner_id,
shop_name=order.shop_name or "",
ecommerce_platform=order.ecommerce_platform or "",
product_id=order.product_id or "",
order_no=order.order_no or "",
payee_name=order.payee_name or "",
payee_account=order.account or "",
amount_fen=order.amount_fen,
payer_account_id=order.payer_account_id,
operator_name=order.operator_name or "系统自动对账",
payer_account=order.payer_account or "",
alipay_trade_no=trade_no,
@ -3522,7 +3749,7 @@ def _finalize_refund_confirmation(
)
return
_snapshot_refund(db, record, kind, user, payer.name, trade_no)
_snapshot_refund(db, record, kind, user, payer, trade_no)
if kind == "ask_batch":
record.status = "paid"
record.updated_at = datetime.utcnow()
@ -3646,7 +3873,7 @@ def _refund_record_query(user, q, start_date, end_date):
def _refund_record_data(record):
return {key: getattr(record, key) for key in (
"id", "shop_name", "order_no", "payee_name", "payee_account", "amount_fen",
"id", "shop_name", "ecommerce_platform", "product_id", "order_no", "payee_name", "payee_account", "amount_fen",
"payer_account", "operator_name", "alipay_trade_no", "transferred_at",
)}
@ -3667,15 +3894,15 @@ def export_refund_records(q: str = "", start_date: str = "", end_date: str = "",
query = _refund_record_query(user, q, start_date, end_date)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["店铺名称", "转账时间", "订单号", "收款人", "收款账号", "返款金额(元)", "付款账户", "操作人", "支付宝流水"])
writer.writerow(["店铺名称", "电商平台", "商品ID", "转账时间", "订单号", "收款人", "收款账号", "返款金额(元)", "付款账户", "操作人", "支付宝流水"])
total = 0
def safe(value):
value = str(value or "")
return "'" + value if value.lstrip().startswith(("=", "+", "-", "@")) or value.startswith(("\t", "\r", "\n")) else value
for record in db.scalars(query.order_by(RefundRecord.transferred_at.desc(), RefundRecord.id.desc()).execution_options(yield_per=500)):
total += record.amount_fen
writer.writerow([safe(record.shop_name), (record.transferred_at + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S"), safe(record.order_no), safe(record.payee_name), safe(record.payee_account), f"{record.amount_fen / 100:.2f}", safe(record.payer_account), safe(record.operator_name), safe(record.alipay_trade_no)])
writer.writerow(["合计", "", "", "", "", f"{total / 100:.2f}", "", "", ""])
writer.writerow([safe(record.shop_name), safe(record.ecommerce_platform), safe(record.product_id), (record.transferred_at + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S"), safe(record.order_no), safe(record.payee_name), safe(record.payee_account), f"{record.amount_fen / 100:.2f}", safe(record.payer_account), safe(record.operator_name), safe(record.alipay_trade_no)])
writer.writerow(["合计", "", "", "", "", "", "", f"{total / 100:.2f}", "", "", ""])
return Response(content="\ufeff" + output.getvalue(), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": 'attachment; filename="refund-records.csv"'})
@ -3774,6 +4001,9 @@ def _perform_alipay_refund(db: Session, record, kind: str, user: AdminUser, paye
order.error_message = "支付宝转账仍在处理中,请稍后重新查询"
db.commit()
raise HTTPException(status_code=409, detail=order.error_message)
available_fen = _payment_account_usage(db, payer)["available_fen"]
if amount_fen > available_fen:
raise HTTPException(status_code=400, detail="付款账户可用余额不足,请先追加资金")
out_biz_no = f"RF{datetime.utcnow():%Y%m%d%H%M%S}{uuid.uuid4().hex[:12].upper()}"
if not order:
task = record.assignment.task if kind == "ask_batch" else record.task if kind == "ask_assignment" else record
@ -3783,6 +4013,8 @@ def _perform_alipay_refund(db: Session, record, kind: str, user: AdminUser, paye
out_biz_no=out_biz_no,
order_no=record.submission_no if kind == "answer_submission" else task.task_no,
shop_name=task.shop_name or "",
ecommerce_platform=getattr(task, "ecommerce_platform", "") or "",
product_id=getattr(task, "product_id", "") or "",
owner_id=task.owner_id,
operator_name=user.display_name,
)
@ -3791,6 +4023,7 @@ def _perform_alipay_refund(db: Session, record, kind: str, user: AdminUser, paye
order.amount_fen = amount_fen
order.account = identity["alipay_account"]
order.payee_name = identity["payee_name"]
order.payer_account_id = payer.id
order.payer_account = payer.name
order.created_by_id = user.id
order.operator_name = user.display_name
@ -3909,6 +4142,8 @@ async def confirm_ask_assignment_refund(assignment_id: int, refund_amount: str |
@app.get("/api/admin/task-board/{submission_id}")
def admin_task_board_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if _expire_answer_tasks(db, submission_id):
db.commit()
record = db.scalar(
select(AnswerSubmission)
.where(AnswerSubmission.id == submission_id)
@ -3920,7 +4155,7 @@ def admin_task_board_detail(submission_id: int, user: AdminUser = Depends(curren
AnswerScreenshot.content_type,
))
)
if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"} or not _can_access_answer_submission(record, user):
if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已超时", "已撤销"} or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="任务不存在")
return {"success": True, "data": _task_board_data(record, image_endpoints=True)}
@ -3954,6 +4189,8 @@ async def confirm_answer_refund(submission_id: int, refund_amount: str | None =
@app.post("/api/admin/task-board/{submission_id}/cancel")
def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if _expire_answer_tasks(db, submission_id):
db.commit()
record = db.get(AnswerSubmission, submission_id)
if not record or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="任务不存在")
@ -3961,19 +4198,38 @@ def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(curren
raise HTTPException(status_code=409, detail="任务已经撤销")
if record.status == "已通过":
raise HTTPException(status_code=409, detail="已完成任务不能撤销")
if record.status not in {"待用户完成", "待审核", "未通过"}:
if record.status not in {"待用户完成", "待审核", "未通过", "已超时"}:
raise HTTPException(status_code=400, detail="当前状态不能撤销")
record.status = "已撤销"
db.commit(); db.refresh(record)
return {"success": True, "data": _task_board_data(record)}
@app.post("/api/admin/task-board/{submission_id}/reactivate")
def admin_task_board_reactivate(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if _expire_answer_tasks(db, submission_id):
db.commit()
record = db.get(AnswerSubmission, submission_id)
if not record or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="任务不存在")
if record.status != "已超时":
raise HTTPException(status_code=409, detail="只有已超时任务可以重新激活")
record.status = "待用户完成"
record.updated_at = datetime.utcnow()
db.commit(); db.refresh(record)
return {"success": True, "data": _task_board_data(record, include_images=True, image_endpoints=True)}
@app.post("/api/answer/me/submissions/{submission_id}/completion")
async def answer_completion(submission_id: int, token: str = Form(...), owner_token: str = Form(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), alipay_account: str = Form(default=""), payee_name: str = Form(default=""), db: Session = Depends(get_db)):
user = _answer_user_from_token(token, db); owner = _answer_link_owner(owner_token, db); record = db.get(AnswerSubmission, submission_id)
user = _answer_user_from_token(token, db); owner = _answer_link_owner(owner_token, db)
if _expire_answer_tasks(db, submission_id):
db.commit()
record = db.get(AnswerSubmission, submission_id)
if not record or record.user_id != user.id or record.owner_id != owner.id:
raise HTTPException(status_code=404, detail="任务不存在")
record_status = record.status
if record_status == "已超时": raise HTTPException(status_code=409, detail="任务已超时,请联系运营重新激活")
if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图")
if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图")
if len(payment_files) > 1: raise HTTPException(status_code=400, detail="最多上传一张收款二维码")
@ -5077,12 +5333,33 @@ def login_logs(_: AdminUser = Depends(require_super_admin), db: Session = Depend
return {"success": True, "data": [{"username": x.username, "success": x.success, "ip_address": x.ip_address, "created_at": x.created_at.isoformat()} for x in logs]}
def _payment_account_data(account: RefundPaymentAccount) -> dict:
def _payment_account_usage(db: Session, account: RefundPaymentAccount) -> dict:
used_fen = db.scalar(select(func.coalesce(func.sum(RefundRecord.amount_fen), 0)).where(
or_(RefundRecord.payer_account_id == account.id,
(RefundRecord.payer_account_id.is_(None)) & (RefundRecord.payer_account == account.name)),
)) or 0
processing_fen = db.scalar(select(func.coalesce(func.sum(AlipayRefundOrder.amount_fen), 0)).where(
or_(AlipayRefundOrder.payer_account_id == account.id,
(AlipayRefundOrder.payer_account_id.is_(None)) & (AlipayRefundOrder.payer_account == account.name)),
AlipayRefundOrder.status == "pending",
)) or 0
return {
"used_fen": int(used_fen),
"processing_fen": int(processing_fen),
"available_fen": max(int(account.balance_fen) - int(used_fen) - int(processing_fen), 0),
}
def _payment_account_data(account: RefundPaymentAccount, db: Session | None = None) -> dict:
usage = _payment_account_usage(db, account) if db is not None else {
"used_fen": 0, "processing_fen": 0, "available_fen": account.balance_fen,
}
return {
"id": account.id, "name": account.name, "type": "alipay",
"alipay_account": account.alipay_account,
"balance_fen": account.balance_fen, "single_limit_fen": account.single_limit_fen,
"is_active": account.is_active, "updated_at": account.updated_at,
**usage,
}
@ -5092,7 +5369,7 @@ def available_refund_payment_accounts(user: AdminUser = Depends(current_user), d
if user.role_code not in {"super_admin", "dept_manager"}:
statement = statement.where(RefundPaymentAccount.id == user.refund_payment_account_id)
accounts = db.scalars(statement.order_by(RefundPaymentAccount.id)).all()
return {"success": True, "data": [_payment_account_data(account) for account in accounts]}
return {"success": True, "data": [_payment_account_data(account, db) for account in accounts]}
@app.get("/api/admin/refund-payment-accounts")
@ -5107,39 +5384,143 @@ def refund_payment_accounts(q: str = "", page: int = 1, page_size: int = 10,
RefundPaymentAccount.alipay_account.contains(term, autoescape=True)))
total = db.scalar(select(func.count()).select_from(statement.subquery())) or 0
records = db.scalars(statement.order_by(RefundPaymentAccount.id.desc()).offset((page - 1) * page_size).limit(page_size)).all()
return {"success": True, "data": {"items": [_payment_account_data(item) for item in records],
items = [_payment_account_data(item, db) for item in records]
all_accounts = db.scalars(select(RefundPaymentAccount)).all()
all_items = [_payment_account_data(item, db) for item in all_accounts]
summary = {
"balance_fen": sum(item["balance_fen"] for item in all_items),
"used_fen": sum(item["used_fen"] for item in all_items),
"processing_fen": sum(item["processing_fen"] for item in all_items),
"available_fen": sum(item["available_fen"] for item in all_items),
}
return {"success": True, "data": {"items": items, "summary": summary,
"total": total, "page": page, "page_size": page_size}}
def _save_payment_account(db: Session, account: RefundPaymentAccount, payload: RefundPaymentAccountSave):
def _save_payment_account(db: Session, account: RefundPaymentAccount, payload: RefundPaymentAccountSave, user: AdminUser):
is_new = account.id is None
account.name = payload.name
account.alipay_account = payload.alipay_account
account.balance_fen = int(payload.balance * 100)
if is_new:
account.balance_fen = int(payload.balance * 100)
account.single_limit_fen = int(payload.single_limit * 100)
account.is_active = payload.is_active
db.add(account)
try:
db.flush()
if is_new and account.balance_fen:
db.add(RefundAccountAdjustment(
account_id=account.id,
amount_fen=account.balance_fen,
balance_after_fen=account.balance_fen,
adjustment_type="初始资金",
note="新增账户初始资金",
operator_name=user.display_name,
))
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="该支付宝账户已存在,请编辑现有账户")
db.refresh(account)
return {"success": True, "data": _payment_account_data(account)}
return {"success": True, "data": _payment_account_data(account, db)}
@app.post("/api/admin/refund-payment-accounts", status_code=201)
def create_refund_payment_account(payload: RefundPaymentAccountSave,
_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
return _save_payment_account(db, RefundPaymentAccount(), payload)
user: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
return _save_payment_account(db, RefundPaymentAccount(), payload, user)
@app.patch("/api/admin/refund-payment-accounts/{account_id}")
def update_refund_payment_account(account_id: int, payload: RefundPaymentAccountSave,
_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
account = db.get(RefundPaymentAccount, account_id)
user: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
account = db.scalar(select(RefundPaymentAccount).where(RefundPaymentAccount.id == account_id).with_for_update())
if account is None:
raise HTTPException(status_code=404, detail="付款账户不存在")
return _save_payment_account(db, account, payload)
return _save_payment_account(db, account, payload, user)
@app.post("/api/admin/refund-payment-accounts/{account_id}/funds")
def adjust_refund_payment_account_funds(
account_id: int, payload: RefundFundAdjustment,
user: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db),
):
account = db.scalar(select(RefundPaymentAccount).where(RefundPaymentAccount.id == account_id).with_for_update())
if account is None:
raise HTTPException(status_code=404, detail="付款账户不存在")
amount_fen = int(payload.amount * 100)
if payload.adjustment_type == "追加资金" and amount_fen <= 0:
raise HTTPException(status_code=400, detail="追加资金金额必须大于 0")
if payload.adjustment_type == "资金调整" and not payload.note:
raise HTTPException(status_code=400, detail="资金调整必须填写原因")
usage = _payment_account_usage(db, account)
new_balance_fen = account.balance_fen + amount_fen
committed_fen = usage["used_fen"] + usage["processing_fen"]
if new_balance_fen < committed_fen:
raise HTTPException(status_code=400, detail="调整后的累计入账不能低于已使用与处理中金额之和")
account.balance_fen = new_balance_fen
db.add(RefundAccountAdjustment(
account_id=account.id,
amount_fen=amount_fen,
balance_after_fen=new_balance_fen,
adjustment_type=payload.adjustment_type,
note=payload.note or "追加资金",
operator_name=user.display_name,
))
db.commit()
db.refresh(account)
return {"success": True, "data": _payment_account_data(account, db)}
@app.get("/api/admin/refund-payment-accounts/{account_id}/ledger")
def refund_payment_account_ledger(account_id: int, page: int = 1, page_size: int = 10,
_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
account = db.get(RefundPaymentAccount, account_id)
if not account:
raise HTTPException(status_code=404, detail="付款账户不存在")
page = max(page, 1)
page_size = min(max(page_size, 1), 50)
adjustments = select(
literal("adjustment").label("type"), RefundAccountAdjustment.id.label("record_id"),
RefundAccountAdjustment.adjustment_type.label("type_label"), RefundAccountAdjustment.amount_fen.label("amount_fen"),
literal("已完成").label("status"), literal("").label("order_no"),
RefundAccountAdjustment.note.label("note"), RefundAccountAdjustment.operator_name.label("operator_name"),
RefundAccountAdjustment.created_at.label("created_at"),
).where(RefundAccountAdjustment.account_id == account_id)
refunds = select(
literal("refund").label("type"), RefundRecord.id.label("record_id"),
literal("返款支出").label("type_label"), (-RefundRecord.amount_fen).label("amount_fen"),
literal("已完成").label("status"), RefundRecord.order_no.label("order_no"),
func.coalesce(RefundRecord.shop_name, RefundRecord.payee_name, "").label("note"),
RefundRecord.operator_name.label("operator_name"), RefundRecord.transferred_at.label("created_at"),
).where(or_(RefundRecord.payer_account_id == account.id,
(RefundRecord.payer_account_id.is_(None)) & (RefundRecord.payer_account == account.name)))
pending = select(
literal("processing").label("type"), AlipayRefundOrder.id.label("record_id"),
literal("处理中冻结").label("type_label"), (-AlipayRefundOrder.amount_fen).label("amount_fen"),
literal("处理中").label("status"), AlipayRefundOrder.order_no.label("order_no"),
func.coalesce(AlipayRefundOrder.shop_name, AlipayRefundOrder.payee_name, "").label("note"),
AlipayRefundOrder.operator_name.label("operator_name"), AlipayRefundOrder.created_at.label("created_at"),
).where(
or_(AlipayRefundOrder.payer_account_id == account.id,
(AlipayRefundOrder.payer_account_id.is_(None)) & (AlipayRefundOrder.payer_account == account.name)),
AlipayRefundOrder.status == "pending",
)
ledger_rows = union_all(adjustments, refunds, pending).subquery()
total = db.scalar(select(func.count()).select_from(ledger_rows)) or 0
rows = db.execute(
select(ledger_rows).order_by(ledger_rows.c.created_at.desc(), ledger_rows.c.record_id.desc())
.offset((page - 1) * page_size).limit(page_size)
).mappings().all()
items = [{**dict(row), "id": f"{row['type']}-{row['record_id']}"} for row in rows]
return {"success": True, "data": {
"account": _payment_account_data(account, db),
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": max(1, (total + page_size - 1) // page_size),
}}
@app.delete("/api/admin/refund-payment-accounts/{account_id}")
@ -5150,6 +5531,7 @@ def delete_refund_payment_account(account_id: int,
raise HTTPException(status_code=404, detail="付款账户不存在")
db.delete(account)
db.execute(update(AdminUser).where(AdminUser.refund_payment_account_id == account_id).values(refund_payment_account_id=None))
db.query(RefundAccountAdjustment).filter(RefundAccountAdjustment.account_id == account_id).delete()
db.commit()
return {"success": True}

View File

@ -20,6 +20,19 @@ class RefundPaymentAccount(Base):
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"
@ -29,10 +42,13 @@ class RefundRecord(Base):
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="")
@ -51,9 +67,12 @@ class AlipayRefundOrder(Base):
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="")
@ -98,6 +117,7 @@ class AdminUser(Base):
# 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)
@ -225,6 +245,8 @@ class AnswerSubmission(Base):
# 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)
@ -303,6 +325,8 @@ class AskTask(Base):
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="")

View File

@ -16,6 +16,24 @@ class RefundPaymentAccountSave(BaseModel):
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):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=128)

View File

@ -439,6 +439,77 @@ def test_task_board_is_available_to_operator():
assert count_data["total"] == count_data["ask"] + count_data["answer"]
def test_answer_task_expires_after_24_hours_and_can_be_reactivated():
unique = uuid4().hex[:10]
submission_id = None
with TestClient(app) as client:
assert client.post(
"/api/admin/auth/login",
json={"username": "operator", "password": "operator123456"},
).status_code == 200
with main_module.SessionLocal() as db:
owner = db.scalar(
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
)
public_user = main_module.AnswerUser(
public_id=f"U{unique[:7]}",
phone=f"137{uuid4().int % 100_000_000:08d}",
auth_token=uuid4().hex,
)
db.add(public_user)
db.flush()
expired_at = datetime.utcnow() - timedelta(hours=24, seconds=1)
submission = main_module.AnswerSubmission(
submission_no=f"ANS{unique.upper()}",
user_id=public_user.id,
owner_id=owner.id,
status="待用户完成",
shop_name="超时测试店铺",
ecommerce_platform="淘宝",
product_id="TIMEOUT-1",
created_at=expired_at,
updated_at=expired_at,
)
db.add(submission)
db.flush()
db.add(main_module.AnswerRecognizedQuestion(
submission_id=submission.id,
sequence_no=1,
product_title="超时测试商品",
question_text="这个任务会超时吗",
answer_text="会",
selected=True,
))
db.commit()
submission_id = submission.id
drafts = client.get("/api/admin/answer-task-drafts")
assert drafts.status_code == 200
assert submission_id not in {item["id"] for item in drafts.json()["data"]}
board_item = next(item for item in task_board_items(client) if item.get("id") == submission_id)
assert board_item["status"] == "已超时"
assert board_item["status_label"] == "已超时"
assert board_item["can_reactivate"] is True
assert board_item["can_cancel"] is True
detail = client.get(f"/api/admin/task-board/{submission_id}")
assert detail.status_code == 200
assert detail.json()["data"]["status"] == "已超时"
reactivated = client.post(f"/api/admin/task-board/{submission_id}/reactivate")
assert reactivated.status_code == 200
assert reactivated.json()["data"]["status"] == "待用户完成"
assert reactivated.json()["data"]["status_label"] == "进行中"
assert reactivated.json()["data"]["can_reactivate"] is False
cancelled = client.post(f"/api/admin/task-board/{submission_id}/cancel")
assert cancelled.status_code == 200
assert cancelled.json()["data"]["status"] == "已撤销"
assert client.delete(f"/api/admin/task-board/{submission_id}").status_code == 200
def test_taobao_id_login_is_shared_by_both_user_portals():
taobao_id = f"tb{uuid4().hex[:14]}"
with TestClient(app) as client:
@ -555,14 +626,31 @@ def test_answer_task_publish_remembers_operator_shop_name():
db.commit()
submission_id = submission.id
missing_platform = client.post(
f"/api/admin/answer-task-drafts/{submission_id}/publish",
json={"shop_name": remembered_shop, "product_id": "ANSWER-MEMORY-1"},
)
assert missing_platform.status_code == 400
assert missing_platform.json()["detail"] == "派发前必须填写电商平台"
missing_product_id = client.post(
f"/api/admin/answer-task-drafts/{submission_id}/publish",
json={"shop_name": remembered_shop, "ecommerce_platform": "淘宝"},
)
assert missing_product_id.status_code == 400
assert missing_product_id.json()["detail"] == "派发前必须填写商品ID"
published = client.post(
f"/api/admin/answer-task-drafts/{submission_id}/publish",
json={"shop_name": remembered_shop},
json={"shop_name": remembered_shop, "ecommerce_platform": "淘宝", "product_id": "ANSWER-MEMORY-1"},
)
assert published.status_code == 200
assert published.json()["data"]["ecommerce_platform"] == "淘宝"
assert published.json()["data"]["product_id"] == "ANSWER-MEMORY-1"
link_settings = client.get("/api/admin/auth/answer-link")
assert link_settings.status_code == 200
assert link_settings.json()["data"]["last_shop_name"] == remembered_shop
assert link_settings.json()["data"]["last_ecommerce_platform"] == "淘宝"
with main_module.SessionLocal() as db:
owner = db.scalar(
main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator")
@ -836,7 +924,7 @@ def test_answer_rejection_reason_syncs_to_public_user(monkeypatch):
assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200
assert client.post(
f"/api/admin/answer-task-drafts/{submission['id']}/publish",
json={"shop_name": "测试店铺"},
json={"shop_name": "测试店铺", "ecommerce_platform": "淘宝", "product_id": "ANSWER-COMPLETE-1"},
).status_code == 200
completed = client.post(
f"/api/answer/me/submissions/{submission['id']}/completion",
@ -869,7 +957,7 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch):
)
assert generation_without_publish_fields.status_code == 200
assert generation_without_publish_fields.json()["data"]["status"] == "草稿"
generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "ecommerce_platform": "淘宝", "product_id": "911640908772", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
assert generated.status_code == 200
task = generated.json()["data"]
assert task["public_token"] is None
@ -887,9 +975,23 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch):
assert created["qa_count"] == 3
assert created["public_token"] == public_token
assert created["shop_name"] == "测试店铺"
assert created["ecommerce_platform"] == "淘宝"
assert created["product_id"] == "911640908772"
detail = client.get(created["detail_endpoint"])
assert detail.status_code == 200
assert detail.json()["data"]["shop_name"] == "测试店铺"
assert detail.json()["data"]["ecommerce_platform"] == "淘宝"
assert detail.json()["data"]["product_id"] == "911640908772"
def test_taobao_product_metadata_extracts_platform_and_item_id():
source = "【淘宝】https://e.tb.cn/h.example?tk=ABC 「测试商品」"
html = "var url='https://pages-fast.m.taobao.com/detail?itemIds=911640908772&shopName=%E6%B5%8B%E8%AF%95%E5%BA%97%E9%93%BA';"
result = main_module._extract_product_metadata(source, "https://e.tb.cn/h.example", html)
assert result["ecommerce_platform"] == "淘宝"
assert result["product_id"] == "911640908772"
assert result["shop_name"] == "测试店铺"
assert result["product_name"] == "测试商品"
def test_imported_questions_can_publish_without_ai_generation():
@ -899,6 +1001,8 @@ def test_imported_questions_can_publish_without_ai_generation():
"/api/admin/ask-tasks/publish",
json={
"shop_name": "直接发布测试店铺",
"ecommerce_platform": "淘宝",
"product_id": "911640908772",
"product_link": "TOKEN-DIRECT-PUBLISH",
"product_name": "直接发布测试商品",
"claim_questions_per_user": 20,
@ -912,6 +1016,8 @@ def test_imported_questions_can_publish_without_ai_generation():
data = published.json()["data"]
assert data["status"] == "已发布"
assert data["shop_name"] == "直接发布测试店铺"
assert data["ecommerce_platform"] == "淘宝"
assert data["product_id"] == "911640908772"
assert data["claim_questions_per_user"] == 20
assert [item["question"] for item in data["selected_questions"]] == ["批量导入问题一?", "自主添加问题二?"]
assert [item["source"] for item in data["selected_questions"]] == ["批量导入", "自主添加"]
@ -922,10 +1028,38 @@ def test_imported_questions_can_publish_without_ai_generation():
)
assert missing_fields.status_code == 400
missing_platform = client.post(
"/api/admin/ask-tasks/publish",
json={
"shop_name": "必填校验店铺",
"product_id": "REQUIRED-1",
"product_link": "TOKEN-REQUIRED-PLATFORM",
"claim_questions_per_user": 5,
"questions": [{"question": "缺少电商平台?", "source": "自主添加"}],
},
)
assert missing_platform.status_code == 400
assert missing_platform.json()["detail"] == "请填写电商平台"
missing_product_id = client.post(
"/api/admin/ask-tasks/publish",
json={
"shop_name": "必填校验店铺",
"ecommerce_platform": "淘宝",
"product_link": "TOKEN-REQUIRED-PRODUCT-ID",
"claim_questions_per_user": 5,
"questions": [{"question": "缺少商品ID?", "source": "自主添加"}],
},
)
assert missing_product_id.status_code == 400
assert missing_product_id.json()["detail"] == "请填写商品ID"
over_limit = client.post(
"/api/admin/ask-tasks/publish",
json={
"shop_name": "领取上限测试店铺",
"ecommerce_platform": "淘宝",
"product_id": "CLAIM-LIMIT-1",
"product_link": "TOKEN-CLAIM-LIMIT",
"claim_questions_per_user": 21,
"questions": [{"question": "超过领取上限?", "source": "自主添加"}],
@ -988,19 +1122,31 @@ def test_ask_task_accepts_qr_image_without_token_and_excludes_it_from_ai(monkeyp
assert public_token
def test_ask_task_regeneration_appends_to_existing_questions(monkeypatch):
def test_ask_task_regeneration_replaces_all_questions_with_requested_count(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
first = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2})
first = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "覆盖测试店铺", "product_link": "https://item.example.com/replace", "product_name": "覆盖测试商品", "question_count": 2})
assert first.status_code == 200
task = first.json()["data"]
assert len(task["selected_questions"]) == 2
second = client.post("/api/admin/ask-tasks/generate", json={"task_id": task["id"], "shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2})
added = client.post(
f"/api/admin/ask-tasks/{task['id']}/questions",
json={"question": "运营手动保留的问题", "source": "自主添加"},
)
assert added.status_code == 200
second = client.post("/api/admin/ask-tasks/generate", json={"task_id": task["id"], "shop_name": "覆盖测试店铺", "product_link": "https://item.example.com/replace", "product_name": "覆盖测试商品", "question_count": 5})
assert second.status_code == 200
questions = second.json()["data"]["selected_questions"]
assert len(questions) == 4
assert [item["sequence_no"] for item in questions] == [1, 2, 3, 4]
assert len(questions) == 5
assert all(item["source"] == "AI生成" for item in questions)
assert all(item["question"] != "运营手动保留的问题" for item in questions)
ai_question = next(item for item in questions if item["source"] == "AI生成")
deleted = client.delete(f"/api/admin/ask-tasks/{task['id']}/questions/{ai_question['id']}")
assert deleted.status_code == 200
with main_module.SessionLocal() as db:
assert db.get(main_module.AskQuestion, ai_question["id"]) is None
def test_public_ask_assignment_expires_after_selection_timeout(monkeypatch):
@ -1308,7 +1454,7 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch):
assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200
assert client.post(
f"/api/admin/answer-task-drafts/{created['id']}/publish",
json={"shop_name": "测试店铺"},
json={"shop_name": "测试店铺", "ecommerce_platform": "淘宝", "product_id": "ANSWER-REFUND-1"},
).status_code == 200
completed = client.post(
f"/api/answer/me/submissions/{created['id']}/completion",

View File

@ -6,7 +6,7 @@ from sqlalchemy.pool import StaticPool
from app import main
from app.database import Base, get_db
from app.models import AdminUser, RefundPaymentAccount
from app.models import AdminUser, AlipayRefundOrder, RefundPaymentAccount, RefundRecord
ENDPOINT = "/api/admin/refund-payment-accounts"
@ -43,12 +43,94 @@ def test_create_edit_and_persist_cents(setup):
assert response.status_code == 200, response.text
db.expire_all()
saved = db.get(RefundPaymentAccount, record["id"])
assert saved.balance_fen == 0
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"]
@ -88,11 +170,13 @@ def test_reject_invalid_account(setup, changes):
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

View File

@ -120,8 +120,8 @@ def test_scope_date_range_pagination_and_export(setup):
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][5] == "6.00"
assert rows[1][0] == "'=unsafe" and rows[1][1] == "2026-10-22 00:00:00"
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

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

@ -7,13 +7,15 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
const [detailBusy, setDetailBusy] = useState('')
const [error, setError] = useState('')
const [rejecting, setRejecting] = useState(null)
const [shopNames, setShopNames] = useState({})
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 editedShopNamesRef = 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
@ -24,10 +26,11 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
if (remembered) {
lastShopNameRef.current = remembered
window.localStorage.setItem(shopMemoryKey, remembered)
setShopNames(current => Object.fromEntries(Object.entries(current).map(([recordId, value]) => [
recordId,
!value && !editedShopNamesRef.current.has(Number(recordId)) ? remembered : value,
])))
}
const rememberedPlatform = String(result.data?.last_ecommerce_platform || '').trim()
if (rememberedPlatform) {
lastPlatformRef.current = rememberedPlatform
window.localStorage.setItem(platformMemoryKey, rememberedPlatform)
}
}).catch(() => {})
return () => { active = false }
@ -66,13 +69,6 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
})),
}
}))
setShopNames(current => {
const next = { ...current }
result.data.forEach(record => {
if (next[record.id] === undefined) next[record.id] = record.shop_name || lastShopNameRef.current || ''
})
return next
})
setExpanded(current => {
const next = { ...current }
result.data.forEach((record, index) => {
@ -165,31 +161,63 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
}
}
async function publish(record) {
const shopName = String(shopNames[record.id] ?? record.shop_name ?? '').trim()
if (!shopName) {
setError(`请先为用户 ${record.user_id} 填写店铺名`)
setExpanded(current => ({ ...current, [record.id]: true }))
return
}
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',
body: JSON.stringify({ shop_name: shopName }),
body: JSON.stringify({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: productId }),
})
lastShopNameRef.current = shopName
lastPlatformRef.current = ecommercePlatform
window.localStorage.setItem(shopMemoryKey, shopName)
setShopNames(current => Object.fromEntries(Object.entries(current).map(([recordId, value]) => [
recordId,
!value && !editedShopNamesRef.current.has(Number(recordId)) ? shopName : value,
])))
window.localStorage.setItem(platformMemoryKey, ecommercePlatform)
setPublishing(null)
setPublishForm({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: '' })
await load()
} catch (err) {
setError(err.message)
@ -198,16 +226,6 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
}
}
function updateShopName(recordId, value) {
editedShopNamesRef.current.add(recordId)
setShopNames(current => ({ ...current, [recordId]: value }))
const remembered = value.trim()
if (remembered) {
lastShopNameRef.current = remembered
window.localStorage.setItem(shopMemoryKey, remembered)
}
}
async function reject(record) {
if (!record || busy) return
setBusy(record.id)
@ -282,11 +300,10 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
<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>淘宝ID:{record.user_id}</strong><code>{record.submission_no}</code><em>已选 {selected}/{record.questions.length} 题</em></div>
<label className="draft-shop-field"><span>店铺名<span className="required-mark">*</span></span><input value={shopNames[record.id] ?? record.shop_name ?? ''} disabled={locked || identifying || recognitionFailed} onChange={event => updateShopName(record.id, event.target.value)} placeholder="请输入店铺名" maxLength={255}/></label>
{identifying && <span className="draft-identifying" role="status">正在识别,请等待…</span>}
{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) : publish(record)}>{busy === record.id && recognitionFailed ? '重新识别中…' : locked ? '已派发' : identifying ? '识别中…' : recognitionFailed ? '重新识别' : '生成任务'}</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 => <React.Fragment key={question.id}><section className={`answer-draft-question ${question.selected ? 'selected' : ''}`}>
@ -309,6 +326,16 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
</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>

View File

@ -1,10 +1,13 @@
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: "",
@ -40,6 +43,17 @@ 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)) {
@ -54,7 +68,10 @@ function isAddedQuestion(item) {
export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed }) {
const cached = readPageCache("create-ask-task", {});
const [form, setForm] = useState(() => {
const restored = { ...blankForm, ...(cached.form || {}) };
// 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";
}
@ -70,6 +87,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
);
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);
@ -83,7 +101,13 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
const processingLibrarySelection = useRef(false);
const consumedLibrarySelectionKey = useRef("");
useEffect(() => {
writePageCache("create-ask-task", { form, task, questions, referenceQa, referenceFileName });
writePageCache("create-ask-task", {
form: withoutTransientProductFields(form),
task: task ? withoutTransientProductFields(task) : task,
questions,
referenceQa,
referenceFileName,
});
}, [form, task, questions, referenceQa, referenceFileName]);
useEffect(() => {
if (!librarySelection.length) {
@ -105,6 +129,50 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
}, [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 || "");
@ -147,6 +215,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
product_qr_image: String(reader.result || ""),
product_qr_image_name: file.name,
}));
void decodeProductQr(file);
};
reader.onerror = () => setError("商品二维码图片读取失败,请重试");
reader.readAsDataURL(file);
@ -161,6 +230,18 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
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) => {
@ -315,7 +396,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
async function removeQuestion(index) {
const question = questions[index];
if (!question || !isAddedQuestion(question)) return;
if (!question) return;
setBusy(true);
setError("");
try {
@ -323,9 +404,9 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, { method: "DELETE" });
}
setQuestions((current) => current.filter((_, itemIndex) => itemIndex !== index));
if (editing === index) setEditing(null);
setEditing((current) => current === index ? null : current > index ? current - 1 : current);
} catch (err) {
setError(err.message || "移除提问失败");
setError(err.message || "删除提问失败");
} finally {
setBusy(false);
}
@ -445,6 +526,8 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
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 的整数");
@ -469,6 +552,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
// 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);
@ -788,6 +872,9 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
) : (
<strong>{question.question}</strong>
)}
<button type="button" className="ask-question-remove" disabled={busy} onClick={() => removeQuestion(index)}>
删除
</button>
<button
type="button"
onClick={() =>
@ -796,11 +883,6 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
>
{editing === index ? "完成" : "编辑"}
</button>
{isAddedQuestion(question) && (
<button type="button" className="ask-question-remove" disabled={busy} onClick={() => removeQuestion(index)}>
移除
</button>
)}
</header>
<small>来源:{question.source || "AI生成"} · 可编辑后发布</small>
</div>
@ -825,20 +907,27 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
{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 && !busy && setPublishOpen(false)}>
<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={() => setPublishOpen(false)}><X size={18} /></button>
<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>
商品淘口令/二维码 *
<input value={form.product_link} onChange={(event) => update("product_link", event.target.value)} placeholder="请输入商品淘口令(可与二维码同时填写)" />
<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 ? (
@ -866,7 +955,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
<small>每名用户在该链接中最多可领取 2-20 条问题,默认 5 条</small>
</label>
{error && <div className="form-error">{error}</div>}
<div className="manual-modal-actions"><button type="button" disabled={busy} onClick={() => setPublishOpen(false)}>取消</button><button type="submit" className="primary-button" disabled={busy}>{busy ? "发布中…" : "确认发布"}</button></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>
)}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Plus, Search, X, Pencil, Trash2 } from "lucide-react";
import { Plus, Search, X, Pencil, Trash2, History } from "lucide-react";
import { formatDateTime } from "./time.js";
const endpoint = "/api/admin/refund-payment-accounts";
@ -7,7 +7,7 @@ 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 });
const [data, setData] = useState({ items: [], total: 0, summary: {} });
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@ -20,6 +20,15 @@ export default function RefundManagementPage({ api }) {
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;
@ -52,7 +61,8 @@ export default function RefundManagementPage({ api }) {
setSaveError("");
try {
if (!form.name.trim() || !form.alipay_account.trim()) throw new Error("请填写账户名称和支付宝账户");
for (const [key, label] of [["balance", "可用余额"], ["single_limit", "单次付款限额"]]) {
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" : ""}`);
@ -82,11 +92,70 @@ export default function RefundManagementPage({ api }) {
} 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-management-notice">代付接口暂未接入,此处仅维护付款账户资料,不会发起转账。可用余额为人工录入值,后续接入接口后再同步。</div>
<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>
@ -95,14 +164,15 @@ export default function RefundManagementPage({ api }) {
{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><th>支付宝账户</th><th>累计入账</th><th>已使用</th><th>处理中</th><th>可用余额</th><th>单次限额</th><th>状态</th><th>操作</th>
</tr></thead><tbody>
{loading ? <tr><td colSpan={8} className="empty-state">加载中…</td></tr> : error ? null : data.items.length ? data.items.map((account) => <tr key={account.id}>
{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 className="refund-account-balance">¥{money(account.balance_fen)}</td><td>¥{money(account.single_limit_fen)}</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>{formatDateTime(account.updated_at)}</td><td><div className="refund-account-actions"><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={8} className="empty-state">{query ? "未找到匹配账户" : "暂无付款账户,请点击新增付款账户"}</td></tr>}
<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">
@ -118,14 +188,42 @@ export default function RefundManagementPage({ api }) {
<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">
<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>
{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">余额由人工维护;保存资料不会产生实际付款。</p>
<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

@ -75,11 +75,11 @@ export default function RefundRecordsPage({ api }) {
{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">
<thead><tr>{["店铺名称", "转账时间", "订单号", "收款人", "收款账号", "返款金额", "付款账户", "操作人", "支付宝流水"].map((title) => <th key={title}>{title}</th>)}</tr></thead>
<tbody>{loading || error ? <tr><td colSpan={9}>{loading ? "加载中…" : "记录加载失败,请调整筛选或重新进入页面"}</td></tr> : data.items.length ? data.items.map((item) => <tr key={item.id}>
<td>{item.shop_name || "—"}</td><td>{formatDateTime(item.transferred_at)}</td><td>{item.order_no}</td><td>{item.payee_name || "—"}</td><td>{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>{item.alipay_trade_no || "—"}</td>
</tr>) : <tr><td colSpan={9}>暂无返款记录</td></tr>}</tbody>
{!loading && !error && <tfoot><tr><th colSpan={5}>合计(当前筛选全部记录)</th><td className="refund-account-balance">{money(data.total_amount_fen)}</td><td colSpan={3} /></tr></tfoot>}
<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>{item.shop_name || "—"}</td><td>{item.ecommerce_platform || "—"}</td><td>{item.product_id || "—"}</td><td>{formatDateTime(item.transferred_at)}</td><td>{item.order_no}</td><td>{item.payee_name || "—"}</td><td>{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>{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">

View File

@ -11,6 +11,7 @@ const STATUS_FILTERS = [
];
const STATUS_ALIASES = {
"待用户完成": "进行中",
"已超时": "已超时",
"未通过": "进行中",
"已发布": "进行中",
};
@ -47,7 +48,7 @@ function TaskDetailLoading() {
);
}
export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, api, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) {
export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, onReactivate, onCancel, actionBusy, api, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) {
const [amount, setAmount] = useState("");
const [payerAccount, setPayerAccount] = useState("");
const [paymentAccounts, setPaymentAccounts] = useState([]);
@ -79,6 +80,9 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, a
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)}`
@ -180,10 +184,16 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, a
</button>
</div>
)}
<div className={`task-detail-meta${isAskTask ? " ask-task-meta" : task.shop_name ? " shop-task-meta" : ""}`}>
<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>
@ -305,6 +315,16 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, a
</section>}
</div>
<footer>
{!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"
@ -593,6 +613,20 @@ export default function TaskBoardPage({ api, user }) {
setBusy("");
}
}
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);
@ -778,6 +812,11 @@ export default function TaskBoardPage({ api, user }) {
{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>
)}
@ -826,6 +865,9 @@ export default function TaskBoardPage({ api, user }) {
close={() => setDetail(null)}
onConfirmRefund={confirmRefund}
onSaveRefundAmount={saveRefundAmount}
onReactivate={reactivate}
onCancel={cancel}
actionBusy={busy === detail.id}
api={api}
hasNextRefund={Boolean(nextRefundTask)}

View File

@ -2727,6 +2727,10 @@
background: #e9eef4;
color: #62758c;
}
.board-status.已超时 {
background: #fff0f0;
color: #c93636;
}
.board-status.待返款 {
background: #fff0e5;
color: #d66c10;
@ -2854,8 +2858,9 @@
border-radius: 12px;
background: #f8fafc;
}
.task-detail-meta.ask-task-meta {
grid-template-columns: 1.25fr 1.5fr 0.9fr 0.9fr 1.1fr;
.task-detail-meta.ask-task-meta,
.task-detail-meta.product-task-meta {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.task-detail-meta.shop-task-meta {
grid-template-columns: 1.25fr 1.5fr 0.9fr 0.9fr 1.1fr;
@ -3016,6 +3021,15 @@
color: #fff;
box-shadow: 0 5px 12px rgba(23, 100, 232, 0.2);
}
.task-detail-modal > footer button.reactivate-task {
background: #1764e8;
color: #fff;
}
.task-detail-modal > footer button.cancel-task {
border: 1px solid #f0b7b7;
background: #fff5f5;
color: #c93636;
}
.task-detail-modal > footer button.next-refund-task {
display: inline-flex;
align-items: center;
@ -3126,6 +3140,9 @@
.task-detail-meta.ask-task-meta {
grid-template-columns: 1fr 1fr;
}
.task-detail-meta.product-task-meta {
grid-template-columns: 1fr 1fr;
}
.task-detail-meta.shop-task-meta {
grid-template-columns: 1fr 1fr;
}
@ -5223,6 +5240,11 @@
display: grid;
gap: 8px;
}
.ask-product-meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.ask-product-meta-grid label { display: grid; gap: 6px; }
.ask-product-resolve-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.ask-product-resolve-row button { white-space: nowrap; }
@media (max-width: 620px) { .ask-product-meta-grid, .ask-product-resolve-row { grid-template-columns: 1fr; } }
.ask-product-token-hint {
color: #8296ad;
font-size: 11px;
@ -6274,22 +6296,47 @@ html.dingtalk-mobile .answer-v2-footer {
.task-detail-refund-amount p { flex-basis: 100%; margin: 0; color: #e64059; }
.refund-management { width: 100%; padding: 22px; }
.refund-fund-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; }
.refund-fund-summary article { display: grid; gap: 5px; padding: 18px; border: 1px solid #e0e8f4; border-radius: 12px; background: #fafcff; }
.refund-fund-summary span { color: #6981a4; font-size: 12px; }
.refund-fund-summary strong { color: #193b68; font-size: 24px; font-variant-numeric: tabular-nums; }
.refund-fund-summary small { color: #91a0b4; }
.refund-fund-summary article.available { border-color: #b9e5ce; background: #f2fcf7; }
.refund-fund-summary article.available strong { color: #07945c; }
.refund-management-notice { padding: 13px 16px; border: 1px solid #d6e3ff; border-radius: 8px; background: #f5f8ff; color: #54729d; font-size: 13px; line-height: 1.7; }
.refund-management-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px 0; }
.refund-account-search { display: flex; align-items: center; gap: 8px; width: 320px; max-width: 100%; padding: 0 12px; border: 1px solid #d6e1f2; border-radius: 7px; color: #8a9db9; }
.refund-account-search input { width: 100%; min-width: 0; height: 38px; border: 0; outline: 0; background: transparent; }
.refund-management-toolbar > button { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.refund-account-table-wrap { overflow-x: auto; }
.refund-account-table { width: 100%; min-width: 1000px; border-collapse: collapse; text-align: left; font-size: 13px; }
.refund-account-table { width: 100%; min-width: 1500px; border-collapse: collapse; text-align: left; font-size: 13px; }
.refund-account-table th { padding: 15px 12px; background: #f6f8fc; color: #6981a4; font-weight: 500; white-space: nowrap; }
.refund-account-table td { padding: 20px 12px; border-bottom: 1px solid #eaf0f8; color: #254369; overflow-wrap: anywhere; max-width: 260px; }
.refund-account-table td:nth-child(4), .refund-account-table td:nth-child(5), .refund-account-table td:nth-child(7) { white-space: nowrap; font-variant-numeric: tabular-nums; }
.refund-account-table td.refund-account-balance { color: #2864ed; font-weight: 600; }
.refund-ledger-modal { display: flex; flex-direction: column; width: min(760px, calc(100vw - 32px)); max-height: min(760px, calc(100vh - 40px)); overflow: hidden; padding: 22px; border-radius: 16px; background: #fff; box-shadow: 0 24px 70px rgba(12, 36, 68, .24); }
.refund-ledger-modal > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid #e6edf6; }
.refund-ledger-modal h2, .refund-ledger-modal p { margin: 0; }
.refund-ledger-modal p { margin-top: 5px; color: #8296ad; font-size: 12px; }
.refund-ledger-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 16px 0; }
.refund-ledger-summary span { display: grid; gap: 4px; padding: 12px; border-radius: 9px; background: #f5f8fc; color: #758ba8; font-size: 11px; }
.refund-ledger-summary b { color: #23466f; font-size: 16px; }
.refund-ledger-list { display: grid; gap: 8px; min-height: 0; overflow-y: auto; }
.refund-ledger-list article { display: flex; justify-content: space-between; gap: 20px; padding: 13px 14px; border: 1px solid #e7edf5; border-radius: 9px; }
.refund-ledger-list article > div { display: grid; gap: 4px; }
.refund-ledger-list article > div:last-child { text-align: right; }
.refund-ledger-list small { color: #8799af; }
.refund-ledger-list b.income { color: #07945c; }
.refund-ledger-list b.expense { color: #d05252; }
.refund-ledger-pagination { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 9px; padding-top: 14px; margin-top: 14px; border-top: 1px solid #e7edf5; color: #7186a3; font-size: 12px; }
.refund-ledger-pagination button, .refund-ledger-pagination select { height: 32px; padding: 0 10px; border: 1px solid #d5e0ef; border-radius: 7px; background: #fff; color: #31567f; }
.refund-ledger-pagination button:disabled { cursor: not-allowed; opacity: .45; }
.refund-account-tag { display: inline-block; padding: 3px 7px; border-radius: 4px; background: #edf3ff; color: #2864ed; white-space: nowrap; font-size: 12px; }
.refund-account-tag.enabled { color: #069474; background: #e7f8f2; }
.refund-account-tag.disabled { color: #e34c66; background: #fff0f3; }
.refund-account-edit { display: inline-flex; align-items: center; gap: 5px; padding: 5px; border: 0; background: transparent; color: #2864ed; white-space: nowrap; cursor: pointer; }
.refund-account-actions { display: flex; align-items: center; gap: 8px; }
.refund-account-actions { display: flex; align-items: center; gap: 5px; }
.refund-account-add-funds { color: #07945c; }
.refund-account-delete { color: #e34c66; }
.refund-account-edit:disabled { opacity: .45; cursor: not-allowed; }
.refund-account-pagination { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 22px; color: #6981a4; font-size: 13px; }
@ -6299,6 +6346,7 @@ html.dingtalk-mobile .answer-v2-footer {
.refund-account-modal { width: 540px; max-width: 100%; max-height: 90vh; overflow-y: auto; padding: 26px; border-radius: 16px; background: white; box-shadow: 0 24px 70px #0d19333d; }
.refund-account-modal header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; }
.refund-account-modal h2 { margin: 0; color: #19365d; font-size: 20px; }
.refund-account-modal header p { margin: 5px 0 0; color: #8193af; font-size: 12px; }
.refund-account-modal header button { display: flex; padding: 4px; border: 0; background: transparent; color: #8a9db9; cursor: pointer; }
.refund-account-modal form > label, .refund-account-money-fields label { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; color: #567198; font-size: 13px; }
.refund-account-modal input, .refund-account-modal select { width: 100%; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #cddcf3; border-radius: 7px; background: white; color: #19365d; }
@ -6306,16 +6354,21 @@ html.dingtalk-mobile .answer-v2-footer {
.refund-account-modal input:disabled { background: #f5f7fb; color: #8293ac; }
.refund-account-money-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.refund-account-hint { color: #8193af; font-size: 12px; line-height: 1.6; }
.refund-fund-current { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
.refund-fund-current span { display: grid; gap: 5px; padding: 13px; border-radius: 9px; background: #f5f8fc; color: #7186a3; font-size: 12px; }
.refund-fund-current b { color: #23466f; font-size: 18px; }
.refund-account-modal footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.refund-account-modal footer button { padding: 10px 20px; border: 1px solid #d6e1f2; border-radius: 7px; cursor: pointer; }
.refund-account-modal footer button:not(.primary-button) { background: white; color: #365782; }
@media (max-width: 650px) {
.refund-management { padding: 14px; }
.refund-fund-summary, .refund-ledger-summary { grid-template-columns: 1fr 1fr; }
.refund-management-toolbar { align-items: stretch; flex-direction: column; }
.refund-account-search { width: auto; }
.refund-management-toolbar > button { justify-content: center; }
.refund-account-modal { padding: 20px; }
.refund-account-money-fields { grid-template-columns: 1fr; gap: 0; }
.refund-fund-current { grid-template-columns: 1fr; }
}
.refund-records { padding: 22px; }
.refund-records-toolbar { display: flex; align-items: end; gap: 12px; flex-wrap: wrap; }