diff --git a/backend/services/project_assistant.py b/backend/services/project_assistant.py index 6f107ff..03db923 100644 --- a/backend/services/project_assistant.py +++ b/backend/services/project_assistant.py @@ -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"] = "查看预审进度" diff --git a/backend/test_project_assistant.py b/backend/test_project_assistant.py index c365efd..db0c177 100644 --- a/backend/test_project_assistant.py +++ b/backend/test_project_assistant.py @@ -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") diff --git a/scripts/test_reviewer_workspace.cjs b/scripts/test_reviewer_workspace.cjs index fee343b..64dd5b7 100644 --- a/scripts/test_reviewer_workspace.cjs +++ b/scripts/test_reviewer_workspace.cjs @@ -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"); diff --git a/src/app/data-access/workspace.css b/src/app/data-access/workspace.css index 13ec3a9..d728836 100644 --- a/src/app/data-access/workspace.css +++ b/src/app/data-access/workspace.css @@ -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} diff --git a/src/app/xingbang-workbench/page.tsx b/src/app/xingbang-workbench/page.tsx index 020507c..2c353da 100644 --- a/src/app/xingbang-workbench/page.tsx +++ b/src/app/xingbang-workbench/page.tsx @@ -61,7 +61,7 @@ export default function XingbangWorkspace() { catch(e:any){setError(e.message);await load();}finally{setBusy(false);} } const alerts = <>{error &&
{error}
}{notice &&
{notice}
}; - if (!project) return
当前身份 · {roleTitle}

{isReviewer ? "待评审项目池" : "兴邦金租 · 对公项目工作台"}

{isReviewer ? "客户经理提交后进入项目池;按等待时间排序,领取后进入你的评审办理。" : "选择一个项目,按“材料—预审/评审—报告—提交”主线办理。"}

{alerts}
setSearch(e.target.value)}/>{identity?.role === "admin" && 进入配置治理}
{items.filter(x => (x.id + x.customer).toLowerCase().includes(search.toLowerCase())).map(x => )}
{isReviewer&&!items.length&&

项目池暂无待评审项目

客户经理正式提交后,项目会按等待时间自动进入这里。

}
; + if (!project) return
当前身份 · {roleTitle}

{isReviewer ? "待评审项目池" : "兴邦金租 · 对公项目工作台"}

{isReviewer ? "客户经理提交后进入项目池;按等待时间排序,领取后进入你的评审办理。" : "选择一个项目,按“材料—预审/评审—报告—提交”主线办理。"}

{alerts}
setSearch(e.target.value)}/>{identity?.role === "admin" && 进入配置治理}
{items.filter(x => (x.id + x.customer).toLowerCase().includes(search.toLowerCase())).map(x => {const returned=!isReviewer&&x.workflow_state==="returned";return })}
{isReviewer&&!items.length&&

项目池暂无待评审项目

客户经理正式提交后,项目会按等待时间自动进入这里。

}
; 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];