返款记录新增平台、商品ID字段,修改返款管理界面,新加资金统计、使用情况功能
This commit is contained in:
parent
7f851593fb
commit
561a34f108
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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("&", "&")) 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
|
||||
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}
|
||||
|
||||
|
||||
@ -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="")
|
||||
|
||||
@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@ -9,13 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-react": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.146.0",
|
||||
@ -679,6 +679,12 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
|
||||
@ -3,7 +3,18 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {"dev": "vite", "build": "vite build", "preview": "vite preview"},
|
||||
"dependencies": {"@vitejs/plugin-react": "latest", "vite": "latest", "typescript": "latest", "react": "latest", "react-dom": "latest", "lucide-react": "latest"},
|
||||
"devDependencies": {}
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-react": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user