feat: complete SOP execution workflow

This commit is contained in:
Eric 1549169735@qq.com
2026-08-09 00:29:26 +08:00
parent e32025d2ca
commit c5ab886b70
63 changed files with 1229 additions and 215 deletions

View File

@@ -0,0 +1 @@
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`DeleteOutlined`,a.inheritAttrs=!1;export{a as t};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.execution-shell[data-v-e2671b1e]{max-width:1220px}.sop-catalog[data-v-e2671b1e]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-e2671b1e]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-e2671b1e]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-e2671b1e]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-e2671b1e]{color:var(--green);font-weight:650}.sop-entry h2[data-v-e2671b1e]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-e2671b1e]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-e2671b1e]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-e2671b1e]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-e2671b1e]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-e2671b1e]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-e2671b1e]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-e2671b1e]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-e2671b1e]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-e2671b1e]{color:#9eada7;font-size:12px}.privacy-note[data-v-e2671b1e]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-e2671b1e]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-e2671b1e]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-e2671b1e]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-e2671b1e]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-e2671b1e]{padding:44px 0 26px}.node-content small[data-v-e2671b1e]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-e2671b1e]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-e2671b1e]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-e2671b1e]{max-width:680px}.choice-group[data-v-e2671b1e]{flex-wrap:wrap;display:flex}.run-actions[data-v-e2671b1e]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.completed-state[data-v-e2671b1e]{text-align:center;padding:40px 0}.completed-state>span[data-v-e2671b1e]{color:var(--green);font-size:50px}.completed-state h3[data-v-e2671b1e]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-e2671b1e]{color:var(--muted);margin:0 0 24px}.quick-feedback[data-v-e2671b1e]{text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:#f5f8f6;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;max-width:620px;margin:0 auto 18px;padding:16px;display:grid}.quick-feedback>span[data-v-e2671b1e]{font-size:12px;font-weight:650}.quick-feedback[data-v-e2671b1e] .ant-rate{font-size:18px}.quick-feedback[data-v-e2671b1e] .ant-input{grid-column:1/3}.feedback-thanks[data-v-e2671b1e]{max-width:520px;color:var(--green);background:#edf6f2;margin:0 auto 18px;padding:12px;font-size:12px}@media (width<=800px){.sop-catalog[data-v-e2671b1e],.run-workspace[data-v-e2671b1e]{grid-template-columns:1fr}.run-context[data-v-e2671b1e]{position:static}.conversation[data-v-e2671b1e]{padding:22px}.run-actions[data-v-e2671b1e]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-e2671b1e]{display:none}.quick-feedback[data-v-e2671b1e]{grid-template-columns:1fr}.quick-feedback[data-v-e2671b1e] .ant-input{grid-column:auto}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.execution-shell[data-v-937f7610]{max-width:1220px}.sop-catalog[data-v-937f7610]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-937f7610]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-937f7610]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-937f7610]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-937f7610]{color:var(--green);font-weight:650}.sop-entry h2[data-v-937f7610]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-937f7610]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-937f7610]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-937f7610]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-937f7610]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-937f7610]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-937f7610]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-937f7610]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-937f7610]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-937f7610]{color:#9eada7;font-size:12px}.privacy-note[data-v-937f7610]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-937f7610]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-937f7610]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-937f7610]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-937f7610]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-937f7610]{padding:44px 0 26px}.node-content small[data-v-937f7610]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-937f7610]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-937f7610]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-937f7610]{max-width:680px}.choice-group[data-v-937f7610]{flex-wrap:wrap;display:flex}.run-actions[data-v-937f7610]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.run-actions>div[data-v-937f7610]{align-items:center;gap:9px;display:flex}.completed-state[data-v-937f7610]{text-align:center;padding:40px 0}.completed-state>span[data-v-937f7610]{color:var(--green);font-size:50px}.completed-state h3[data-v-937f7610]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-937f7610]{color:var(--muted);margin:0 0 24px}.quick-feedback[data-v-937f7610]{text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:#f5f8f6;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;max-width:620px;margin:0 auto 18px;padding:16px;display:grid}.quick-feedback>span[data-v-937f7610]{font-size:12px;font-weight:650}.quick-feedback[data-v-937f7610] .ant-rate{font-size:18px}.quick-feedback[data-v-937f7610] .ant-input{grid-column:1/3}.feedback-thanks[data-v-937f7610]{max-width:520px;color:var(--green);background:#edf6f2;margin:0 auto 18px;padding:12px;font-size:12px}.knowledge-snapshot[data-v-937f7610]{border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:0 0 24px}.knowledge-head[data-v-937f7610]{justify-content:space-between;align-items:center;padding:12px 0;display:flex}.knowledge-head span[data-v-937f7610]{color:var(--muted);font-size:11px}.standard-copy[data-v-937f7610],.knowledge-note[data-v-937f7610]{border-left:3px solid var(--green);background:#f2f7f5;padding:14px 16px}.standard-copy small[data-v-937f7610],.knowledge-note small[data-v-937f7610]{font-size:10px;font-weight:750}.standard-copy p[data-v-937f7610],.knowledge-note p[data-v-937f7610]{margin:6px 0 0;line-height:1.7}.knowledge-note[data-v-937f7610]{background:#fbf3f0;border-left-color:#c4573f;margin-top:8px}.knowledge-note.risk[data-v-937f7610]{background:#fbf6eb;border-left-color:#bd7b24}@media (width<=800px){.sop-catalog[data-v-937f7610],.run-workspace[data-v-937f7610]{grid-template-columns:1fr}.run-context[data-v-937f7610]{position:static}.conversation[data-v-937f7610]{padding:22px}.run-actions[data-v-937f7610]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-937f7610]{display:none}.quick-feedback[data-v-937f7610]{grid-template-columns:1fr}.quick-feedback[data-v-937f7610] .ant-input{grid-column:auto}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,_t as l,a as u,j as d,k as f,lt as p,n as m,r as h,t as g,tt as _,vt as v}from"./client-Bk7n1O1W.js";import{a as y}from"./config-provider-CxhqvDI_.js";import{n as b,r as x,t as S}from"./EyeOutlined-C1VmLQCG.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372 0-89 31.3-170.8 83.5-234.8l523.3 523.3C682.8 852.7 601 884 512 884zm288.5-137.2L277.2 223.5C341.2 171.3 423 140 512 140c205.4 0 372 166.6 372 372 0 89-31.3 170.8-83.5 234.8z`}}]},name:`stop`,theme:`outlined`};function T(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){E(e,t,n[t])})}return e}function E(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var D=function(e,t){var r=T({},e,t.attrs);return n(u,T({},r,{icon:w}),null)};D.displayName=`StopOutlined`,D.inheritAttrs=!1;var O={class:`page-shell review-page`},k={class:`page-heading`},A={class:`queue-count`},j={class:`surface review-table`},M={key:0,class:`sop-cell`},N={key:2,class:`waiting`},P={key:3,class:`row-actions`},F={class:`review-empty`},I={class:`modal-copy`},L=h(r({__name:`ReviewCenterView`,setup(r){let u=C(),h=v(!1),w=v([]),T=v(null),E=v(!1),L=l({reason:``}),R=[{title:`SOP / 场景`,key:`sop`},{title:`版本`,dataIndex:`version`,width:90},{title:`提交人`,dataIndex:`creator_name`,width:140},{title:`等待时间`,dataIndex:`submitted_at`,width:180},{title:``,key:`action`,width:250}];async function z(){h.value=!0;try{let e=await g.get(`/reviews`);w.value=e.items||[]}catch(e){y.error(m(e))}finally{h.value=!1}}async function B(e){E.value=!0;try{await g.post(`/sops/${e.sop_id}/publish`),y.success(`${e.sop_name}”已通过并发布`),await z()}catch(e){y.error(m(e))}finally{E.value=!1}}function V(e){T.value=e,L.reason=``}async function H(){if(!T.value||!L.reason.trim())return y.warning(`请填写退回原因`);E.value=!0;try{await g.post(`/sops/${T.value.sop_id}/reject`,{reason:L.reason}),y.success(`已退回编辑者修改`),T.value=null,await z()}catch(e){y.error(m(e))}finally{E.value=!1}}function U(e){let t=Math.max(1,Math.round((Date.now()-new Date(e).getTime())/6e4));if(t<60)return`${t} 分钟`;let n=Math.round(t/60);return n<48?`${n} 小时`:`${Math.round(n/24)}`}return c(z),(r,c)=>{let l=_(`a-tag`),m=_(`a-button`),g=_(`a-table`),v=_(`a-textarea`),y=_(`a-modal`);return o(),d(`div`,O,[i(`div`,k,[c[3]||=i(`div`,null,[i(`h1`,null,`审核中心`),i(`p`,null,`检查流程完整性、风险分支和话术口径后再决定发布。`)],-1),i(`div`,A,[n(s(b)),i(`b`,null,t(w.value.length),1),c[2]||=i(`span`,null,`待审核`,-1)])]),i(`section`,j,[c[9]||=i(`div`,{class:`review-guideline`},[i(`span`,null,`审核关注`),i(`b`,null,`字段完整`),i(`i`),i(`b`,null,`条件可达`),i(`i`),i(`b`,null,`高风险有出口`),i(`i`),i(`b`,null,`知识卡已发布`)],-1),n(g,{"row-key":`version_id`,loading:h.value,"data-source":w.value,columns:R,pagination:!1,scroll:{x:850}},{bodyCell:p(({column:r,record:h})=>[r.key===`sop`?(o(),d(`div`,M,[i(`b`,null,t(h.sop_name),1),i(`span`,null,t(h.scenario_name)+` · `+t(h.description||`暂无说明`),1)])):r.dataIndex===`version`?(o(),f(l,{key:1},{default:p(()=>[a(`V`+t(h.version),1)]),_:2},1024)):r.dataIndex===`submitted_at`?(o(),d(`span`,N,t(U(h.submitted_at)),1)):r.key===`action`?(o(),d(`div`,P,[n(m,{onClick:e=>s(u).push(`/sops/${h.sop_id}`)},{default:p(()=>[n(s(S)),c[4]||=a(`查看`,-1)]),_:1},8,[`onClick`]),n(m,{danger:``,onClick:e=>V(h)},{default:p(()=>[n(s(D)),c[5]||=a(`退回`,-1)]),_:1},8,[`onClick`]),n(m,{type:`primary`,loading:E.value,onClick:e=>B(h)},{default:p(()=>[n(s(x)),c[6]||=a(`通过`,-1)]),_:1},8,[`loading`,`onClick`])])):e(``,!0)]),emptyText:p(()=>[i(`div`,F,[n(s(x)),c[7]||=i(`b`,null,`审核队列已清空`,-1),c[8]||=i(`span`,null,`新提交的 SOP 会出现在这里。`,-1)])]),_:1},8,[`loading`,`data-source`])]),n(y,{open:!!T.value,title:`退回修改`,"ok-text":`确认退回`,"confirm-loading":E.value,onOk:H,onCancel:c[1]||=e=>T.value=null},{default:p(()=>[i(`p`,I,`请说明“`+t(T.value?.sop_name)+`”需要调整的具体内容,编辑者可据此修改后重新提交。`,1),n(v,{value:L.reason,"onUpdate:value":c[0]||=e=>L.reason=e,rows:5,maxlength:1e3,"show-count":``,placeholder:`例如:急症判断缺少默认路径,请补充非急症分支。`},null,8,[`value`])]),_:1},8,[`open`,`confirm-loading`])])}}}),[[`__scopeId`,`data-v-88d0e1a8`]]);export{L as default};

View File

@@ -0,0 +1 @@
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,_t as l,j as u,k as d,lt as f,n as p,r as m,t as h,tt as g,vt as _}from"./client-Bk7n1O1W.js";import{a as v}from"./config-provider-CxhqvDI_.js";import{n as y,r as b,t as x}from"./EyeOutlined-C1VmLQCG.js";import{t as S}from"./StopOutlined-X-yne_Zl.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={class:`page-shell review-page`},T={class:`page-heading`},E={class:`queue-count`},D={class:`surface review-table`},O={key:0,class:`sop-cell`},k={key:2,class:`waiting`},A={key:3,class:`row-actions`},j={class:`review-empty`},M={class:`modal-copy`},N=m(r({__name:`ReviewCenterView`,setup(r){let m=C(),N=_(!1),P=_([]),F=_(null),I=_(!1),L=l({reason:``}),R=[{title:`SOP / 场景`,key:`sop`},{title:`版本`,dataIndex:`version`,width:90},{title:`提交人`,dataIndex:`creator_name`,width:140},{title:`等待时间`,dataIndex:`submitted_at`,width:180},{title:``,key:`action`,width:250}];async function z(){N.value=!0;try{let e=await h.get(`/reviews`);P.value=e.items||[]}catch(e){v.error(p(e))}finally{N.value=!1}}async function B(e){I.value=!0;try{await h.post(`/sops/${e.sop_id}/publish`),v.success(`${e.sop_name}”已通过并发布`),await z()}catch(e){v.error(p(e))}finally{I.value=!1}}function V(e){F.value=e,L.reason=``}async function H(){if(!F.value||!L.reason.trim())return v.warning(`请填写退回原因`);I.value=!0;try{await h.post(`/sops/${F.value.sop_id}/reject`,{reason:L.reason}),v.success(`已退回编辑者修改`),F.value=null,await z()}catch(e){v.error(p(e))}finally{I.value=!1}}function U(e){let t=Math.max(1,Math.round((Date.now()-new Date(e).getTime())/6e4));if(t<60)return`${t} 分钟`;let n=Math.round(t/60);return n<48?`${n} 小时`:`${Math.round(n/24)}`}return c(z),(r,c)=>{let l=g(`a-tag`),p=g(`a-button`),h=g(`a-table`),_=g(`a-textarea`),v=g(`a-modal`);return o(),u(`div`,w,[i(`div`,T,[c[3]||=i(`div`,null,[i(`h1`,null,`审核中心`),i(`p`,null,`检查流程完整性、风险分支和话术口径后再决定发布。`)],-1),i(`div`,E,[n(s(y)),i(`b`,null,t(P.value.length),1),c[2]||=i(`span`,null,`待审核`,-1)])]),i(`section`,D,[c[9]||=i(`div`,{class:`review-guideline`},[i(`span`,null,`审核关注`),i(`b`,null,`字段完整`),i(`i`),i(`b`,null,`条件可达`),i(`i`),i(`b`,null,`高风险有出口`),i(`i`),i(`b`,null,`知识卡已发布`)],-1),n(h,{"row-key":`version_id`,loading:N.value,"data-source":P.value,columns:R,pagination:!1,scroll:{x:850}},{bodyCell:f(({column:r,record:h})=>[r.key===`sop`?(o(),u(`div`,O,[i(`b`,null,t(h.sop_name),1),i(`span`,null,t(h.scenario_name)+` · `+t(h.description||`暂无说明`),1)])):r.dataIndex===`version`?(o(),d(l,{key:1},{default:f(()=>[a(`V`+t(h.version),1)]),_:2},1024)):r.dataIndex===`submitted_at`?(o(),u(`span`,k,t(U(h.submitted_at)),1)):r.key===`action`?(o(),u(`div`,A,[n(p,{onClick:e=>s(m).push(`/sops/${h.sop_id}`)},{default:f(()=>[n(s(x)),c[4]||=a(`查看`,-1)]),_:1},8,[`onClick`]),n(p,{danger:``,onClick:e=>V(h)},{default:f(()=>[n(s(S)),c[5]||=a(`退回`,-1)]),_:1},8,[`onClick`]),n(p,{type:`primary`,loading:I.value,onClick:e=>B(h)},{default:f(()=>[n(s(b)),c[6]||=a(`通过`,-1)]),_:1},8,[`loading`,`onClick`])])):e(``,!0)]),emptyText:f(()=>[i(`div`,j,[n(s(b)),c[7]||=i(`b`,null,`审核队列已清空`,-1),c[8]||=i(`span`,null,`新提交的 SOP 会出现在这里。`,-1)])]),_:1},8,[`loading`,`data-source`])]),n(v,{open:!!F.value,title:`退回修改`,"ok-text":`确认退回`,"confirm-loading":I.value,onOk:H,onCancel:c[1]||=e=>F.value=null},{default:f(()=>[i(`p`,M,`请说明“`+t(F.value?.sop_name)+`”需要调整的具体内容,编辑者可据此修改后重新提交。`,1),n(_,{value:L.reason,"onUpdate:value":c[0]||=e=>L.reason=e,rows:5,maxlength:1e3,"show-count":``,placeholder:`例如:急症判断缺少默认路径,请补充非急症分支。`},null,8,[`value`])]),_:1},8,[`open`,`confirm-loading`])])}}}),[[`__scopeId`,`data-v-88d0e1a8`]]);export{N as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.run-detail-page[data-v-65fb6a52]{max-width:1320px}.back-link[data-v-65fb6a52]{height:auto;margin:0 0 14px;padding:0}.detail-heading[data-v-65fb6a52]{justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:20px;display:flex}.run-code[data-v-65fb6a52]{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1[data-v-65fb6a52]{margin:7px 0 5px;font-family:Noto Serif SC,serif;font-size:28px}.detail-heading p[data-v-65fb6a52]{color:var(--muted);margin:0}.result-block[data-v-65fb6a52]{border-left:3px solid;align-items:center;gap:12px;min-width:210px;padding:15px 17px;display:flex}.result-block>span[data-v-65fb6a52]:first-child{font-size:25px}.result-block>span[data-v-65fb6a52]:last-child{flex-direction:column;display:flex}.result-block small[data-v-65fb6a52]{font-size:10px}.result-block b[data-v-65fb6a52]{margin-top:3px}.result-block.success[data-v-65fb6a52]{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning[data-v-65fb6a52]{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout[data-v-65fb6a52]{grid-template-columns:minmax(0,1fr) 330px;gap:16px;display:grid}.timeline-panel[data-v-65fb6a52]{min-height:560px;padding:22px 24px}.section-title[data-v-65fb6a52]{justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:22px;display:flex}.section-title span[data-v-65fb6a52]{font-weight:700}.section-title small[data-v-65fb6a52]{color:var(--muted);font-size:11px}.run-timeline[data-v-65fb6a52]{padding-top:5px}.event-head[data-v-65fb6a52]{align-items:center;gap:9px;min-height:27px;display:flex}.event-head time[data-v-65fb6a52]{color:#8b9691;margin-left:auto;font-size:11px}.run-timeline p[data-v-65fb6a52]{color:#56635d;margin:8px 0 0;line-height:1.65}.event-answers[data-v-65fb6a52]{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px;display:grid}.event-answers span[data-v-65fb6a52]{background:#f4f7f5;border-left:2px solid #a6cbbd;justify-content:space-between;gap:10px;padding:8px 10px;display:flex}.event-answers small[data-v-65fb6a52]{color:var(--muted)}.event-answers b[data-v-65fb6a52]{font-size:12px}.detail-aside[data-v-65fb6a52]{flex-direction:column;gap:16px;display:flex}.summary-panel[data-v-65fb6a52],.answer-panel[data-v-65fb6a52]{padding:20px}.summary-panel dl[data-v-65fb6a52]{margin:0}.summary-panel dl>div[data-v-65fb6a52]{border-bottom:1px solid #edf0ee;justify-content:space-between;gap:15px;padding:10px 0;display:flex}.summary-panel dl>div[data-v-65fb6a52]:last-child{border-bottom:0}.summary-panel dt[data-v-65fb6a52]{color:var(--muted);font-size:12px}.summary-panel dd[data-v-65fb6a52]{text-align:right;margin:0;font-size:12px}.answer-list>div[data-v-65fb6a52]{border-bottom:1px solid #edf0ee;flex-direction:column;padding:10px 0;display:flex}.answer-list>div[data-v-65fb6a52]:last-child{border-bottom:0}.answer-list span[data-v-65fb6a52]{color:var(--muted);font-size:11px}.answer-list b[data-v-65fb6a52]{margin-top:4px;font-size:13px}.aside-empty[data-v-65fb6a52]{color:#929d98;text-align:center;padding:24px 0;font-size:12px}.feedback-band[data-v-65fb6a52]{margin-top:16px;padding:22px 24px}.feedback-list[data-v-65fb6a52]{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;display:grid}.feedback-list article[data-v-65fb6a52]{border-top:1px solid var(--line);padding:14px 0}.feedback-list article>div[data-v-65fb6a52]{justify-content:space-between;align-items:center;display:flex}.feedback-list[data-v-65fb6a52] .ant-rate{font-size:15px}.feedback-list p[data-v-65fb6a52]{color:#54615b;margin:9px 0}.feedback-list time[data-v-65fb6a52]{color:#929d98;font-size:11px}.feedback-form[data-v-65fb6a52]{border-top:1px solid var(--line);grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;display:grid}.feedback-form>div[data-v-65fb6a52]{flex-direction:column;gap:6px;display:flex}.feedback-form>div>span[data-v-65fb6a52]{font-size:12px;font-weight:650}.feedback-form[data-v-65fb6a52] .ant-rate{font-size:20px}@media (width<=900px){.detail-layout[data-v-65fb6a52]{grid-template-columns:1fr}.detail-aside[data-v-65fb6a52]{grid-template-columns:1fr 1fr;display:grid}.feedback-form[data-v-65fb6a52],.feedback-list[data-v-65fb6a52]{grid-template-columns:1fr}}@media (width<=620px){.detail-heading[data-v-65fb6a52]{flex-direction:column;align-items:flex-start}.result-block[data-v-65fb6a52]{width:100%}.detail-aside[data-v-65fb6a52]{grid-template-columns:1fr}.timeline-panel[data-v-65fb6a52]{padding:18px}.event-head[data-v-65fb6a52]{flex-wrap:wrap;align-items:flex-start}.event-head time[data-v-65fb6a52]{width:100%;margin:0}.event-answers[data-v-65fb6a52]{grid-template-columns:1fr}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.history-toolbar[data-v-d3f4eec1]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;min-height:58px;padding:10px 14px;display:flex}.filter-set[data-v-d3f4eec1]{color:#7a8781;flex-wrap:wrap;align-items:center;gap:9px;display:flex}.filter-set[data-v-d3f4eec1] .ant-select{width:150px}.filter-set[data-v-d3f4eec1] .ant-picker{width:240px}.run-sop[data-v-d3f4eec1]{flex-direction:column;display:flex}.run-sop small[data-v-d3f4eec1]{color:var(--muted);margin-top:4px}.surface[data-v-d3f4eec1] .ant-table-row{cursor:pointer}.surface[data-v-d3f4eec1] .ant-table-row:hover .row-arrow{color:var(--green);transform:translate(2px)}code[data-v-d3f4eec1]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.row-arrow[data-v-d3f4eec1]{color:#9ba6a1;transition:all .16s}@media (width<=700px){.history-toolbar[data-v-d3f4eec1]{align-items:flex-start}.filter-set[data-v-d3f4eec1]{width:100%}.filter-set[data-v-d3f4eec1] .ant-select{width:calc(50% - 18px)}.filter-set[data-v-d3f4eec1] .ant-picker{width:100%}}

