feat: 在客户经理工作台突出退回项目
This commit is contained in:
@@ -166,12 +166,18 @@ class ProjectAssistantService(DocumentsMixin, ExperiencesMixin):
|
||||
project["submitted_at"] = submitted_at
|
||||
project["review_started_at"] = project.get("review_started_at") or (review and review["started_at"])
|
||||
project["review_wait_hours"] = self._hours(submitted_at, project["review_started_at"]) if submitted_at else None
|
||||
project["review_state"] = "评审中" if project.get("reviewer_id") else "待领取" if submitted_at else "尚未送审"
|
||||
decisions = listing(db, "review_decision", project["id"])
|
||||
latest_return = next((item for item in decisions if item.get("decision") == "退回" and not item.get("finding_id") and not item.get("node_id")), None)
|
||||
project["return_reason"] = (latest_return or {}).get("opinion", "")
|
||||
project["returned_at"] = project.get("review_decided_at") or (latest_return or {}).get("updated_at")
|
||||
project["review_state"] = "已退回" if project.get("workflow_state") == "returned" else "评审中" if project.get("reviewer_id") else "待领取" if submitted_at else "尚未送审"
|
||||
runs = listing(db, "run", project["id"])
|
||||
project["latest_run"] = self.public_run(runs[0]) if runs else None
|
||||
project["open_findings"] = sum(f["status"] not in ("已解决", "不适用") for f in listing(db, "finding", project["id"]))
|
||||
reports = listing(db, "report", project["id"])
|
||||
if not runs:
|
||||
if project.get("workflow_state") == "returned":
|
||||
project["next_action"] = "查看退回要求并补充材料"
|
||||
elif not runs:
|
||||
project["next_action"] = "发起AI预审"
|
||||
elif runs[0]["status"] in ("queued", "running"):
|
||||
project["next_action"] = "查看预审进度"
|
||||
|
||||
@@ -343,6 +343,13 @@ class ProjectAssistantTests(unittest.TestCase):
|
||||
decision = self.post("/projects/DEMO-A/review-decisions", {"decision": "退回", "opinion": "补充最新银行流水"}, headers={"X-Xingbang-Demo-Role": "reviewer"})
|
||||
self.assertEqual(decision.status_code, 200, decision.text)
|
||||
self.assertEqual(decision.json()["decision"], "退回")
|
||||
manager_list = self.client.get(self.base + "/projects", headers={"X-Xingbang-Demo-Role": "manager"})
|
||||
returned = next(item for item in manager_list.json()["projects"] if item["id"] == "DEMO-A")
|
||||
self.assertEqual(returned["workflow_state"], "returned")
|
||||
self.assertEqual(returned["review_state"], "已退回")
|
||||
self.assertEqual(returned["return_reason"], "补充最新银行流水")
|
||||
self.assertEqual(returned["next_action"], "查看退回要求并补充材料")
|
||||
self.assertTrue(returned["returned_at"])
|
||||
|
||||
def test_full_review_topics_gate_approval_and_freeze_trace(self):
|
||||
pre_run = self.run_project("DEMO-A")
|
||||
|
||||
@@ -14,5 +14,8 @@ assert.match(component, /aria-label="退回补充材料原因"/, "退回操作
|
||||
assert.match(component, /disabled=\{busy\|\|!hasRun\|\|!returnReason\.trim\(\)\}/, "未填写退回原因时不应允许退回");
|
||||
assert.match(component, /onComplete\("退回",returnReason\.trim\(\)\)/, "退回原因应随退回决定一并保存");
|
||||
assert.match(page, /setReviewOpinion\]=useState\(""\)/, "常用模板默认不应冒充人工意见");
|
||||
assert.match(page, /x\.workflow_state==="returned"/, "客户经理工作台应识别评审退回项目");
|
||||
assert.match(page, /xb-project-card\$\{returned\?" is-returned":""\}/, "退回项目卡应使用醒目的独立样式");
|
||||
assert.match(page, /x\.return_reason\|\|"请打开项目查看评审退回要求"/, "退回项目卡应直接展示退回原因");
|
||||
|
||||
console.log("Reviewer workspace input behavior checks passed");
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
.ra-heading-actions{display:flex;gap:10px;align-items:center}.ra-folder-input{display:none}@media(max-width:760px){.ra-heading-actions{width:100%;flex-wrap:wrap}}
|
||||
.xb-decision-editor{display:block;margin-top:10px}.xb-decision-editor>span{display:block;margin-bottom:6px;color:#51675a;font-size:10px;font-weight:700}.xb-decision-editor textarea{box-sizing:border-box;width:100%;min-height:82px;padding:10px 11px;resize:vertical;border:1px solid #d9e2dc;border-radius:7px;background:#fff;color:#273c30;font:inherit;line-height:1.6;outline:none}.xb-decision-editor textarea:focus{border-color:#3b8d64;box-shadow:0 0 0 3px #3b8d6418}.xb-decision-editor>small{display:block;margin-top:4px;text-align:right;color:#8a958e;font-size:9px}
|
||||
.xb-return-reason{display:block;width:min(360px,100%)}.xb-return-reason>span{display:block;margin-bottom:5px;color:#587063;font-size:9px;font-weight:700}.xb-return-reason textarea{box-sizing:border-box;width:100%;min-height:58px;padding:8px 10px;resize:vertical;border:1px solid #ccdcd2;border-radius:7px;background:#fff;color:#273c30;font:inherit;line-height:1.5;outline:none}.xb-return-reason textarea:focus{border-color:#3b8d64;box-shadow:0 0 0 3px #3b8d6418}@media(max-width:900px){.xb-return-reason{width:100%}}
|
||||
.xb-project-card.is-returned{border-color:#e7847b;background:#fff1ef;box-shadow:0 7px 24px #b8403020}.xb-project-card.is-returned:hover{border-color:#c74d42;box-shadow:0 9px 28px #b8403030}.xb-project-card.is-returned>span{background:#c9463b;color:#fff}.xb-returned-summary{display:grid;gap:6px;margin:15px 0;padding:12px 14px;border-left:3px solid #d04d43;border-radius:7px;background:#fff8f7}.xb-returned-summary small{color:#9a5c56}.xb-returned-summary strong{font-size:12px;line-height:1.6;color:#8d3029}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function XingbangWorkspace() {
|
||||
catch(e:any){setError(e.message);await load();}finally{setBusy(false);}
|
||||
}
|
||||
const alerts = <>{error && <div role="alert" className="ra-alert error">{error}</div>}{notice && <div role="status" className="ra-alert">{notice}</div>}</>;
|
||||
if (!project) return <div className="ra-app"><AgentNav active="overview" identity={identity}/><main className="xb-overview"><div className="xb-overview-heading"><div><span className={`xb-role-banner role-${identity?.role}`}>当前身份 · {roleTitle}</span><h1>{isReviewer ? "待评审项目池" : "兴邦金租 · 对公项目工作台"}</h1><p>{isReviewer ? "客户经理提交后进入项目池;按等待时间排序,领取后进入你的评审办理。" : "选择一个项目,按“材料—预审/评审—报告—提交”主线办理。"}</p></div><button className="ra-button" onClick={load}><RefreshCw size={15}/>刷新</button></div>{alerts}<div className="xb-actions"><input aria-label="搜索项目" placeholder="项目编号 / 客户名称" value={search} onChange={e => setSearch(e.target.value)}/>{identity?.role === "admin" && <a className="ra-button" href="/data-access">进入配置治理</a>}</div><div className="xb-project-grid">{items.filter(x => (x.id + x.customer).toLowerCase().includes(search.toLowerCase())).map(x => <button className="xb-project-card" key={x.id} disabled={busy} onClick={() => {setTab("overview");isReviewer?claimAndOpen(x):open(x.id).catch(e => setError(e.message));}}><span>{isReviewer?(x.reviewer_id?"我已领取":"项目池待领取"):(x.data_domain === "demo" ? "演示项目" : "正式项目")}</span><h2>{x.customer}</h2><p>{x.id} · {x.industry} · {x.stage}</p>{isReviewer&&x.submitted_at&&<div className="xb-review-clock"><small>客户经理提交</small><b>{new Date(x.submitted_at).toLocaleString()}</b><strong>已等待 {durationText(x.review_wait_hours)}</strong><span>{x.review_state}</span></div>}<div className="xb-next"><small>{isReviewer?"办理操作":"建议下一步"}</small><b>{x.next_action}</b></div><footer><b>{x.open_findings} 项待处理</b><small>{statuses[x.latest_run?.status] || "尚未检查"}</small><ArrowRight size={16}/></footer></button>)}</div>{isReviewer&&!items.length&&<div className="ra-empty"><h2>项目池暂无待评审项目</h2><p>客户经理正式提交后,项目会按等待时间自动进入这里。</p></div>}</main></div>;
|
||||
if (!project) return <div className="ra-app"><AgentNav active="overview" identity={identity}/><main className="xb-overview"><div className="xb-overview-heading"><div><span className={`xb-role-banner role-${identity?.role}`}>当前身份 · {roleTitle}</span><h1>{isReviewer ? "待评审项目池" : "兴邦金租 · 对公项目工作台"}</h1><p>{isReviewer ? "客户经理提交后进入项目池;按等待时间排序,领取后进入你的评审办理。" : "选择一个项目,按“材料—预审/评审—报告—提交”主线办理。"}</p></div><button className="ra-button" onClick={load}><RefreshCw size={15}/>刷新</button></div>{alerts}<div className="xb-actions"><input aria-label="搜索项目" placeholder="项目编号 / 客户名称" value={search} onChange={e => setSearch(e.target.value)}/>{identity?.role === "admin" && <a className="ra-button" href="/data-access">进入配置治理</a>}</div><div className="xb-project-grid">{items.filter(x => (x.id + x.customer).toLowerCase().includes(search.toLowerCase())).map(x => {const returned=!isReviewer&&x.workflow_state==="returned";return <button className={`xb-project-card${returned?" is-returned":""}`} key={x.id} disabled={busy} onClick={() => {setTab("overview");isReviewer?claimAndOpen(x):open(x.id).catch(e => setError(e.message));}}><span>{returned?"评审退回 · 待补材料":isReviewer?(x.reviewer_id?"我已领取":"项目池待领取"):(x.data_domain === "demo" ? "演示项目" : "正式项目")}</span><h2>{x.customer}</h2><p>{x.id} · {x.industry} · {x.stage}</p>{returned&&<div className="xb-returned-summary"><small>{x.returned_at?`${new Date(x.returned_at).toLocaleString()} 退回`:"评审已退回"}</small><strong>{x.return_reason||"请打开项目查看评审退回要求"}</strong></div>}{isReviewer&&x.submitted_at&&<div className="xb-review-clock"><small>客户经理提交</small><b>{new Date(x.submitted_at).toLocaleString()}</b><strong>已等待 {durationText(x.review_wait_hours)}</strong><span>{x.review_state}</span></div>}<div className="xb-next"><small>{isReviewer?"办理操作":"建议下一步"}</small><b>{x.next_action}</b></div><footer><b>{x.open_findings} 项待处理</b><small>{returned?"请优先处理":statuses[x.latest_run?.status] || "尚未检查"}</small><ArrowRight size={16}/></footer></button>})}</div>{isReviewer&&!items.length&&<div className="ra-empty"><h2>项目池暂无待评审项目</h2><p>客户经理正式提交后,项目会按等待时间自动进入这里。</p></div>}</main></div>;
|
||||
const base = `/projects/${encodeURIComponent(project.id)}`;
|
||||
const chosenRun = project.runs.find((r: any) => r.id === runId) || project.runs[0];
|
||||
const chosenReport = project.reports.find((r: any) => r.id === reportId) || project.reports[0];
|
||||
|
||||
Reference in New Issue
Block a user