修改权限隔离,优化bug
This commit is contained in:
parent
598a2cbc29
commit
0b79c6812c
@ -23,5 +23,13 @@ def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
# A request may exit through an exception or an early return before a
|
||||
# route explicitly commits. Roll back first so any checked-out
|
||||
# connection is returned to QueuePool in a clean state, then close the
|
||||
# Session itself. Session.close() also rolls back, but doing it here
|
||||
# makes the lifecycle guarantee explicit and observable in tests.
|
||||
try:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,14 @@ class AdminUser(Base):
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
display_name: Mapped[str] = mapped_column(String(64))
|
||||
role_code: Mapped[str] = mapped_column(String(32), default="operator", index=True)
|
||||
dingtalk_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
dingtalk_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
dingtalk_corp_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
dingtalk_dept_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
dingtalk_dept_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
dingtalk_dept_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
dingtalk_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
is_dept_manager: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
@ -73,12 +81,14 @@ class AnswerSubmission(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True)
|
||||
owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
|
||||
# 用户上传受邀截图后立即进入运营筛选队列。
|
||||
status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user: Mapped[AnswerUser] = relationship(back_populates="submissions", lazy="selectin")
|
||||
owner: Mapped[AdminUser | None] = relationship()
|
||||
questions: Mapped[list["AnswerRecognizedQuestion"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin")
|
||||
screenshots: Mapped[list["AnswerScreenshot"]] = relationship(back_populates="submission", cascade="all, delete-orphan")
|
||||
review_decisions: Mapped[list["AnswerReviewDecision"]] = relationship(back_populates="submission", cascade="all, delete-orphan", lazy="selectin")
|
||||
@ -137,6 +147,7 @@ class AskTask(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||
public_token: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
|
||||
owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
|
||||
product_link: Mapped[str] = mapped_column(String(1000))
|
||||
product_name: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
@ -16,13 +16,13 @@ class AdminUserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
display_name: str = Field(min_length=1, max_length=64)
|
||||
role_code: str = Field(default="operator", pattern=r"^(super_admin|operator)$")
|
||||
role_code: str = Field(default="operator", pattern=r"^(super_admin|dept_manager|operator)$")
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
role_code: str | None = Field(default=None, pattern=r"^(super_admin|operator)$")
|
||||
role_code: str | None = Field(default=None, pattern=r"^(super_admin|dept_manager|operator)$")
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
|
||||
BIN
backend/data/pool_audit_temp.db
Normal file
BIN
backend/data/pool_audit_temp.db
Normal file
Binary file not shown.
@ -34,3 +34,4 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
|
||||
|
||||
-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。
|
||||
-- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。
|
||||
-- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。
|
||||
|
||||
@ -170,13 +170,19 @@ def test_ask_task_publish_appears_on_task_board():
|
||||
generated = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
|
||||
assert generated.status_code == 200
|
||||
task = generated.json()["data"]
|
||||
assert task["public_token"] is None
|
||||
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
|
||||
assert published.status_code == 200
|
||||
public_token = published.json()["data"]["public_token"]
|
||||
assert public_token
|
||||
assert public_token != task["task_id"]
|
||||
assert len(public_token) >= 24
|
||||
board = client.get("/api/admin/task-board").json()["data"]
|
||||
created = next(item for item in board if item["task_id"] == task["task_id"])
|
||||
assert created["task_type"] == "ask"
|
||||
assert created["task_type_label"] == "提问任务"
|
||||
assert created["qa_count"] == 3
|
||||
assert created["public_token"] == public_token
|
||||
|
||||
|
||||
def test_ask_task_generation_honors_question_count():
|
||||
|
||||
BIN
data/local_login_test.db
Normal file
BIN
data/local_login_test.db
Normal file
Binary file not shown.
BIN
data/local_test.db
Normal file
BIN
data/local_test.db
Normal file
Binary file not shown.
@ -85,6 +85,7 @@ function AssignmentStage({ api, session, latest, reload, setError }) {
|
||||
|
||||
function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
||||
const paid = latest.status === '已完成'
|
||||
const refundProofs = latest.refund_screenshots || []
|
||||
const passed = ['已通过', '已完成'].includes(latest.status)
|
||||
const rejected = latest.status === '未通过'
|
||||
const cancelled = latest.status === '已撤销'
|
||||
@ -110,6 +111,7 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
||||
<section className={`answer-v2-card status-card ${passed ? 'passed' : cancelled ? 'rejected' : ''}`}>
|
||||
<div className="status-title"><span>{passed ? <Check size={25}/> : cancelled ? '!' : <Clock3 size={24}/>}</span><div><h1>{paid ? '已返款' : passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} <em>{STATUS_TEXT[latest.status]}</em></h1><p>{paid ? '运营已完成本次任务返款,请留意您的收款账户。' : passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}</p></div></div>
|
||||
{passed && <div className={`answer-v2-payment-wait ${paid ? 'paid' : ''}`}><strong>{paid ? '返款已完成' : '审核已完成,等待打款'}</strong><span>{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}</span></div>}
|
||||
{paid && refundProofs.length > 0 && <section className="answer-v2-card material-card refund-proof-user"><header><h2>返款凭证</h2><span>运营已上传</span></header><div>{refundProofs.map((image, index) => <figure key={image.id || image.data_url}><img src={image.data_url || image.url} alt={`返款凭证${index + 1}`} /><figcaption>返款支付截图</figcaption></figure>)}</div></section>}
|
||||
<footer><small>提交编号:{latest.submission_no}</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer>
|
||||
</section>
|
||||
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>}
|
||||
@ -155,6 +157,7 @@ export default function AnswerUserPortalV2({ api }) {
|
||||
invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots,
|
||||
completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_screenshots,
|
||||
payment_screenshots: record.payment_screenshots?.length ? record.payment_screenshots : previous.payment_screenshots,
|
||||
refund_screenshots: record.refund_screenshots?.length ? record.refund_screenshots : previous.refund_screenshots,
|
||||
} : record
|
||||
}))
|
||||
setOptimisticStatuses(current => {
|
||||
@ -195,6 +198,7 @@ export default function AnswerUserPortalV2({ api }) {
|
||||
invited_screenshots: submitted.invited_screenshots?.length ? submitted.invited_screenshots : record.invited_screenshots,
|
||||
completion_screenshots: submitted.completion_screenshots || [],
|
||||
payment_screenshots: submitted.payment_screenshots || [],
|
||||
refund_screenshots: submitted.refund_screenshots || [],
|
||||
} : record))
|
||||
}
|
||||
const visibleLatest = newTaskMode ? null : latest
|
||||
|
||||
@ -3,7 +3,6 @@ import {
|
||||
Check,
|
||||
ClipboardCheck,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
ImagePlus,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
@ -15,6 +14,7 @@ import {
|
||||
|
||||
const SESSION_KEY = "ask_user_session";
|
||||
const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
|
||||
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
|
||||
const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`;
|
||||
const maskPhone = (phone = "") =>
|
||||
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
|
||||
@ -24,6 +24,10 @@ function getTaskNo() {
|
||||
return segments[1] || "latest";
|
||||
}
|
||||
|
||||
function isInvalidTaskError(message = "") {
|
||||
return message.includes("提问任务链接无效") || message.includes("提问任务不存在");
|
||||
}
|
||||
|
||||
function PhoneLogin({ api, onLogin }) {
|
||||
const [phone, setPhone] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
@ -140,6 +144,26 @@ function TaskExhausted({ session, onLogout }) {
|
||||
);
|
||||
}
|
||||
|
||||
function InvalidTask({ message, onReload }) {
|
||||
return (
|
||||
<main className="ask-user-shell">
|
||||
<section className="ask-user-app ask-invalid-app">
|
||||
<header className="ask-user-topbar">
|
||||
<span><ShieldCheck size={14} /> 淘宝问答运营官方承接通道</span>
|
||||
<em>官方认证</em>
|
||||
</header>
|
||||
<section className="ask-invalid-card">
|
||||
<span className="ask-invalid-icon"><ShieldCheck size={34} /></span>
|
||||
<em>任务链接不可用</em>
|
||||
<h1>暂时无法读取任务</h1>
|
||||
<p>{message || INVALID_TASK_MESSAGE}。请向运营确认是否已成功发布,并重新打开最新任务链接。</p>
|
||||
<button type="button" onClick={onReload}>重新读取任务</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
session,
|
||||
assignment,
|
||||
@ -370,6 +394,7 @@ function ConfirmStage({
|
||||
setReviewOpen(false);
|
||||
onSubmitted(result.data);
|
||||
} catch (requestError) {
|
||||
setReviewOpen(false);
|
||||
setError(requestError.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@ -394,18 +419,15 @@ function ConfirmStage({
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<code>{assignment.product_link}</code>
|
||||
<code title={assignment.product_link}>{assignment.product_link || "未提供商品淘口令"}</code>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigator.clipboard?.writeText(assignment.product_link)
|
||||
}
|
||||
>
|
||||
<Copy size={13} />
|
||||
复制链接
|
||||
复制淘口令
|
||||
</button>
|
||||
<a href={assignment.product_link} target="_blank" rel="noreferrer">
|
||||
去淘宝提问 <ExternalLink size={13} />
|
||||
</a>
|
||||
</footer>
|
||||
</section>
|
||||
<section className="ask-selected-card">
|
||||
@ -574,18 +596,38 @@ function ConfirmStage({
|
||||
|
||||
function AuditStatus({ assignment, reload, busy, onBackHome }) {
|
||||
const pending = assignment.status === "待审核";
|
||||
const passed = assignment.status === "已通过";
|
||||
const paid = assignment.status === "已完成";
|
||||
const passed = ["已通过", "已完成"].includes(assignment.status);
|
||||
const review = assignment.latest_review;
|
||||
const refundProofs = assignment.refund_screenshots || [];
|
||||
return (
|
||||
<div className="ask-audit-body">
|
||||
<section
|
||||
className={`ask-audit-card ${passed ? "passed" : assignment.status === "未通过" ? "rejected" : ""}`}
|
||||
>
|
||||
<span>{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}</span>
|
||||
{paid && refundProofs.length > 0 ? (
|
||||
<div className="ask-audit-refund-proof">
|
||||
{refundProofs.map((image, index) => (
|
||||
<figure key={image.id || image.data_url}>
|
||||
<img
|
||||
src={image.data_url || image.url}
|
||||
alt={`返款凭证${index + 1}`}
|
||||
/>
|
||||
<figcaption>返款凭证</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
{passed ? "✓" : assignment.status === "未通过" ? "!" : "⌁"}
|
||||
</span>
|
||||
)}
|
||||
<em>{assignment.status}</em>
|
||||
<h1>
|
||||
{pending
|
||||
? "任务正在审核,请耐心等待。"
|
||||
: paid
|
||||
? "返款已完成"
|
||||
: passed
|
||||
? "运营审核已通过"
|
||||
: "运营审核未通过"}
|
||||
@ -593,6 +635,8 @@ function AuditStatus({ assignment, reload, busy, onBackHome }) {
|
||||
<p>
|
||||
{pending
|
||||
? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。"
|
||||
: paid
|
||||
? "运营已完成返款,以下为返款支付凭证,请留意您的收款账户。"
|
||||
: passed
|
||||
? "您提交的提问凭证已经核验通过,本次任务审核完成。"
|
||||
: "请根据运营反馈修改截图后重新提交审核。"}
|
||||
@ -743,6 +787,7 @@ export default function AskUserPortal({ api }) {
|
||||
const [notice, setNotice] = useState("");
|
||||
const [claimEdit, setClaimEdit] = useState(false);
|
||||
const [exhausted, setExhausted] = useState(false);
|
||||
const [invalidTask, setInvalidTask] = useState("");
|
||||
|
||||
async function loadAssignment(silent = false) {
|
||||
if (!session) return;
|
||||
@ -754,6 +799,7 @@ export default function AskUserPortal({ api }) {
|
||||
);
|
||||
setAssignment(result.data);
|
||||
setExhausted(false);
|
||||
setInvalidTask("");
|
||||
// Keep the current view. In particular, polling a pending review must
|
||||
// not pull the user back to the audit screen after they chose to return
|
||||
// to the claim page.
|
||||
@ -774,6 +820,11 @@ export default function AskUserPortal({ api }) {
|
||||
if (requestError.message === EXHAUSTED_MESSAGE) {
|
||||
setAssignment(null);
|
||||
setExhausted(true);
|
||||
setInvalidTask("");
|
||||
setError("");
|
||||
} else if (isInvalidTaskError(requestError.message)) {
|
||||
setAssignment(null);
|
||||
setInvalidTask(requestError.message);
|
||||
setError("");
|
||||
} else {
|
||||
setError(requestError.message);
|
||||
@ -808,10 +859,13 @@ export default function AskUserPortal({ api }) {
|
||||
setSession(null);
|
||||
setAssignment(null);
|
||||
setExhausted(false);
|
||||
setInvalidTask("");
|
||||
};
|
||||
if (!session) return <PhoneLogin api={api} onLogin={setSession} />;
|
||||
if (exhausted)
|
||||
return <TaskExhausted session={session} onLogout={logout} />;
|
||||
if (invalidTask)
|
||||
return <InvalidTask message={invalidTask} onReload={() => loadAssignment()} />;
|
||||
async function confirm() {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
@ -890,7 +944,7 @@ export default function AskUserPortal({ api }) {
|
||||
onConfirm={confirm}
|
||||
busy={busy}
|
||||
/>
|
||||
) : ["待审核", "已通过"].includes(assignment.status) ? (
|
||||
) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? (
|
||||
<AuditStatus
|
||||
assignment={assignment}
|
||||
reload={() => loadAssignment()}
|
||||
|
||||
@ -196,9 +196,9 @@ export default function CreateAskTasksPage({ api }) {
|
||||
提问任务链接
|
||||
</span>
|
||||
<code>
|
||||
{window.location.origin}/ask/{task.task_id}
|
||||
{window.location.origin}/ask/{task.public_token}
|
||||
</code>
|
||||
<small>当前链接为任务预留入口,后续接入提问用户页面。</small>
|
||||
<small>该随机链接用于用户领取任务,请勿通过任务ID拼接访问。</small>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
@ -253,14 +253,14 @@ export default function CreateAskTasksPage({ api }) {
|
||||
<section className="ask-config">
|
||||
<div className="panel-title">
|
||||
<Sparkles size={17} />
|
||||
生成条件配置 <small>* 商品链接为必填项</small>
|
||||
生成条件配置 <small>* 商品淘口令为必填项</small>
|
||||
</div>
|
||||
<label>
|
||||
商品链接 *
|
||||
商品淘口令 *
|
||||
<input
|
||||
value={form.product_link}
|
||||
onChange={(event) => update("product_link", event.target.value)}
|
||||
placeholder="粘贴商品链接,例如:https://item.jd.com/100028981.html"
|
||||
placeholder="请填写淘宝商品淘口令"
|
||||
/>
|
||||
</label>
|
||||
<h4>商品信息</h4>
|
||||
|
||||
@ -19,29 +19,40 @@ const STATUS_ALIASES = {
|
||||
// Returning to the board renders immediately and refreshes silently.
|
||||
let taskBoardCache = null;
|
||||
|
||||
const formatTime = (value) =>
|
||||
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||||
const formatTime = (value) => {
|
||||
if (!value) return "--";
|
||||
const raw = String(value);
|
||||
// The API stores legacy UTC datetimes without an offset (datetime.utcnow()).
|
||||
// Treat those values as UTC before displaying them in the business timezone.
|
||||
const hasOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw);
|
||||
const date = new Date(hasOffset ? raw : `${raw}Z`);
|
||||
return Number.isNaN(date.getTime())
|
||||
? "--"
|
||||
: date.toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" });
|
||||
};
|
||||
const maskPhone = (phone = "") =>
|
||||
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
|
||||
|
||||
function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
const [refundOpen, setRefundOpen] = useState(false);
|
||||
const [refundBusy, setRefundBusy] = useState(false);
|
||||
const [refundFile, setRefundFile] = useState(null);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const isAskTask =
|
||||
task.task_type === "ask" || task.task_type_label === "提问任务";
|
||||
const taskLink = isAskTask
|
||||
? `${window.location.origin}/ask/${task.task_id}`
|
||||
? `${window.location.origin}/ask/${task.public_token}`
|
||||
: "";
|
||||
const proofScreenshots = [
|
||||
...(task.question_screenshots || []),
|
||||
...(task.completion_screenshots || []),
|
||||
];
|
||||
const paymentScreenshots = task.payment_screenshots || [];
|
||||
const refundScreenshots = task.refund_screenshots || [];
|
||||
async function confirmRefund() {
|
||||
setRefundBusy(true);
|
||||
try {
|
||||
await onConfirmRefund(task);
|
||||
await onConfirmRefund(task, refundFile);
|
||||
setRefundOpen(false);
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
@ -136,28 +147,64 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
<span>关联问题与回答</span>
|
||||
<span>认领用户</span>
|
||||
<span>状态</span>
|
||||
<span>返款凭证</span>
|
||||
</div>
|
||||
{task.selected_questions.length === 0 ? (
|
||||
<div className="task-detail-empty">暂无问题</div>
|
||||
) : (
|
||||
task.selected_questions.map((question, index) => (
|
||||
<div className="row" key={question.id}>
|
||||
<span>{index + 1}</span>
|
||||
<span>
|
||||
<div className="task-detail-table-body">
|
||||
{task.selected_questions.map((question, index) => (
|
||||
<React.Fragment key={question.id}>
|
||||
<span
|
||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||
style={{ gridColumn: 1, gridRow: index + 1 }}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||
style={{ gridColumn: 2, gridRow: index + 1 }}
|
||||
>
|
||||
<b>Q:{question.question}</b>
|
||||
<small>A:{question.answer}</small>
|
||||
</span>
|
||||
<span>
|
||||
<span
|
||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||
style={{ gridColumn: 3, gridRow: index + 1 }}
|
||||
>
|
||||
{task.user_id}
|
||||
<small>{maskPhone(task.phone)}</small>
|
||||
</span>
|
||||
<span>
|
||||
<span
|
||||
className={`task-detail-cell${index ? " row-border" : ""}`}
|
||||
style={{ gridColumn: 4, gridRow: index + 1 }}
|
||||
>
|
||||
<em className={`board-status ${task.status_label}`}>
|
||||
{task.status_label}
|
||||
</em>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span
|
||||
className="task-detail-refund-proof task-detail-refund-proof-merged"
|
||||
style={{
|
||||
gridColumn: 5,
|
||||
gridRow: `1 / span ${task.selected_questions.length}`,
|
||||
}}
|
||||
>
|
||||
{refundScreenshots.length ? (
|
||||
<button type="button">
|
||||
<img
|
||||
src={refundScreenshots[0].data_url || refundScreenshots[0].url}
|
||||
alt="返款凭证"
|
||||
/>
|
||||
<small>已上传</small>
|
||||
</button>
|
||||
) : (
|
||||
"未上传"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="task-detail-evidence">
|
||||
@ -223,10 +270,19 @@ function TaskDetail({ task, close, onConfirmRefund }) {
|
||||
<p>
|
||||
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
|
||||
</p>
|
||||
<label className="refund-proof-upload">
|
||||
<span>上传返款凭证(必传)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={(event) => setRefundFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<small>{refundFile ? refundFile.name : "请选择支付截图"}</small>
|
||||
</label>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={refundBusy}
|
||||
disabled={refundBusy || !refundFile}
|
||||
onClick={() => setRefundOpen(false)}
|
||||
>
|
||||
取消
|
||||
@ -344,7 +400,7 @@ export default function TaskBoardPage({ api }) {
|
||||
window.alert("当前没有可导出的任务");
|
||||
return;
|
||||
}
|
||||
const headers = ["用户ID", "任务ID", "商品信息", "类型", "Q&A数", "待审/完成", "状态", "创建人", "创建时间"];
|
||||
const headers = ["用户ID", "任务ID", "商品信息", "类型", "Q&A数", "完成", "状态", "创建人", "返款凭证", "创建时间"];
|
||||
const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
|
||||
const lines = [headers, ...rows.map((task) => [
|
||||
task.user_id,
|
||||
@ -352,9 +408,10 @@ export default function TaskBoardPage({ api }) {
|
||||
task.product_name,
|
||||
task.task_type_label,
|
||||
task.qa_count,
|
||||
`${task.pending_review_count} / ${task.completed_count}`,
|
||||
task.completed_count,
|
||||
task.status_label,
|
||||
task.creator_name,
|
||||
task.refund_screenshot_count ? "已上传" : "--",
|
||||
formatTime(task.created_at),
|
||||
])].map((line) => line.map(csvCell).join(","));
|
||||
const blob = new Blob([`\uFEFF${lines.join("\r\n")}`], { type: "text/csv;charset=utf-8" });
|
||||
@ -453,8 +510,11 @@ export default function TaskBoardPage({ api }) {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function confirmRefund(task) {
|
||||
const result = await api(task.refund_endpoint, { method: "POST" });
|
||||
async function confirmRefund(task, refundFile) {
|
||||
if (!refundFile) throw new Error("请先上传返款凭证");
|
||||
const body = new FormData();
|
||||
body.append("refund_file", refundFile);
|
||||
const result = await api(task.refund_endpoint, { method: "POST", body });
|
||||
const updatedDetail = {
|
||||
...task,
|
||||
...result.data,
|
||||
@ -464,6 +524,9 @@ export default function TaskBoardPage({ api }) {
|
||||
task.completion_screenshots || result.data.completion_screenshots || [],
|
||||
payment_screenshots:
|
||||
task.payment_screenshots || result.data.payment_screenshots || [],
|
||||
refund_screenshots:
|
||||
result.data.refund_screenshots || task.refund_screenshots || [],
|
||||
refund_screenshot_count: result.data.refund_screenshots?.length || task.refund_screenshot_count || 0,
|
||||
};
|
||||
setDetail(updatedDetail);
|
||||
setTasks((current) => {
|
||||
@ -551,9 +614,10 @@ export default function TaskBoardPage({ api }) {
|
||||
<span>商品信息</span>
|
||||
<span>类型</span>
|
||||
<span>Q&A数</span>
|
||||
<span>待审/完成</span>
|
||||
<span>完成</span>
|
||||
<span>状态</span>
|
||||
<span>创建人</span>
|
||||
<span>返款凭证</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
@ -590,9 +654,10 @@ export default function TaskBoardPage({ api }) {
|
||||
<span><strong>{task.product_name}</strong><small>{maskPhone(task.phone)}</small></span>
|
||||
<span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
|
||||
<span>{task.qa_count}</span>
|
||||
<span>{task.pending_review_count} / {task.completed_count}</span>
|
||||
<span>{task.completed_count}</span>
|
||||
<span><em className={`board-status ${task.status_label}`}>{task.status_label}</em></span>
|
||||
<span>{task.creator_name}<small>{formatTime(task.created_at)}</small></span>
|
||||
<span className="board-refund-proof">{task.refund_screenshot_count ? "已上传" : "--"}</span>
|
||||
<span className="board-actions">
|
||||
<button onClick={() => openDetail(task)}>详情</button>
|
||||
{task.can_cancel && (
|
||||
@ -614,9 +679,10 @@ export default function TaskBoardPage({ api }) {
|
||||
<span><strong>{child.product_name}</strong><small>{maskPhone(child.phone)}</small></span>
|
||||
<span><em className="board-type ask">提问任务</em></span>
|
||||
<span>{child.qa_count}</span>
|
||||
<span>{child.pending_review_count} / {child.completed_count}</span>
|
||||
<span>{child.completed_count}</span>
|
||||
<span><em className={`board-status ${child.status_label}`}>{child.status_label}</em></span>
|
||||
<span>{child.creator_name}<small>{formatTime(child.created_at)}</small></span>
|
||||
<span className="board-refund-proof">{child.refund_proof_uploaded ? "已上传" : child.refund_endpoint ? "待上传" : "--"}</span>
|
||||
<span className="board-actions"><button onClick={() => openDetail(child)}>详情</button></span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -11,6 +11,12 @@
|
||||
.ask-exhausted-card p{max-width:330px;margin:0;color:#71859e;font-size:13px;line-height:1.8}
|
||||
.ask-exhausted-card small{margin:22px 0 14px;color:#91a1b5}
|
||||
.ask-exhausted-card button{width:100%;padding:12px;border-radius:10px;background:#1764e8;color:#fff;font-weight:700;box-shadow:0 7px 16px rgba(23,100,232,.2)}
|
||||
.ask-invalid-card{display:flex;align-items:center;flex-direction:column;width:min(420px,calc(100% - 32px));margin:90px auto 24px;padding:34px 24px;border:1px solid #b8d0f3;border-radius:20px;background:linear-gradient(180deg,#f8fbff,#fff);text-align:center;box-shadow:0 12px 32px rgba(26,70,135,.1)}
|
||||
.ask-invalid-icon{display:grid;place-items:center;width:72px;height:72px;margin-bottom:14px;border:1px solid #8bb4ef;border-radius:22px;background:#eaf3ff;color:#2864e8}
|
||||
.ask-invalid-card>em{padding:5px 12px;border-radius:999px;background:#eaf2ff;color:#2864e8;font-size:11px;font-style:normal;font-weight:700}
|
||||
.ask-invalid-card h1{margin:18px 0 8px;color:#102744;font-size:24px}
|
||||
.ask-invalid-card p{max-width:330px;margin:0;color:#71859e;font-size:13px;line-height:1.8}
|
||||
.ask-invalid-card button{width:100%;margin-top:22px;padding:12px;border-radius:10px;background:#1764e8;color:#fff;font-weight:700;box-shadow:0 7px 16px rgba(23,100,232,.2)}
|
||||
.ask-claim-summary{margin:12px 16px;padding:14px 16px;border:1px solid #b9d4ff;border-radius:14px;background:linear-gradient(135deg,#f5f9ff,#eef5ff);color:#194b91}.ask-claim-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}.ask-claim-summary strong{font-size:13px}.ask-claim-summary span,.ask-claim-summary small{color:#7188a5;font-size:11px}.ask-claim-summary>b{display:block;margin-top:8px;color:#1764e8;font-size:18px}.ask-claim-progress{height:7px;margin:8px 0;border-radius:9px;background:#dce8f8;overflow:hidden}.ask-claim-progress i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#2166ef,#5b46ee)}
|
||||
.ask-question-list>button.used{border-color:#d7e1ec;background:#f3f6fa;color:#7d8fa5;cursor:not-allowed;box-shadow:none}.ask-question-list>button.used i{border-color:#d0dbe7;background:#e6edf5;color:#8ca0b6}.ask-question-list>button em{margin-left:auto;padding:4px 7px;border-radius:8px;background:#e6edf5;color:#7288a2;font-size:10px;font-style:normal;white-space:nowrap}
|
||||
.ask-status-page{padding:14px 16px 80px}.ask-status-page .ask-history-tabs{margin-top:0}.ask-status-page .ask-history-item{box-shadow:0 2px 7px rgba(32,62,102,.05)}
|
||||
@ -1867,7 +1873,7 @@
|
||||
.board-table-head,
|
||||
.board-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr minmax(150px, 1.2fr);
|
||||
grid-template-columns: 34px 1.05fr 1.05fr 1.9fr 0.75fr 0.55fr 0.75fr 0.75fr 1fr 0.75fr minmax(150px, 1.2fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 13px 14px;
|
||||
@ -1936,6 +1942,11 @@
|
||||
.board-child-row .board-actions button {
|
||||
color: #2864e8;
|
||||
}
|
||||
.board-refund-proof {
|
||||
color: #12965b;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.board-select-cell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@ -2180,7 +2191,7 @@
|
||||
.task-detail-table .head,
|
||||
.task-detail-table .row {
|
||||
display: grid;
|
||||
grid-template-columns: 45px 2fr 1fr 0.7fr;
|
||||
grid-template-columns: 45px 2fr 1fr 0.7fr 0.9fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 11px;
|
||||
@ -2190,6 +2201,29 @@
|
||||
color: #647b98;
|
||||
font-size: 11px;
|
||||
}
|
||||
.task-detail-table-body {
|
||||
display: grid;
|
||||
grid-template-columns: 45px 2fr 1fr 0.7fr 0.9fr;
|
||||
}
|
||||
.task-detail-cell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: center;
|
||||
gap: 5px;
|
||||
padding: 11px;
|
||||
color: #536b88;
|
||||
font-size: 11px;
|
||||
}
|
||||
.task-detail-cell.row-border {
|
||||
border-top: 1px solid #edf1f6;
|
||||
}
|
||||
.task-detail-cell b {
|
||||
color: #203c5e;
|
||||
}
|
||||
.task-detail-cell small {
|
||||
color: #8094ac;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.task-detail-table .row {
|
||||
border-top: 1px solid #edf1f6;
|
||||
color: #536b88;
|
||||
@ -2211,6 +2245,73 @@
|
||||
text-align: center;
|
||||
color: #8a9cb1;
|
||||
}
|
||||
.task-detail-refund-proof button {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #12965b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.task-detail-refund-proof img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.task-detail-refund-proof-merged {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 11px;
|
||||
border-left: 1px solid #edf1f6;
|
||||
color: #8094ac;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.ask-refund-proof-card {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid #bfe8d1;
|
||||
border-radius: 12px;
|
||||
background: #f4fff8;
|
||||
}
|
||||
.ask-refund-proof-card > header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
color: #168451;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ask-refund-proof-card > div,
|
||||
.refund-proof-user > div {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ask-refund-proof-card figure,
|
||||
.refund-proof-user figure {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d3e8da;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.ask-refund-proof-card img,
|
||||
.refund-proof-user img {
|
||||
display: block;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.ask-refund-proof-card figcaption,
|
||||
.refund-proof-user figcaption {
|
||||
padding: 5px;
|
||||
color: #5d816b;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.task-detail-evidence {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
@ -2325,6 +2426,25 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.refund-proof-upload {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border: 1px dashed #b8cbe2;
|
||||
border-radius: 10px;
|
||||
color: #294767;
|
||||
text-align: left;
|
||||
}
|
||||
.refund-proof-upload input {
|
||||
width: 100%;
|
||||
}
|
||||
.refund-proof-upload small {
|
||||
overflow: hidden;
|
||||
color: #8195ad;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.refund-confirm-modal > div {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.35fr;
|
||||
@ -2389,7 +2509,8 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
.task-detail-table .head,
|
||||
.task-detail-table .row {
|
||||
.task-detail-table .row,
|
||||
.task-detail-table-body {
|
||||
min-width: 620px;
|
||||
}
|
||||
.task-detail-evidence {
|
||||
@ -4655,3 +4776,117 @@ body img {
|
||||
bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ask-invalid-product-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: #fff0f3;
|
||||
color: #c93652;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Public mobile portals: prevent fixed action bars, long filenames and modal
|
||||
content from creating a horizontal overflow on narrow DingTalk webviews. */
|
||||
@media (max-width: 700px) {
|
||||
.ask-user-app,
|
||||
.answer-v2-app,
|
||||
.ask-user-app * ,
|
||||
.answer-v2-app * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ask-user-app input,
|
||||
.ask-user-app textarea,
|
||||
.ask-user-app select,
|
||||
.answer-v2-app input,
|
||||
.answer-v2-app textarea,
|
||||
.answer-v2-app select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ask-user-sticky {
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.ask-user-sticky span {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ask-user-sticky button {
|
||||
flex: 0 1 180px;
|
||||
width: min(180px, 46vw);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ask-product-card > footer,
|
||||
.ask-payment-file {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ask-product-card > footer code,
|
||||
.ask-payment-file span {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ask-product-card > footer code {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.ask-payment-file span {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.ask-submit-backdrop {
|
||||
align-items: end;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ask-submit-modal {
|
||||
width: 100%;
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.ask-submit-modal > footer {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.ask-submit-modal > footer button {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ask-invalid-card,
|
||||
.ask-exhausted-card {
|
||||
width: calc(100% - 24px);
|
||||
margin: 56px auto 24px;
|
||||
padding: 28px 18px;
|
||||
}
|
||||
|
||||
.ask-invalid-card h1,
|
||||
.ask-exhausted-card h1 {
|
||||
font-size: 21px;
|
||||
}
|
||||
}
|
||||
.ask-audit-card>span.refund-proof-badge{width:auto;min-width:60px;height:38px;padding:0 14px;border-radius:12px;font-size:13px;font-weight:700}
|
||||
.ask-audit-card>div.ask-audit-refund-proof{display:flex;align-items:center;justify-content:center;gap:8px;width:auto;min-height:60px;margin:0;padding:0;border:0;background:transparent;text-align:center}
|
||||
.ask-audit-refund-proof figure{margin:0;text-align:center}
|
||||
.ask-audit-refund-proof img{display:block;width:78px;height:78px;object-fit:cover;border:1px solid #67ca98;border-radius:12px;background:#fff}
|
||||
.ask-audit-refund-proof figcaption{margin-top:4px;color:#168451;font-size:10px}
|
||||
|
||||
@ -90,7 +90,10 @@ function LoginPage({ onLogin }) {
|
||||
const autoLoginStarted = useRef(false);
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const getDingTalkBridge = () => window.dd || window.DingTalkPC || window.dingtalk;
|
||||
const inDingtalkWorkbench = /DingTalk|AliApp\(DT/i.test(navigator.userAgent || "") || query.get("from") === "dingtalk" || Boolean(getDingTalkBridge());
|
||||
// The DingTalk SDK is loaded on every page, including a normal browser.
|
||||
// Do not treat the mere presence of `window.dd` as proof that we are inside
|
||||
// the DingTalk workbench, otherwise local/browser users lose password login.
|
||||
const inDingtalkWorkbench = /DingTalk|AliApp\(DT/i.test(navigator.userAgent || "") || query.get("from") === "dingtalk";
|
||||
function loadDingTalkSdk() {
|
||||
if (getDingTalkBridge()) return Promise.resolve(getDingTalkBridge());
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user