View File

@@ -1 +0,0 @@
.history-toolbar[data-v-681d0563]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;min-height:58px;padding:10px 14px;display:flex}.filter-set[data-v-681d0563]{color:#7a8781;align-items:center;gap:9px;display:flex}.filter-set[data-v-681d0563] .ant-select{width:160px}.surface[data-v-681d0563] .ant-table-row{cursor:pointer}.surface[data-v-681d0563] .ant-table-row:hover .row-arrow{color:var(--green);transform:translate(2px)}code[data-v-681d0563]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.row-arrow[data-v-681d0563]{color:#9ba6a1;transition:all .16s}@media (width<=600px){.history-toolbar[data-v-681d0563]{align-items:flex-start}.filter-set[data-v-681d0563]{flex-wrap:wrap}.filter-set[data-v-681d0563] .ant-select{width:138px}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,S as s,Tt as c,Y as l,_t as u,a as d,j as f,k as p,lt as m,n as h,r as g,t as _,tt as v,vt as y}from"./client-Bk7n1O1W.js";import{a as b}from"./config-provider-CxhqvDI_.js";import{n as x,t as S}from"./ReloadOutlined-Cih9hKpY.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M880.1 154H143.9c-24.5 0-39.8 26.7-27.5 48L349 597.4V838c0 17.7 14.2 32 31.8 32h262.4c17.6 0 31.8-14.3 31.8-32V597.4L907.7 202c12.2-21.3-3.1-48-27.6-48zM603.4 798H420.6V642h182.9v156zm9.6-236.6l-9.5 16.6h-183l-9.5-16.6L212.7 226h598.6L613 561.4z`}}]},name:`filter`,theme:`outlined`};function T(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){E(e,t,n[t])})}return e}function E(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var D=function(e,t){var r=T({},e,t.attrs);return n(d,T({},r,{icon:w}),null)};D.displayName=`FilterOutlined`,D.inheritAttrs=!1;var O={class:`page-shell`},k={class:`surface`},A={class:`history-toolbar`},j={class:`filter-set`},M={key:0},N=g(r({__name:`RunHistoryView`,setup(r){let d=C(),g=y(!1),w=y([]),T=y(0),E=y(1),N=y(20),P=u({status:``,result:``}),F=[{title:`执行编号`,dataIndex:`id`,width:130},{title:`SOP`,dataIndex:`sop_name`},{title:`状态`,dataIndex:`status`,width:110},{title:`结果`,dataIndex:`result`,width:130},{title:`开始时间`,dataIndex:`started_at`,width:190},{title:`完成时间`,dataIndex:`completed_at`,width:190},{title:``,key:`action`,width:54}];async function I(e=!1){e&&(E.value=1),g.value=!0;try{let e=await _.get(`/runs`,{params:{page:E.value,page_size:N.value,status:P.status||void 0,result:P.result||void 0}});w.value=e.items||[],T.value=e.total}catch(e){b.error(h(e))}finally{g.value=!1}}function L(e){return e===`completed`?`已完成`:`进行中`}function R(e){return{finish:`正常结束`,escalate:`转人工 / 转诊`,manual:`人工结束`}[e]||e||`-`}function z(e,t){E.value=e,N.value=t,I()}function B(e){return{onClick:()=>d.push(`/runs/${e.id}`)}}function V(e){z(e.current||1,e.pageSize||20)}return l(I),(r,l)=>{let u=v(`a-select`),d=v(`a-button`),h=v(`a-tag`),_=v(`a-table`);return o(),f(`div`,O,[l[6]||=i(`div`,{class:`page-heading`},[i(`div`,null,[i(`h1`,null,`执行记录`),i(`p`,null,`按结果筛选实际执行,进入详情复盘每一步回答和分流。`)])],-1),i(`section`,k,[i(`div`,A,[i(`div`,j,[n(c(D)),n(u,{value:P.status,"onUpdate:value":l[0]||=e=>P.status=e,"allow-clear":``,placeholder:`全部状态`,options:[{value:`running`,label:`进行中`},{value:`completed`,label:`已完成`}],onChange:l[1]||=e=>I(!0)},null,8,[`value`]),n(u,{value:P.result,"onUpdate:value":l[2]||=e=>P.result=e,"allow-clear":``,placeholder:`全部结果`,options:[{value:`finish`,label:`正常结束`},{value:`escalate`,label:`转人工 / 转诊`},{value:`manual`,label:`人工结束`}],onChange:l[3]||=e=>I(!0)},null,8,[`value`])]),n(d,{type:`text`,"aria-label":`刷新执行记录`,onClick:l[4]||=e=>I()},{default:m(()=>[n(c(S))]),_:1})]),n(_,{"row-key":`id`,loading:g.value,"data-source":w.value,columns:F,pagination:{current:E.value,pageSize:N.value,total:T.value,showSizeChanger:!0,showTotal:e=>`${e}`},scroll:{x:980},"custom-row":B,onChange:V},{bodyCell:m(({column:n,record:r})=>[n.dataIndex===`id`?(o(),f(`code`,M,`RUN-`+t(String(r.id).padStart(5,`0`)),1)):n.dataIndex===`status`?(o(),p(h,{key:1,color:r.status===`completed`?`green`:`orange`},{default:m(()=>[a(t(L(r.status)),1)]),_:2},1032,[`color`])):n.dataIndex===`result`?(o(),f(s,{key:2},[a(t(R(r.result)),1)],64)):n.dataIndex===`started_at`||n.dataIndex===`completed_at`?(o(),f(s,{key:3},[a(t(r[n.dataIndex]?new Date(r[n.dataIndex]).toLocaleString(`zh-CN`):`-`),1)],64)):n.key===`action`?(o(),p(c(x),{key:4,class:`row-arrow`})):e(``,!0)]),emptyText:m(()=>[...l[5]||=[i(`div`,{class:`empty-copy`},`当前筛选条件下没有执行记录。`,-1)]]),_:1},8,[`loading`,`data-source`,`pagination`])])])}}}),[[`__scopeId`,`data-v-681d0563`]]);export{N as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372 0-89 31.3-170.8 83.5-234.8l523.3 523.3C682.8 852.7 601 884 512 884zm288.5-137.2L277.2 223.5C341.2 171.3 423 140 512 140c205.4 0 372 166.6 372 372 0 89-31.3 170.8-83.5 234.8z`}}]},name:`stop`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`StopOutlined`,a.inheritAttrs=!1;export{a as t};

File diff suppressed because one or more lines are too long

37
web/dist/assets/modal-C5Vo2Ug5.js vendored Normal file

File diff suppressed because one or more lines are too long

5
web/dist/index.html vendored
View File

@@ -5,17 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202825" />
<title>销冠 SOP 平台</title>
<script type="module" crossorigin src="/assets/index-BeO8QXu8.js"></script>
<script type="module" crossorigin src="/assets/index-DpE4cxr4.js"></script>
<link rel="modulepreload" crossorigin href="/assets/client-Bk7n1O1W.js">
<link rel="modulepreload" crossorigin href="/assets/config-provider-CxhqvDI_.js">
<link rel="modulepreload" crossorigin href="/assets/auth-D6Tj4g1X.js">
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-CRNedxr9.js">
<link rel="modulepreload" crossorigin href="/assets/modal-C5Vo2Ug5.js">
<link rel="modulepreload" crossorigin href="/assets/EyeOutlined-C1VmLQCG.js">
<link rel="modulepreload" crossorigin href="/assets/SearchOutlined-D6c8vQZ_.js">
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-Cih9hKpY.js">
<link rel="modulepreload" crossorigin href="/assets/PlusOutlined-CosM7zFe.js">
<link rel="modulepreload" crossorigin href="/assets/ArrowLeftOutlined-B1KoeknA.js">
<link rel="modulepreload" crossorigin href="/assets/AppstoreOutlined-BD2QJkU0.js">
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-Bt9-1HZZ.js">
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-BIUW-zYI.js">
<link rel="modulepreload" crossorigin href="/assets/BookOutlined-Canuw-q-.js">
<link rel="modulepreload" crossorigin href="/assets/HistoryOutlined-ABEqcIfz.js">

View File

@@ -4,13 +4,14 @@ export interface Scenario extends BaseEntity { tenant_id: number; name: string;
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string }
export interface SOPVersion extends BaseEntity { sop_id: number; version: number; status: string; start_node_key: string; published_at?: string; created_by?:number; reviewed_by?:number; creator_name?:string; reviewer_name?:string }
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number }
export interface KnowledgeSnapshot { card_id:number; card_version_id:number; version:number; title:string; standard_copy:string; forbidden_copy:string; risk_note:string }
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number; knowledge?:KnowledgeSnapshot }
export interface SOPEdge extends BaseEntity { source_node_key: string; target_node_key: string; condition: Record<string, any>; priority: number }
export interface SOPRun extends BaseEntity { sop_id: number; sop_version_id: number; current_node_key: string; status: string; answers: Record<string, any>; result: string; started_at: string; completed_at?: string; sop_name?: string }
export interface SOPRun extends BaseEntity { sop_id: number; sop_version_id: number; operator_id:number; current_node_key: string; status: string; answers: Record<string, any>; result: string; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; version?:number; operator_name?:string }
export interface KnowledgeCard extends BaseEntity { scenario_id: number; title: string; status: string; version?: number; content?: Record<string, any> }
export interface KnowledgeCardVersion extends BaseEntity { knowledge_card_id:number; version:number; status:string; content:Record<string,any> }
export interface ReviewItem { sop_id:number; sop_name:string; description:string; scenario_id:number; scenario_name:string; version_id:number; version:number; creator_id:number; creator_name:string; submitted_at:string }
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string }
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string; knowledge?:KnowledgeSnapshot }
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
export interface RunDetail extends SOPRun { scenario_name:string; version:number; operator_name:string }
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }

