新加支付板块,修改数据库位置
This commit is contained in:
parent
61039682e9
commit
ccae5a3a3c
@ -1,4 +1,5 @@
|
||||
DATABASE_URL=mysql+pymysql://ai_agent_test:WGzc6mKawk@49.233.179.206:3306/wendajia
|
||||
SCREENSHOT_STORAGE_DIR=data/uploads/screenshots
|
||||
SECRET_KEY=replace-with-a-long-random-secret
|
||||
COOKIE_SECURE=false
|
||||
SESSION_HOURS=12
|
||||
@ -24,7 +25,11 @@ DINGTALK_REDIRECT_URI=http://localhost:8000/api/admin/auth/dingtalk/callback
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
# 可选:限制可登录的 unionId;多个值用逗号分隔
|
||||
DINGTALK_ALLOWED_UNION_IDS=
|
||||
# 新钉钉用户仅允许来自以下部门(包含其下级部门);部门 ID 比名称更稳定
|
||||
DINGTALK_ALLOWED_DEPT_IDS=
|
||||
DINGTALK_ALLOWED_DEPT_NAMES=战狼事业部,雪狼事业部
|
||||
# 可选:指定钉钉 unionId 自动授予超级管理员
|
||||
DINGTALK_SUPER_ADMIN_UNION_IDS=
|
||||
# 以下部门及其下级部门成员自动授予超级管理员;部门 ID 比名称更稳定
|
||||
DINGTALK_SUPER_ADMIN_DEPT_IDS=
|
||||
DINGTALK_SUPER_ADMIN_DEPT_NAMES=
|
||||
DINGTALK_SUPER_ADMIN_DEPT_NAMES=AI指挥部
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
data/uploads/
|
||||
|
||||
5
backend/app/alipay_provider.py
Normal file
5
backend/app/alipay_provider.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Reserved payout adapter. No external transfer is made in this version."""
|
||||
|
||||
|
||||
def transfer(order):
|
||||
raise NotImplementedError("支付宝代付接口尚未接入,禁止将订单标记为支付成功")
|
||||
@ -11,6 +11,12 @@ load_dotenv(BASE_DIR / ".env")
|
||||
class Settings:
|
||||
app_name = "问大家运营管理后台"
|
||||
database_url = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'data' / 'app.db'}")
|
||||
_screenshot_storage_value = Path(os.getenv("SCREENSHOT_STORAGE_DIR", "data/uploads/screenshots"))
|
||||
screenshot_storage_dir = (
|
||||
_screenshot_storage_value
|
||||
if _screenshot_storage_value.is_absolute()
|
||||
else BASE_DIR / _screenshot_storage_value
|
||||
)
|
||||
secret_key = os.getenv("SECRET_KEY", "change-this-secret-key-in-production")
|
||||
cookie_name = "admin_session"
|
||||
session_hours = int(os.getenv("SESSION_HOURS", "12"))
|
||||
@ -35,11 +41,16 @@ class Settings:
|
||||
dingtalk_redirect_uri = os.getenv("DINGTALK_REDIRECT_URI", "http://localhost:8000/api/admin/auth/dingtalk/callback")
|
||||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||||
dingtalk_allowed_union_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_UNION_IDS", "").split(",") if item.strip()}
|
||||
# New DingTalk users may enter only when they belong to one of these
|
||||
# business divisions (including child departments). Existing active
|
||||
# admin_users are grandfathered and continue to work.
|
||||
dingtalk_allowed_dept_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_DEPT_IDS", "").split(",") if item.strip()}
|
||||
dingtalk_allowed_dept_names = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_DEPT_NAMES", "战狼事业部,雪狼事业部").split(",") if item.strip()}
|
||||
dingtalk_super_admin_union_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_UNION_IDS", "").split(",") if item.strip()}
|
||||
# Members of these departments receive the same access as super admins.
|
||||
# IDs are preferred; names are supported as a readable fallback.
|
||||
dingtalk_super_admin_dept_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_IDS", "").split(",") if item.strip()}
|
||||
dingtalk_super_admin_dept_names = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_NAMES", "").split(",") if item.strip()}
|
||||
dingtalk_super_admin_dept_names = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_NAMES", "AI指挥部").split(",") if item.strip()}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from .models import RefundRecipient, AlipayRefundOrder
|
||||
|
||||
import base64
|
||||
import csv
|
||||
@ -21,7 +24,7 @@ from pathlib import Path
|
||||
from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import func, inspect, literal, or_, select, text, union_all, update
|
||||
from sqlalchemy.orm import Session, defer, noload, selectinload
|
||||
from sqlalchemy.orm import Session, defer, noload, object_session, selectinload
|
||||
|
||||
from .config import settings
|
||||
from .database import Base, SessionLocal, engine, get_db
|
||||
@ -29,6 +32,7 @@ from .llm import extract_questions_from_images, generate_answer_for_question, ge
|
||||
from .models import AdminLoginLog, AdminUsageEntry, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QABulkGenerationItem, QABulkGenerationJob, QALibraryEntry, RefundUploadSession
|
||||
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest
|
||||
from .security import create_session_token, decode_session_token, hash_password, verify_password
|
||||
from .screenshot_storage import screenshot_bytes, screenshot_data_url, screenshot_fields, screenshot_response
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||||
@ -241,20 +245,50 @@ DINGTALK_STATES: dict[str, float] = {}
|
||||
|
||||
ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"}
|
||||
ROLE_PERMISSIONS = {
|
||||
"super_admin": ["assistant", "library", "create", "board", "review", "usage", "users", "logs", "settings"],
|
||||
"dept_manager": ["assistant", "library", "create", "board", "review"],
|
||||
"operator": ["assistant", "library", "create", "board", "review"],
|
||||
"super_admin": ["assistant", "library", "create", "board", "review", "refunds", "usage", "users", "logs", "settings"],
|
||||
"dept_manager": ["assistant", "library", "create", "board", "review", "refunds"],
|
||||
"operator": ["assistant", "library", "create", "board", "review", "refunds"],
|
||||
}
|
||||
|
||||
|
||||
def _dingtalk_department_is_super_admin(dept_id: str | None, dept_name: str | None, dept_path: str | None = None) -> bool:
|
||||
def _dingtalk_department_is_allowed(
|
||||
dept_id: str | None,
|
||||
dept_name: str | None,
|
||||
dept_path: str | None = None,
|
||||
dept_name_path: str | None = None,
|
||||
) -> bool:
|
||||
configured_ids = settings.dingtalk_allowed_dept_ids
|
||||
configured_names = settings.dingtalk_allowed_dept_names
|
||||
if dept_id and dept_id in configured_ids:
|
||||
return True
|
||||
if dept_path and any(f",{candidate}," in dept_path for candidate in configured_ids):
|
||||
return True
|
||||
department_names = {
|
||||
item.strip()
|
||||
for item in str(dept_name_path or dept_name or "").split("/")
|
||||
if item.strip()
|
||||
}
|
||||
return bool(department_names & configured_names)
|
||||
|
||||
|
||||
def _dingtalk_department_is_super_admin(
|
||||
dept_id: str | None,
|
||||
dept_name: str | None,
|
||||
dept_path: str | None = None,
|
||||
dept_name_path: str | None = None,
|
||||
) -> bool:
|
||||
configured_ids = settings.dingtalk_super_admin_dept_ids
|
||||
configured_names = settings.dingtalk_super_admin_dept_names
|
||||
if dept_id and dept_id in configured_ids:
|
||||
return True
|
||||
if dept_path and any(f",{candidate}," in dept_path for candidate in configured_ids):
|
||||
return True
|
||||
return bool(dept_name and dept_name in configured_names)
|
||||
department_names = {
|
||||
item.strip()
|
||||
for item in str(dept_name_path or dept_name or "").split("/")
|
||||
if item.strip()
|
||||
}
|
||||
return bool(department_names & configured_names)
|
||||
|
||||
|
||||
def public_user(user: AdminUser) -> dict:
|
||||
@ -286,6 +320,16 @@ def seed_users(db: Session):
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
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)}
|
||||
screenshot_columns = set(screenshot_column_defs)
|
||||
if "file_path" not in screenshot_columns:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"ALTER TABLE {screenshot_table} ADD COLUMN file_path VARCHAR(1000) NULL"))
|
||||
if engine.dialect.name == "mysql" and not screenshot_column_defs["image_data"].get("nullable", False):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"ALTER TABLE {screenshot_table} MODIFY COLUMN image_data MEDIUMBLOB NULL"))
|
||||
# Add fields introduced after the initial SQLite database was created.
|
||||
columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")}
|
||||
if "claim_questions_per_user" not in columns:
|
||||
@ -386,23 +430,20 @@ def startup():
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)"))
|
||||
seed_users(db)
|
||||
# Apply configured department-wide administrator access to existing
|
||||
# DingTalk accounts. New accounts are handled during SSO login below.
|
||||
configured_dept_ids = settings.dingtalk_super_admin_dept_ids
|
||||
configured_dept_names = settings.dingtalk_super_admin_dept_names
|
||||
if configured_dept_ids or configured_dept_names:
|
||||
department_users = []
|
||||
if configured_dept_ids:
|
||||
department_users.extend(db.scalars(select(AdminUser).where(AdminUser.dingtalk_dept_id.in_(configured_dept_ids))).all())
|
||||
if configured_dept_names:
|
||||
department_users.extend(db.scalars(select(AdminUser).where(AdminUser.dingtalk_dept_name.in_(configured_dept_names))).all())
|
||||
changed = False
|
||||
for department_user in {user.id: user for user in department_users}.values():
|
||||
if department_user.role_code != "super_admin":
|
||||
department_user.role_code = "super_admin"
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
# Restore department-wide administrator access for existing rows. ID
|
||||
# paths also cover members stored in child departments.
|
||||
department_admins = db.scalars(select(AdminUser).where(AdminUser.is_active.is_(True))).all()
|
||||
promoted = False
|
||||
for department_admin in department_admins:
|
||||
if _dingtalk_department_is_super_admin(
|
||||
department_admin.dingtalk_dept_id,
|
||||
department_admin.dingtalk_dept_name,
|
||||
department_admin.dingtalk_dept_path,
|
||||
) and department_admin.role_code != "super_admin":
|
||||
department_admin.role_code = "super_admin"
|
||||
promoted = True
|
||||
if promoted:
|
||||
db.commit()
|
||||
# Every operator keeps one stable public answer-task URL. Generate
|
||||
# tokens after seeding so the default accounts receive one as well.
|
||||
answer_users = db.scalars(
|
||||
@ -670,26 +711,48 @@ def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict:
|
||||
data["_primary_dept_id"] = dept_ids[0] if dept_ids else ""
|
||||
data["_dept_name"] = ""
|
||||
data["_dept_path"] = ""
|
||||
data["_dept_name_path"] = ""
|
||||
if access_token and dept_ids:
|
||||
current_id = dept_ids[0]
|
||||
chain = []
|
||||
visited = set()
|
||||
while current_id and current_id not in visited and len(chain) < 20:
|
||||
visited.add(current_id)
|
||||
result = _dingtalk_oapi_post(
|
||||
"/topapi/v2/department/get",
|
||||
access_token,
|
||||
{"dept_id": int(current_id) if current_id.isdigit() else current_id, "language": "zh_CN"},
|
||||
).get("result") or {}
|
||||
if not isinstance(result, dict):
|
||||
break
|
||||
chain.append((current_id, str(result.get("name") or "").strip()))
|
||||
parent_id = result.get("parent_id") or result.get("parentId")
|
||||
current_id = str(parent_id).strip() if parent_id not in (None, "", 0, "0") else ""
|
||||
if chain:
|
||||
chain.reverse()
|
||||
data["_dept_name"] = chain[-1][1] or ""
|
||||
data["_dept_path"] = "," + ",".join(item[0] for item in chain) + ","
|
||||
department_cache = {}
|
||||
candidates = []
|
||||
for member_dept_id in dept_ids:
|
||||
current_id = member_dept_id
|
||||
chain = []
|
||||
visited = set()
|
||||
while current_id and current_id not in visited and len(chain) < 20:
|
||||
visited.add(current_id)
|
||||
if current_id not in department_cache:
|
||||
department_cache[current_id] = _dingtalk_oapi_post(
|
||||
"/topapi/v2/department/get",
|
||||
access_token,
|
||||
{"dept_id": int(current_id) if current_id.isdigit() else current_id, "language": "zh_CN"},
|
||||
).get("result") or {}
|
||||
result = department_cache[current_id]
|
||||
if not isinstance(result, dict) or not result:
|
||||
break
|
||||
chain.append((current_id, str(result.get("name") or "").strip()))
|
||||
parent_id = result.get("parent_id") or result.get("parentId")
|
||||
current_id = str(parent_id).strip() if parent_id not in (None, "", 0, "0") else ""
|
||||
if chain:
|
||||
chain.reverse()
|
||||
candidates.append({
|
||||
"dept_id": member_dept_id,
|
||||
"dept_name": chain[-1][1] or "",
|
||||
"dept_path": "," + ",".join(item[0] for item in chain) + ",",
|
||||
"dept_name_path": "/".join(item[1] for item in chain if item[1]),
|
||||
})
|
||||
selected = next((item for item in candidates if _dingtalk_department_is_super_admin(
|
||||
item["dept_id"], item["dept_name"], item["dept_path"], item["dept_name_path"]
|
||||
)), None)
|
||||
selected = selected or next((item for item in candidates if _dingtalk_department_is_allowed(
|
||||
item["dept_id"], item["dept_name"], item["dept_path"], item["dept_name_path"]
|
||||
)), None)
|
||||
selected = selected or (candidates[0] if candidates else None)
|
||||
if selected:
|
||||
data["_primary_dept_id"] = selected["dept_id"]
|
||||
data["_dept_name"] = selected["dept_name"]
|
||||
data["_dept_path"] = selected["dept_path"]
|
||||
data["_dept_name_path"] = selected["dept_name_path"]
|
||||
|
||||
leader_info = data.get("leader_in_dept") or data.get("leaderInDept") or []
|
||||
is_manager = bool(data.get("is_dept_manager") or data.get("isDeptManager"))
|
||||
@ -698,9 +761,16 @@ def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict:
|
||||
elif isinstance(leader_info, dict):
|
||||
is_manager = is_manager or bool(leader_info.get("is_leader") or leader_info.get("isLeader"))
|
||||
elif isinstance(leader_info, list):
|
||||
selected_dept_id = data.get("_primary_dept_id")
|
||||
matching_leaders = [
|
||||
item for item in leader_info
|
||||
if isinstance(item, dict)
|
||||
and str(item.get("dept_id") or item.get("deptId") or "") == str(selected_dept_id or "")
|
||||
]
|
||||
leader_candidates = matching_leaders or [item for item in leader_info if isinstance(item, dict)]
|
||||
is_manager = is_manager or any(
|
||||
isinstance(item, dict) and bool(item.get("is_leader") or item.get("isLeader"))
|
||||
for item in leader_info
|
||||
bool(item.get("is_leader") or item.get("isLeader") or item.get("leader"))
|
||||
for item in leader_candidates
|
||||
)
|
||||
data["_is_dept_manager"] = is_manager
|
||||
return data
|
||||
@ -753,8 +823,6 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) ->
|
||||
union_id = str(profile.get("unionId") or profile.get("unionid") or profile.get("openId") or profile.get("userid") or profile.get("userId") or "").strip()
|
||||
if not union_id:
|
||||
raise HTTPException(status_code=502, detail="钉钉用户信息缺少唯一标识")
|
||||
if settings.dingtalk_allowed_union_ids and union_id not in settings.dingtalk_allowed_union_ids:
|
||||
raise HTTPException(status_code=403, detail="当前钉钉账号未被授权使用运营后台")
|
||||
username = f"dingtalk_{union_id}"[:64]
|
||||
user = db.scalar(select(AdminUser).where(AdminUser.username == username))
|
||||
display_name = str(profile.get("nick") or profile.get("name") or "钉钉用户").strip()[:64]
|
||||
@ -762,8 +830,19 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) ->
|
||||
dept_id = str(profile.get("_primary_dept_id") or "").strip() or None
|
||||
dept_name = str(profile.get("_dept_name") or profile.get("dept_name") or "").strip() or None
|
||||
dept_path = str(profile.get("_dept_path") or "").strip() or None
|
||||
dept_name_path = str(profile.get("_dept_name_path") or dept_name or "").strip() or None
|
||||
is_dept_manager = bool(profile.get("_is_dept_manager"))
|
||||
department_super_admin = _dingtalk_department_is_super_admin(dept_id, dept_name, dept_path)
|
||||
department_super_admin = _dingtalk_department_is_super_admin(
|
||||
dept_id, dept_name, dept_path, dept_name_path
|
||||
)
|
||||
# Existing active rows form the explicit user-management allowlist. New
|
||||
# rows may be created only for the two authorized divisions or for a
|
||||
# department whose members are explicitly configured as administrators.
|
||||
if not user and not (
|
||||
_dingtalk_department_is_allowed(dept_id, dept_name, dept_path, dept_name_path)
|
||||
or department_super_admin
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="当前账号不在系统开放部门,请联系管理员")
|
||||
role_code = (
|
||||
"super_admin" if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin
|
||||
else "dept_manager" if is_dept_manager
|
||||
@ -792,7 +871,7 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) ->
|
||||
user.is_dept_manager = is_dept_manager
|
||||
if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin:
|
||||
user.role_code = "super_admin"
|
||||
elif is_dept_manager:
|
||||
elif is_dept_manager and user.role_code != "super_admin":
|
||||
user.role_code = "dept_manager"
|
||||
elif user.role_code == "dept_manager" and profile.get("_org_data_available"):
|
||||
user.role_code = "operator"
|
||||
@ -1028,7 +1107,24 @@ def _ask_screenshot_url(
|
||||
) -> str:
|
||||
if use_endpoint:
|
||||
return f"/api/admin/ask-{image_kind}-screenshots/{image.id}"
|
||||
return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"
|
||||
return screenshot_data_url(image)
|
||||
|
||||
|
||||
def _refund_recipient_data(record, target_kind: str) -> dict:
|
||||
"""Expose the payout identity saved alongside a user submission."""
|
||||
db = object_session(record)
|
||||
if db is None:
|
||||
return {"alipay_account": "", "payee_name": ""}
|
||||
recipient = db.scalar(
|
||||
select(RefundRecipient).where(
|
||||
RefundRecipient.target_kind == target_kind,
|
||||
RefundRecipient.target_id == record.id,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"alipay_account": recipient.account if recipient else "",
|
||||
"payee_name": recipient.payee_name if recipient else "",
|
||||
}
|
||||
|
||||
|
||||
def _ask_assignment_data(
|
||||
@ -1077,6 +1173,7 @@ def _ask_assignment_data(
|
||||
"user_id": _public_user_id(assignment.user),
|
||||
"taobao_id": assignment.user.taobao_id,
|
||||
"phone": _public_user_account(assignment.user),
|
||||
**(_refund_recipient_data(assignment, "ask_assignment") if include_images else {"alipay_account": "", "payee_name": ""}),
|
||||
"created_at": assignment.created_at.isoformat(),
|
||||
"updated_at": assignment.updated_at.isoformat(),
|
||||
"expires_at": (
|
||||
@ -1148,6 +1245,7 @@ def _ask_batch_data(
|
||||
"status": {"pending": "待审核", "approved": "已通过", "paid": "已完成", "rejected": "未通过"}.get(batch.status, batch.status),
|
||||
"created_at": batch.created_at.isoformat(),
|
||||
"updated_at": batch.updated_at.isoformat(),
|
||||
**(_refund_recipient_data(batch, "ask_batch") if include_images else {"alipay_account": "", "payee_name": ""}),
|
||||
"questions": questions,
|
||||
"question_screenshots": [
|
||||
{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)}
|
||||
@ -1192,7 +1290,7 @@ def _sync_ask_assignment_status(assignment: AskAssignment) -> None:
|
||||
def _image_url(image: AnswerScreenshot, use_endpoint: bool = False) -> str:
|
||||
if use_endpoint:
|
||||
return f"/api/admin/answer-screenshots/{image.id}"
|
||||
return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"
|
||||
return screenshot_data_url(image)
|
||||
|
||||
|
||||
LEGACY_FAKE_OCR_QUESTIONS = {
|
||||
@ -1233,6 +1331,7 @@ def _submission_data(
|
||||
"user_id": _public_user_id(record.user),
|
||||
"taobao_id": record.user.taobao_id,
|
||||
"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 "",
|
||||
"status": record.status,
|
||||
"recognition_failed": record.status == "识别失败" or _has_legacy_fake_ocr_questions(record),
|
||||
@ -2004,6 +2103,7 @@ async def submit_public_ask_task(
|
||||
files: list[UploadFile] = File(...),
|
||||
payment_file: UploadFile = File(...),
|
||||
payee_name: str = Form(default=""),
|
||||
alipay_account: str = Form(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = _answer_user_from_token(token, db)
|
||||
@ -2045,9 +2145,16 @@ async def submit_public_ask_task(
|
||||
db.add(batch)
|
||||
db.flush()
|
||||
for filename, content_type, image_data in question_uploads:
|
||||
db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="question", filename=filename, content_type=content_type, image_data=image_data))
|
||||
db.add(AskAssignmentBatchScreenshot(
|
||||
batch_id=batch.id, stage="question",
|
||||
**screenshot_fields(image_data, filename, content_type, "ask-batches"),
|
||||
))
|
||||
_save_refund_recipient(db, "ask_batch", batch.id, alipay_account, payee_name)
|
||||
filename, content_type, image_data = payment_upload
|
||||
db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="payment", filename=filename, content_type=content_type, image_data=image_data))
|
||||
db.add(AskAssignmentBatchScreenshot(
|
||||
batch_id=batch.id, stage="payment",
|
||||
**screenshot_fields(image_data, filename, content_type, "ask-batches"),
|
||||
))
|
||||
assignment.status = "待审核"
|
||||
assignment.reservation_started_at = None
|
||||
used_ids = {item.question_id for item in assignment.used_questions}
|
||||
@ -2156,11 +2263,7 @@ def admin_ask_batch_screenshot(screenshot_id: int, user: AdminUser = Depends(cur
|
||||
image = db.get(AskAssignmentBatchScreenshot, screenshot_id)
|
||||
if not image or not _can_access_ask_task(image.batch.assignment.task, user):
|
||||
raise HTTPException(status_code=404, detail="截图不存在")
|
||||
return Response(
|
||||
content=image.image_data,
|
||||
media_type=image.content_type,
|
||||
headers={"Cache-Control": "private, max-age=600"},
|
||||
)
|
||||
return screenshot_response(image)
|
||||
|
||||
|
||||
@app.get("/api/admin/ask-assignment-screenshots/{screenshot_id}")
|
||||
@ -2168,11 +2271,7 @@ def admin_ask_assignment_screenshot(screenshot_id: int, user: AdminUser = Depend
|
||||
image = db.get(AskAssignmentScreenshot, screenshot_id)
|
||||
if not image or not _can_access_ask_task(image.assignment.task, user):
|
||||
raise HTTPException(status_code=404, detail="截图不存在")
|
||||
return Response(
|
||||
content=image.image_data,
|
||||
media_type=image.content_type,
|
||||
headers={"Cache-Control": "private, max-age=600"},
|
||||
)
|
||||
return screenshot_response(image)
|
||||
|
||||
|
||||
@app.post("/api/admin/ask-batch-reviews/{batch_id}/decision")
|
||||
@ -2256,7 +2355,7 @@ def _process_answer_submission(submission_id: int):
|
||||
# A SQLAlchemy Session keeps its transaction/connection until
|
||||
# commit/rollback. Do not hold that connection while OCR/LLM calls
|
||||
# (which can take up to the configured timeout) are running.
|
||||
saved_payload = [(shot.image_data, shot.content_type) for shot in saved]
|
||||
saved_payload = [(screenshot_bytes(shot), shot.content_type) for shot in saved]
|
||||
db.rollback()
|
||||
extracted = extract_questions_from_images(saved_payload)
|
||||
if not extracted:
|
||||
@ -2346,7 +2445,10 @@ async def answer_create_submission(
|
||||
record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, owner_id=owner.id, status="待筛选")
|
||||
db.add(record); db.flush()
|
||||
for filename, content_type, image_data in uploaded:
|
||||
db.add(AnswerScreenshot(submission_id=record.id, stage="invited", filename=filename, content_type=content_type, image_data=image_data))
|
||||
db.add(AnswerScreenshot(
|
||||
submission_id=record.id, stage="invited",
|
||||
**screenshot_fields(image_data, filename, content_type, "answers"),
|
||||
))
|
||||
db.commit(); db.refresh(record)
|
||||
background_tasks.add_task(_process_answer_submission, record.id)
|
||||
# 先返回轻量结果,图片由后续状态查询按需加载。
|
||||
@ -2429,7 +2531,10 @@ async def upload_answer_operator_images(
|
||||
db.delete(old)
|
||||
new_ids = []
|
||||
for filename, content_type, image_data in uploaded:
|
||||
image = AnswerScreenshot(submission_id=record.id, stage="operator", filename=filename, content_type=content_type, image_data=image_data)
|
||||
image = AnswerScreenshot(
|
||||
submission_id=record.id, stage="operator",
|
||||
**screenshot_fields(image_data, filename, content_type, "answers"),
|
||||
)
|
||||
db.add(image); db.flush(); new_ids.append(str(image.id))
|
||||
question.operator_image_screenshot_ids = ",".join(new_ids)
|
||||
question.operator_image_screenshot_id = int(new_ids[0]) if new_ids else None
|
||||
@ -3142,6 +3247,83 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser =
|
||||
return {"success": True, "data": {"id": assignment_id}}
|
||||
|
||||
|
||||
def _save_refund_recipient(db, kind, target_id, account, name):
|
||||
# Legacy clients can still submit screenshots without bank details.
|
||||
if not account.strip():
|
||||
return
|
||||
if not account.strip() or not name.strip() or len(account.strip()) > 128 or len(name.strip()) > 64:
|
||||
raise HTTPException(status_code=400, detail="请填写支付宝账号和收款人姓名")
|
||||
recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == kind, RefundRecipient.target_id == target_id))
|
||||
if not recipient:
|
||||
recipient = RefundRecipient(target_kind=kind, target_id=target_id)
|
||||
db.add(recipient)
|
||||
recipient.account = account.strip()
|
||||
recipient.payee_name = name.strip()
|
||||
|
||||
|
||||
def _payable_target(db, kind, target_id, user):
|
||||
target, payable = _refund_session_target_for_payload(kind, target_id, db)
|
||||
if not target or not payable:
|
||||
raise HTTPException(status_code=409, detail="任务已不在待返款状态")
|
||||
assignment = target.assignment if kind == "ask_batch" else target
|
||||
allowed = _can_access_ask_task(assignment.task, user) if kind != "answer_submission" else _can_access_answer_submission(target, user)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=404, detail="无权操作该任务")
|
||||
return target
|
||||
|
||||
|
||||
@app.get("/api/admin/alipay-refunds")
|
||||
def list_alipay_refunds(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
result = []
|
||||
for kind, model, payable_status in [("answer_submission", AnswerSubmission, "已通过"), ("ask_batch", AskAssignmentBatch, "approved"), ("ask_assignment", AskAssignment, "已通过")]:
|
||||
for target in db.scalars(select(model).where(model.status == payable_status)).all():
|
||||
try:
|
||||
_payable_target(db, kind, target.id, user)
|
||||
except HTTPException:
|
||||
continue
|
||||
if kind == "ask_assignment" and target.batches:
|
||||
continue
|
||||
recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == kind, RefundRecipient.target_id == target.id))
|
||||
order = db.scalar(select(AlipayRefundOrder).where(AlipayRefundOrder.target_kind == kind, AlipayRefundOrder.target_id == target.id))
|
||||
assignment = target.assignment if kind == "ask_batch" else target
|
||||
detail_path = {"answer_submission": f"/api/admin/task-board/{target.id}", "ask_batch": f"/api/admin/task-board/ask-batches/{target.id}", "ask_assignment": f"/api/admin/task-board/ask-assignments/{target.id}"}[kind]
|
||||
result.append({"target_kind": kind, "target_id": target.id, "task_no": target.submission_no if kind == "answer_submission" else assignment.task.task_no, "account": order.account if order else recipient.account if recipient else "", "payee_name": order.payee_name if order else recipient.payee_name if recipient else "", "amount": str(Decimal(order.amount_fen) / 100) if order else "", "payment_status": "待支付(代付接口未接入)" if order else "待提交", "submitted": bool(order), "taobao_id": assignment.user.taobao_id if kind != "answer_submission" else target.user.taobao_id, "shop_name": assignment.task.shop_name if kind != "answer_submission" else target.shop_name, "product_name": assignment.task.product_name if kind != "answer_submission" else (target.questions[0].product_title if target.questions else "待识别商品"), "detail_endpoint": detail_path})
|
||||
return {"success": True, "data": result}
|
||||
|
||||
|
||||
@app.post("/api/admin/alipay-refunds")
|
||||
def submit_alipay_refunds(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
items = payload.get("items")
|
||||
if not isinstance(items, list) or not 1 <= len(items) <= 100:
|
||||
raise HTTPException(status_code=400, detail="每次请选择1至100笔返款")
|
||||
seen = set()
|
||||
for item in items:
|
||||
try:
|
||||
kind, target_id = item["target_kind"], int(item["target_id"])
|
||||
amount = Decimal(str(item["amount"]))
|
||||
if not amount.is_finite() or amount <= 0 or amount > Decimal("100000") or amount != amount.quantize(Decimal("0.01")):
|
||||
raise ValueError()
|
||||
except (KeyError, TypeError, ValueError, InvalidOperation):
|
||||
raise HTTPException(status_code=400, detail="金额必须大于0,最多两位小数,单笔不超过100000元")
|
||||
if (kind, target_id) in seen:
|
||||
raise HTTPException(status_code=400, detail="请勿重复选择同一笔任务")
|
||||
seen.add((kind, target_id))
|
||||
_payable_target(db, kind, target_id, user)
|
||||
if db.scalar(select(AlipayRefundOrder.id).where(AlipayRefundOrder.target_kind == kind, AlipayRefundOrder.target_id == target_id)):
|
||||
raise HTTPException(status_code=409, detail="该任务已提交返款,请勿重复提交")
|
||||
account, name = str(item.get("account") or "").strip(), str(item.get("payee_name") or "").strip()
|
||||
if not account or not name or len(account) > 128 or len(name) > 64:
|
||||
raise HTTPException(status_code=400, detail="请填写支付宝账号和收款人姓名")
|
||||
db.add(AlipayRefundOrder(target_kind=kind, target_id=target_id, amount_fen=int(amount * 100), account=account, payee_name=name, created_by_id=user.id))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="任务已提交,请刷新后重试")
|
||||
# Provider integration intentionally not invoked; no money is transferred.
|
||||
return {"success": True, "data": {"count": len(items), "status": "pending", "message": "已保存,待支付;代付接口尚未接入"}}
|
||||
|
||||
|
||||
@app.post("/api/admin/refund-upload-sessions")
|
||||
def create_refund_upload_session(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
"""Create a short-lived, record-bound upload URL for the operator modal."""
|
||||
@ -3213,11 +3395,20 @@ async def upload_refund_proof_by_session(token: str, refund_file: UploadFile = F
|
||||
if image.stage == "refund":
|
||||
db.delete(image)
|
||||
if session.target_kind == "ask_batch":
|
||||
db.add(AskAssignmentBatchScreenshot(batch_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data))
|
||||
db.add(AskAssignmentBatchScreenshot(
|
||||
batch_id=target.id, stage="refund",
|
||||
**screenshot_fields(data, filename, content_type, "ask-batches"),
|
||||
))
|
||||
elif session.target_kind == "ask_assignment":
|
||||
db.add(AskAssignmentScreenshot(assignment_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data))
|
||||
db.add(AskAssignmentScreenshot(
|
||||
assignment_id=target.id, stage="refund",
|
||||
**screenshot_fields(data, filename, content_type, "ask-assignments"),
|
||||
))
|
||||
else:
|
||||
db.add(AnswerScreenshot(submission_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data))
|
||||
db.add(AnswerScreenshot(
|
||||
submission_id=target.id, stage="refund",
|
||||
**screenshot_fields(data, filename, content_type, "answers"),
|
||||
))
|
||||
session.uploaded_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
@ -3238,7 +3429,10 @@ async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None
|
||||
for image in list(batch.screenshots):
|
||||
if image.stage == "refund":
|
||||
db.delete(image)
|
||||
db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data))
|
||||
db.add(AskAssignmentBatchScreenshot(
|
||||
batch_id=batch.id, stage="refund",
|
||||
**screenshot_fields(refund_data, filename, content_type, "ask-batches"),
|
||||
))
|
||||
elif not any(image.stage == "refund" for image in batch.screenshots):
|
||||
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
|
||||
if upload_token:
|
||||
@ -3267,7 +3461,10 @@ async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadF
|
||||
for image in list(assignment.screenshots):
|
||||
if image.stage == "refund":
|
||||
db.delete(image)
|
||||
db.add(AskAssignmentScreenshot(assignment_id=assignment.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data))
|
||||
db.add(AskAssignmentScreenshot(
|
||||
assignment_id=assignment.id, stage="refund",
|
||||
**screenshot_fields(refund_data, filename, content_type, "ask-assignments"),
|
||||
))
|
||||
elif not any(image.stage == "refund" for image in assignment.screenshots):
|
||||
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
|
||||
if upload_token:
|
||||
@ -3324,7 +3521,10 @@ async def confirm_answer_refund(submission_id: int, refund_file: UploadFile | No
|
||||
for image in list(record.screenshots):
|
||||
if image.stage == "refund":
|
||||
db.delete(image)
|
||||
db.add(AnswerScreenshot(submission_id=record.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data))
|
||||
db.add(AnswerScreenshot(
|
||||
submission_id=record.id, stage="refund",
|
||||
**screenshot_fields(refund_data, filename, content_type, "answers"),
|
||||
))
|
||||
elif not any(image.stage == "refund" for image in record.screenshots):
|
||||
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
|
||||
if upload_token:
|
||||
@ -3354,7 +3554,7 @@ def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(curren
|
||||
|
||||
|
||||
@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=[]), db: Session = Depends(get_db)):
|
||||
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)
|
||||
if not record or record.user_id != user.id or record.owner_id != owner.id:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
@ -3380,7 +3580,11 @@ async def answer_completion(submission_id: int, token: str = Form(...), owner_to
|
||||
if old_screenshot.stage in {"completion", "payment"}:
|
||||
db.delete(old_screenshot)
|
||||
for stage, filename, content_type, image_data in uploaded:
|
||||
db.add(AnswerScreenshot(submission_id=record.id, stage=stage, filename=filename, content_type=content_type, image_data=image_data))
|
||||
db.add(AnswerScreenshot(
|
||||
submission_id=record.id, stage=stage,
|
||||
**screenshot_fields(image_data, filename, content_type, "answers"),
|
||||
))
|
||||
_save_refund_recipient(db, "answer_submission", record.id, alipay_account, payee_name)
|
||||
record.status = "待审核"; db.commit()
|
||||
# 只返回轻量状态,避免重新编码截图阻塞审核状态切换。
|
||||
return {"success": True, "data": _submission_data(record, include_images=False)}
|
||||
@ -3422,11 +3626,7 @@ def admin_answer_screenshot(screenshot_id: int, user: AdminUser = Depends(curren
|
||||
image = db.get(AnswerScreenshot, screenshot_id)
|
||||
if not image or not _can_access_answer_submission(image.submission, user):
|
||||
raise HTTPException(status_code=404, detail="截图不存在")
|
||||
return Response(
|
||||
content=image.image_data,
|
||||
media_type=image.content_type,
|
||||
headers={"Cache-Control": "private, max-age=600"},
|
||||
)
|
||||
return screenshot_response(image)
|
||||
|
||||
|
||||
@app.post("/api/admin/answer-completion-reviews/{submission_id}/decision")
|
||||
@ -4370,6 +4570,7 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session =
|
||||
today = now.date()
|
||||
today_entries = [entry for entry in entries if entry.entered_at.date() == today]
|
||||
user_totals = {}
|
||||
department_totals = {}
|
||||
trend = {}
|
||||
for offset in range(30):
|
||||
day = (start + timedelta(days=offset)).date().isoformat()
|
||||
@ -4380,6 +4581,13 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session =
|
||||
trend[day]["count"] += 1
|
||||
key = (entry.user_id, entry.username, entry.display_name, entry.department_name)
|
||||
user_totals[key] = user_totals.get(key, 0) + 1
|
||||
department = entry.department_name or "未分配部门"
|
||||
department_totals.setdefault(department, {"count": 0, "latest": entry.entered_at, "users": {}})
|
||||
department_totals[department]["count"] += 1
|
||||
if entry.entered_at > department_totals[department]["latest"]:
|
||||
department_totals[department]["latest"] = entry.entered_at
|
||||
member_key = (entry.user_id, entry.username, entry.display_name)
|
||||
department_totals[department]["users"][member_key] = department_totals[department]["users"].get(member_key, 0) + 1
|
||||
for day in trend:
|
||||
trend[day]["users"] = len({entry.user_id for entry in entries if entry.entered_at.date().isoformat() == day})
|
||||
users = [
|
||||
@ -4394,6 +4602,28 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session =
|
||||
user_totals.items(), key=lambda item: (-item[1], item[0][2])
|
||||
)
|
||||
]
|
||||
departments = [
|
||||
{
|
||||
"name": department_name,
|
||||
"count": values["count"],
|
||||
"user_count": len(values["users"]),
|
||||
"latest_at": values["latest"].isoformat(),
|
||||
"members": [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
"count": count,
|
||||
}
|
||||
for (user_id, username, display_name), count in sorted(
|
||||
values["users"].items(), key=lambda item: (-item[1], item[0][2])
|
||||
)
|
||||
],
|
||||
}
|
||||
for department_name, values in sorted(
|
||||
department_totals.items(), key=lambda item: (-item[1]["count"], item[0])
|
||||
)
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
@ -4401,8 +4631,10 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session =
|
||||
"total_count": len(entries),
|
||||
"today_count": len(today_entries),
|
||||
"today_users": len({entry.user_id for entry in today_entries}),
|
||||
"department_count": len(departments),
|
||||
"trend": list(trend.values()),
|
||||
"users": users,
|
||||
"departments": departments,
|
||||
"records": [
|
||||
{
|
||||
"display_name": entry.display_name,
|
||||
@ -4469,9 +4701,9 @@ def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Dep
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if user.id == current.id and (changes.get("is_active") is False or changes.get("role_code") == "operator"):
|
||||
if user.id == current.id and (changes.get("is_active") is False or changes.get("role_code") not in (None, "super_admin")):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用或降级当前登录账号")
|
||||
if changes.get("role_code") == "operator" and user.role_code == "super_admin":
|
||||
if changes.get("role_code") in {"operator", "dept_manager"} and user.role_code == "super_admin":
|
||||
super_count = db.scalar(select(func.count()).select_from(AdminUser).where(AdminUser.role_code == "super_admin", AdminUser.is_active.is_(True))) or 0
|
||||
if super_count <= 1:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少需要保留一个启用中的超级管理员")
|
||||
|
||||
@ -7,6 +7,30 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from .database import Base
|
||||
|
||||
|
||||
class RefundRecipient(Base):
|
||||
__tablename__ = "refund_recipients"
|
||||
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_refund_recipient_target"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
target_kind: Mapped[str] = mapped_column(String(32))
|
||||
target_id: Mapped[int] = mapped_column(Integer)
|
||||
account: Mapped[str] = mapped_column(String(128))
|
||||
payee_name: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
|
||||
class AlipayRefundOrder(Base):
|
||||
__tablename__ = "alipay_refund_orders"
|
||||
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_alipay_refund_target"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
target_kind: Mapped[str] = mapped_column(String(32))
|
||||
target_id: Mapped[int] = mapped_column(Integer)
|
||||
amount_fen: Mapped[int] = mapped_column(Integer)
|
||||
account: Mapped[str] = mapped_column(String(128))
|
||||
payee_name: Mapped[str] = mapped_column(String(64))
|
||||
created_by_id: Mapped[int] = mapped_column(Integer)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
@ -175,7 +199,8 @@ class AnswerScreenshot(Base):
|
||||
stage: Mapped[str] = mapped_column(String(20), index=True) # invited/completion
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str] = mapped_column(String(64))
|
||||
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
|
||||
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
|
||||
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
submission: Mapped[AnswerSubmission] = relationship(back_populates="screenshots")
|
||||
@ -321,7 +346,8 @@ class AskAssignmentBatchScreenshot(Base):
|
||||
stage: Mapped[str] = mapped_column(String(20), index=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str] = mapped_column(String(64))
|
||||
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
|
||||
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
|
||||
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
batch: Mapped[AskAssignmentBatch] = relationship(back_populates="screenshots")
|
||||
@ -387,7 +413,8 @@ class AskAssignmentScreenshot(Base):
|
||||
stage: Mapped[str] = mapped_column(String(20), index=True) # question/payment
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str] = mapped_column(String(64))
|
||||
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
|
||||
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
|
||||
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
assignment: Mapped[AskAssignment] = relationship(back_populates="screenshots")
|
||||
|
||||
82
backend/app/screenshot_storage.py
Normal file
82
backend/app/screenshot_storage.py
Normal file
@ -0,0 +1,82 @@
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Response
|
||||
from .config import settings
|
||||
|
||||
|
||||
def _safe_extension(filename: str, content_type: str) -> str:
|
||||
extension = Path(filename or "").suffix.lower()
|
||||
if extension in {".jpg", ".jpeg", ".png", ".webp"}:
|
||||
return extension
|
||||
guessed = mimetypes.guess_extension(content_type or "") or ".bin"
|
||||
return ".jpg" if guessed == ".jpe" else guessed
|
||||
|
||||
|
||||
def _absolute_path(relative_path: str) -> Path:
|
||||
root = settings.screenshot_storage_dir.resolve()
|
||||
candidate = (root / relative_path).resolve()
|
||||
if root != candidate and root not in candidate.parents:
|
||||
raise ValueError("非法截图文件路径")
|
||||
return candidate
|
||||
|
||||
|
||||
def store_screenshot(data: bytes, filename: str, content_type: str, category: str) -> str:
|
||||
if not data:
|
||||
raise ValueError("截图内容不能为空")
|
||||
now_path = Path(category) / uuid.uuid4().hex[:2]
|
||||
relative_path = now_path / f"{uuid.uuid4().hex}{_safe_extension(filename, content_type)}"
|
||||
target = _absolute_path(relative_path.as_posix())
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(target.suffix + ".tmp")
|
||||
temporary.write_bytes(data)
|
||||
if temporary.stat().st_size != len(data):
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise OSError("截图写入不完整")
|
||||
temporary.replace(target)
|
||||
return relative_path.as_posix()
|
||||
|
||||
|
||||
def screenshot_fields(data: bytes, filename: str, content_type: str, category: str) -> dict:
|
||||
return {
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"file_path": store_screenshot(data, filename, content_type, category),
|
||||
"image_data": None,
|
||||
}
|
||||
|
||||
|
||||
def screenshot_bytes(image) -> bytes:
|
||||
if image.file_path:
|
||||
try:
|
||||
path = _absolute_path(image.file_path)
|
||||
if path.is_file():
|
||||
return path.read_bytes()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if image.image_data:
|
||||
return bytes(image.image_data)
|
||||
raise HTTPException(status_code=404, detail="截图文件不存在")
|
||||
|
||||
|
||||
def screenshot_response(image) -> Response:
|
||||
return Response(
|
||||
content=screenshot_bytes(image),
|
||||
media_type=image.content_type or "application/octet-stream",
|
||||
headers={"Cache-Control": "private, max-age=600"},
|
||||
)
|
||||
|
||||
|
||||
def screenshot_data_url(image) -> str:
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(screenshot_bytes(image)).decode()
|
||||
return f"data:{image.content_type};base64,{encoded}"
|
||||
|
||||
|
||||
def file_digest(relative_path: str) -> tuple[int, str]:
|
||||
data = _absolute_path(relative_path).read_bytes()
|
||||
return len(data), hashlib.sha256(data).hexdigest()
|
||||
|
||||
@ -19,6 +19,31 @@ CREATE TABLE IF NOT EXISTS admin_users (
|
||||
KEY idx_admin_users_status (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refund_recipients (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
target_kind VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
account VARCHAR(128) NOT NULL,
|
||||
payee_name VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_refund_recipient_target (target_kind, target_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alipay_refund_orders (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
target_kind VARCHAR(32) NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
amount_fen INT NOT NULL,
|
||||
account VARCHAR(128) NOT NULL,
|
||||
payee_name VARCHAR(64) NOT NULL,
|
||||
created_by_id BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_alipay_refund_target (target_kind, target_id),
|
||||
KEY idx_alipay_refund_orders_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_login_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NULL,
|
||||
|
||||
87
backend/scripts/migrate_screenshots_to_files.py
Normal file
87
backend/scripts/migrate_screenshots_to_files.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Move legacy screenshot BLOBs to server files.
|
||||
|
||||
Run from the project root with::
|
||||
|
||||
python backend/scripts/migrate_screenshots_to_files.py
|
||||
|
||||
Each row is committed only after the written file matches the source byte
|
||||
length and SHA-256 digest. Re-running is safe: rows with a valid file_path and
|
||||
an empty image_data value are skipped.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_ROOT = PROJECT_ROOT / "backend"
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from sqlalchemy import inspect, select, text # noqa: E402
|
||||
|
||||
from app.config import settings # noqa: E402
|
||||
from app.database import SessionLocal, engine # noqa: E402
|
||||
from app.models import AnswerScreenshot, AskAssignmentBatchScreenshot, AskAssignmentScreenshot # noqa: E402
|
||||
from app.screenshot_storage import file_digest, store_screenshot # noqa: E402
|
||||
|
||||
|
||||
TARGETS = (
|
||||
(AnswerScreenshot, "answer_screenshots", "answers"),
|
||||
(AskAssignmentBatchScreenshot, "ask_assignment_batch_screenshots", "ask-batches"),
|
||||
(AskAssignmentScreenshot, "ask_assignment_screenshots", "ask-assignments"),
|
||||
)
|
||||
|
||||
|
||||
def ensure_columns() -> None:
|
||||
for _model, table, _category in TARGETS:
|
||||
columns = {column["name"]: column for column in inspect(engine).get_columns(table)}
|
||||
if "file_path" not in columns:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"ALTER TABLE {table} ADD COLUMN file_path VARCHAR(1000) NULL"))
|
||||
if engine.dialect.name == "mysql" and not columns["image_data"].get("nullable", False):
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"ALTER TABLE {table} MODIFY COLUMN image_data MEDIUMBLOB NULL"))
|
||||
|
||||
|
||||
def migrate() -> None:
|
||||
ensure_columns()
|
||||
settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
migrated_rows = 0
|
||||
migrated_bytes = 0
|
||||
for model, table, category in TARGETS:
|
||||
table_rows = 0
|
||||
table_bytes = 0
|
||||
while True:
|
||||
with SessionLocal() as db:
|
||||
image = db.scalar(
|
||||
select(model)
|
||||
.where(model.image_data.is_not(None))
|
||||
.order_by(model.id)
|
||||
.limit(1)
|
||||
)
|
||||
if image is None:
|
||||
break
|
||||
source = bytes(image.image_data)
|
||||
source_digest = hashlib.sha256(source).hexdigest()
|
||||
relative_path = store_screenshot(source, image.filename, image.content_type, category)
|
||||
stored_size, stored_digest = file_digest(relative_path)
|
||||
if stored_size != len(source) or stored_digest != source_digest:
|
||||
raise RuntimeError(f"{table}#{image.id} 文件校验失败")
|
||||
image.file_path = relative_path
|
||||
image.image_data = None
|
||||
db.commit()
|
||||
table_rows += 1
|
||||
table_bytes += len(source)
|
||||
if table_rows % 100 == 0:
|
||||
print(f"{table}: migrated {table_rows} rows", flush=True)
|
||||
migrated_rows += table_rows
|
||||
migrated_bytes += table_bytes
|
||||
print(f"{table}: complete rows={table_rows} bytes={table_bytes}", flush=True)
|
||||
print(f"TOTAL rows={migrated_rows} bytes={migrated_bytes}", flush=True)
|
||||
print(f"FILES {settings.screenshot_storage_dir.resolve()}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@ -8,6 +8,7 @@ from pathlib import Path
|
||||
# database must be selected before any test module imports app.main.
|
||||
_test_dir = Path(tempfile.mkdtemp(prefix="wendajia-tests-"))
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{(_test_dir / 'app.db').as_posix()}"
|
||||
os.environ["SCREENSHOT_STORAGE_DIR"] = str(_test_dir / "screenshots")
|
||||
os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "admin123456"
|
||||
os.environ["OPERATOR_USERNAME"] = "operator"
|
||||
|
||||
56
backend/tests/test_alipay_refunds.py
Normal file
56
backend/tests/test_alipay_refunds.py
Normal file
@ -0,0 +1,56 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import app.main as main
|
||||
|
||||
|
||||
def test_refund_submission_is_atomic_idempotent_and_does_not_mark_paid():
|
||||
with TestClient(main.app) as client:
|
||||
client.post('/api/admin/auth/login', json={'username': 'operator', 'password': 'operator123456'})
|
||||
with main.SessionLocal() as db:
|
||||
owner = db.scalar(main.select(main.AdminUser).where(main.AdminUser.username == 'operator'))
|
||||
user = main.AnswerUser(public_id=uuid4().hex[:10], phone=uuid4().hex[:20], auth_token=uuid4().hex)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
records = [main.AnswerSubmission(submission_no=uuid4().hex, user_id=user.id, owner_id=owner.id, status='已通过') for _ in range(2)]
|
||||
db.add_all(records)
|
||||
db.flush()
|
||||
ids = [record.id for record in records]
|
||||
main._save_refund_recipient(db, 'answer_submission', ids[0], 'payer@example.com', '测试收款人')
|
||||
db.commit()
|
||||
detail = client.get(f'/api/admin/task-board/{ids[0]}')
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()['data']['alipay_account'] == 'payer@example.com'
|
||||
assert detail.json()['data']['payee_name'] == '测试收款人'
|
||||
items = [{'target_kind': 'answer_submission', 'target_id': target_id, 'amount': amount, 'account': 'payer@example.com', 'payee_name': '测试收款人'} for target_id, amount in zip(ids, ['1.25', '2.36'])]
|
||||
bad = [items[0], {**items[1], 'amount': 'NaN'}]
|
||||
assert client.post('/api/admin/alipay-refunds', json={'items': bad}).status_code == 400
|
||||
with main.SessionLocal() as db:
|
||||
assert db.scalar(main.select(main.AlipayRefundOrder).where(main.AlipayRefundOrder.target_id.in_(ids))) is None
|
||||
assert client.post('/api/admin/alipay-refunds', json={'items': items}).status_code == 200
|
||||
assert client.post('/api/admin/alipay-refunds', json={'items': items}).status_code == 409
|
||||
with main.SessionLocal() as db:
|
||||
assert [db.get(main.AnswerSubmission, target_id).status for target_id in ids] == ['已通过', '已通过']
|
||||
orders = db.scalars(main.select(main.AlipayRefundOrder).where(main.AlipayRefundOrder.target_id.in_(ids))).all()
|
||||
assert sorted(order.amount_fen for order in orders) == [125, 236]
|
||||
assert all(order.status == 'pending' for order in orders)
|
||||
listed = client.get('/api/admin/alipay-refunds').json()['data']
|
||||
assert any(row['target_id'] == ids[0] and row['submitted'] for row in listed)
|
||||
|
||||
|
||||
def test_refund_requires_login_and_task_access():
|
||||
with TestClient(main.app) as client:
|
||||
assert client.get('/api/admin/alipay-refunds').status_code == 401
|
||||
client.post('/api/admin/auth/login', json={'username': 'operator', 'password': 'operator123456'})
|
||||
with main.SessionLocal() as db:
|
||||
admin = db.scalar(main.select(main.AdminUser).where(main.AdminUser.username == 'admin'))
|
||||
user = main.AnswerUser(public_id=uuid4().hex[:10], phone=uuid4().hex[:20], auth_token=uuid4().hex)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
record = main.AnswerSubmission(submission_no=uuid4().hex, user_id=user.id, owner_id=admin.id, status='已通过')
|
||||
db.add(record)
|
||||
db.flush()
|
||||
target_id = record.id
|
||||
db.commit()
|
||||
response = client.post('/api/admin/alipay-refunds', json={'items': [{'target_kind': 'answer_submission', 'target_id': target_id, 'amount': '1.00', 'account': 'test@example.com', 'payee_name': '测试'}]})
|
||||
assert response.status_code == 404
|
||||
@ -66,6 +66,9 @@ def test_admin_usage_entry_is_deduplicated_and_visible_in_stats():
|
||||
assert stats.status_code == 200
|
||||
operator = next(item for item in stats.json()["data"]["users"] if item["username"] == "operator")
|
||||
assert operator["count"] >= 1
|
||||
department = next(item for item in stats.json()["data"]["departments"] if operator["department_name"] == item["name"])
|
||||
assert department["user_count"] >= 1
|
||||
assert any(member["username"] == "operator" for member in department["members"])
|
||||
|
||||
|
||||
def test_operator_cannot_access_super_admin_apis():
|
||||
@ -77,6 +80,167 @@ def test_operator_cannot_access_super_admin_apis():
|
||||
assert client.get("/api/admin/settings").status_code == 403
|
||||
|
||||
|
||||
def test_dingtalk_admission_allows_existing_users_and_two_business_divisions(monkeypatch):
|
||||
from fastapi import HTTPException, Response
|
||||
|
||||
monkeypatch.setattr(settings, "dingtalk_allowed_union_ids", set())
|
||||
monkeypatch.setattr(settings, "dingtalk_allowed_dept_ids", set())
|
||||
monkeypatch.setattr(settings, "dingtalk_allowed_dept_names", {"战狼事业部", "雪狼事业部"})
|
||||
monkeypatch.setattr(settings, "dingtalk_super_admin_union_ids", set())
|
||||
monkeypatch.setattr(settings, "dingtalk_super_admin_dept_ids", set())
|
||||
monkeypatch.setattr(settings, "dingtalk_super_admin_dept_names", {"AI指挥部"})
|
||||
suffix = uuid4().hex[:10]
|
||||
allowed_union = f"allowed_{suffix}"
|
||||
existing_union = f"existing_{suffix}"
|
||||
blocked_union = f"blocked_{suffix}"
|
||||
ai_union = f"ai_{suffix}"
|
||||
with TestClient(app), main_module.SessionLocal() as db:
|
||||
existing = main_module.AdminUser(
|
||||
username=f"dingtalk_{existing_union}",
|
||||
password_hash=main_module.hash_password("unused-password"),
|
||||
display_name="已有名单用户",
|
||||
role_code="operator",
|
||||
dingtalk_union_id=existing_union,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(existing)
|
||||
db.commit()
|
||||
|
||||
allowed = main_module._dingtalk_create_session({
|
||||
"unionId": allowed_union,
|
||||
"userid": f"user_{suffix}",
|
||||
"name": "目标部门主管",
|
||||
"_primary_dept_id": "child-101",
|
||||
"_dept_name": "战狼一组",
|
||||
"_dept_path": ",1,100,101,",
|
||||
"_dept_name_path": "深圳市蝶雅化妆品有限公司/战狼事业部/战狼一组",
|
||||
"_org_data_available": True,
|
||||
"_is_dept_manager": True,
|
||||
}, db, Response())
|
||||
assert allowed.role_code == "dept_manager"
|
||||
assert main_module._dingtalk_department_is_allowed("snow-child", "雪狼一组", None, "公司/雪狼事业部/雪狼一组")
|
||||
assert not main_module._dingtalk_department_is_allowed("outside", "其他事业部", None, "公司/其他事业部")
|
||||
|
||||
ai_admin = main_module._dingtalk_create_session({
|
||||
"unionId": ai_union,
|
||||
"userid": f"ai_user_{suffix}",
|
||||
"name": "AI指挥部成员",
|
||||
"_primary_dept_id": "ai-child-1",
|
||||
"_dept_name": "AI应用开发",
|
||||
"_dept_path": ",1,ai,ai-child-1,",
|
||||
"_dept_name_path": "深圳市蝶雅化妆品有限公司/中后台部门/AI指挥部/AI应用开发",
|
||||
"_org_data_available": True,
|
||||
"_is_dept_manager": False,
|
||||
}, db, Response())
|
||||
assert ai_admin.role_code == "super_admin"
|
||||
|
||||
continued = main_module._dingtalk_create_session({
|
||||
"unionId": existing_union,
|
||||
"name": "已有名单用户",
|
||||
"_primary_dept_id": "outside-1",
|
||||
"_dept_name": "其他事业部",
|
||||
"_dept_name_path": "深圳市蝶雅化妆品有限公司/其他事业部",
|
||||
"_org_data_available": True,
|
||||
}, db, Response())
|
||||
assert continued.id == existing.id
|
||||
existing.role_code = "super_admin"
|
||||
db.commit()
|
||||
preserved = main_module._dingtalk_create_session({
|
||||
"unionId": existing_union,
|
||||
"name": "已有管理员",
|
||||
"_org_data_available": True,
|
||||
"_is_dept_manager": True,
|
||||
}, db, Response())
|
||||
assert preserved.role_code == "super_admin"
|
||||
existing.is_active = False
|
||||
db.commit()
|
||||
try:
|
||||
main_module._dingtalk_create_session({"unionId": existing_union}, db, Response())
|
||||
assert False, "停用账号不能继续登录"
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 403
|
||||
|
||||
try:
|
||||
main_module._dingtalk_create_session({
|
||||
"unionId": blocked_union,
|
||||
"name": "名单外用户",
|
||||
"_primary_dept_id": "outside-2",
|
||||
"_dept_name": "其他事业部",
|
||||
"_dept_name_path": "深圳市蝶雅化妆品有限公司/其他事业部",
|
||||
"_org_data_available": True,
|
||||
}, db, Response())
|
||||
assert False, "名单外非目标部门用户应被拒绝"
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 403
|
||||
|
||||
created_usernames = [f"dingtalk_{allowed_union}", f"dingtalk_{existing_union}", f"dingtalk_{ai_union}"]
|
||||
created = db.scalars(main_module.select(main_module.AdminUser).where(main_module.AdminUser.username.in_(created_usernames))).all()
|
||||
for user in created:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_dingtalk_org_profile_prefers_ai_command_department(monkeypatch):
|
||||
monkeypatch.setattr(settings, "dingtalk_allowed_dept_ids", set())
|
||||
monkeypatch.setattr(settings, "dingtalk_allowed_dept_names", {"战狼事业部", "雪狼事业部"})
|
||||
monkeypatch.setattr(settings, "dingtalk_super_admin_dept_ids", {"200"})
|
||||
monkeypatch.setattr(settings, "dingtalk_super_admin_dept_names", {"AI指挥部"})
|
||||
|
||||
departments = {
|
||||
"1": {"name": "公司", "parent_id": 0},
|
||||
"100": {"name": "其他事业部", "parent_id": 1},
|
||||
"101": {"name": "其他小组", "parent_id": 100},
|
||||
"200": {"name": "AI指挥部", "parent_id": 1},
|
||||
"201": {"name": "AI应用开发", "parent_id": 200},
|
||||
}
|
||||
|
||||
def fake_oapi(path, access_token, payload):
|
||||
if path.endswith("/user/get"):
|
||||
return {"result": {
|
||||
"userid": "multi-dept-user",
|
||||
"dept_id_list": [101, 201],
|
||||
"leader_in_dept": [
|
||||
{"dept_id": 101, "leader": True},
|
||||
{"dept_id": 201, "leader": False},
|
||||
],
|
||||
}}
|
||||
return {"result": departments[str(payload["dept_id"])]}
|
||||
|
||||
monkeypatch.setattr(main_module, "_dingtalk_oapi_post", fake_oapi)
|
||||
profile = main_module._dingtalk_org_profile({"userid": "multi-dept-user"}, "token")
|
||||
assert profile["_primary_dept_id"] == "201"
|
||||
assert profile["_dept_name_path"].endswith("AI指挥部/AI应用开发")
|
||||
assert profile["_is_dept_manager"] is False
|
||||
|
||||
|
||||
def test_operator_and_department_manager_data_scope():
|
||||
suffix = uuid4().hex[:8]
|
||||
with TestClient(app), main_module.SessionLocal() as db:
|
||||
manager = main_module.AdminUser(
|
||||
username=f"manager_{suffix}", password_hash="unused", display_name="部门主管",
|
||||
role_code="dept_manager", dingtalk_dept_id="dept-100", dingtalk_dept_path=",root,dept-100,",
|
||||
)
|
||||
member = main_module.AdminUser(
|
||||
username=f"member_{suffix}", password_hash="unused", display_name="部门成员",
|
||||
role_code="operator", dingtalk_dept_id="dept-101", dingtalk_dept_path=",root,dept-100,dept-101,",
|
||||
)
|
||||
outsider = main_module.AdminUser(
|
||||
username=f"outsider_{suffix}", password_hash="unused", display_name="其他部门",
|
||||
role_code="operator", dingtalk_dept_id="dept-200", dingtalk_dept_path=",root,dept-200,",
|
||||
)
|
||||
db.add_all([manager, member, outsider])
|
||||
db.flush()
|
||||
member_task = main_module.AskTask(task_no=f"TASK-M-{suffix}", owner_id=member.id, product_link="", product_name="测试", status="草稿")
|
||||
outsider_task = main_module.AskTask(task_no=f"TASK-O-{suffix}", owner_id=outsider.id, product_link="", product_name="测试", status="草稿")
|
||||
db.add_all([member_task, outsider_task])
|
||||
db.flush()
|
||||
assert main_module._can_access_ask_task(member_task, member)
|
||||
assert not main_module._can_access_ask_task(outsider_task, member)
|
||||
assert main_module._can_access_ask_task(member_task, manager)
|
||||
assert not main_module._can_access_ask_task(outsider_task, manager)
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_register_and_super_admin_user_crud():
|
||||
username = f"tester_{uuid4().hex[:8]}"
|
||||
with TestClient(app) as client:
|
||||
|
||||
63
frontend/src/AlipayRefundPanel.jsx
Normal file
63
frontend/src/AlipayRefundPanel.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TaskDetail } from './TaskBoardPage.jsx';
|
||||
|
||||
export default function AlipayRefundPanel({ api, onClose, standalone = false }) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const key = row => `${row.target_kind}-${row.target_id}`;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try { const result = await api('/api/admin/alipay-refunds'); setRows(result.data); setSelected([]); }
|
||||
catch (error) { setMessage(error.message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
function edit(row, field, value) {
|
||||
setRows(current => current.map(item => key(item) === key(row) ? { ...item, [field]: value } : item));
|
||||
}
|
||||
async function submit(items) {
|
||||
if (busy || !items.length) return;
|
||||
if (items.some(item => !item.account.trim() || !item.payee_name.trim() || !/^\d+(\.\d{1,2})?$/.test(item.amount) || Number(item.amount) <= 0 || Number(item.amount) > 100000)) {
|
||||
setMessage('请逐笔填写支付宝账号、收款人姓名和金额(0至100000元,最多两位小数)'); return;
|
||||
}
|
||||
setBusy(true); setMessage('');
|
||||
try {
|
||||
const result = await api('/api/admin/alipay-refunds', { method: 'POST', body: JSON.stringify({ items }) });
|
||||
setMessage(`已提交 ${result.data.count} 笔,待支付。代付接口未接入,尚未实际付款。`);
|
||||
await load();
|
||||
} catch (error) { setMessage(error.message); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const available = rows.filter(row => !row.submitted);
|
||||
async function showDetail(row) {
|
||||
setDetailLoading(true); setDetail(null);
|
||||
try { const result = await api(row.detail_endpoint); setDetail(result.data); }
|
||||
catch (error) { setMessage(error.message); }
|
||||
finally { setDetailLoading(false); }
|
||||
}
|
||||
const content = <section className={`${standalone ? 'panel' : 'manual-modal'} alipay-refund-panel`} role="dialog" aria-modal={!standalone} aria-label="支付宝返款">
|
||||
<div className="manual-modal-header"><h3>支付宝返款</h3>{onClose && <button disabled={busy} onClick={onClose}>×</button>}</div>
|
||||
<p>待返款任务按每次提交分别列出。可单笔提交,或勾选后批量提交,每笔金额可不同。</p>
|
||||
<div className="review-tip">提交后保存为“待支付”。代付接口暂未接入,不会实际转账,也不会将任务标记为已返款。</div>
|
||||
{message && <div role="status" className="form-error">{message}</div>}
|
||||
{loading ? <p>正在读取待返款任务…</p> : <div className="alipay-refund-scroll"><table className="data-table"><thead><tr>
|
||||
<th><input type="checkbox" disabled={busy || !available.length} checked={available.length > 0 && available.every(row => selected.includes(key(row)))} onChange={event => setSelected(event.target.checked ? available.map(key) : [])} aria-label="全选待提交返款" /></th>
|
||||
<th>任务 / 提交记录</th><th>支付宝账号</th><th>收款人姓名</th><th>金额(元)</th><th>支付状态</th><th>操作</th>
|
||||
</tr></thead><tbody>{rows.map(row => <tr key={key(row)}>
|
||||
<td><input type="checkbox" disabled={busy || row.submitted} checked={selected.includes(key(row))} onChange={event => setSelected(current => event.target.checked ? [...current, key(row)] : current.filter(id => id !== key(row)))} /></td>
|
||||
<td><strong>{row.task_no}</strong><small>{row.taobao_id || '未填写淘宝ID'} · {row.target_kind === 'answer_submission' ? '回答' : '提问'}提交 #{row.target_id}</small><small>{row.product_name || '待识别商品'} · {row.shop_name || '未填写店铺'}</small></td>
|
||||
<td><input aria-label="支付宝账号" maxLength={128} disabled={busy || row.submitted} value={row.account} onChange={event => edit(row, 'account', event.target.value)} /></td>
|
||||
<td><input aria-label="收款人姓名" maxLength={64} disabled={busy || row.submitted} value={row.payee_name} onChange={event => edit(row, 'payee_name', event.target.value)} /></td>
|
||||
<td><input aria-label="返款金额" type="number" min="0.01" max="100000" step="0.01" disabled={busy || row.submitted} value={row.amount} onChange={event => edit(row, 'amount', event.target.value)} /></td>
|
||||
<td>{row.payment_status}</td><td><button type="button" className="ghost-button" onClick={() => showDetail(row)}>查看详情</button> <button className="primary-button" disabled={busy || row.submitted} onClick={() => submit([row])}>单笔提交</button></td>
|
||||
</tr>)}{!rows.length && <tr><td colSpan={7}>暂无待返款任务</td></tr>}</tbody></table></div>}
|
||||
<div className="manual-modal-actions">{onClose && <button disabled={busy} onClick={onClose}>关闭</button>}<button className="primary-button" disabled={busy || !selected.length} onClick={() => submit(rows.filter(row => selected.includes(key(row))))}>{busy ? '提交中…' : `批量提交(${selected.length}笔)`}</button></div>
|
||||
{detail && <TaskDetail task={detail} close={() => setDetail(null)} readOnly hasNextRefund={false} nextBusy={false} />}
|
||||
</section>;
|
||||
return standalone ? content : <div className="manual-modal-backdrop">{content}</div>;
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ClipboardCheck, Clock3, Copy, Eye, FileCheck2, History, ImagePlus, LogOut, MessageCircle, ReceiptText, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from './taobaoId.js'
|
||||
import { formatDateTime } from './time.js'
|
||||
|
||||
const SESSION_KEY = 'answer_user_taobao_session_v2'
|
||||
const LEGACY_SESSION_KEYS = ['answer_user_taobao_session', 'answer_user_session']
|
||||
const STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
||||
const accountName = session => session?.taobao_id || session?.account || session?.user_id || ''
|
||||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||||
const formatTime = formatDateTime
|
||||
const fileKey = file => `${file.name}-${file.size}-${file.lastModified}`
|
||||
|
||||
function Top({ session, latest, logout }) {
|
||||
@ -71,6 +72,7 @@ function AssignmentStage({ api, session, latest, reload, setError, linkToken })
|
||||
const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}`
|
||||
const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } })
|
||||
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false)
|
||||
const [alipayAccount, setAlipayAccount] = useState(''); const [payeeName, setPayeeName] = useState('')
|
||||
const completed = selected.filter(question => done.includes(question.id)).length
|
||||
useEffect(() => {
|
||||
if (latest.status !== '未通过' || !latest.latest_review) return
|
||||
@ -82,10 +84,12 @@ function AssignmentStage({ api, session, latest, reload, setError, linkToken })
|
||||
async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } }
|
||||
async function complete(event) {
|
||||
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交')
|
||||
if (!alipayAccount.trim() || !payeeName.trim()) return setError('请填写支付宝账号和收款人姓名')
|
||||
const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true)
|
||||
body.append('alipay_account', alipayAccount.trim()); body.append('payee_name', payeeName.trim())
|
||||
try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传(截图与收款码)</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small>{question.operator_images?.length > 0 && <div className="answer-question-operator-image"><span>运营指定图片(请在回答本题时添加)</span><div>{question.operator_images.map((image, imageIndex) => <img key={image.id || image.data_url || image} src={image.data_url || image.url || image} alt={`运营指定回答图片${imageIndex + 1}`}/>)}</div></div>}<div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><p>请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
|
||||
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传(截图与收款码)</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small>{question.operator_images?.length > 0 && <div className="answer-question-operator-image"><span>运营指定图片(请在回答本题时添加)</span><div>{question.operator_images.map((image, imageIndex) => <img key={image.id || image.data_url || image} src={image.data_url || image.url || image} alt={`运营指定回答图片${imageIndex + 1}`}/>)}</div></div>}<div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><label>支付宝账号(必填)<input required maxLength={128} value={alipayAccount} onChange={event => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" /></label><label>收款人姓名(必填)<input required maxLength={64} value={payeeName} onChange={event => setPayeeName(event.target.value)} placeholder="请输入支付宝实名姓名" /></label><p>请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
|
||||
}
|
||||
|
||||
function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from "./taobaoId.js";
|
||||
import { formatDateTime } from "./time.js";
|
||||
|
||||
const SESSION_KEY = "ask_user_taobao_session_v2";
|
||||
const LEGACY_SESSION_KEYS = ["ask_user_taobao_session", "ask_user_session"];
|
||||
@ -430,6 +431,7 @@ function ConfirmStage({
|
||||
const [files, setFiles] = useState([]);
|
||||
const [paymentFile, setPaymentFile] = useState(null);
|
||||
const [payeeName, setPayeeName] = useState("");
|
||||
const [alipayAccount, setAlipayAccount] = useState("");
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
@ -474,6 +476,7 @@ function ConfirmStage({
|
||||
files.forEach((file) => body.append("files", file));
|
||||
body.append("payment_file", paymentFile);
|
||||
body.append("payee_name", payeeName);
|
||||
body.append("alipay_account", alipayAccount.trim());
|
||||
try {
|
||||
const result = await api(
|
||||
`/api/ask/tasks/${encodeURIComponent(taskNo)}/submission`,
|
||||
@ -635,7 +638,11 @@ function ConfirmStage({
|
||||
onChange={(event) => setPaymentFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<label>
|
||||
收款人真实姓名(选填,用于核验一致性)
|
||||
支付宝账号(必填)
|
||||
<input value={alipayAccount} maxLength={128} onChange={(event) => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" />
|
||||
</label>
|
||||
<label>
|
||||
收款人真实姓名(必填)
|
||||
<input
|
||||
value={payeeName}
|
||||
onChange={(event) => setPayeeName(event.target.value)}
|
||||
@ -645,7 +652,7 @@ function ConfirmStage({
|
||||
</section>
|
||||
<button
|
||||
className="ask-submit-review"
|
||||
disabled={!files.length || !paymentFile}
|
||||
disabled={!files.length || !paymentFile || !alipayAccount.trim() || !payeeName.trim()}
|
||||
onClick={() => setReviewOpen(true)}
|
||||
>
|
||||
下一步:核对并提交审核
|
||||
@ -758,9 +765,7 @@ function AuditStatus({ assignment, reload, busy, onBackHome }) {
|
||||
</dd>
|
||||
<dt>提交时间:</dt>
|
||||
<dd>
|
||||
{new Date(assignment.updated_at).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
})}
|
||||
{formatDateTime(assignment.updated_at)}
|
||||
</dd>
|
||||
<dt>锁定状态:</dt>
|
||||
<dd>{pending ? "已锁定不可撤销" : assignment.status}</dd>
|
||||
@ -852,9 +857,7 @@ function TaskStatusPage({ history, onView }) {
|
||||
认领问题{" "}
|
||||
{item.questions.filter((question) => question.selected).length} 条
|
||||
· 更新时间{" "}
|
||||
{new Date(item.updated_at).toLocaleTimeString("zh-CN", {
|
||||
hour12: false,
|
||||
})}
|
||||
{formatDateTime(item.updated_at)}
|
||||
</p>
|
||||
<footer>
|
||||
<span>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronRight, LoaderCircle, MessageCircleMore, RefreshCw, X } from "lucide-react";
|
||||
import { formatDateTime } from "./time.js";
|
||||
|
||||
const REASONS = [
|
||||
"内容不清晰",
|
||||
@ -10,8 +11,7 @@ const REASONS = [
|
||||
"其他",
|
||||
];
|
||||
const STATUSES = ["全部", "待审核"];
|
||||
const formatTime = (value) =>
|
||||
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||||
const formatTime = formatDateTime;
|
||||
const productName = (record) =>
|
||||
[
|
||||
...new Set(
|
||||
@ -22,6 +22,22 @@ const StatusBadge = ({ status }) => (
|
||||
<em className={`review-v2-status ${status}`}>{status}</em>
|
||||
);
|
||||
|
||||
function RefundRecipientInfo({ record }) {
|
||||
return (
|
||||
<div className="refund-recipient-info">
|
||||
<strong>支付宝收款信息</strong>
|
||||
<span>
|
||||
支付宝账号
|
||||
<b>{record.alipay_account || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
<span>
|
||||
收款人姓名
|
||||
<b>{record.payee_name || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const reviewKind = (record) => record?.batch_id ? "batch" : "assignment";
|
||||
const reviewId = (record) => record?.batch_id || record?.assignment_id || record?.id;
|
||||
const reviewKey = (record) => `${reviewKind(record)}:${reviewId(record)}`;
|
||||
@ -108,6 +124,7 @@ function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }
|
||||
<span>商品:{productName(record)}</span>
|
||||
<span>提交时间:{formatTime(record.updated_at)}</span>
|
||||
</div>
|
||||
<RefundRecipientInfo record={record} />
|
||||
<h3>① 问答记录(发布问题 ↔ 用户回答)</h3>
|
||||
<div className="review-v2-qa">
|
||||
{selected.map((question, index) => (
|
||||
@ -287,6 +304,7 @@ function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, next
|
||||
<span>创建人:{record.creator_name || "运营后台"}</span>
|
||||
<span>提交时间:{formatTime(record.updated_at)}</span>
|
||||
</div>
|
||||
<RefundRecipientInfo record={record} />
|
||||
<h3>① 用户认领并完成的提问</h3>
|
||||
<div className="review-v2-qa">
|
||||
{selected.map((item, index) => (
|
||||
|
||||
@ -48,7 +48,7 @@ function TaskDetailLoading() {
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload, hasNextRefund, onNextRefund, nextBusy }) {
|
||||
export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) {
|
||||
const [refundOpen, setRefundOpen] = useState(false);
|
||||
const [refundBusy, setRefundBusy] = useState(false);
|
||||
const [refundFile, setRefundFile] = useState(null);
|
||||
@ -253,6 +253,17 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
||||
创建时间<b>{formatTime(task.created_at)}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="refund-recipient-info task-detail-recipient-info">
|
||||
<strong>支付宝收款信息</strong>
|
||||
<span>
|
||||
支付宝账号
|
||||
<b>{task.alipay_account || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
<span>
|
||||
收款人姓名
|
||||
<b>{task.payee_name || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
</div>
|
||||
<h3>认领用户的问题与回答(共 {task.assigned_users.length} 位)</h3>
|
||||
<div className="task-detail-table">
|
||||
<div className="head">
|
||||
@ -361,7 +372,7 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
{task.refund_endpoint && (
|
||||
{!readOnly && task.refund_endpoint && (
|
||||
<button
|
||||
type="button"
|
||||
className="confirm-refund"
|
||||
|
||||
@ -623,6 +623,30 @@
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.alipay-refund-panel {
|
||||
width: min(1200px, 95vw) !important;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.alipay-refund-panel.panel {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
padding: 22px;
|
||||
}
|
||||
.alipay-refund-scroll { overflow: auto; min-height: 100px; }
|
||||
.alipay-refund-scroll table { min-width: 1000px; }
|
||||
.alipay-refund-scroll th,
|
||||
.alipay-refund-scroll td { vertical-align: middle; }
|
||||
.alipay-refund-scroll td:first-child { width: 42px; }
|
||||
.alipay-refund-scroll td:nth-child(2) { min-width: 300px; }
|
||||
.alipay-refund-scroll td:nth-child(3),
|
||||
.alipay-refund-scroll td:nth-child(4) { min-width: 165px; }
|
||||
.alipay-refund-scroll td:nth-child(5) { min-width: 120px; }
|
||||
.alipay-refund-scroll td:last-child { min-width: 170px; white-space: nowrap; }
|
||||
.alipay-refund-scroll input:not([type="checkbox"]) { width: 100%; min-width: 100px; padding: 8px; border: 1px solid #cbdaf0; border-radius: 6px; }
|
||||
.alipay-refund-scroll small { display: block; color: #7187a3; }
|
||||
.usage-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@ -700,6 +724,58 @@
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
.usage-department-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.usage-department {
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9e5f3;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
.usage-department-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 13px 14px;
|
||||
border: 0;
|
||||
color: #173b66;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.usage-department-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.usage-department-name svg {
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
.usage-department.expanded .usage-department-name svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.usage-department-meta {
|
||||
color: #7187a3;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.usage-department-members {
|
||||
padding: 0 14px 4px;
|
||||
border-top: 1px solid #d9e5f3;
|
||||
background: #fff;
|
||||
}
|
||||
.usage-member-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0 4px;
|
||||
color: #8b9db3;
|
||||
font-size: 12px;
|
||||
}
|
||||
.usage-user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -732,6 +808,11 @@
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.usage-department-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.usage-chart {
|
||||
gap: 2px;
|
||||
}
|
||||
@ -2773,6 +2854,9 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-detail-recipient-info {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.task-detail-modal > h3 {
|
||||
margin: 20px 0 10px;
|
||||
font-size: 13px;
|
||||
@ -4484,6 +4568,22 @@
|
||||
background: #e8f1ff;
|
||||
color: #1764e8;
|
||||
}
|
||||
.refund-recipient-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #cfe0f7;
|
||||
border-radius: 9px;
|
||||
background: #f5f9ff;
|
||||
color: #607998;
|
||||
font-size: 11px;
|
||||
}
|
||||
.refund-recipient-info strong { color: #24558f; font-size: 12px; }
|
||||
.refund-recipient-info span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.refund-recipient-info b { color: #173258; font-weight: 700; }
|
||||
.review-v2-modal > h3,
|
||||
.review-v2-media h3 {
|
||||
margin: 18px 0 9px;
|
||||
@ -5040,6 +5140,24 @@
|
||||
color: #748aa3;
|
||||
font-size: 11px;
|
||||
}
|
||||
.qa-library-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding: 12px 4px;
|
||||
color: #7187a3;
|
||||
font-size: 12px;
|
||||
}
|
||||
.qa-library-pagination > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.qa-library-pagination button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .5;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.qa-library-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
@ -5051,6 +5169,10 @@
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.qa-library-pagination {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.review-v2-modal > header h2 {
|
||||
display: flex;
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
ClipboardList,
|
||||
Database,
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
@ -21,11 +22,13 @@ import CreateAnswerTasksPageV2 from "./CreateAnswerTasksPage.jsx";
|
||||
import AnswerUserPortalV2 from "./AnswerUserPortal.jsx";
|
||||
import TaskBoardPage, { clearTaskBoardCache } from "./TaskBoardPage.jsx";
|
||||
import ReviewCenterPage from "./ReviewCenterPage.jsx";
|
||||
import AlipayRefundPanel from "./AlipayRefundPanel.jsx";
|
||||
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
|
||||
import AskUserPortal from "./AskUserPortal.jsx";
|
||||
import RefundUploadPage from "./RefundUploadPage.jsx";
|
||||
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
|
||||
import { readPageCache, writePageCache } from "./pageCache.js";
|
||||
import { formatDateTime } from "./time.js";
|
||||
|
||||
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
|
||||
if (window.location.pathname === "/answer")
|
||||
@ -328,6 +331,12 @@ const BASE_ITEMS = [
|
||||
label: "\u5ba1\u6838\u4e2d\u5fc3",
|
||||
group: "\u4efb\u52a1\u7ba1\u7406",
|
||||
},
|
||||
{
|
||||
id: "refunds",
|
||||
icon: FileText,
|
||||
label: "支付宝返款",
|
||||
group: "任务管理",
|
||||
},
|
||||
{
|
||||
id: "guide",
|
||||
icon: BookOpen,
|
||||
@ -1515,6 +1524,13 @@ function QALibraryPage({ selectionMode = false }) {
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
const pageCount = Math.max(1, Math.ceil(records.length / pageSize));
|
||||
const visibleRecords = records.slice((page - 1) * pageSize, page * pageSize);
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount));
|
||||
}, [pageCount]);
|
||||
async function load(keyword = search) {
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -1522,6 +1538,7 @@ function QALibraryPage({ selectionMode = false }) {
|
||||
`/api/admin/qa-library?search=${encodeURIComponent(keyword.trim())}`,
|
||||
);
|
||||
setRecords(result.data);
|
||||
setPage(1);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@ -1571,7 +1588,7 @@ function QALibraryPage({ selectionMode = false }) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="qa-library-selection-actions">
|
||||
{selectionMode && <label className="qa-library-select-all"><input type="checkbox" checked={records.length > 0 && records.every((item) => selectedIds.includes(item.id))} onChange={(event) => setSelectedIds(event.target.checked ? records.map((item) => item.id) : [])} /> 全选当前搜索结果</label>}
|
||||
{selectionMode && <label className="qa-library-select-all"><input type="checkbox" checked={visibleRecords.length > 0 && visibleRecords.every((item) => selectedIds.includes(item.id))} onChange={(event) => setSelectedIds(event.target.checked ? [...new Set([...selectedIds, ...visibleRecords.map((item) => item.id)])] : selectedIds.filter((id) => !visibleRecords.some((item) => item.id === id)))} /> 全选当前页</label>}
|
||||
<span>共 {records.length} 条问答</span>
|
||||
{selectionMode && <button type="button" className="ghost-button" onClick={() => window.dispatchEvent(new CustomEvent("switch-create-ask"))}>返回创建任务</button>}
|
||||
{selectionMode && <button type="button" className="primary-button" disabled={!selectedIds.length} onClick={() => window.dispatchEvent(new CustomEvent("qa-library-selected", { detail: records.filter((item) => selectedIds.includes(item.id)) }))}>添加已选并返回({selectedIds.length})</button>}
|
||||
@ -1586,7 +1603,7 @@ function QALibraryPage({ selectionMode = false }) {
|
||||
暂无已采纳问答,请先在问答助手中采纳内容。
|
||||
</div>
|
||||
) : (
|
||||
records.map((record) => (
|
||||
visibleRecords.map((record) => (
|
||||
<article className={`qa-library-card panel${selectionMode ? " selectable" : ""}`} key={record.id}>
|
||||
<header>
|
||||
<div>
|
||||
@ -1597,9 +1614,7 @@ function QALibraryPage({ selectionMode = false }) {
|
||||
<div className="qa-library-card-tools">
|
||||
<small>
|
||||
{record.source} · {record.created_by} ·{" "}
|
||||
{new Date(record.created_at).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
})}
|
||||
{formatDateTime(record.created_at)}
|
||||
</small>
|
||||
<button onClick={() => setEditing(record)}>编辑</button>
|
||||
<button className="danger-button" onClick={() => setDeleting(record)}>删除</button>
|
||||
@ -1626,6 +1641,15 @@ function QALibraryPage({ selectionMode = false }) {
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
{!loading && records.length > 0 && (
|
||||
<div className="qa-library-pagination">
|
||||
<span>第 {page} / {pageCount} 页,共 {records.length} 条</span>
|
||||
<div>
|
||||
<button type="button" className="ghost-button" disabled={page <= 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>上一页</button>
|
||||
<button type="button" className="primary-button" disabled={page >= pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<QALibraryEditModal
|
||||
record={editing}
|
||||
@ -2215,7 +2239,8 @@ function AssistantPageFinal() {
|
||||
);
|
||||
}
|
||||
|
||||
function UserTable({ users, onChanged }) {
|
||||
function UserTable({ users = [], onChanged }) {
|
||||
const rows = Array.isArray(users) ? users : [];
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
username: "",
|
||||
@ -2343,6 +2368,7 @@ function UserTable({ users, onChanged }) {
|
||||
}
|
||||
>
|
||||
<option value="operator">{"\u666e\u901a\u8fd0\u8425"}</option>
|
||||
<option value="dept_manager">部门主管</option>
|
||||
<option value="super_admin">
|
||||
{"\u8d85\u7ea7\u7ba1\u7406\u5458"}
|
||||
</option>
|
||||
@ -2385,7 +2411,7 @@ function UserTable({ users, onChanged }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
{rows.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.display_name}</td>
|
||||
@ -2403,7 +2429,7 @@ function UserTable({ users, onChanged }) {
|
||||
</td>
|
||||
<td>
|
||||
{user.last_login_at
|
||||
? new Date(user.last_login_at).toLocaleString()
|
||||
? formatDateTime(user.last_login_at)
|
||||
: "\u672a\u767b\u5f55"}
|
||||
</td>
|
||||
<td>
|
||||
@ -2424,7 +2450,8 @@ function UserTable({ users, onChanged }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
function LogTable({ logs }) {
|
||||
function LogTable({ logs = [] }) {
|
||||
const rows = Array.isArray(logs) ? logs : [];
|
||||
return (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
@ -2436,7 +2463,9 @@ function LogTable({ logs }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, index) => (
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan="4"><div className="empty-state">暂无登录日志</div></td></tr>
|
||||
) : rows.map((log, index) => (
|
||||
<tr key={`${log.created_at}-${index}`}>
|
||||
<td>{log.username}</td>
|
||||
<td>
|
||||
@ -2447,7 +2476,7 @@ function LogTable({ logs }) {
|
||||
</span>
|
||||
</td>
|
||||
<td>{log.ip_address || "-"}</td>
|
||||
<td>{new Date(log.created_at).toLocaleString()}</td>
|
||||
<td>{formatDateTime(log.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -2585,13 +2614,19 @@ function ReviewPanel({ records, onRefresh }) {
|
||||
);
|
||||
}
|
||||
function UsagePanel({ data }) {
|
||||
const departments = Array.isArray(data.departments) ? data.departments : [];
|
||||
const [expanded, setExpanded] = useState(departments.length ? [departments[0].name] : []);
|
||||
const maxCount = Math.max(1, ...(data.trend || []).map((item) => item.count || 0));
|
||||
function toggleDepartment(name) {
|
||||
setExpanded((current) => current.includes(name) ? current.filter((item) => item !== name) : [...current, name]);
|
||||
}
|
||||
return (
|
||||
<div className="usage-statistics">
|
||||
<div className="usage-summary-grid">
|
||||
<div><span>近30天使用次数</span><strong>{data.total_count || 0}</strong></div>
|
||||
<div><span>今日使用次数</span><strong>{data.today_count || 0}</strong></div>
|
||||
<div><span>今日使用人数</span><strong>{data.today_users || 0}</strong></div>
|
||||
<div><span>覆盖部门</span><strong>{data.department_count || departments.length}</strong></div>
|
||||
</div>
|
||||
<section className="usage-chart-section">
|
||||
<div className="usage-section-heading"><h3>近30天使用趋势</h3><span>每次进入后台计 1 次(10 分钟内不重复)</span></div>
|
||||
@ -2605,15 +2640,31 @@ function UsagePanel({ data }) {
|
||||
</div>
|
||||
</section>
|
||||
<section className="usage-users-section">
|
||||
<div className="usage-section-heading"><h3>使用人员</h3><span>按近30天使用次数排序</span></div>
|
||||
{(data.users || []).length ? (
|
||||
<div className="usage-user-list">
|
||||
{data.users.map((item) => (
|
||||
<div className="usage-user-row" key={`${item.user_id}-${item.username}`}>
|
||||
<div><strong>{item.display_name}</strong><span>{item.department_name}</span></div>
|
||||
<b>{item.count} 次</b>
|
||||
</div>
|
||||
))}
|
||||
<div className="usage-section-heading"><h3>按部门统计</h3><span>点击部门查看成员明细</span></div>
|
||||
{departments.length ? (
|
||||
<div className="usage-department-list">
|
||||
{departments.map((department) => {
|
||||
const isExpanded = expanded.includes(department.name);
|
||||
return (
|
||||
<div className={`usage-department${isExpanded ? " expanded" : ""}`} key={department.name}>
|
||||
<button className="usage-department-header" onClick={() => toggleDepartment(department.name)}>
|
||||
<span className="usage-department-name"><ChevronDown size={15} />{department.name}</span>
|
||||
<span className="usage-department-meta">使用 {department.count} 次 {department.user_count} 人 最近 {formatDateTime(department.latest_at)}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="usage-department-members">
|
||||
<div className="usage-member-heading"><span>成员</span><span>使用次数</span></div>
|
||||
{department.members.map((member) => (
|
||||
<div className="usage-user-row" key={`${department.name}-${member.user_id}-${member.username}`}>
|
||||
<div><strong>{member.display_name}</strong><span>{member.username}</span></div>
|
||||
<b>{member.count} 次</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <div className="empty-state">暂无使用记录</div>}
|
||||
</section>
|
||||
@ -2659,10 +2710,14 @@ function AdminPage({ type }) {
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
setError("");
|
||||
if (!config) return undefined;
|
||||
api(config.endpoint)
|
||||
.then((result) => setData(result.data))
|
||||
.catch((err) => setError(err.message));
|
||||
}, [type, reload]);
|
||||
if (!config) {
|
||||
return <div className="form-error">页面配置不存在,请刷新后重试</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeading title={config.title} description={config.description} />
|
||||
@ -2675,7 +2730,7 @@ function AdminPage({ type }) {
|
||||
<UsagePanel data={data} />
|
||||
) : type === "users" ? (
|
||||
<UserTable
|
||||
users={data}
|
||||
users={Array.isArray(data) ? data : []}
|
||||
onChanged={() => setReload((value) => value + 1)}
|
||||
/>
|
||||
) : type === "logs" ? (
|
||||
@ -2996,7 +3051,7 @@ function SubmissionStatusDynamic({
|
||||
<div className="answer-status-meta">
|
||||
<div>
|
||||
<span>{passed || rejected ? "处理时间" : "提交时间"}</span>
|
||||
<b>{new Date(record.updated_at).toLocaleString()}</b>
|
||||
<b>{formatDateTime(record.updated_at)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前状态</span>
|
||||
@ -3283,7 +3338,7 @@ function AnswerUserPortal() {
|
||||
{records.map((item) => (
|
||||
<p key={item.id}>
|
||||
{item.submission_no} · {item.status} ·{" "}
|
||||
{new Date(item.created_at).toLocaleString()}
|
||||
{formatDateTime(item.created_at)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@ -3658,7 +3713,7 @@ function SubmissionStatus({ record, history, onRefresh, onNew, onHistory }) {
|
||||
<div className="answer-status-meta">
|
||||
<div>
|
||||
<span>提交时间</span>
|
||||
<b>{new Date(record.updated_at).toLocaleString()}</b>
|
||||
<b>{formatDateTime(record.updated_at)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>预计审核耗时</span>
|
||||
@ -3716,7 +3771,7 @@ function SubmissionHistory({ records, onBack }) {
|
||||
<span className="status-pill">{record.status}</span>
|
||||
</div>
|
||||
<p>
|
||||
{new Date(record.updated_at).toLocaleString()} ·{" "}
|
||||
{formatDateTime(record.updated_at)} ·{" "}
|
||||
{record.invited_screenshots.length} 张截图
|
||||
</p>
|
||||
<div className="answer-history-thumbs">
|
||||
@ -4372,6 +4427,8 @@ function Dashboard({ user, onLogout }) {
|
||||
<TaskBoardPage api={api} user={user} />
|
||||
) : active === "review" ? (
|
||||
<ReviewCenterPage api={api} />
|
||||
) : active === "refunds" ? (
|
||||
<AlipayRefundPanel api={api} standalone />
|
||||
) : active === "library" ? (
|
||||
<QALibraryPage selectionMode={Boolean(window.__qaLibrarySelectionMode)} />
|
||||
) : isAdminPage ? (
|
||||
|
||||
13
frontend/src/time.js
Normal file
13
frontend/src/time.js
Normal file
@ -0,0 +1,13 @@
|
||||
// Backend datetime values are stored as UTC. Older records omit the timezone suffix.
|
||||
export function formatDateTime(value, options = {}) {
|
||||
if (!value) return "--";
|
||||
const raw = String(value);
|
||||
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw) ? raw : `${raw}Z`;
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user