修改权限隔离,优化bug

This commit is contained in:
kx_guo 2026-09-08 10:35:25 +08:00
parent 598a2cbc29
commit 0b79c6812c
15 changed files with 913 additions and 153 deletions

View File

@ -24,4 +24,12 @@ def get_db():
try: try:
yield db yield db
finally: finally:
db.close() # A request may exit through an exception or an early return before a
# route explicitly commits. Roll back first so any checked-out
# connection is returned to QueuePool in a clean state, then close the
# Session itself. Session.close() also rolls back, but doing it here
# makes the lifecycle guarantee explicit and observable in tests.
try:
db.rollback()
finally:
db.close()

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,14 @@ class AdminUser(Base):
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str] = mapped_column(String(64)) display_name: Mapped[str] = mapped_column(String(64))
role_code: Mapped[str] = mapped_column(String(32), default="operator", index=True) 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) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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) id: Mapped[int] = mapped_column(Integer, primary_key=True)
submission_no: Mapped[str] = mapped_column(String(40), unique=True, index=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) 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) status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, 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) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user: Mapped[AnswerUser] = relationship(back_populates="submissions", lazy="selectin") 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") 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") 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") 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) id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_no: Mapped[str] = mapped_column(String(40), unique=True, index=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) owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
product_link: Mapped[str] = mapped_column(String(1000)) product_link: Mapped[str] = mapped_column(String(1000))
product_name: Mapped[str] = mapped_column(String(255)) product_name: Mapped[str] = mapped_column(String(255))

View File

@ -16,13 +16,13 @@ class AdminUserCreate(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$") username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=8, max_length=128) password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=64) 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): class AdminUserUpdate(BaseModel):
password: str | None = Field(default=None, min_length=8, max_length=128) 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) 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 is_active: bool | None = None

Binary file not shown.

View File

@ -34,3 +34,4 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。 -- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。
-- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。 -- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。
-- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。

View File

@ -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}) 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 assert generated.status_code == 200
task = generated.json()["data"] task = generated.json()["data"]
assert task["public_token"] is None
published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish") published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish")
assert published.status_code == 200 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"] board = client.get("/api/admin/task-board").json()["data"]
created = next(item for item in board if item["task_id"] == task["task_id"]) created = next(item for item in board if item["task_id"] == task["task_id"])
assert created["task_type"] == "ask" assert created["task_type"] == "ask"
assert created["task_type_label"] == "提问任务" assert created["task_type_label"] == "提问任务"
assert created["qa_count"] == 3 assert created["qa_count"] == 3
assert created["public_token"] == public_token
def test_ask_task_generation_honors_question_count(): def test_ask_task_generation_honors_question_count():

BIN
data/local_login_test.db Normal file

Binary file not shown.

BIN
data/local_test.db Normal file

Binary file not shown.

View File

@ -85,6 +85,7 @@ function AssignmentStage({ api, session, latest, reload, setError }) {
function FinalStage({ latest, reload, busy, reopen, startNew }) { function FinalStage({ latest, reload, busy, reopen, startNew }) {
const paid = latest.status === '已完成' const paid = latest.status === '已完成'
const refundProofs = latest.refund_screenshots || []
const passed = ['已通过', '已完成'].includes(latest.status) const passed = ['已通过', '已完成'].includes(latest.status)
const rejected = latest.status === '未通过' const rejected = latest.status === '未通过'
const cancelled = 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' : ''}`}> <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> <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>} {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> <footer><small>提交编号:{latest.submission_no}</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer>
</section> </section>
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>} {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, invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots,
completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_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, 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 } : record
})) }))
setOptimisticStatuses(current => { setOptimisticStatuses(current => {
@ -195,6 +198,7 @@ export default function AnswerUserPortalV2({ api }) {
invited_screenshots: submitted.invited_screenshots?.length ? submitted.invited_screenshots : record.invited_screenshots, invited_screenshots: submitted.invited_screenshots?.length ? submitted.invited_screenshots : record.invited_screenshots,
completion_screenshots: submitted.completion_screenshots || [], completion_screenshots: submitted.completion_screenshots || [],
payment_screenshots: submitted.payment_screenshots || [], payment_screenshots: submitted.payment_screenshots || [],
refund_screenshots: submitted.refund_screenshots || [],
} : record)) } : record))
} }
const visibleLatest = newTaskMode ? null : latest const visibleLatest = newTaskMode ? null : latest

View File