View File

@@ -1,22 +1,60 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import { useRoute, useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import type { ScenarioField, SOPNode, SOPRun } from '@/types'
interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number }
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] }
const route=useRoute();const router=useRouter()
const loading=ref(true);const sops=ref<PublishedSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false)
const answerForm=ref<FormInstance>()
const feedback=reactive({score:5,comment:''});const feedbackSent=ref(false)
const currentConfig=computed(()=>active.value?.node.config||{})
const currentFields=computed(()=>{if(!active.value)return[];const node=active.value.node;if(node.type==='question'||node.type==='choice')return active.value.fields.filter(f=>f.field_key===currentConfig.value.field_key);if(node.type==='form')return active.value.fields.filter(f=>(currentConfig.value.field_keys||[]).includes(f.field_key));return[]})
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k]);Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}catch(error){message.error(apiMessage(error))}}
async function next(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items;const runID=Number(route.query.run||0);if(runID>0)active.value=await api.get<RunView>(`/runs/${runID}`)}catch(error){message.error(apiMessage(error));await router.replace({query:{}})}finally{loading.value=false}}
async function clearAnswers(){Object.keys(answers).forEach(k=>delete answers[k]);await nextTick();answerForm.value?.clearValidate()}
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});await router.replace({query:{run:String(active.value.run.id)}});await clearAnswers();Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}catch(error){message.error(apiMessage(error))}}
async function next(){if(!active.value)return;try{await answerForm.value?.validate()}catch{return}submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});await clearAnswers()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function finishManually(){
if(!active.value)return
Modal.confirm({
title:'结束本次执行?',
content:'当前节点尚未提交的内容不会保存,执行记录将标记为“人工结束”。',
okText:'确认结束',cancelText:'继续执行',okType:'danger',
async onOk(){
if(!active.value)return
submitting.value=true
try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/finish`,{result:'manual'});Object.keys(answers).forEach(k=>delete answers[k]);message.success('本次执行已结束')}
catch(error){message.error(apiMessage(error));throw error}
finally{submitting.value=false}
},
})
}
function inputFor(field:ScenarioField){return field.field_type}
function validationNumber(field:ScenarioField,key:string){const value=field.validation?.[key];return typeof value==='number'?value:undefined}
function fieldRequired(field:ScenarioField){return field.required||(['question','choice'].includes(active.value?.node.type||'')&&currentConfig.value.required===true)}
function fieldRules(field:ScenarioField){
const rules:any[]=[]
if(fieldRequired(field))rules.push({required:true,message:`请填写${field.field_name}`,trigger:['change','blur']})
if(['text','textarea'].includes(field.field_type)){
const min=validationNumber(field,'min_length');const max=validationNumber(field,'max_length')
if(min!==undefined)rules.push({min,message:`${field.field_name}不能少于 ${min} 个字符`,trigger:'blur'})
if(max!==undefined)rules.push({max,message:`${field.field_name}不能超过 ${max} 个字符`,trigger:'blur'})
}
if(field.field_type==='number'){
const min=validationNumber(field,'min');const max=validationNumber(field,'max')
rules.push({type:'number',message:`${field.field_name}必须是数字`,trigger:'change'})
if(min!==undefined)rules.push({type:'number',min,message:`${field.field_name}不能小于 ${min}`,trigger:'change'})
if(max!==undefined)rules.push({type:'number',max,message:`${field.field_name}不能大于 ${max}`,trigger:'change'})
}
return rules
}
async function sendFeedback(){if(!active.value)return;submitting.value=true;try{await api.post(`/runs/${active.value.run.id}/feedback`,feedback);feedbackSent.value=true;message.success('反馈已提交')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function reset(){active.value=null;load()}
async function reset(){active.value=null;await router.replace({query:{}});await load()}
onMounted(load)
</script>
@@ -31,29 +69,35 @@ onMounted(load)
<div v-else class="run-workspace">
<aside class="run-context">
<span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span>
<h2>执行进行中</h2><p>客户回答会自动保存在当前执行记录中</p>
<h2>{{ active.run.status==='completed'?'执行已结束':'执行进行中' }}</h2><p>客户回答会自动保存在当前执行记录中</p>
<div class="context-meta"><span>当前节点</span><b>{{ active.node.title }}</b></div><div class="context-meta"><span>已采集字段</span><b>{{ Object.keys(active.run.answers || {}).length }}</b></div>
<div class="privacy-note"><SafetyCertificateOutlined /><span>敏感信息请遵循企业数据规范</span></div>
</aside>
<main class="conversation surface">
<div class="conversation-progress"><span :class="{done:active.run.status==='completed'}"></span><b>{{ active.run.status==='completed'?'流程已完成':'正在执行' }}</b></div>
<div class="node-content"><small>{{ active.node.type.toUpperCase() }}</small><h1>{{ active.node.title }}</h1><blockquote v-if="active.node.content">{{ active.node.content }}</blockquote></div>
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.node.content }}</p><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @click="reset">执行另一套 SOP</a-button></div>
<section v-if="active.node.knowledge" class="knowledge-snapshot">
<div class="knowledge-head"><b>{{ active.node.knowledge.title }}</b><span>知识卡 V{{ active.node.knowledge.version }}</span></div>
<div class="standard-copy"><small>标准话术</small><p>{{ active.node.knowledge.standard_copy }}</p></div>
<div v-if="active.node.knowledge.forbidden_copy" class="knowledge-note forbidden"><small>禁用表达</small><p>{{ active.node.knowledge.forbidden_copy }}</p></div>
<div v-if="active.node.knowledge.risk_note" class="knowledge-note risk"><small>风险提醒</small><p>{{ active.node.knowledge.risk_note }}</p></div>
</section>
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.run.result==='manual'?'已人工结束':active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.run.result==='manual'?'本次执行由操作人员在当前节点结束。':active.node.content }}</p><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @click="reset">执行另一套 SOP</a-button></div>
<template v-else>
<a-form layout="vertical" class="answer-form">
<a-form-item v-for="field in currentFields" :key="field.id" :label="field.field_name" :required="field.required">
<a-input v-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" />
<a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" />
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" style="width:100%" />
<a-form ref="answerForm" :model="answers" layout="vertical" class="answer-form">
<a-form-item v-for="field in currentFields" :key="field.id" :name="field.field_key" :label="field.field_name" :required="fieldRequired(field)" :rules="fieldRules(field)">
<a-radio-group v-if="active.node.type==='choice'" v-model:value="answers[field.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group>
<a-input v-else-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" :maxlength="validationNumber(field,'max_length')" show-count />
<a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" :maxlength="validationNumber(field,'max_length')" show-count />
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" :min="validationNumber(field,'min')" :max="validationNumber(field,'max')" style="width:100%" />
<a-radio-group v-else-if="inputFor(field)==='boolean'" v-model:value="answers[field.field_key]"><a-radio :value="true">是</a-radio><a-radio :value="false"></a-radio></a-radio-group>
<a-select v-else-if="inputFor(field)==='select'" v-model:value="answers[field.field_key]" :options="(field.options||[]).map(value=>({value,label:value}))" />
<a-select v-else-if="inputFor(field)==='multiselect'" v-model:value="answers[field.field_key]" mode="multiple" :options="(field.options||[]).map(value=>({value,label:value}))" />
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" style="width:100%" />
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" value-format="YYYY-MM-DD" style="width:100%" />
<a-input v-else v-model:value="answers[field.field_key]" />
</a-form-item>
<a-form-item v-if="active.node.type==='choice'&&currentFields.length===0"><a-radio-group v-model:value="answers[currentConfig.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group></a-form-item>
</a-form>
<div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div>
<div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><div><a-button danger :disabled="submitting" @click="finishManually"><StopOutlined />人工结束</a-button><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div></div>
</template>
</main>
</div>
@@ -62,6 +106,7 @@ onMounted(load)
</template>
<style scoped>
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.run-actions>div{display:flex;align-items:center;gap:9px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
.knowledge-snapshot{margin:0 0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.knowledge-head{display:flex;align-items:center;justify-content:space-between;padding:12px 0}.knowledge-head span{color:var(--muted);font-size:11px}.standard-copy,.knowledge-note{padding:14px 16px;border-left:3px solid var(--green);background:#f2f7f5}.standard-copy small,.knowledge-note small{font-size:10px;font-weight:750}.standard-copy p,.knowledge-note p{margin:6px 0 0;line-height:1.7}.knowledge-note{margin-top:8px;border-left-color:#c4573f;background:#fbf3f0}.knowledge-note.risk{border-left-color:#bd7b24;background:#fbf6eb}
@media(max-width:800px){.sop-catalog{grid-template-columns:1fr}.run-workspace{grid-template-columns:1fr}.run-context{position:static}.conversation{padding:22px}.run-actions{margin:24px -22px -22px;padding:16px 22px}.run-actions span{display:none}.quick-feedback{grid-template-columns:1fr}.quick-feedback :deep(.ant-input){grid-column:auto}}
</style>

View File

@@ -13,7 +13,7 @@ const fieldMap=computed(()=>new Map((detail.value?.fields||[]).map(field=>[field
const answers=computed(()=>Object.entries(detail.value?.run.answers||{}))
const canFeedback=computed(()=>auth.can('runs.feedback')&&detail.value?.run.status==='completed'&&!detail.value.feedback.some(item=>item.user_id===auth.user?.user_id))
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'进行中'}
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答'}[v]||v}
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答',finish:'人工结束'}[v]||v}
function valueText(value:any){if(value===true)return'是';if(value===false)return'否';if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return'-';return String(value)}
function answerEntries(event:RunEvent){const values=event.payload?.answers||{};return Object.entries(values)}
async function load(){loading.value=true;try{detail.value=await api.get<DetailResponse>(`/runs/${route.params.id}/detail`)}catch(error){message.error(apiMessage(error));router.replace('/runs')}finally{loading.value=false}}
@@ -35,9 +35,10 @@ onMounted(load)
<main class="surface timeline-panel">
<div class="section-title"><span>执行轨迹</span><small>按实际发生顺序记录</small></div>
<a-timeline class="run-timeline">
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='answer'?'blue':event.node_type==='escalate'?'red':'green'">
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='finish'||event.node_type==='escalate'?'red':event.action==='answer'?'blue':'green'">
<div class="event-head"><b>{{ event.node_title||event.node_key }}</b><a-tag>{{ actionText(event.action) }}</a-tag><time>{{ new Date(event.created_at).toLocaleString('zh-CN') }}</time></div>
<p v-if="event.action==='enter'&&event.node_content">{{ event.node_content }}</p>
<div v-if="event.action==='enter'&&event.knowledge" class="event-knowledge"><div><b>{{ event.knowledge.title }}</b><small>V{{ event.knowledge.version }}</small></div><p>{{ event.knowledge.standard_copy }}</p><p v-if="event.knowledge.risk_note" class="risk-note">{{ event.knowledge.risk_note }}</p></div>
<div v-if="answerEntries(event).length" class="event-answers"><span v-for="[key,value] in answerEntries(event)" :key="key"><small>{{ fieldMap.get(key)||key }}</small><b>{{ valueText(value) }}</b></span></div>
</a-timeline-item>
</a-timeline>
@@ -62,4 +63,5 @@ onMounted(load)
<style scoped>
.run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;text-align:right;font-size:12px}.answer-list>div{display:flex;flex-direction:column;padding:10px 0;border-bottom:1px solid #edf0ee}.answer-list>div:last-child{border-bottom:0}.answer-list span{color:var(--muted);font-size:11px}.answer-list b{margin-top:4px;font-size:13px}.aside-empty{padding:24px 0;color:#929d98;text-align:center;font-size:12px}.feedback-band{margin-top:16px;padding:22px 24px}.feedback-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.feedback-list article{padding:14px 0;border-top:1px solid var(--line)}.feedback-list article>div{display:flex;align-items:center;justify-content:space-between}.feedback-list :deep(.ant-rate){font-size:15px}.feedback-list p{margin:9px 0;color:#54615b}.feedback-list time{color:#929d98;font-size:11px}.feedback-form{display:grid;grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;border-top:1px solid var(--line)}.feedback-form>div{display:flex;flex-direction:column;gap:6px}.feedback-form>div>span{font-size:12px;font-weight:650}.feedback-form :deep(.ant-rate){font-size:20px}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-aside{display:grid;grid-template-columns:1fr 1fr}.feedback-form{grid-template-columns:1fr}.feedback-list{grid-template-columns:1fr}}@media(max-width:620px){.detail-heading{align-items:flex-start;flex-direction:column}.result-block{width:100%}.detail-aside{grid-template-columns:1fr}.timeline-panel{padding:18px}.event-head{align-items:flex-start;flex-wrap:wrap}.event-head time{width:100%;margin:0}.event-answers{grid-template-columns:1fr}}
.event-knowledge{margin-top:10px;padding:12px 14px;background:#f2f7f5;border-left:3px solid var(--green)}.event-knowledge>div{display:flex;justify-content:space-between;gap:12px}.event-knowledge small{color:var(--muted)}.event-knowledge .risk-note{padding-top:8px;border-top:1px solid #dce9e4;color:#8d5b16}
</style>

View File

@@ -1,22 +1,32 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { FilterOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
import { FilterOutlined, PlayCircleOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { api, apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import type { SOPRun } from '@/types'
const router=useRouter()
const auth=useAuthStore()
const loading=ref(false);const items=ref<SOPRun[]>([]);const total=ref(0);const page=ref(1);const pageSize=ref(20)
const filters=reactive({status:'',result:''})
const columns=[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP',dataIndex:'sop_name'},{title:'状态',dataIndex:'status',width:110},{title:'结果',dataIndex:'result',width:130},{title:'开始时间',dataIndex:'started_at',width:190},{title:'完成时间',dataIndex:'completed_at',width:190},{title:'',key:'action',width:54}]
async function load(reset=false){if(reset)page.value=1;loading.value=true;try{const data=await api.get<{items:SOPRun[];total:number}>('/runs',{params:{page:page.value,page_size:pageSize.value,status:filters.status||undefined,result:filters.result||undefined}});items.value=data.items||[];total.value=data.total}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
interface FilterOption{value:number;label:string}
const filters=reactive({status:'',result:'',sop_id:undefined as number|undefined,operator_id:undefined as number|undefined})
const dateRange=ref<any[]>([])
const filterOptions=reactive<{sops:FilterOption[];operators:FilterOption[]}>({sops:[],operators:[]})
const columns=[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP / 场景',key:'sop',width:260},{title:'执行人',dataIndex:'operator_name',width:130},{title:'状态',dataIndex:'status',width:100},{title:'结果',dataIndex:'result',width:130},{title:'开始时间',dataIndex:'started_at',width:180},{title:'完成时间',dataIndex:'completed_at',width:180},{title:'',key:'action',width:54}]
function queryParams(){return{page:page.value,page_size:pageSize.value,status:filters.status||undefined,result:filters.result||undefined,sop_id:filters.sop_id,operator_id:filters.operator_id,started_from:dateRange.value?.[0]?.startOf('day').toISOString(),started_to:dateRange.value?.[1]?.endOf('day').toISOString()}}
async function load(reset=false){if(reset)page.value=1;loading.value=true;try{const data=await api.get<{items:SOPRun[];total:number}>('/runs',{params:queryParams()});items.value=data.items||[];total.value=data.total}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function loadOptions(){try{const data=await api.get<{sops:FilterOption[];operators:FilterOption[]}>('/runs/options');filterOptions.sops=data.sops||[];filterOptions.operators=data.operators||[]}catch(error){message.error(apiMessage(error))}}
function resetFilters(){Object.assign(filters,{status:'',result:'',sop_id:undefined,operator_id:undefined});dateRange.value=[];load(true)}
function statusText(v:string){return v==='completed'?'已完成':'进行中'}
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'-'}
function changePage(next:number,size:number){page.value=next;pageSize.value=size;load()}
function rowProps(record:SOPRun){return{onClick:()=>router.push(`/runs/${record.id}`)}}
function canContinue(record:SOPRun){return record.status==='running'&&auth.can('sop.execute')&&(record.operator_id===auth.user?.user_id||auth.can('*'))}
function continueRun(record:SOPRun){router.push({path:'/execute',query:{run:String(record.id)}})}
function tableChange(pager:{current?:number;pageSize?:number}){changePage(pager.current||1,pager.pageSize||20)}
onMounted(load)
onMounted(()=>{load();loadOptions()})
</script>
<template>
@@ -24,11 +34,11 @@ onMounted(load)
<div class="page-heading"><div><h1>执行记录</h1><p>按结果筛选实际执行进入详情复盘每一步回答和分流</p></div></div>
<section class="surface">
<div class="history-toolbar">
<div class="filter-set"><FilterOutlined /><a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="[{value:'running',label:'进行中'},{value:'completed',label:'已完成'}]" @change="load(true)"/><a-select v-model:value="filters.result" allow-clear placeholder="全部结果" :options="[{value:'finish',label:'正常结束'},{value:'escalate',label:'转人工 / 转诊'},{value:'manual',label:'人工结束'}]" @change="load(true)"/></div>
<div class="filter-set"><FilterOutlined /><a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="[{value:'running',label:'进行中'},{value:'completed',label:'已完成'}]" @change="load(true)"/><a-select v-model:value="filters.result" allow-clear placeholder="全部结果" :options="[{value:'finish',label:'正常结束'},{value:'escalate',label:'转人工 / 转诊'},{value:'manual',label:'人工结束'}]" @change="load(true)"/><a-select v-model:value="filters.sop_id" allow-clear show-search option-filter-prop="label" placeholder="全部 SOP" :options="filterOptions.sops" @change="load(true)"/><a-select v-model:value="filters.operator_id" allow-clear show-search option-filter-prop="label" placeholder="全部执行人" :options="filterOptions.operators" @change="load(true)"/><a-range-picker v-model:value="dateRange" :allow-clear="true" @change="load(true)"/><a-button type="link" @click="resetFilters">重置</a-button></div>
<a-button type="text" aria-label="刷新执行记录" @click="load()"><ReloadOutlined /></a-button>
</div>
<a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="{current:page,pageSize,total,showSizeChanger:true,showTotal:(value:number)=>`共 ${value} 条`}" :scroll="{x:980}" :custom-row="rowProps" @change="tableChange">
<template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ resultText(record.result) }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template><template v-else-if="column.key==='action'"><RightOutlined class="row-arrow" /></template></template>
<template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.key==='sop'"><div class="run-sop"><b>{{ record.sop_name }}</b><small>{{ record.scenario_name }} · V{{ record.version }}</small></div></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ resultText(record.result) }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template><template v-else-if="column.key==='action'"><a-tooltip v-if="canContinue(record)" title="继续执行"><a-button type="text" aria-label="继续执行" @click.stop="continueRun(record)"><PlayCircleOutlined /></a-button></a-tooltip><RightOutlined v-else class="row-arrow" /></template></template>
<template #emptyText><div class="empty-copy">当前筛选条件下没有执行记录</div></template>
</a-table>
</section>
@@ -36,5 +46,5 @@ onMounted(load)
</template>
<style scoped>
.history-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.filter-set{display:flex;align-items:center;gap:9px;color:#7a8781}.filter-set :deep(.ant-select){width:160px}.surface :deep(.ant-table-row){cursor:pointer}.surface :deep(.ant-table-row:hover) .row-arrow{color:var(--green);transform:translateX(2px)}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.row-arrow{color:#9ba6a1;transition:all .16s ease}@media(max-width:600px){.history-toolbar{align-items:flex-start}.filter-set{flex-wrap:wrap}.filter-set :deep(.ant-select){width:138px}}
.history-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.filter-set{display:flex;align-items:center;flex-wrap:wrap;gap:9px;color:#7a8781}.filter-set :deep(.ant-select){width:150px}.filter-set :deep(.ant-picker){width:240px}.run-sop{display:flex;flex-direction:column}.run-sop small{margin-top:4px;color:var(--muted)}.surface :deep(.ant-table-row){cursor:pointer}.surface :deep(.ant-table-row:hover) .row-arrow{color:var(--green);transform:translateX(2px)}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.row-arrow{color:#9ba6a1;transition:all .16s ease}@media(max-width:700px){.history-toolbar{align-items:flex-start}.filter-set{width:100%}.filter-set :deep(.ant-select){width:calc(50% - 18px)}.filter-set :deep(.ant-picker){width:100%}}
</style>

View File

@@ -70,6 +70,7 @@ function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(item=>item!==ed
function addConditionRule(){edgeDraft.rules.push(newConditionRule())}
function removeConditionRule(index:number){if(edgeDraft.rules.length>1)edgeDraft.rules.splice(index,1)}
function parseValue(value:string){if(value==='true')return true;if(value==='false')return false;if(value!==''&&!Number.isNaN(Number(value)))return Number(value);return value}
function knowledgeCardChanged(){if(selected.value)delete selected.value.config.knowledge_card_version_id}
function conditionText(condition:Record<string,any>):string{if(!condition||Object.keys(condition).length===0)return '默认路径';if(Array.isArray(condition.any))return `任一:${condition.any.map(conditionText).join('')}`;if(Array.isArray(condition.all))return `全部:${condition.all.map(conditionText).join('')}`;if(!condition.field)return '未识别条件';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',in:'属于列表',exists:'已填写',not_exists:'未填写'};const fieldName=fields.value.find(field=>field.field_key===condition.field)?.field_name||condition.field;const value=Array.isArray(condition.value)?condition.value.join('、'):(condition.value??'');return `${fieldName} ${names[condition.operator]||condition.operator} ${value}`}
async function save():Promise<boolean>{saving.value=true;try{await api.put(`/sops/${id}/draft`,{start_node_key:version.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('草稿已保存');await load();return true}catch(error){message.error(apiMessage(error));return false}finally{saving.value=false}}
async function validate(){try{const suffix=route.query.version?`?version=${encodeURIComponent(String(route.query.version))}`:'';const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate${suffix}`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join('')})}catch(error){message.error(apiMessage(error))}}
@@ -105,7 +106,7 @@ onMounted(load)
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入场景字段"><a-select v-model:value="selected.config.field_key" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!canEdit" /></a-form-item></div></template>
<a-form-item v-if="selected.type==='form'" label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择本步骤需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item>
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!canEdit" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!canEdit" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" /></a-form-item>
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!canEdit" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" @change="knowledgeCardChanged" /><div v-if="selected.config.knowledge_card_version_id" class="snapshot-note">已发布快照知识卡版本 ID {{ selected.config.knowledge_card_version_id }}</div></a-form-item>
</a-form>
<div class="transition-head"><div><b>下一步路径</b><span>条件路径优先匹配默认路径始终排在最后</span></div></div>
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="canEdit" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
@@ -135,5 +136,6 @@ onMounted(load)
<style scoped>
.editor-page{min-height:calc(100vh - 62px);background:#eef1ef}.editor-header{min-height:66px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 24px;background:white;border-bottom:1px solid var(--line);position:sticky;top:62px;z-index:10}.editor-title{display:flex;align-items:center;gap:8px}.editor-title>div{display:flex;flex-direction:column}.editor-title b{font-size:15px}.editor-title span{margin-top:3px;color:var(--muted);font-size:11px}.page-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.editor-workbench{display:grid;grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px}.node-rail{align-self:start;overflow:hidden;position:sticky;top:146px}.rail-title{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--line);font-weight:650}.node-list{padding:8px}.node-list button{width:100%;min-height:58px;display:grid;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;padding:7px 8px;text-align:left;background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}.node-list button:hover{background:#f5f8f6}.node-list button.active{background:#edf6f2;border-color:#b9d8cc}.node-index{color:#88958f;font-family:monospace;font-size:11px}.node-meta{min-width:0;display:flex;flex-direction:column}.node-meta b{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.node-meta small{margin-top:3px;color:var(--muted);font-size:11px}.node-move{display:flex;gap:4px;color:#87938e}.node-move>*:hover{color:var(--green)}.node-editor{align-self:start;min-height:600px;padding:24px}.node-editor-head{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--line)}.node-editor-head h2{margin:6px 0 0;font-family:"Noto Serif SC",serif;font-size:22px}.node-type-mark{color:var(--green);font-size:10px;font-weight:750}.property-form{padding-top:22px}.property-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.transition-head{display:flex;justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;border-top:1px solid var(--line)}.transition-head>div{display:flex;flex-direction:column}.transition-head span{margin-top:4px;color:var(--muted);font-size:11px}.transition-row{min-height:48px;display:grid;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;border-bottom:1px solid #edf0ee}.route-line{width:16px;height:1px;background:var(--green)}.route-target{font-weight:600}.route-empty{padding:20px 0;color:var(--muted);font-size:12px}.edge-builder{padding-top:16px}.edge-main{display:grid;grid-template-columns:minmax(220px,1fr) auto auto;align-items:center;gap:10px}.condition-toggle{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.condition-builder{margin-top:12px;padding:14px;background:#f7f9f8;border:1px solid var(--line);border-radius:4px}.condition-mode{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;color:var(--muted);font-size:12px}.condition-rule{display:grid;grid-template-columns:1.2fr 130px 1fr 34px;gap:8px;margin-top:8px}.editor-empty{min-height:400px;display:grid;place-items:center}.version-row{display:grid;grid-template-columns:54px 1fr auto;align-items:center;gap:12px;padding:16px 8px;border-bottom:1px solid var(--line)}.version-row.current{background:#f1f7f4}.version-number{font-weight:750;color:#245f4d}.version-copy>div{display:flex;align-items:center;gap:6px}.version-copy time{display:block;margin-top:5px;color:var(--muted);font-size:11px}.version-actions{display:flex;gap:6px}
.snapshot-note{margin-top:6px;color:var(--muted);font-size:11px}
@media(max-width:1050px){.editor-workbench{grid-template-columns:230px minmax(0,1fr)}.condition-rule{grid-template-columns:1fr 120px 1fr 34px}}@media(max-width:760px){.editor-header{top:62px;align-items:flex-start;flex-direction:column;padding:12px}.page-actions{justify-content:flex-start}.editor-workbench{grid-template-columns:1fr;padding:10px}.node-rail{position:static}.property-grid{grid-template-columns:1fr}.node-editor{padding:16px}.transition-head{margin:8px -16px 0;padding:16px}.edge-main,.condition-rule{grid-template-columns:1fr}.condition-rule .ant-btn{justify-self:end}.version-row{grid-template-columns:46px 1fr}.version-actions{grid-column:2}}
</style>