@ -3,7 +3,6 @@ import {
Check, Check,
ClipboardCheck, ClipboardCheck,
Copy, Copy,
ExternalLink,
ImagePlus, ImagePlus,
ListChecks, ListChecks,
LogOut, LogOut,
@ -15,6 +14,7 @@ import {
const SESSION_KEY = "ask_user_session"; const SESSION_KEY = "ask_user_session";
const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!"; const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`; const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`;
const maskPhone = (phone = "") => const maskPhone = (phone = "") =>
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"); phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
@ -24,6 +24,10 @@ function getTaskNo() {
return segments[1] || "latest"; return segments[1] || "latest";
} }
function isInvalidTaskError(message = "") {
return message.includes("提问任务链接无效") || message.includes("提问任务不存在");
}
function PhoneLogin({ api, onLogin }) { function PhoneLogin({ api, onLogin }) {
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [code, setCode] = 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({ function Header({
session, session,
assignment, assignment,
@ -370,6 +394,7 @@ function ConfirmStage({
setReviewOpen(false); setReviewOpen(false);
onSubmitted(result.data); onSubmitted(result.data);
} catch (requestError) { } catch (requestError) {
setReviewOpen(false);
setError(requestError.message); setError(requestError.message);
} finally { } finally {
setBusy(false); setBusy(false);
@ -394,18 +419,15 @@ function ConfirmStage({
</div> </div>
</div> </div>
<footer> <footer>
<code>{assignment.product_link}</code> <code title={assignment.product_link}>{assignment.product_link || "未提供商品淘口令"}</code>
<button <button
onClick={() => onClick={() =>
navigator.clipboard?.writeText(assignment.product_link) navigator.clipboard?.writeText(assignment.product_link)
} }
> >
<Copy size={13} /> <Copy size={13} />
复制链接 复制淘口令
</button> </button>
<a href={assignment.product_link} target="_blank" rel="noreferrer">
去淘宝提问 <ExternalLink size={13} />
</a>
</footer> </footer>
</section> </section>
<section className="ask-selected-card"> <section className="ask-selected-card">
@ -574,26 +596,48 @@ function ConfirmStage({
function AuditStatus({ assignment, reload, busy, onBackHome }) { function AuditStatus({ assignment, reload, busy, onBackHome }) {
const pending = assignment.status === "待审核"; const pending = assignment.status === "待审核";
const passed = assignment.status === "已通过"; const paid = assignment.status === "已完成";
const passed = ["已通过", "已完成"].includes(assignment.status);
const review = assignment.latest_review; const review = assignment.latest_review;
const refundProofs = assignment.refund_screenshots || [];
return ( return (
<div className="ask-audit-body"> <div className="ask-audit-body">
<section <section
className={`ask-audit-card ${passed ? "passed" : assignment.status === "未通过" ? "rejected" : ""}`} 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> <em>{assignment.status}</em>
<h1> <h1>
{pending {pending
? "任务正在审核,请耐心等待。" ? "任务正在审核,请耐心等待。"
: passed : paid
? "返款已完成"
: passed
? "运营审核已通过" ? "运营审核已通过"
: "运营审核未通过"} : "运营审核未通过"}
</h1> </h1>
<p> <p>
{pending {pending
? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。" ? "审核专员正在核验您的淘宝提问截图与发布有效性,核验通过后将自动更新状态。"
: passed : paid
? "运营已完成返款,以下为返款支付凭证,请留意您的收款账户。"
: passed
? "您提交的提问凭证已经核验通过,本次任务审核完成。" ? "您提交的提问凭证已经核验通过,本次任务审核完成。"
: "请根据运营反馈修改截图后重新提交审核。"} : "请根据运营反馈修改截图后重新提交审核。"}
</p> </p>
@ -743,6 +787,7 @@ export default function AskUserPortal({ api }) {
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [claimEdit, setClaimEdit] = useState(false); const [claimEdit, setClaimEdit] = useState(false);
const [exhausted, setExhausted] = useState(false); const [exhausted, setExhausted] = useState(false);
const [invalidTask, setInvalidTask] = useState("");
async function loadAssignment(silent = false) { async function loadAssignment(silent = false) {
if (!session) return; if (!session) return;
@ -752,8 +797,9 @@ export default function AskUserPortal({ api }) {
const result = await api( const result = await api(
`/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}`, `/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}`,
); );
setAssignment(result.data); setAssignment(result.data);
setExhausted(false); setExhausted(false);
setInvalidTask("");
// Keep the current view. In particular, polling a pending review must // 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 // not pull the user back to the audit screen after they chose to return
// to the claim page. // to the claim page.
@ -774,6 +820,11 @@ export default function AskUserPortal({ api }) {
if (requestError.message === EXHAUSTED_MESSAGE) { if (requestError.message === EXHAUSTED_MESSAGE) {
setAssignment(null); setAssignment(null);
setExhausted(true); setExhausted(true);
setInvalidTask("");
setError("");
} else if (isInvalidTaskError(requestError.message)) {
setAssignment(null);
setInvalidTask(requestError.message);
setError(""); setError("");
} else { } else {
setError(requestError.message); setError(requestError.message);
@ -808,10 +859,13 @@ export default function AskUserPortal({ api }) {
setSession(null); setSession(null);
setAssignment(null); setAssignment(null);
setExhausted(false); setExhausted(false);
setInvalidTask("");
}; };
if (!session) return <PhoneLogin api={api} onLogin={setSession} />; if (!session) return <PhoneLogin api={api} onLogin={setSession} />;
if (exhausted) if (exhausted)
return <TaskExhausted session={session} onLogout={logout} />; return <TaskExhausted session={session} onLogout={logout} />;
if (invalidTask)
return <InvalidTask message={invalidTask} onReload={() => loadAssignment()} />;
async function confirm() { async function confirm() {
if (!selected.length) return; if (!selected.length) return;
setBusy(true); setBusy(true);
@ -890,7 +944,7 @@ export default function AskUserPortal({ api }) {
onConfirm={confirm} onConfirm={confirm}
busy={busy} busy={busy}
/> />
) : ["待审核", "已通过"].includes(assignment.status) ? ( ) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? (
<AuditStatus <AuditStatus
assignment={assignment} assignment={assignment}
reload={() => loadAssignment()} reload={() => loadAssignment()}

View File

@ -196,9 +196,9 @@ export default function CreateAskTasksPage({ api }) {
提问任务链接 提问任务链接
</span> </span>
<code> <code>
{window.location.origin}/ask/{task.task_id} {window.location.origin}/ask/{task.public_token}
</code> </code>
<small>当前链接为任务预留入口,后续接入提问用户页面。</small> <small>该随机链接用于用户领取任务,请勿通过任务ID拼接访问。</small>
</div> </div>
<div> <div>
<button <button
@ -253,14 +253,14 @@ export default function CreateAskTasksPage({ api }) {
<section className="ask-config"> <section className="ask-config">
<div className="panel-title"> <div className="panel-title">
<Sparkles size={17} /> <Sparkles size={17} />
生成条件配置 <small>* 商品链接为必填项</small> 生成条件配置 <small>* 商品淘口令为必填项</small>
</div> </div>
<label> <label>
商品链接 * 商品淘口令 *
<input <input
value={form.product_link} value={form.product_link}
onChange={(event) => update("product_link", event.target.value)} onChange={(event) => update("product_link", event.target.value)}
placeholder="粘贴商品链接,例如:https://item.jd.com/100028981.html" placeholder="请填写淘宝商品淘口令"
/> />
</label> </label>
<h4>商品信息</h4> <h4>商品信息</h4>

View File

@ -19,29 +19,40 @@ const STATUS_ALIASES = {
// Returning to the board renders immediately and refreshes silently. // Returning to the board renders immediately and refreshes silently.
let taskBoardCache = null; let taskBoardCache = null;
const formatTime = (value) => const formatTime = (value) => {
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; 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 = "") => const maskPhone = (phone = "") =>
phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"); phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
function TaskDetail({ task, close, onConfirmRefund }) { function TaskDetail({ task, close, onConfirmRefund }) {
const [refundOpen, setRefundOpen] = useState(false); const [refundOpen, setRefundOpen] = useState(false);
const [refundBusy, setRefundBusy] = useState(false); const [refundBusy, setRefundBusy] = useState(false);
const [refundFile, setRefundFile] = useState(null);
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const isAskTask = const isAskTask =
task.task_type === "ask" || task.task_type_label === "提问任务"; task.task_type === "ask" || task.task_type_label === "提问任务";
const taskLink = isAskTask const taskLink = isAskTask
? `${window.location.origin}/ask/${task.task_id}` ? `${window.location.origin}/ask/${task.public_token}`
: ""; : "";
const proofScreenshots = [ const proofScreenshots = [
...(task.question_screenshots || []), ...(task.question_screenshots || []),
...(task.completion_screenshots || []), ...(task.completion_screenshots || []),
]; ];
const paymentScreenshots = task.payment_screenshots || []; const paymentScreenshots = task.payment_screenshots || [];
const refundScreenshots = task.refund_screenshots || [];
async function confirmRefund() { async function confirmRefund() {
setRefundBusy(true); setRefundBusy(true);
try { try {
await onConfirmRefund(task); await onConfirmRefund(task, refundFile);
setRefundOpen(false); setRefundOpen(false);
} catch (error) { } catch (error) {
window.alert(error.message); window.alert(error.message);
@ -136,28 +147,64 @@ function TaskDetail({ task, close, onConfirmRefund }) {
<span>关联问题与回答</span> <span>关联问题与回答</span>
<span>认领用户</span> <span>认领用户</span>
<span>状态</span> <span>状态</span>
<span>返款凭证</span>
</div> </div>
{task.selected_questions.length === 0 ? ( {task.selected_questions.length === 0 ? (
<div className="task-detail-empty">暂无问题</div> <div className="task-detail-empty">暂无问题</div>
) : ( ) : (
task.selected_questions.map((question, index) => ( <div className="task-detail-table-body">
<div className="row" key={question.id}> {task.selected_questions.map((question, index) => (
<span>{index + 1}</span> <React.Fragment key={question.id}>
<span> <span
<b>Q:{question.question}</b> className={`task-detail-cell${index ? " row-border" : ""}`}
<small>A:{question.answer}</small> style={{ gridColumn: 1, gridRow: index + 1 }}
</span> >
<span> {index + 1}
{task.user_id} </span>
<small>{maskPhone(task.phone)}</small> <span
</span> className={`task-detail-cell${index ? " row-border" : ""}`}
<span> style={{ gridColumn: 2, gridRow: index + 1 }}
<em className={`board-status ${task.status_label}`}> >
{task.status_label} <b>Q:{question.question}</b>
</em> <small>A:{question.answer}</small>
</span> </span>
</div> <span
)) className={`task-detail-cell${index ? " row-border" : ""}`}
style={{ gridColumn: 3, gridRow: index + 1 }}
>
{task.user_id}
<small>{maskPhone(task.phone)}</small>
</span>
<span
className={`task-detail-cell${index ? " row-border" : ""}`}
style={{ gridColumn: 4, gridRow: index + 1 }}
>
<em className={`board-status ${task.status_label}`}>
{task.status_label}
</em>
</span>
</React.Fragment>
))}
<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>
<div className="task-detail-evidence"> <div className="task-detail-evidence">
@ -223,10 +270,19 @@ function TaskDetail({ task, close, onConfirmRefund }) {
<p> <p>
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。 请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
</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> <div>
<button <button
type="button" type="button"
disabled={refundBusy} disabled={refundBusy || !refundFile}
onClick={() => setRefundOpen(false)} onClick={() => setRefundOpen(false)}
> >
取消 取消
@ -344,7 +400,7 @@ export default function TaskBoardPage({ api }) {
window.alert("当前没有可导出的任务"); window.alert("当前没有可导出的任务");
return; return;
} }
const headers = ["用户ID", "任务ID", "商品信息", "类型", "Q&A数", "待审/完成", "状态", "创建人", "创建时间"]; const headers = ["用户ID", "任务ID", "商品信息", "类型", "Q&A数", "完成", "状态", "创建人", "返款凭证", "创建时间"];
const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`; const csvCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
const lines = [headers, ...rows.map((task) => [ const lines = [headers, ...rows.map((task) => [
task.user_id, task.user_id,
@ -352,9 +408,10 @@ export default function TaskBoardPage({ api }) {
task.product_name, task.product_name,
task.task_type_label, task.task_type_label,
task.qa_count, task.qa_count,
`${task.pending_review_count} / ${task.completed_count}`, task.completed_count,
task.status_label, task.status_label,
task.creator_name, task.creator_name,
task.refund_screenshot_count ? "已上传" : "--",
formatTime(task.created_at), formatTime(task.created_at),
])].map((line) => line.map(csvCell).join(",")); ])].map((line) => line.map(csvCell).join(","));
const blob = new Blob([`\uFEFF${lines.join("\r\n")}`], { type: "text/csv;charset=utf-8" }); 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(""); setBusy("");
} }
} }
async function confirmRefund(task) { async function confirmRefund(task, refundFile) {
const result = await api(task.refund_endpoint, { method: "POST" }); 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 = { const updatedDetail = {
...task, ...task,
...result.data, ...result.data,
@ -464,6 +524,9 @@ export default function TaskBoardPage({ api }) {
task.completion_screenshots || result.data.completion_screenshots || [], task.completion_screenshots || result.data.completion_screenshots || [],
payment_screenshots: payment_screenshots:
task.payment_screenshots || result.data.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); setDetail(updatedDetail);
setTasks((current) => { setTasks((current) => {
@ -551,9 +614,10 @@ export default function TaskBoardPage({ api }) {
<span>商品信息</span> <span>商品信息</span>
<span>类型</span> <span>类型</span>
<span>Q&A数</span> <span>Q&A数</span>
<span>待审/完成</span> <span>完成</span>
<span>状态</span> <span>状态</span>
<span>创建人</span> <span>创建人</span>
<span>返款凭证</span>
<span>操作</span> <span>操作</span>
</div> </div>
{loading ? ( {loading ? (
@ -590,9 +654,10 @@ export default function TaskBoardPage({ api }) {
<span><strong>{task.product_name}</strong><small>{maskPhone(task.phone)}</small></span> <span><strong>{task.product_name}</strong><small>{maskPhone(task.phone)}</small></span>
<span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span> <span><em className={`board-type ${task.task_type}`}>{task.task_type_label}</em></span>
<span>{task.qa_count}</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><em className={`board-status ${task.status_label}`}>{task.status_label}</em></span>
<span>{task.creator_name}<small>{formatTime(task.created_at)}</small></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"> <span className="board-actions">
<button onClick={() => openDetail(task)}>详情</button> <button onClick={() => openDetail(task)}>详情</button>
{task.can_cancel && ( {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><strong>{child.product_name}</strong><small>{maskPhone(child.phone)}</small></span>
<span><em className="board-type ask">提问任务</em></span> <span><em className="board-type ask">提问任务</em></span>
<span>{child.qa_count}</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><em className={`board-status ${child.status_label}`}>{child.status_label}</em></span>
<span>{child.creator_name}<small>{formatTime(child.created_at)}</small></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> <span className="board-actions"><button onClick={() => openDetail(child)}>详情</button></span>
</div> </div>
))} ))}

View File

@ -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 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 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-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-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-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)} .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-head,
.board-table-row { .board-table-row {
display: grid; 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; align-items: center;
gap: 10px; gap: 10px;
padding: 13px 14px; padding: 13px 14px;
@ -1936,6 +1942,11 @@
.board-child-row .board-actions button { .board-child-row .board-actions button {
color: #2864e8; color: #2864e8;
} }
.board-refund-proof {
color: #12965b;
font-size: 11px;
white-space: nowrap;
}
.board-select-cell { .board-select-cell {
display: grid; display: grid;
place-items: center; place-items: center;
@ -2180,7 +2191,7 @@
.task-detail-table .head, .task-detail-table .head,
.task-detail-table .row { .task-detail-table .row {
display: grid; display: grid;
grid-template-columns: 45px 2fr 1fr 0.7fr; grid-template-columns: 45px 2fr 1fr 0.7fr 0.9fr;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
padding: 11px; padding: 11px;
@ -2190,6 +2201,29 @@
color: #647b98; color: #647b98;
font-size: 11px; 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 { .task-detail-table .row {
border-top: 1px solid #edf1f6; border-top: 1px solid #edf1f6;
color: #536b88; color: #536b88;
@ -2211,6 +2245,73 @@
text-align: center; text-align: center;
color: #8a9cb1; 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 { .task-detail-evidence {
display: grid; display: grid;
grid-template-columns: 1.5fr 1fr; grid-template-columns: 1.5fr 1fr;
@ -2325,6 +2426,25 @@
font-size: 12px; font-size: 12px;
line-height: 1.75; 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 { .refund-confirm-modal > div {
display: grid; display: grid;
grid-template-columns: 1fr 1.35fr; grid-template-columns: 1fr 1.35fr;
@ -2389,7 +2509,8 @@
overflow-x: auto; overflow-x: auto;
} }
.task-detail-table .head, .task-detail-table .head,
.task-detail-table .row { .task-detail-table .row,
.task-detail-table-body {
min-width: 620px; min-width: 620px;
} }
.task-detail-evidence { .task-detail-evidence {
@ -4655,3 +4776,117 @@ body img {
bottom: 14px; 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}

View File

@ -90,7 +90,10 @@ function LoginPage({ onLogin }) {
const autoLoginStarted = useRef(false); const autoLoginStarted = useRef(false);
const query = new URLSearchParams(window.location.search); const query = new URLSearchParams(window.location.search);
const getDingTalkBridge = () => window.dd || window.DingTalkPC || window.dingtalk; 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() { function loadDingTalkSdk() {
if (getDingTalkBridge()) return Promise.resolve(getDingTalkBridge()); if (getDingTalkBridge()) return Promise.resolve(getDingTalkBridge());
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {