feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) == - 代办事项真实数据源集成 (企微审批API 8bug修复链) - H5/坐席端 Logo样式统一+绿色背景 - 视频引导页修复 (localStorage key v2) - 坐席端 v9 Vue版本修复 (ElMessage._context) - 截图按钮 v10 修复 (getDisplayMedia user gesture) - 扫码样式恢复+H5扫码登录跳转修复 - H5截图快捷键提示 == 代码完成待部署 (3项) == - 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查) - 会议室预定-小鱼易联终端 (40文件, 40/40测试通过) - IT资产升级审批推送 (asset_service.py) == 需求文档 (2项) == - 坐席端AI辅助消息框-PRD (4项新功能确认) - 坐席端布局优化建议 v2.0 (7天计划) == 新增文档 == - 日报-2026-07-11.md - 知识迭代Bug修复报告-20260711.md - 会议室预定-部署指南.md - CHANGELOG.md 更新 == 测试 == - test_todo_integration.py: 40/40 - test_meetingroom.py: 40/40 - test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.agent-preview[data-v-bf7e750f]{display:flex;flex-direction:column;height:100dvh;background:#f5f7fa;font-family:-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif}.agent-preview__topbar[data-v-bf7e750f]{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;background:#fff;border-bottom:1px solid #e4e7ed;box-shadow:0 1px 4px #0000000a;z-index:10}.topbar-left[data-v-bf7e750f]{display:flex;align-items:center;gap:12px}.logo[data-v-bf7e750f]{font-size:28px;width:40px;height:40px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#fef3c7,#fde68a);border-radius:10px}.title-block[data-v-bf7e750f]{display:flex;flex-direction:column}.title[data-v-bf7e750f]{font-size:16px;font-weight:600;color:#303133;margin:0}.subtitle[data-v-bf7e750f]{font-size:12px;color:#909399;margin:0}.userid-tag[data-v-bf7e750f]{font-size:12px;color:#909399;padding:4px 10px;background:#f5f7fa;border-radius:4px}.agent-preview__content[data-v-bf7e750f]{flex:1;overflow:hidden;display:flex;max-width:1200px;margin:0 auto;width:100%;background:#fff}.agent-preview__drawer-header[data-v-bf7e750f]{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #e4e7ed;background:#fafafa}.drawer-title[data-v-bf7e750f]{font-size:15px;font-weight:600;color:#303133}.agent-preview__drawer-body[data-v-bf7e750f]{height:calc(100% - 45px);overflow-y:auto;background:#fff}.agent-preview__mobile-hint[data-v-bf7e750f]{padding:12px 20px;background:#fdf6ec;color:#e6a23c;font-size:12px;text-align:center;line-height:1.6;border-top:1px solid #faecd8}.agent-preview__mobile-hint p[data-v-bf7e750f]{margin:0}
|
||||
@@ -0,0 +1 @@
|
||||
import{d as g,E as y,b as s,c as n,f as t,Q as k,n as _,w as i,t as V,g as a,e as d,r as A,x as f,i as u,u as x,R as C,h as I,q as N,m as B}from"./index-B1u3nieA.js";import{A as c}from"./AiAssistantPanel-31-Ys6Rc.js";import"./conversation-C4tqOoff.js";import"./index-ChNUjLLo.js";const h={class:"agent-preview"},z={class:"agent-preview__topbar"},E={class:"topbar-right"},P={key:1,class:"userid-tag"},q={key:0,class:"agent-preview__content"},M={class:"agent-preview__drawer-header"},R={class:"agent-preview__drawer-body"},S={key:2,class:"agent-preview__mobile-hint"},T=g({__name:"AgentPreviewView",setup($){const m=N(),o=A(!1),p=f(()=>m.query.userid||""),l=f(()=>window.innerWidth<500);return p.value&&y({message:"坐席模式",type:"success",duration:1500}),(D,e)=>{const w=u("el-icon"),v=u("el-button"),b=u("el-drawer");return s(),n("div",h,[t("div",z,[e[4]||(e[4]=k('<div class="topbar-left" data-v-bf7e750f><span class="logo" data-v-bf7e750f>🎧</span><div class="title-block" data-v-bf7e750f><h1 class="title" data-v-bf7e750f>坐席助手</h1><p class="subtitle" data-v-bf7e750f>IT 智能服务台 · 应急模式</p></div></div>',1)),t("div",E,[l.value?(s(),_(v,{key:0,type:"primary",size:"small",onClick:e[0]||(e[0]=r=>o.value=!0)},{default:i(()=>[a(w,null,{default:i(()=>[a(x(C))]),_:1}),e[3]||(e[3]=t("span",null,"AI 助手",-1))]),_:1})):(s(),n("div",P," userid: "+V(p.value||"anonymous"),1))])]),l.value?d("",!0):(s(),n("div",q,[a(c)])),l.value?(s(),_(b,{key:1,modelValue:o.value,"onUpdate:modelValue":e[2]||(e[2]=r=>o.value=r),direction:"rtl",size:"90%","with-header":!1},{default:i(()=>[t("div",M,[e[6]||(e[6]=t("span",{class:"drawer-title"},"🤖 AI 助手",-1)),a(v,{type:"text",size:"small",onClick:e[1]||(e[1]=r=>o.value=!1)},{default:i(()=>[...e[5]||(e[5]=[I(" 关闭 ",-1)])]),_:1})]),t("div",R,[a(c)])]),_:1},8,["modelValue"])):d("",!0),l.value?(s(),n("div",S,[...e[7]||(e[7]=[t("p",null,"💡 电脑端访问可获得完整体验(AI 助手常驻右侧)",-1),t("p",null,'移动端请点上方"AI 助手"按钮打开',-1)])])):d("",!0)])}}}),F=B(T,[["__scopeId","data-v-bf7e750f"]]);export{F 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
@@ -0,0 +1 @@
|
||||
.approval-queue-page[data-v-7767270c]{padding:20px;max-width:1000px;margin:0 auto}.approval-queue-page__header[data-v-7767270c]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.approval-queue-page__header h2[data-v-7767270c]{margin:0;font-size:20px;font-weight:600;color:#303133}.approval-queue-page__stats[data-v-7767270c]{display:flex;gap:8px}.approval-queue-page__filters[data-v-7767270c]{display:flex;gap:10px;margin-bottom:12px;align-items:center}.approval-queue-page__stat-cards[data-v-7767270c]{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}.approval-queue-page__stat-card[data-v-7767270c]{display:flex;flex-direction:column;align-items:center;padding:8px 16px;background:#f5f7fa;border-radius:6px;min-width:100px}.approval-queue-page__stat-label[data-v-7767270c]{font-size:11px;color:#909399}.approval-queue-page__stat-count[data-v-7767270c]{font-size:20px;font-weight:700;color:#303133}.approval-queue-page__list[data-v-7767270c]{min-height:200px}.approval-queue-page__empty[data-v-7767270c]{padding:40px 0}.approval-queue-page__item[data-v-7767270c]{padding:14px;margin-bottom:10px;background:#fff;border:1px solid #e4e7ed;border-radius:8px;transition:box-shadow .2s}.approval-queue-page__item[data-v-7767270c]:hover{box-shadow:0 2px 8px #0000000f}.approval-queue-page__item--overdue[data-v-7767270c]{border-color:#fde2e2;background:#fef0f0}.approval-queue-page__item-header[data-v-7767270c]{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.approval-queue-page__item-type[data-v-7767270c]{display:flex;align-items:center;gap:4px;font-size:14px;font-weight:600;color:#303133}.approval-queue-page__item-badges[data-v-7767270c]{display:flex;gap:6px}.approval-queue-page__item-content[data-v-7767270c]{font-size:13px;color:#606266;margin-bottom:8px;line-height:1.5}.approval-queue-page__item-meta[data-v-7767270c]{display:flex;gap:16px;font-size:12px;color:#909399;margin-bottom:10px}.approval-queue-page__item-actions[data-v-7767270c]{display:flex;gap:8px;justify-content:flex-end}.approval-queue-page__pagination[data-v-7767270c]{display:flex;justify-content:center;margin-top:20px}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.bind-page[data-v-42b4cccf]{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#667eea,#764ba2);padding:24px}.bind-card[data-v-42b4cccf]{width:100%;max-width:400px;background:var(--bg-secondary, #ffffff);border-radius:16px;padding:40px 32px;box-shadow:0 8px 32px #00000026}.bind-header[data-v-42b4cccf]{text-align:center;margin-bottom:32px}.bind-icon[data-v-42b4cccf]{font-size:48px;margin-bottom:12px}.bind-title[data-v-42b4cccf]{font-size:24px;font-weight:700;color:var(--text-primary, #303133);margin:0 0 8px}.bind-subtitle[data-v-42b4cccf]{font-size:14px;color:var(--text-tertiary, #909399);margin:0}.wecom-user-info[data-v-42b4cccf]{margin-bottom:24px}.bind-options[data-v-42b4cccf]{display:flex;flex-direction:column;gap:16px}.bind-options[data-v-42b4cccf] .el-radio-group{display:flex;flex-direction:column;gap:12px}.bind-options[data-v-42b4cccf] .el-radio{padding:12px;background:var(--bg-tertiary, #f5f5f5);border-radius:8px}.next-btn[data-v-42b4cccf]{width:100%;margin-top:24px}.bind-form[data-v-42b4cccf]{margin-top:16px}.form-actions[data-v-42b4cccf]{display:flex;gap:12px;margin-top:24px}.form-actions[data-v-42b4cccf] .el-button{flex:1}
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/auth-DmITJcle.js","assets/index-ChNUjLLo.js","assets/index-B1u3nieA.js","assets/index-Do7r--Ys.css"])))=>i.map(i=>d[i]);
|
||||
import{d as R,o as V,a as B,b as s,c as o,u as h,e as w,f as a,g as l,w as y,h as C,t as I,r as c,E as i,_ as N,i as E,j as D,l as O,k as M,m as Q}from"./index-B1u3nieA.js";import{getQrcode as W,handleOAuthCallback as j,getScanStatus as F}from"./auth-DmITJcle.js";import{u as G}from"./agent-WRYR5BAs.js";import"./index-ChNUjLLo.js";import"./conversation-C4tqOoff.js";const H={class:"login-page"},J={key:0,class:"test-env-badge"},K={class:"login-card"},X={key:0,class:"qr-login"},Y={class:"qr-container"},Z={key:0,class:"qr-loading"},$={key:1,class:"qr-code"},ee=["src"],te={key:2,class:"qr-error"},ne={key:0,class:"scan-tip"},ae={key:1,class:"loading-container"},se=R({__name:"Login",setup(oe){const g=D(),u=G(),T=window.location.hostname.includes("localhost"),k=c(!0),S=c(""),q=c(!1),v=c(""),r=c(!1),m=c("请稍候...");let p=null,A="";function U(){return typeof navigator>"u"?!1:/wxwork/i.test(navigator.userAgent)}async function d(){q.value=!0,v.value="waiting";try{const t=await W();S.value=t.qrcode_url,A=t.ticket,x()}catch(t){console.error("获取二维码失败:",t),i.error("获取二维码失败,请重试")}finally{q.value=!1}}async function b(){if(A)try{const t=await F(A);t.status==="scanned"?v.value="scanned":t.status==="confirmed"&&t.token?(_(),await P(t.token,t.user_info)):t.status==="expired"?(_(),i.warning("二维码已过期,请重新扫码"),d()):t.status==="cancelled"&&(_(),i.info("扫码已取消"),v.value="waiting",d())}catch(t){console.warn("轮询扫码状态失败:",t)}}function x(){_(),p=setInterval(b,2e3)}function _(){p&&(clearInterval(p),p=null)}async function P(t,e){r.value=!0,m.value="登录中...";try{if(e!=null&&e.is_new_user){g.push({name:"Bind",query:{userid:e.userid,name:e.name}});return}localStorage.setItem("agent_token",t),u.token=t,e&&(localStorage.setItem("agent_user_id",e.userid),u.agentUserId=e.userid,u.agentInfo={user_id:e.userid,name:e.name,status:"online"}),i.success("登录成功"),g.push("/workspace")}catch(n){console.error("登录失败:",n),i.error("登录失败,请重试")}finally{r.value=!1}}async function L(t,e){r.value=!0,m.value="授权中...";try{const n=await j(t,e);if(n.is_new_user){g.push({name:"Bind",query:{userid:n.user_info.userid,name:n.user_info.name}});return}localStorage.setItem("agent_token",n.token),u.token=n.token,localStorage.setItem("agent_user_id",n.user_info.userid),u.agentUserId=n.user_info.userid,u.agentInfo={user_id:n.user_info.userid,name:n.user_info.name,status:"online"},i.success("登录成功"),g.push("/workspace")}catch(n){console.error("OAuth回调处理失败:",n),i.error("授权失败,请重试")}finally{r.value=!1}}async function z(){const t=window.location.origin+"/itagent/";try{const{getOAuthAuthorizeUrl:e}=await N(async()=>{const{getOAuthAuthorizeUrl:f}=await import("./auth-DmITJcle.js");return{getOAuthAuthorizeUrl:f}},__vite__mapDeps([0,1,2,3])),n=await e(t);if(n.authorize_url){window.location.href=n.authorize_url;return}}catch(e){console.warn("从后端获取授权URL失败,尝试本地构造:",e)}{console.warn("未配置 VITE_WECOM_CORP_ID,无法跳转授权");return}}return V(async()=>{const t=new URLSearchParams(window.location.search),e=t.get("code"),n=t.get("state");if(e){await L(e,n||void 0);return}if(U()){r.value=!0,m.value="正在跳转企微授权...";try{await z()}catch(f){console.error("跳转授权失败:",f),k.value=!0,d()}finally{r.value=!1}}else k.value=!0,d()}),B(()=>{_()}),(t,e)=>{const n=E("el-icon"),f=E("el-button");return s(),o("div",H,[h(T)?(s(),o("div",J," 🔧 测试环境 ")):w("",!0),a("div",K,[e[5]||(e[5]=a("div",{class:"login-title"},[a("h1",null,"🛠️ IT智能服务台"),a("p",null,"坐席工作台")],-1)),k.value?(s(),o("div",X,[a("div",Y,[q.value?(s(),o("div",Z,[l(n,{class:"is-loading"},{default:y(()=>[l(h(O))]),_:1}),e[0]||(e[0]=a("p",null,"加载中...",-1))])):S.value?(s(),o("div",$,[a("img",{src:S.value,alt:"企微扫码登录"},null,8,ee)])):(s(),o("div",te,[e[2]||(e[2]=a("p",null,"获取二维码失败",-1)),l(f,{size:"small",onClick:d},{default:y(()=>[...e[1]||(e[1]=[C("重试",-1)])]),_:1})]))]),e[4]||(e[4]=a("p",{class:"qr-hint"},"请使用企业微信扫码登录",-1)),v.value==="scanned"?(s(),o("p",ne,[l(n,null,{default:y(()=>[l(h(M))]),_:1}),e[3]||(e[3]=C(" 已扫码,请在手机确认 ",-1))])):w("",!0)])):w("",!0),r.value?(s(),o("div",ae,[l(n,{class:"is-loading",size:"32"},{default:y(()=>[l(h(O))]),_:1}),a("p",null,I(m.value),1)])):w("",!0)])])}}}),de=Q(se,[["__scopeId","data-v-bb0a303d"]]);export{de as default};
|
||||
@@ -0,0 +1 @@
|
||||
.test-env-badge[data-v-bb0a303d]{position:absolute;top:16px;left:16px;z-index:100;background:linear-gradient(135deg,#ff6b6b,orange);color:#fff;padding:4px 12px;border-radius:4px;font-size:12px;font-weight:600;box-shadow:0 2px 8px #0003}.login-page[data-v-bb0a303d]{position:relative;display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#667eea,#764ba2);padding:24px}.login-card[data-v-bb0a303d]{width:100%;max-width:400px;background:var(--bg-secondary, #ffffff);border-radius:16px;padding:40px 32px;box-shadow:0 8px 32px #00000026}.login-title[data-v-bb0a303d]{text-align:center;margin-bottom:32px}.login-title h1[data-v-bb0a303d]{font-size:24px;font-weight:700;color:var(--text-primary, #303133);margin:0 0 8px}.login-title p[data-v-bb0a303d]{font-size:14px;color:var(--text-tertiary, #909399);margin:0}.qr-login[data-v-bb0a303d]{display:flex;flex-direction:column;align-items:center}.qr-container[data-v-bb0a303d]{width:200px;height:200px;display:flex;align-items:center;justify-content:center;background:var(--bg-tertiary, #f5f5f5);border-radius:12px;margin-bottom:16px}.qr-loading[data-v-bb0a303d]{display:flex;flex-direction:column;align-items:center;gap:8px;color:var(--text-tertiary, #909399)}.qr-code img[data-v-bb0a303d]{width:180px;height:180px}.qr-error[data-v-bb0a303d]{text-align:center;color:var(--error-color, #f56c6c)}.qr-hint[data-v-bb0a303d]{font-size:14px;color:var(--text-tertiary, #909399);margin:0}.scan-tip[data-v-bb0a303d]{display:flex;align-items:center;gap:4px;color:var(--success-color, #07c160);font-size:14px;margin-top:12px}.loading-container[data-v-bb0a303d]{display:flex;flex-direction:column;align-items:center;gap:16px;padding:40px 0;color:var(--text-secondary, #606266)}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.mfa-bind-page[data-v-4bc12407]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#f5f7fa,#c3cfe2);padding:20px}.mfa-card[data-v-4bc12407]{max-width:520px;width:100%}.card-header[data-v-4bc12407]{display:flex;align-items:center;gap:8px}.header-icon[data-v-4bc12407]{color:#409eff;font-size:20px}.header-title[data-v-4bc12407]{font-size:18px;font-weight:600}.state-loading[data-v-4bc12407]{display:flex;align-items:center;justify-content:center;padding:40px 0;gap:8px;color:#606266}.steps[data-v-4bc12407]{margin-bottom:32px}.step-tip[data-v-4bc12407]{display:flex;align-items:center;gap:6px;color:#606266;font-size:14px;margin-bottom:16px;justify-content:center}.intro-alert[data-v-4bc12407]{margin-bottom:24px}.intro-content[data-v-4bc12407]{font-size:13px;line-height:1.6;color:#606266;margin-top:4px}.intro-steps[data-v-4bc12407]{display:flex;flex-direction:column;gap:12px;margin-bottom:24px}.intro-step[data-v-4bc12407]{display:flex;align-items:center;gap:12px}.intro-num[data-v-4bc12407]{width:28px;height:28px;border-radius:50%;background:#409eff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;flex-shrink:0}.intro-text[data-v-4bc12407]{font-size:14px;color:#303133;line-height:1.5}.start-btn[data-v-4bc12407]{width:100%}.qrcode-container[data-v-4bc12407]{display:flex;align-items:center;justify-content:center;min-height:220px;margin-bottom:16px}.qrcode-image[data-v-4bc12407]{width:200px;height:200px;border-radius:8px;box-shadow:0 2px 12px #00000014}.qrcode-placeholder[data-v-4bc12407]{display:flex;align-items:center;justify-content:center;gap:8px;color:#909399}.manual-collapse[data-v-4bc12407]{margin-bottom:16px}.manual-hint[data-v-4bc12407]{font-size:13px;color:#606266;margin:0 0 8px}.secret-input[data-v-4bc12407]{margin-bottom:8px}.secret-input[data-v-4bc12407] .el-input__inner{font-family:monospace;font-size:12px;letter-spacing:1px}.manual-tip[data-v-4bc12407]{font-size:12px;color:#909399;margin:0}.qrcode-actions[data-v-4bc12407]{display:flex;gap:12px}.qrcode-actions .el-button[data-v-4bc12407]{flex:1}.code-input[data-v-4bc12407]{margin-bottom:16px}.code-input[data-v-4bc12407] .el-input__inner{font-size:24px;letter-spacing:8px;text-align:center;font-family:monospace}.verify-actions[data-v-4bc12407]{display:flex;gap:12px}.verify-actions .el-button[data-v-4bc12407]{flex:1}.error-alert[data-v-4bc12407]{margin-bottom:16px}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.action-approval-card[data-v-f998ce57]{margin-bottom:12px;border-left:3px solid var(--el-color-warning)}.title-row[data-v-f998ce57]{display:flex;align-items:center;justify-content:space-between;gap:8px}.action-title[data-v-f998ce57]{font-weight:600;font-size:14px}.action-desc[data-v-f998ce57]{color:var(--el-text-color-secondary);font-size:13px;margin-top:4px}.action-type[data-v-f998ce57]{color:var(--el-text-color-secondary);font-size:12px;margin-top:2px}.payload-block[data-v-f998ce57]{margin-top:8px}.block-label[data-v-f998ce57]{font-size:12px;color:var(--el-text-color-secondary);margin-bottom:4px}.payload-pre[data-v-f998ce57]{background:var(--el-fill-color-light);border-radius:6px;padding:8px;font-size:12px;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-all}.ticket-block[data-v-f998ce57]{margin-top:8px;display:flex;align-items:center;gap:8px}.ticket-status[data-v-f998ce57]{font-size:12px;color:var(--el-text-color-secondary)}.action-buttons[data-v-f998ce57]{margin-top:12px;display:flex;gap:8px}.note-input[data-v-f998ce57]{margin-top:8px}.takeover-panel[data-v-1bdb699a]{margin-top:12px}.panel-title[data-v-1bdb699a]{font-weight:600;margin-bottom:10px}.actions[data-v-1bdb699a]{display:flex;justify-content:flex-end}.session-workbench[data-v-de7dae2d]{padding:16px;max-width:960px;margin:0 auto}.wb-header[data-v-de7dae2d]{display:flex;align-items:center;gap:12px;margin-bottom:12px}.wb-title[data-v-de7dae2d]{flex:1;display:flex;align-items:center;gap:8px}.wb-title-text[data-v-de7dae2d]{font-weight:600;font-size:16px}.wb-ws[data-v-de7dae2d]{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--el-text-color-secondary)}.wb-ws .dot[data-v-de7dae2d]{width:8px;height:8px;border-radius:50%;background:var(--el-color-info)}.wb-ws.connected .dot[data-v-de7dae2d]{background:var(--el-color-success)}.wb-alert[data-v-de7dae2d],.wb-card[data-v-de7dae2d]{margin-bottom:12px}.overview[data-v-de7dae2d]{display:flex;flex-wrap:wrap;gap:16px}.ov-item[data-v-de7dae2d]{display:flex;flex-direction:column;font-size:14px}.ov-label[data-v-de7dae2d]{font-size:12px;color:var(--el-text-color-secondary);margin-bottom:2px}.intent[data-v-de7dae2d]{margin-top:10px;font-size:13px;color:var(--el-text-color-secondary);white-space:pre-wrap}.tl-title[data-v-de7dae2d]{display:flex;align-items:center;gap:8px;font-weight:600}.tl-desc[data-v-de7dae2d]{font-size:13px;color:var(--el-text-color-secondary);margin-top:2px}.tl-status[data-v-de7dae2d]{font-size:12px;color:var(--el-text-color-secondary);margin-top:2px}.tl-result[data-v-de7dae2d]{margin-top:6px;background:var(--el-fill-color-light);border-radius:6px;padding:8px;font-size:12px;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-all}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.otp-bind-panel[data-v-44baea43]{padding:8px 0}.bind-title[data-v-44baea43]{text-align:center;margin-bottom:24px}.bind-title h2[data-v-44baea43]{font-size:18px;font-weight:600;color:var(--text-primary, #303133);margin:0 0 8px;line-height:1.4}.bind-subtitle[data-v-44baea43]{font-size:14px;color:var(--text-secondary, #606266);margin:0}.bind-loading[data-v-44baea43]{display:flex;flex-direction:column;align-items:center;gap:12px;padding:40px 0;color:var(--text-tertiary, #909399)}.bind-loading .el-icon[data-v-44baea43]{font-size:32px}.bind-loading p[data-v-44baea43]{margin:0;font-size:14px}.qr-section[data-v-44baea43]{display:flex;flex-direction:column;align-items:center;margin-bottom:20px}.qr-wrapper[data-v-44baea43]{width:200px;height:200px;display:flex;align-items:center;justify-content:center;background:#f5f7fa;border-radius:12px;border:2px solid #e4e7ed;overflow:hidden;margin-bottom:12px}.qr-image[data-v-44baea43]{width:180px;height:180px;display:block}.qr-placeholder[data-v-44baea43]{display:flex;flex-direction:column;align-items:center;gap:8px;color:var(--text-placeholder, #c0c4cc)}.qr-placeholder p[data-v-44baea43]{margin:0;font-size:13px}.qr-hint[data-v-44baea43]{font-size:13px;color:var(--text-tertiary, #909399);margin:0;text-align:center;line-height:1.5}.divider-line[data-v-44baea43]{display:flex;align-items:center;width:100%;margin:0 0 20px;color:var(--text-placeholder, #c0c4cc);font-size:13px}.divider-line[data-v-44baea43]:before,.divider-line[data-v-44baea43]:after{content:"";flex:1;height:1px;background:#e4e7ed}.divider-line span[data-v-44baea43]{padding:0 12px;white-space:nowrap}.secret-section[data-v-44baea43]{margin-bottom:20px}.secret-display[data-v-44baea43]{display:flex;align-items:center;gap:8px;background:#f5f7fa;border-radius:8px;padding:10px 12px;border:1px solid #e4e7ed}.secret-text[data-v-44baea43]{flex:1;font-family:Courier New,Consolas,monospace;font-size:14px;font-weight:600;color:var(--text-primary, #303133);letter-spacing:1px;word-break:break-all;-webkit-user-select:all;user-select:all}.copy-btn[data-v-44baea43]{flex-shrink:0}.verify-section[data-v-44baea43]{margin-bottom:20px}[data-v-44baea43] .verify-section .el-form-item__label{font-weight:500;color:var(--text-secondary, #606266)}.bind-actions[data-v-44baea43]{display:flex;flex-direction:column;gap:12px}.verify-btn[data-v-44baea43]{width:100%;background-color:#07c160;border-color:#07c160}.verify-btn[data-v-44baea43]:hover,.verify-btn[data-v-44baea43]:focus{background-color:#06ad56;border-color:#06ad56}.verify-btn[data-v-44baea43]:active{background-color:#059a4d;border-color:#059a4d}.bind-error[data-v-44baea43]{margin-top:16px}.settings-page[data-v-e0297981]{max-width:720px;margin:0 auto;padding:24px 20px 48px}.page-title[data-v-e0297981]{font-size:22px;font-weight:600;color:var(--text-primary, #303133);margin:0 0 24px;line-height:1.4}.settings-card[data-v-e0297981]{border-radius:12px;border:1px solid #e4e7ed}.settings-card[data-v-e0297981] .el-card__header{padding:16px 20px;border-bottom:1px solid #ebeef5}.settings-card[data-v-e0297981] .el-card__body{padding:20px}.card-header[data-v-e0297981]{display:flex;align-items:center;justify-content:space-between}.card-title[data-v-e0297981]{font-size:16px;font-weight:600;color:var(--text-primary, #303133)}.status-loading[data-v-e0297981]{display:flex;align-items:center;gap:8px;padding:24px 0;justify-content:center;color:var(--text-tertiary, #909399);font-size:14px}.status-loading .el-icon[data-v-e0297981]{font-size:18px}.otp-info[data-v-e0297981]{display:flex;flex-direction:column;gap:12px}.info-row[data-v-e0297981]{display:flex;align-items:center;gap:12px}.info-label[data-v-e0297981]{min-width:72px;font-size:14px;color:var(--text-secondary, #606266);flex-shrink:0}.info-value[data-v-e0297981]{font-size:14px;color:var(--text-primary, #303133);display:flex;align-items:center;gap:6px}.check-icon[data-v-e0297981]{color:#07c160;font-size:16px}.info-time[data-v-e0297981]{color:var(--text-tertiary, #909399);font-size:13px}.otp-warning[data-v-e0297981]{display:flex;align-items:flex-start;gap:12px;padding:16px;background:#fdf6ec;border-radius:8px;border:1px solid #faecd8}.warning-icon[data-v-e0297981]{color:#e6a23c;font-size:20px;flex-shrink:0;margin-top:2px}.warning-text[data-v-e0297981]{flex:1}.warning-title[data-v-e0297981]{font-size:14px;font-weight:600;color:var(--text-primary, #303133);margin:0 0 4px}.warning-desc[data-v-e0297981]{font-size:13px;color:var(--text-secondary, #606266);margin:0;line-height:1.5}.otp-actions[data-v-e0297981]{display:flex;flex-direction:column;align-items:flex-start;gap:12px}.action-hint[data-v-e0297981]{font-size:13px;color:var(--text-tertiary, #909399);margin:0;line-height:1.5}.bind-btn[data-v-e0297981]{background-color:#07c160;border-color:#07c160}.bind-btn[data-v-e0297981]:hover,.bind-btn[data-v-e0297981]:focus{background-color:#06ad56;border-color:#06ad56}.bind-btn[data-v-e0297981]:active{background-color:#059a4d;border-color:#059a4d}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-B1u3nieA.js","assets/index-Do7r--Ys.css"])))=>i.map(i=>d[i]);
|
||||
import{_ as $,v as R,r as b,x as w,a0 as O}from"./index-B1u3nieA.js";import{a as g}from"./index-ChNUjLLo.js";import{u as N}from"./conversation-C4tqOoff.js";async function q(t,r,s){return await g.post("/agents/login",{user_id:t,name:t,password:r,otp_code:s||void 0})}async function x(){return await g.get("/agents/me")}async function P(t){return await g.put("/agents/me/status",{status:t})}async function D(t){const r={};return r.status=t,await g.get("/agents",{params:r})}async function j(){return await g.post("/agents/otp-bind")}async function J(t,r){return await g.post("/agents/otp-verify",{user_id:t,otp_code:r})}async function K(){return await g.post("/agents/otp-unbind")}const L=3e4,M=3e4,V=1e3;function H(){let t=null,r=null,s=null,c=0,d=!1;function m(){const e=h();let o=e.userId,n=e.token;if(o||(o=localStorage.getItem("agent_user_id")||""),n||(n=localStorage.getItem("agent_token")||""),console.log("[WebSocket] 连接参数:",{userId:o,token:n?n.substring(0,20)+"...":null,localStorage_agent_token:localStorage.getItem("agent_token"),localStorage_agent_user_id:localStorage.getItem("agent_user_id")}),!o){console.warn("[WebSocket] 未登录,跳过连接 - agentId为空");return}t&&y(),d=!1;const l=window.location.protocol==="https:"?"wss:":"ws:",u=window.location.host,W=`${l}//${u}/ws/${o}`;console.log(`[WebSocket] 正在连接: ${W}`),t=new WebSocket(W,[`bearer.${n}`]),t.onopen=()=>{console.log("[WebSocket] 连接成功"),c=0,I(),N().stopAllPolling()},t.onmessage=S=>{try{const C=JSON.parse(S.data);v(C)}catch(C){console.error("[WebSocket] 消息解析失败:",C)}},t.onclose=()=>{console.log("[WebSocket] 连接关闭"),_(),t=null,d||(N().startAllPolling(),T())},t.onerror=S=>{console.error("[WebSocket] 连接错误:",S)}}function y(){d=!0,s&&(clearTimeout(s),s=null),_(),t&&(t.close(),t=null),c=0,console.log("[WebSocket] 已主动断开连接")}function I(){_(),r=setInterval(()=>{t&&t.readyState===WebSocket.OPEN&&t.send(JSON.stringify({type:"ping"}))},L)}function _(){r&&(clearInterval(r),r=null)}function T(){if(d)return;const e=Math.min(V*Math.pow(2,c),M);c++,console.log(`[WebSocket] 将在 ${e/1e3} 秒后重连(第 ${c} 次)`),s&&clearTimeout(s),s=setTimeout(()=>{console.log("[WebSocket] 正在重连..."),m()},e)}function v(e){const o=N();switch(e.type){case"new_message":e.data&&(o.handleNewMessage(e.data),A());break;case"conversation_updated":e.data&&o.handleConversationUpdated(e.data);break;case"collaborator_invited":if(e.data){o.handleCollaboratorInvited(e.data);const n=h();e.data.invitee_agent_id===n.userId&&$(async()=>{const{ElNotification:l}=await import("./index-B1u3nieA.js").then(u=>u.a2);return{ElNotification:l}},__vite__mapDeps([0,1])).then(({ElNotification:l})=>{l({title:"🔔 摇人邀请",message:`坐席 ${e.data.inviter_agent_id} 邀请你协助处理会话「${e.data.employee_name||"未知"}」
|
||||
${e.data.last_message_summary||""}`,type:"info",duration:0,position:"top-right",onClick:()=>{o.selectConversation(e.data.conversation_id)}})})}break;case"collaborator_joined":case"collaborator_left":o.handleCollaboratorChanged();break;case"participant_invited":o.handleCollaboratorChanged();break;case"participant_joined":o.handleCollaboratorChanged();break;case"participant_removed":case"participant_left":o.handleCollaboratorChanged();break;case"pong":break;case"typing":if(e.data){const n=h();e.data.sender_id!==n.userId&&o.handleTypingEvent(e.data)}break;default:console.warn(`[WebSocket] 未知消息类型: ${e.type}`)}}let i=null;function A(){try{i||(i=new AudioContext);const e=i.currentTime,o=i.createOscillator(),n=i.createGain();o.type="sine",o.frequency.value=880,n.gain.setValueAtTime(.3,e),n.gain.exponentialRampToValueAtTime(.001,e+.15),o.connect(n),n.connect(i.destination),o.start(e),o.stop(e+.15);const l=i.createOscillator(),u=i.createGain();l.type="sine",l.frequency.value=660,u.gain.setValueAtTime(.3,e+.1),u.gain.exponentialRampToValueAtTime(.001,e+.3),l.connect(u),u.connect(i.destination),l.start(e+.1),l.stop(e+.3)}catch{}}let k=0;const E=3e3;function a(e){if(!t||t.readyState!==WebSocket.OPEN)return;const o=Date.now();if(o-k<E)return;k=o;const n=h();t.send(JSON.stringify({type:"typing",conversation_id:e,sender_name:n.agentName||"坐席"}))}return{connect:m,disconnect:y,sendTyping:a}}const p="agent_token",f="agent_user_id",h=R("agent",()=>{const t=b(null),r=b(localStorage.getItem(p)),s=b(localStorage.getItem(f)),c=b(!1),d=b([]),m=w(()=>!!r.value&&!!t.value),y=w(()=>{var a;return((a=t.value)==null?void 0:a.status)||"offline"}),I=w(()=>{var a;return((a=t.value)==null?void 0:a.name)||""}),_=w(()=>{var a;return((a=t.value)==null?void 0:a.user_id)||s.value||""});async function T(a,e,o){try{c.value=!0,localStorage.removeItem(p),localStorage.removeItem(f),r.value=null,s.value=null,t.value=null;const n=await q(a,e,o);if("require_otp_bind"in n&&n.require_otp_bind)return n.token&&(r.value=n.token,s.value=n.user_id,localStorage.setItem(p,n.token),localStorage.setItem(f,n.user_id)),c.value=!1,{require_otp_bind:!0,user_id:n.user_id,name:n.name,role:n.role};if("require_otp"in n&&n.require_otp)throw c.value=!1,new Error("require_otp");r.value=n.token,s.value=n.user_id,localStorage.setItem(p,n.token),localStorage.setItem(f,n.user_id);const{token:l,...u}=n;t.value=u,O.push("/workspace")}catch(n){throw console.error("登录失败:",n),n instanceof Error&&n.message==="require_otp",n}finally{c.value=!1}}function v(){const{disconnect:a}=H();a(),r.value=null,s.value=null,t.value=null,localStorage.removeItem(p),localStorage.removeItem(f),O.push("/login")}async function i(){var a;try{if(!r.value)return;const e=await x();t.value=e}catch(e){console.error("获取坐席信息失败:",e),e&&typeof e=="object"&&"response"in e&&((a=e.response)==null?void 0:a.status)===401&&v()}}async function A(a){try{const e=await P(a);t.value=e}catch(e){console.error("更新坐席状态失败:",e)}}async function k(){try{const a=await D("online");d.value=a.items.filter(e=>{var o;return e.user_id!==((o=t.value)==null?void 0:o.user_id)})}catch(a){console.error("获取坐席列表失败:",a)}}async function E(){const a=localStorage.getItem(p);if(a){r.value=a,s.value=localStorage.getItem(f);try{await i()}catch{v()}}}return{agentInfo:t,token:r,agentUserId:s,logging:c,availableAgents:d,isLoggedIn:m,agentStatus:y,agentName:I,userId:_,login:T,logout:v,refreshAgentInfo:i,changeStatus:A,loadAvailableAgents:k,initAuth:E}});export{H as a,j as b,K as c,h as u,J as v};
|
||||
@@ -0,0 +1 @@
|
||||
import{a as n}from"./index-ChNUjLLo.js";import"./index-B1u3nieA.js";async function e(){return n.get("/auth/qrcode")}async function o(t){return n.get("/auth/scan/status",{params:{ticket:t}})}async function c(t,a){return n.get("/auth/oauth2/callback",{params:{code:t,state:a}})}async function s(t){return n.post("/auth/bind",t)}async function i(t){return n.post("/auth/verify",{token:t})}async function h(){return n.post("/auth/logout")}async function f(){return n.get("/auth/me")}async function g(t){return n.get("/auth/oauth2/url",{params:{redirect_uri:t}})}export{s as bindAccount,f as getCurrentUser,g as getOAuthAuthorizeUrl,e as getQrcode,o as getScanStatus,c as handleOAuthCallback,h as logout,i as verifyToken};
|
||||
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
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 移动端视口设置 -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- CSP 安全策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1 ws://localhost:8000 ws://127.0.0.1:8000;" />
|
||||
<!-- 页面标题 -->
|
||||
<title>智能IT支持服务台 - 坐席工作台</title>
|
||||
<!-- 企微 JS-SDK (用于企微客户端检测和快捷登录) -->
|
||||
<script src="https://res.wx.qq.com/wwopen/js/jwxwork-1.0.0.js"></script>
|
||||
<!-- ElementPlus 图标 -->
|
||||
<link rel="icon" type="image/svg+xml" href="/itagent/vite.svg" />
|
||||
<script type="module" crossorigin src="/itagent/assets/index-B1u3nieA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/itagent/assets/index-Do7r--Ys.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app"></div>
|
||||
<!-- 入口脚本 -->
|
||||
|
||||
</body>
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- CSP 安全策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1 ws://localhost:8000 ws://127.0.0.1:8000;" />
|
||||
<!-- 页面标题 -->
|
||||
<title>智能IT支持服务台 - 坐席工作台</title>
|
||||
<title>智能IT服务台 - 坐席工作台</title>
|
||||
<!-- 企微 JS-SDK (用于企微客户端检测和快捷登录) -->
|
||||
<script src="https://res.wx.qq.com/wwopen/js/jwxwork-1.0.0.js"></script>
|
||||
<!-- ElementPlus 图标 -->
|
||||
|
||||
Generated
+1889
-234
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"cropperjs": "^2.1.1",
|
||||
"element-plus": "^2.7.0",
|
||||
@@ -32,6 +33,7 @@
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1092
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 统一认证 API 模块
|
||||
// =============================================================================
|
||||
// 说明:三端(H5/坐席/管理)共用同一套认证API
|
||||
// API 设计文档:docs/03-技术架构/02-技术方案/技术方案-认证API设计.md
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 用户信息 */
|
||||
export interface UserInfo {
|
||||
userid: string
|
||||
name: string
|
||||
role: string
|
||||
avatar?: string
|
||||
department?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
corp_name?: string
|
||||
}
|
||||
|
||||
/** 二维码响应 */
|
||||
export interface QrcodeResponse {
|
||||
qrcode_url: string
|
||||
qrcode_png_base64: string
|
||||
ticket: string
|
||||
expires_in: number
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
/** 扫码状态响应 */
|
||||
export interface ScanStatusResponse {
|
||||
status: 'waiting' | 'scanned' | 'confirmed' | 'expired' | 'cancelled'
|
||||
user_info?: UserInfo
|
||||
token?: string
|
||||
is_new_user?: boolean
|
||||
}
|
||||
|
||||
/** OAuth2 回调响应 */
|
||||
export interface OAuthCallbackResponse {
|
||||
token: string
|
||||
user_info: UserInfo
|
||||
is_new_user: boolean
|
||||
}
|
||||
|
||||
/** 绑定请求 */
|
||||
export interface BindRequest {
|
||||
employee_id: string
|
||||
corp_id?: string
|
||||
bind_type?: 'existing' | 'new'
|
||||
employee_no?: string
|
||||
real_name?: string
|
||||
department?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
/** 绑定响应 */
|
||||
export interface BindResponse {
|
||||
token: string
|
||||
user_info: UserInfo
|
||||
}
|
||||
|
||||
/** 验证 Token 响应 */
|
||||
export interface VerifyResponse {
|
||||
valid: boolean
|
||||
user_info: UserInfo
|
||||
expire_at: string
|
||||
}
|
||||
|
||||
/** 当前用户响应 */
|
||||
export interface MeResponse extends UserInfo {}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取扫码登录二维码
|
||||
* 生成登录二维码,供企微外用户扫描
|
||||
*/
|
||||
export async function getQrcode(): Promise<QrcodeResponse> {
|
||||
return apiClient.get('/auth/qrcode')
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询查询扫码状态
|
||||
* 客户端轮询此接口查询用户扫码结果
|
||||
* @param ticket - 扫码登录票据
|
||||
*/
|
||||
export async function getScanStatus(ticket: string): Promise<ScanStatusResponse> {
|
||||
return apiClient.get('/auth/scan/status', { params: { ticket } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 OAuth2 回调
|
||||
* 企微内打开页面时,通过OAuth2获取userid
|
||||
* @param code - 企微授权码
|
||||
* @param state - 随机state,防止CSRF
|
||||
*/
|
||||
export async function handleOAuthCallback(
|
||||
code: string,
|
||||
state?: string
|
||||
): Promise<OAuthCallbackResponse> {
|
||||
return apiClient.get('/auth/oauth2/callback', {
|
||||
params: { code, state },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号绑定
|
||||
* 互联企业用户扫码后无本地记录,绑定已有账号或申请新账号
|
||||
*/
|
||||
export async function bindAccount(data: BindRequest): Promise<BindResponse> {
|
||||
return apiClient.post('/auth/bind', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
* 验证Token有效性,续期
|
||||
* @param token - 要验证的 Token 字符串
|
||||
*/
|
||||
export async function verifyToken(token: string): Promise<VerifyResponse> {
|
||||
return apiClient.post('/auth/verify', { token })
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
* 清除Token
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
return apiClient.post('/auth/logout')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* 获取已登录用户详情
|
||||
*/
|
||||
export async function getCurrentUser(): Promise<MeResponse> {
|
||||
return apiClient.get('/auth/me')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OAuth2 授权 URL
|
||||
* 用于企微内自动跳转授权
|
||||
* @param redirect_uri - 授权成功后的回调地址
|
||||
*/
|
||||
export async function getOAuthAuthorizeUrl(redirect_uri: string): Promise<{ authorize_url: string }> {
|
||||
return apiClient.get('/auth/oauth2/url', { params: { redirect_uri } })
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export interface AutomationSession {
|
||||
auto_close_at: string | null
|
||||
resolved_at: string | null
|
||||
closed_by: string | null
|
||||
paused_at: string | null
|
||||
meta: Record<string, any> | null
|
||||
actions: AutomationAction[]
|
||||
approval: ApprovalTicket | null
|
||||
@@ -121,6 +122,43 @@ export interface FeedbackPayload {
|
||||
note?: string
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 复杂场景重构 — 新增类型定义
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 信息项 */
|
||||
export interface InformationItem {
|
||||
id: string
|
||||
session_id: string
|
||||
name: string
|
||||
value: string
|
||||
modifiers: string[]
|
||||
is_filled: boolean
|
||||
is_locked: boolean
|
||||
version: number
|
||||
update_history: Record<string, any>[]
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/** 暂停会话列表项 */
|
||||
export interface PausedSession {
|
||||
session_id: string
|
||||
title: string
|
||||
scenario_key: string | null
|
||||
paused_at: string | null
|
||||
paused_duration: string
|
||||
}
|
||||
|
||||
/** 坐席代恢复请求体 */
|
||||
export interface AgentResumePayload {
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** 坐席关闭请求体 */
|
||||
export interface AgentClosePayload {
|
||||
note?: string
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 坐席端接口
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -193,3 +231,39 @@ export async function getAutomationMetrics(): Promise<AutoMetrics> {
|
||||
const res = await apiClient.get('/itportal/automation/admin/metrics')
|
||||
return res as AutoMetrics
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 复杂场景重构 — 新增 API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 获取员工的暂停会话列表 */
|
||||
export async function listPausedSessions(employeeId: string): Promise<PausedSession[]> {
|
||||
const res = await apiClient.get('/itportal/automation/sessions/paused', {
|
||||
params: { employee_id: employeeId },
|
||||
})
|
||||
return res as PausedSession[]
|
||||
}
|
||||
|
||||
/** 坐席代恢复暂停会话 */
|
||||
export async function agentResumeSession(
|
||||
sessionId: string,
|
||||
payload: AgentResumePayload,
|
||||
): Promise<AutomationSession> {
|
||||
const res = await apiClient.post(`/itportal/automation/sessions/${sessionId}/agent-resume`, payload)
|
||||
return res as AutomationSession
|
||||
}
|
||||
|
||||
/** 坐席关闭暂停会话 */
|
||||
export async function agentCloseSession(
|
||||
sessionId: string,
|
||||
payload: AgentClosePayload,
|
||||
): Promise<AutomationSession> {
|
||||
const res = await apiClient.post(`/itportal/automation/sessions/${sessionId}/agent-close`, payload)
|
||||
return res as AutomationSession
|
||||
}
|
||||
|
||||
/** 获取会话信息项列表 */
|
||||
export async function getInfoItems(sessionId: string): Promise<InformationItem[]> {
|
||||
const res = await apiClient.get(`/itportal/automation/sessions/${sessionId}/info-items`)
|
||||
return res as InformationItem[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 复杂场景重构 P2/P3 API 调用模块(坐席端)
|
||||
// =============================================================================
|
||||
// 说明:封装上下文压缩(P2)与多轮纠错(P3)相关的 HTTP 请求。
|
||||
// 约定:与 automation.ts 一致,baseURL 已在 api/index.ts 配置为 /api,
|
||||
// 响应拦截器已解包为内层 data,这里直接 return res as Type。
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 类型定义(与后端 Schema 保持一致)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 批量更正请求体 — 单条更正项 */
|
||||
export interface BatchCorrection {
|
||||
field: string
|
||||
new_value: string
|
||||
old_value?: string
|
||||
}
|
||||
|
||||
/** 批量更正响应 */
|
||||
export interface BatchCorrectResult {
|
||||
corrected_items: Array<{ name: string; value: string; version: number }>
|
||||
snapshot_id: number
|
||||
dependency_warnings: Array<{
|
||||
item_key: string
|
||||
current_value: string
|
||||
derived_from: string
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** 撤销更正响应 */
|
||||
export interface UndoResult {
|
||||
undone_items: string[]
|
||||
restored_values: Record<string, string>
|
||||
snapshot_id: number
|
||||
remaining_undo_count: number
|
||||
}
|
||||
|
||||
/** 更正历史条目 */
|
||||
export interface CorrectionHistoryItem {
|
||||
snapshot_id: number
|
||||
trigger_item_key: string
|
||||
correction_ids: string[]
|
||||
is_undone: boolean
|
||||
created_at: string | null
|
||||
snapshot_data: Record<string, { value: string; version: number }>
|
||||
}
|
||||
|
||||
/** 版本链条目 */
|
||||
export interface VersionChainItem {
|
||||
version: number
|
||||
value: string
|
||||
new_value: string
|
||||
action: string
|
||||
reason: string | null
|
||||
timestamp: string | null
|
||||
}
|
||||
|
||||
/** 版本对比结果 */
|
||||
export interface VersionDiffResult {
|
||||
item_key: string
|
||||
v1: number
|
||||
v1_value: string
|
||||
v2: number
|
||||
v2_value: string
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
/** 压缩日志条目 */
|
||||
export interface CompressionLog {
|
||||
id: number
|
||||
session_id: string
|
||||
tokens_before: number
|
||||
tokens_after: number
|
||||
compression_ratio: number
|
||||
task_node: string | null
|
||||
duration_ms: number
|
||||
compression_level: number
|
||||
summary: string | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// P3:多轮纠错 API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 批量更正信息项(创建快照、触发依赖联动检测) */
|
||||
export async function batchCorrect(
|
||||
sessionId: string,
|
||||
corrections: BatchCorrection[],
|
||||
reason?: string,
|
||||
): Promise<BatchCorrectResult> {
|
||||
const res = await apiClient.post(
|
||||
`/itportal/automation/sessions/${sessionId}/batch-correct`,
|
||||
{ corrections, reason },
|
||||
)
|
||||
return res as BatchCorrectResult
|
||||
}
|
||||
|
||||
/** 撤销最近一次更正(恢复到上一个快照) */
|
||||
export async function undoCorrection(sessionId: string): Promise<UndoResult> {
|
||||
const res = await apiClient.post(
|
||||
`/itportal/automation/sessions/${sessionId}/undo-correction`,
|
||||
)
|
||||
return res as UndoResult
|
||||
}
|
||||
|
||||
/** 获取更正历史(快照时间线) */
|
||||
export async function getCorrectionHistory(
|
||||
sessionId: string,
|
||||
): Promise<CorrectionHistoryItem[]> {
|
||||
const res = await apiClient.get(
|
||||
`/itportal/automation/sessions/${sessionId}/correction-history`,
|
||||
)
|
||||
return res as CorrectionHistoryItem[]
|
||||
}
|
||||
|
||||
/** 获取指定信息项的版本链 */
|
||||
export async function getVersionChain(
|
||||
sessionId: string,
|
||||
itemName: string,
|
||||
): Promise<VersionChainItem[]> {
|
||||
const res = await apiClient.get(
|
||||
`/itportal/automation/sessions/${sessionId}/version-chain/${itemName}`,
|
||||
)
|
||||
return res as VersionChainItem[]
|
||||
}
|
||||
|
||||
/** 版本对比(指定两个版本号) */
|
||||
export async function getVersionDiff(
|
||||
sessionId: string,
|
||||
itemName: string,
|
||||
v1: number,
|
||||
v2: number,
|
||||
): Promise<VersionDiffResult> {
|
||||
const res = await apiClient.post(
|
||||
`/itportal/automation/sessions/${sessionId}/version-diff/${itemName}`,
|
||||
{ v1, v2 },
|
||||
)
|
||||
return res as VersionDiffResult
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// P2:上下文压缩 API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 获取压缩日志(分页) */
|
||||
export async function getCompressionLogs(
|
||||
sessionId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<{ logs: CompressionLog[]; total: number }> {
|
||||
const res = await apiClient.get(
|
||||
`/itportal/automation/sessions/${sessionId}/compression-logs`,
|
||||
{ params: { page, page_size: pageSize } },
|
||||
)
|
||||
return res as { logs: CompressionLog[]; total: number }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 员工目录 API 调用模块
|
||||
// =============================================================================
|
||||
// 说明:封装坐席端员工搜索和组织架构树相关的 HTTP 请求
|
||||
// 对应后端 API:
|
||||
// GET /api/agent/employees/search?keyword=xxx — 模糊搜索员工
|
||||
// GET /api/agent/org/tree — 获取组织架构树
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TypeScript 类型定义
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 员工目录项(搜索结果) */
|
||||
export interface DirectoryEmployee {
|
||||
/** 企微员工 UserID */
|
||||
id: string
|
||||
/** 员工姓名 */
|
||||
name: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
}
|
||||
|
||||
/** 组织架构树节点 */
|
||||
export interface OrgTreeNode {
|
||||
/** 节点 ID(部门名为部门节点 ID,员工 UserID 为叶子节点 ID) */
|
||||
id: string
|
||||
/** 节点显示名称(部门名或员工姓名) */
|
||||
label: string
|
||||
/** 子节点列表(仅部门节点有,员工叶子节点无此字段) */
|
||||
children?: OrgTreeNode[]
|
||||
/** 是否为叶子节点(员工节点为 true,部门节点不设置) */
|
||||
isLeaf?: boolean
|
||||
/** 部门名称(仅员工叶子节点携带,用于前端显示) */
|
||||
department?: string
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 模糊搜索员工(按姓名或工号)
|
||||
*
|
||||
* 调用后端 GET /api/agent/employees/search?keyword=xxx
|
||||
* 响应拦截器已剥离外层 {code, message},直接返回 data 数组
|
||||
*
|
||||
* @param keyword - 搜索关键词(姓名或工号)
|
||||
* @returns 匹配的员工列表(已排除当前坐席自己)
|
||||
*/
|
||||
export async function searchEmployees(keyword: string): Promise<DirectoryEmployee[]> {
|
||||
return await apiClient.get('/agent/employees/search', {
|
||||
params: { keyword },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组织架构树(部门层级 + 员工列表)
|
||||
*
|
||||
* 调用后端 GET /api/agent/org/tree
|
||||
* 响应拦截器已剥离外层 {code, message},直接返回 data 数组
|
||||
*
|
||||
* @returns 组织架构树节点列表(顶层为部门,叶子为员工)
|
||||
*/
|
||||
export async function getOrgTree(): Promise<OrgTreeNode[]> {
|
||||
return await apiClient.get('/agent/org/tree')
|
||||
}
|
||||
@@ -12,6 +12,25 @@ import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'a
|
||||
// ElementPlus 消息提示
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 安全消息提示:防止 ElMessage._context 未初始化时抛出 TypeError 导致级联崩溃
|
||||
// --------------------------------------------------------------------------
|
||||
// 问题根因:ElMessage.info/error/warning 在 _context 为 null 时,
|
||||
// createComponentInstance 中 Object.create(appContext.provides) 会抛出
|
||||
// "TypeError: Object prototype may only be an Object or null: undefined"
|
||||
// 修复:用 try-catch 包装,确保即使 ElMessage 崩溃也不影响业务逻辑
|
||||
const safeMessage = (type: 'success' | 'warning' | 'info' | 'error', message: string, duration?: number) => {
|
||||
try {
|
||||
if (duration !== undefined) {
|
||||
ElMessage[type]({ message, duration })
|
||||
} else {
|
||||
ElMessage[type](message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ElMessage] 消息提示失败(已降级为 console):', message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 创建 Axios 实例
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -124,13 +143,13 @@ async function handleAuthExpired(source: 'http401' | 'biz1002'): Promise<void> {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
|
||||
// 跳转登录页
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
safeMessage('warning', '登录已过期,请重新登录')
|
||||
|
||||
// 防循环检测:超过最大重定向次数时停止跳转
|
||||
const currentCount = parseInt(localStorage.getItem(OAUTH_REDIRECT_COUNT_KEY) || '0', 10)
|
||||
if (currentCount >= OAUTH_MAX_REDIRECT_COUNT) {
|
||||
console.error('[API] 登录重定向次数超限,疑似无限循环,停止重定向')
|
||||
ElMessage.error('登录状态异常,请刷新页面重试')
|
||||
safeMessage('error', '登录状态异常,请刷新页面重试')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -183,7 +202,7 @@ apiClient.interceptors.response.use(
|
||||
// code === 0 表示业务成功
|
||||
if (res.code !== 0) {
|
||||
// 业务错误:显示错误消息
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
safeMessage('error', res.message || '请求失败')
|
||||
|
||||
// AUTH-P0-02: 特殊错误码处理 - 尝试刷新 Token
|
||||
if (res.code === 1002) {
|
||||
@@ -235,7 +254,7 @@ apiClient.interceptors.response.use(
|
||||
|
||||
// 显示错误提示(401 时不显示通用提示,因为会自动处理)
|
||||
if (code !== 401) {
|
||||
ElMessage.error(message)
|
||||
safeMessage('error', message)
|
||||
}
|
||||
|
||||
return Promise.reject({ code, message })
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface Message {
|
||||
status?: string
|
||||
/** 可撤回截止时间(M2 新增) */
|
||||
recallable_until?: string
|
||||
/** 发送者头像(AI用静态图,员工用企微API获取) */
|
||||
sender_avatar?: string
|
||||
}
|
||||
|
||||
/** 消息列表响应(对应后端 MessageListResponse) */
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端 路由推荐 API
|
||||
// =============================================================================
|
||||
// 说明:封装坐席端业务联系人查询和手动发名片相关 API
|
||||
// - getContacts: 获取联系人列表(支持业务类别筛选和关键词搜索)
|
||||
// - sendContactCard: 坐席手动发送名片卡片到指定会话
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 业务联系人信息(与后端 ContactResponse 对应) */
|
||||
export interface BusinessContactInfo {
|
||||
/** 联系人ID */
|
||||
id: number
|
||||
/** 姓名 */
|
||||
name: string
|
||||
/** 性别(male/female) */
|
||||
gender: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
/** 岗位 */
|
||||
position: string
|
||||
/** 负责业务描述 */
|
||||
responsibility: string
|
||||
/** 分机号 */
|
||||
extension: string
|
||||
/** 服务区域/办公地点 */
|
||||
service_area: string
|
||||
/** 企微用户ID */
|
||||
wecom_userid: string
|
||||
/** 头像URL */
|
||||
avatar_url: string
|
||||
/** 业务类别 */
|
||||
business_category: string
|
||||
}
|
||||
|
||||
/** 联系人列表响应 */
|
||||
export interface ContactListData {
|
||||
/** 联系人列表 */
|
||||
items: BusinessContactInfo[]
|
||||
}
|
||||
|
||||
/** 发送名片响应 */
|
||||
export interface SendContactCardResult {
|
||||
/** 消息标识 */
|
||||
message_id: string
|
||||
/** 联系人姓名 */
|
||||
contact_name: string
|
||||
/** 业务类别 */
|
||||
business_category: string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API 方法
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取联系人列表
|
||||
* 支持按业务类别筛选和关键词搜索,用于坐席手动发名片时的联系人选择面板。
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @param params.businessCategory 业务类别筛选(可选)
|
||||
* @param params.keyword 姓名/部门关键词搜索(可选)
|
||||
* @returns 联系人列表数据
|
||||
*/
|
||||
export async function getContacts(params?: {
|
||||
businessCategory?: string
|
||||
keyword?: string
|
||||
}): Promise<ContactListData> {
|
||||
const queryParams: Record<string, string> = {}
|
||||
if (params?.businessCategory) {
|
||||
queryParams.business_category = params.businessCategory
|
||||
}
|
||||
if (params?.keyword) {
|
||||
queryParams.keyword = params.keyword
|
||||
}
|
||||
const response: AxiosResponse = await apiClient.get('/routing/contacts', {
|
||||
params: queryParams,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 坐席手动发送名片卡片
|
||||
* 坐席在会话中手动选择联系人,后端创建 contact_card 消息并 WS 推送双通道。
|
||||
*
|
||||
* @param conversationId 会话ID
|
||||
* @param contactId 联系人ID
|
||||
* @param reason 推荐原因(可选)
|
||||
* @returns 发送结果
|
||||
*/
|
||||
export async function sendContactCard(
|
||||
conversationId: string,
|
||||
contactId: number,
|
||||
reason?: string
|
||||
): Promise<SendContactCardResult> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/conversations/${conversationId}/send-contact-card`,
|
||||
{
|
||||
contact_id: contactId,
|
||||
reason: reason || null,
|
||||
}
|
||||
)
|
||||
return response
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import type { AxiosResponse } from 'axios'
|
||||
export interface TodoItemData {
|
||||
/** 待办唯一标识 */
|
||||
id: string
|
||||
/** 待办类型: ticket/approval/device */
|
||||
/** 待办类型: ticket/approval */
|
||||
type: string
|
||||
/** 待办标题 */
|
||||
title: string
|
||||
@@ -53,11 +53,13 @@ export interface TodoItemListData {
|
||||
* 获取当前坐席待办列表
|
||||
*
|
||||
* @param params - 查询参数
|
||||
* @param params.type - 按类型过滤(可选):ticket/approval
|
||||
* @param params.status - 按状态过滤(可选)
|
||||
* @param params.priority - 按优先级过滤(可选)
|
||||
* @returns 待办事项列表数据
|
||||
*/
|
||||
export async function getTodoItems(params?: {
|
||||
type?: 'ticket' | 'approval'
|
||||
status?: string
|
||||
priority?: string
|
||||
}): Promise<TodoItemListData> {
|
||||
@@ -65,6 +67,25 @@ export async function getTodoItems(params?: {
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新待办列表(跳过后端缓存)
|
||||
*
|
||||
* 通过 _force=1 参数告知后端跳过 Redis 缓存,重新查询外部 API。
|
||||
* 适用于手动刷新场景。
|
||||
*
|
||||
* @param params - 查询参数
|
||||
* @param params.type - 按类型过滤(可选):ticket/approval
|
||||
* @returns 待办事项列表数据
|
||||
*/
|
||||
export async function refreshTodoItems(params?: {
|
||||
type?: 'ticket' | 'approval'
|
||||
}): Promise<TodoItemListData> {
|
||||
const response: AxiosResponse = await apiClient.get('/todo-items', {
|
||||
params: { ...params, _force: 1 },
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待办事项详情
|
||||
*
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<!--
|
||||
依赖联动提示组件
|
||||
展示批量更正后受影响的关联信息项列表
|
||||
-->
|
||||
<template>
|
||||
<el-alert v-if="warnings.length > 0" type="warning" :closable="false" show-icon>
|
||||
<template #title>依赖联动提醒</template>
|
||||
<div class="dependency-list">
|
||||
<div v-for="(w, i) in warnings" :key="i" class="dependency-item">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span class="dep-name">{{ w.item_key }}</span>
|
||||
<span class="dep-current">当前值:{{ w.current_value }}</span>
|
||||
<span class="dep-message">{{ w.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { WarningFilled } from '@element-plus/icons-vue'
|
||||
|
||||
/** 依赖联动警告项 */
|
||||
interface DependencyWarning {
|
||||
item_key: string
|
||||
current_value: string
|
||||
derived_from: string
|
||||
message: string
|
||||
}
|
||||
|
||||
defineProps<{ warnings: DependencyWarning[] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dependency-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dependency-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dep-name {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-warning-dark-2);
|
||||
}
|
||||
.dep-current {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.dep-message {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,243 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 复杂场景重构 信息项面板组件(坐席端)
|
||||
// =============================================================================
|
||||
// 说明:Element Plus 折叠面板,展示会话中收集的所有信息项。
|
||||
// 每个信息项显示名称、当前值、版本号、修饰符标签。
|
||||
// 点击展开可查看完整变更历史(版本号、旧值、新值、变更时间)。
|
||||
// 锁定状态(is_locked=True)的项显示锁图标。
|
||||
// ============================================================================= -->
|
||||
<template>
|
||||
<el-card class="info-item-panel" shadow="never">
|
||||
<template #header>
|
||||
<div class="panel-header">
|
||||
<span>信息项({{ items.length }})</span>
|
||||
<el-tag size="small" type="info">{{ filledCount }}/{{ items.length }} 已填写</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse-item
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:name="item.id"
|
||||
>
|
||||
<template #title>
|
||||
<div class="item-title-row">
|
||||
<el-icon v-if="item.is_locked" class="lock-icon"><Lock /></el-icon>
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<el-tag
|
||||
:type="itemStatusType(item)"
|
||||
size="small"
|
||||
class="item-status-tag"
|
||||
>
|
||||
{{ itemStatusText(item) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.version > 1" size="small" plain class="version-tag">
|
||||
v{{ item.version }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 当前值 -->
|
||||
<div class="item-section">
|
||||
<div class="section-label">当前值</div>
|
||||
<div class="section-value">{{ item.value || '(空)' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 修饰符 -->
|
||||
<div v-if="item.modifiers.length > 0" class="item-section">
|
||||
<div class="section-label">修饰符</div>
|
||||
<div class="modifier-tags">
|
||||
<el-tag
|
||||
v-for="mod in item.modifiers"
|
||||
:key="mod"
|
||||
:type="modifierTagType(mod)"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ mod }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变更历史 -->
|
||||
<div v-if="item.update_history.length > 0" class="item-section">
|
||||
<div class="section-label">变更历史</div>
|
||||
<el-timeline class="history-timeline">
|
||||
<el-timeline-item
|
||||
v-for="(h, idx) in item.update_history"
|
||||
:key="idx"
|
||||
:type="h.action === 'correct' ? 'warning' : 'primary'"
|
||||
:timestamp="formatTime(h.timestamp)"
|
||||
size="normal"
|
||||
>
|
||||
<div class="history-entry">
|
||||
<span class="history-version">v{{ h.version }}</span>
|
||||
<span class="history-action-tag">{{ h.action === 'correct' ? '更正' : '补充' }}</span>
|
||||
<div class="history-values">
|
||||
<span class="old-value">{{ h.old_value || '(空)' }}</span>
|
||||
<el-icon class="arrow-icon"><Right /></el-icon>
|
||||
<span class="new-value">{{ h.new_value || '(空)' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
|
||||
<!-- 最后更新时间 -->
|
||||
<div v-if="item.updated_at" class="item-footer">
|
||||
最后更新:{{ formatTime(item.updated_at) }}
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Lock, Right } from '@element-plus/icons-vue'
|
||||
import type { InformationItem } from '@/api/automation'
|
||||
|
||||
const props = defineProps<{
|
||||
items: InformationItem[]
|
||||
}>()
|
||||
|
||||
// 折叠面板展开状态(默认全部折叠)
|
||||
const activeNames = ref<string[]>([])
|
||||
|
||||
// 已填写信息项数量
|
||||
const filledCount = computed(() =>
|
||||
props.items.filter((item) => item.is_filled).length,
|
||||
)
|
||||
|
||||
function itemStatusType(item: InformationItem): 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (item.is_locked) return 'danger'
|
||||
if (item.is_filled) return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function itemStatusText(item: InformationItem): string {
|
||||
if (item.is_locked) return '已锁定'
|
||||
if (item.is_filled) return '已填写'
|
||||
return '待填写'
|
||||
}
|
||||
|
||||
function modifierTagType(mod: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
|
||||
switch (mod) {
|
||||
case '固定':
|
||||
return 'danger'
|
||||
case '必需':
|
||||
return 'warning'
|
||||
case '增量':
|
||||
return 'primary'
|
||||
case '明确':
|
||||
return 'success'
|
||||
case '隐含':
|
||||
return 'info'
|
||||
case '复述':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
try {
|
||||
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return t
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-item-panel {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.item-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.lock-icon {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.item-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.item-status-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
.version-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.item-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.section-value {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.modifier-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.history-timeline {
|
||||
padding-left: 8px;
|
||||
}
|
||||
.history-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.history-version {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.history-action-tag {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
width: fit-content;
|
||||
}
|
||||
.history-values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.old-value {
|
||||
color: var(--el-text-color-secondary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.arrow-icon {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.new-value {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.item-footer {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,530 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 摄像头拍照弹窗组件
|
||||
// =============================================================================
|
||||
// 说明:基于 el-dialog 的拍照弹窗,提供实时摄像头预览、拍照、重拍、确认功能
|
||||
// 架构文档:docs/03-技术架构/坐席端截图拍照功能-架构设计.md
|
||||
//
|
||||
// 交互流程:
|
||||
// 1. 弹窗打开 → 自动启动摄像头 → 显示实时预览
|
||||
// 2. 点击拍照 → 捕获当前帧 → 显示静态画面
|
||||
// 3. 点击重拍 → 清空静态画面 → 恢复实时预览
|
||||
// 4. 点击确认 → 再次捕获最新帧 → 转 Blob → emit('confirm', blob) → 关闭弹窗
|
||||
// 5. 点击取消/ESC → emit('cancel') → 关闭弹窗
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="拍照"
|
||||
width="640px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- 预览区 -->
|
||||
<div class="camera-preview">
|
||||
<!-- 实时视频预览(拍照前显示) -->
|
||||
<video
|
||||
v-show="!capturedImage && !cameraError"
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
class="camera-video"
|
||||
></video>
|
||||
|
||||
<!-- 拍照后的静态画面(含 getUserMedia 拍照和系统相机拍照) -->
|
||||
<img
|
||||
v-if="capturedImage"
|
||||
:src="capturedImage"
|
||||
class="captured-photo"
|
||||
alt="拍照结果"
|
||||
/>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="cameraError" class="camera-error">
|
||||
<div class="error-icon">📷</div>
|
||||
<p class="error-text">{{ errorMessage }}</p>
|
||||
<!-- Bug 2 修复:在错误状态下提示用户可以使用系统相机/本地图片兜底 -->
|
||||
<p class="error-hint">{{ fallbackHintText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载中提示 -->
|
||||
<div v-if="!capturedImage && !cameraError && !cameraReady" class="camera-loading">
|
||||
<p>正在启动摄像头...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bug 2 修复:隐藏的文件输入,用于在 getUserMedia 不可用时唤起系统相机
|
||||
桌面端会忽略 capture 属性,直接弹出文件选择器;
|
||||
移动端 capture="user" 提示浏览器优先使用前置摄像头 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
:capture="isDesktop ? undefined : 'user'"
|
||||
style="display: none"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
|
||||
<!-- 控制区 -->
|
||||
<template #footer>
|
||||
<!-- 拍照前(实时预览中,无错误) -->
|
||||
<div v-if="!capturedImage && !cameraError" class="footer-controls">
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
size="large"
|
||||
:disabled="!cameraReady"
|
||||
title="拍照"
|
||||
@click="capture"
|
||||
>
|
||||
<span class="capture-icon">📷</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 拍照后(显示静态画面) -->
|
||||
<div v-else-if="capturedImage" class="footer-controls">
|
||||
<el-button @click="retake">重拍</el-button>
|
||||
<el-button type="primary" @click="confirm">
|
||||
确认 ✓
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态(Bug 2 修复:增加"选择本地图片/使用系统相机"兜底按钮) -->
|
||||
<div v-else class="footer-controls">
|
||||
<el-button @click="close">关闭</el-button>
|
||||
<el-button type="success" @click="openSystemCamera">{{ fallbackButtonText }}</el-button>
|
||||
<el-button type="primary" @click="retryCamera">重试</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// CameraCapture.vue — 拍照弹窗组件
|
||||
// =============================================================================
|
||||
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useCamera, getCameraErrorMessage } from '@/composables/useCamera'
|
||||
import type { CameraError } from '@/types/screenshot'
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** v-model 控制弹窗显隐 */
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** v-model 更新 */
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
/** 拍照确认,返回图片 Blob */
|
||||
(e: 'confirm', blob: Blob): void
|
||||
/** 取消拍照 */
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 弹窗可见性(v-model 双向绑定) */
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: boolean) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
/** video 元素引用 */
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
/** 拍照后的图片 dataURL(null 表示未拍照,显示实时预览) */
|
||||
const capturedImage = ref<string | null>(null)
|
||||
|
||||
/** 摄像头错误类型 */
|
||||
const cameraError = ref<CameraError | null>(null)
|
||||
|
||||
/** 摄像头是否已就绪(video 流已绑定并可以拍照) */
|
||||
const cameraReady = ref(false)
|
||||
|
||||
/** Bug 2 修复:隐藏的文件输入元素引用,用于在 getUserMedia 不可用时唤起系统相机 */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** Bug 2 修复:标记当前照片是否来自系统相机(用于"重拍"时正确判断重开方式) */
|
||||
const isFromSystemCamera = ref(false)
|
||||
|
||||
/** useCamera composable */
|
||||
const {
|
||||
error: cameraComposableError,
|
||||
stream,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
captureFrame,
|
||||
} = useCamera()
|
||||
|
||||
/** 错误提示文本 */
|
||||
const errorMessage = computed(() => getCameraErrorMessage(cameraError.value))
|
||||
|
||||
/** 当前是否为桌面环境(非移动设备)
|
||||
* 桌面端 <input capture> 不会唤起系统相机,只会弹出文件选择器,
|
||||
* 因此 UI 文案需要按"选择本地图片"引导,避免误导用户 */
|
||||
const isDesktop = computed(() => {
|
||||
const ua = navigator.userAgent
|
||||
return !/Mobile|Android|iPhone|iPad|iPod|Windows Phone/i.test(ua)
|
||||
})
|
||||
|
||||
/** 兜底按钮文案:桌面端显示"选择本地图片",移动端显示"使用系统相机" */
|
||||
const fallbackButtonText = computed(() => isDesktop.value ? '选择本地图片' : '使用系统相机')
|
||||
|
||||
/** 错误状态下的补充提示:桌面端提示选择文件,移动端提示使用系统相机 */
|
||||
const fallbackHintText = computed(() =>
|
||||
isDesktop.value
|
||||
? '点击下方"选择本地图片"可从本地选择图片'
|
||||
: '点击下方"使用系统相机"可通过系统相机拍照'
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Bug 2 修复:系统相机兜底方案
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 打开系统相机(兜底方案)
|
||||
*
|
||||
* 做什么:触发隐藏的 <input type="file" accept="image/*" capture="camera"> 的点击事件,
|
||||
* 由浏览器/系统唤起原生相机应用或文件选择器。
|
||||
*
|
||||
* 为什么需要兜底:
|
||||
* 在企微桌面端或某些浏览器环境中,getUserMedia 可能被禁用或权限被拒绝,
|
||||
* 导致用户完全无法拍照。通过系统相机兜底,用户仍可完成拍照需求,
|
||||
* 拍摄的照片走现有的预览→确认流程,最终通过 emit('confirm', blob) 传回 ReplyBox。
|
||||
*/
|
||||
function openSystemCamera(): void {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统相机/文件选择器选择图片后的回调
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 读取用户选择/拍摄的照片文件
|
||||
* 2. 用 FileReader 转 dataURL 显示到预览区
|
||||
* 3. 清除错误状态,标记 cameraReady 让确认按钮可用
|
||||
* 4. 重置 input.value,允许重复选择同一张图片
|
||||
*
|
||||
* @param e - change 事件对象
|
||||
*/
|
||||
function onFileSelected(e: Event): void {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 使用 FileReader 将图片转为 dataURL 用于预览
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
capturedImage.value = reader.result as string
|
||||
// 清除错误状态,进入拍照后的预览流程
|
||||
cameraError.value = null
|
||||
cameraReady.value = true
|
||||
// 标记照片来自系统相机,"重拍"时需要重新唤起系统相机
|
||||
isFromSystemCamera.value = true
|
||||
}
|
||||
reader.onerror = () => {
|
||||
ElMessage.error('图片读取失败,请重试')
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
// 重置 input,允许重复选择同一张图片
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 摄像头生命周期管理
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 启动摄像头并绑定到 video 元素
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 调用 useCamera.startCamera() 获取摄像头流
|
||||
* 2. 将流绑定到 video.srcObject 实现实时预览
|
||||
* 3. 等待 video 元数据加载完成,标记 cameraReady
|
||||
*
|
||||
* 错误处理:根据错误类型设置 cameraError,显示友好提示
|
||||
*/
|
||||
async function initCamera(): Promise<void> {
|
||||
cameraError.value = null
|
||||
cameraReady.value = false
|
||||
capturedImage.value = null
|
||||
isFromSystemCamera.value = false
|
||||
|
||||
ElMessage.info('即将请求摄像头权限...')
|
||||
|
||||
const success = await startCamera('user')
|
||||
|
||||
if (!success) {
|
||||
cameraError.value = cameraComposableError.value || 'unknown'
|
||||
return
|
||||
}
|
||||
|
||||
// 等待 video 元素渲染后绑定流
|
||||
await nextTick()
|
||||
if (videoRef.value && stream.value) {
|
||||
videoRef.value.srcObject = stream.value
|
||||
|
||||
// 等待视频元数据加载完成
|
||||
videoRef.value.onloadedmetadata = () => {
|
||||
videoRef.value?.play().then(() => {
|
||||
cameraReady.value = true
|
||||
}).catch((err) => {
|
||||
console.error('[CameraCapture] 视频播放失败:', err)
|
||||
cameraError.value = 'unknown'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 拍照操作
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 拍照
|
||||
*
|
||||
* 做什么:从 video 当前帧捕获图片,转为 dataURL 显示静态画面
|
||||
* 为什么用 dataURL:方便在 <img> 标签中显示拍照结果
|
||||
*/
|
||||
async function capture(): Promise<void> {
|
||||
if (!videoRef.value || !cameraReady.value) return
|
||||
|
||||
const blob = await captureFrame(videoRef.value)
|
||||
if (!blob) {
|
||||
ElMessage.error('拍照失败,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
// 转 dataURL 用于显示
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
capturedImage.value = reader.result as string
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重拍
|
||||
*
|
||||
* 做什么:清空 capturedImage,恢复预览
|
||||
*
|
||||
* Bug 2 修复:如果当前照片来自系统相机(getUserMedia 不可用),
|
||||
* 则直接重新唤起系统相机,而不是恢复 getUserMedia 视频预览
|
||||
* (因为此时摄像头流未启动,恢复视频预览只会显示黑屏)
|
||||
*/
|
||||
function retake(): void {
|
||||
// 先判断是否来自系统相机,再清空标记
|
||||
const wasFromSystemCamera = isFromSystemCamera.value
|
||||
capturedImage.value = null
|
||||
isFromSystemCamera.value = false
|
||||
|
||||
// 如果照片来自系统相机,重拍时直接重新唤起系统相机
|
||||
if (wasFromSystemCamera) {
|
||||
openSystemCamera()
|
||||
}
|
||||
// 否则正常恢复 getUserMedia 视频预览(video 的 v-show 会自动显示)
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认拍照
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 如果有 capturedImage,将其转回 Blob
|
||||
* 2. 如果没有 capturedImage(理论上不会发生,但防御性处理),重新拍一张
|
||||
* 3. emit('confirm', blob) 传回 ReplyBox
|
||||
* 4. 停止摄像头
|
||||
* 5. 关闭弹窗
|
||||
*/
|
||||
async function confirm(): Promise<void> {
|
||||
let blob: Blob | null = null
|
||||
|
||||
if (capturedImage.value) {
|
||||
// 将 dataURL 转回 Blob
|
||||
blob = dataURLtoBlob(capturedImage.value)
|
||||
} else if (videoRef.value && cameraReady.value) {
|
||||
// 防御性:如果用户没拍照直接点确认,重新捕获一帧
|
||||
blob = await captureFrame(videoRef.value)
|
||||
}
|
||||
|
||||
if (!blob) {
|
||||
ElMessage.error('拍照数据获取失败,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
// 停止摄像头
|
||||
stopCamera()
|
||||
|
||||
// 传回 Blob
|
||||
emit('confirm', blob)
|
||||
|
||||
// 关闭弹窗
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
function close(): void {
|
||||
emit('cancel')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗关闭时的清理
|
||||
*/
|
||||
function handleClose(): void {
|
||||
stopCamera()
|
||||
capturedImage.value = null
|
||||
cameraError.value = null
|
||||
cameraReady.value = false
|
||||
isFromSystemCamera.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试摄像头
|
||||
*/
|
||||
async function retryCamera(): Promise<void> {
|
||||
await initCamera()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 将 dataURL 转为 Blob
|
||||
* @param dataURL - data:image/png;base64,... 格式的字符串
|
||||
* @returns Blob 对象
|
||||
*/
|
||||
function dataURLtoBlob(dataURL: string): Blob {
|
||||
const parts = dataURL.split(',')
|
||||
const meta = parts[0]
|
||||
const data = parts[1]
|
||||
|
||||
// 解析 MIME 类型
|
||||
const mimeMatch = meta.match(/:(.*?);/)
|
||||
const mime = mimeMatch ? mimeMatch[1] : 'image/png'
|
||||
|
||||
// 解码 base64
|
||||
const binaryStr = atob(data)
|
||||
const len = binaryStr.length
|
||||
const arr = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
arr[i] = binaryStr.charCodeAt(i)
|
||||
}
|
||||
|
||||
return new Blob([arr], { type: mime })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听弹窗显隐
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 监听 modelValue 变化
|
||||
* 弹窗打开时自动启动摄像头,关闭时停止摄像头
|
||||
*/
|
||||
watch(visible, async (newVal) => {
|
||||
if (newVal) {
|
||||
// 弹窗打开 → 启动摄像头
|
||||
await nextTick()
|
||||
await initCamera()
|
||||
} else {
|
||||
// 弹窗关闭 → 停止摄像头
|
||||
stopCamera()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时确保释放摄像头
|
||||
onUnmounted(() => {
|
||||
stopCamera()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.captured-photo {
|
||||
width: 100%;
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-hint {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
color: #bbb;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.camera-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.capture-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -40,9 +40,14 @@
|
||||
v-if="hasAnyParticipants"
|
||||
:participants="currentConv?.participants || []"
|
||||
:agent-name="currentConv?.assigned_agent_name || '坐席'"
|
||||
:agent-id="currentConv?.assigned_agent_id || ''"
|
||||
:employee-name="currentConv?.employee_name || ''"
|
||||
:employee-id="currentConv?.employee_id || ''"
|
||||
:is-primary-agent="isPrimaryAgent"
|
||||
:collaborating-agent-ids="currentConv?.collaborating_agent_ids || []"
|
||||
:collaborating-agent-names="currentConv?.collaborating_agent_names || {}"
|
||||
:current-agent-id="agentStore.userId"
|
||||
:conversation-id="currentConv?.id || ''"
|
||||
@invite="showInviteParticipantDialog = true"
|
||||
@remove="handleRemoveParticipant"
|
||||
/>
|
||||
@@ -551,6 +556,10 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
/* 组件样式在 global.css 中定义 */
|
||||
/* 消息列表最小高度:保证展开参与者面板时消息区仍可用(PRD 约束 ≥200px) */
|
||||
.message-list-scroll {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 输入指示器样式 */
|
||||
.typing-indicator {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端业务联系人名片卡片(内联)
|
||||
// =============================================================================
|
||||
// 说明:坐席端消息流中展示 AI 推送的路由名片,或坐席手动发送的名片。
|
||||
// 与 H5 端 ContactCard.vue 的区别:
|
||||
// - Element Plus 风格适配
|
||||
// - 不显示「联系TA」按钮(坐席无需跳转企微单聊)
|
||||
// - 展示推荐原因(routing_reason)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="contact-card-inline">
|
||||
<!-- 卡片头部:头像 + 姓名 + 部门岗位 -->
|
||||
<div class="contact-card-inline__header">
|
||||
<div class="contact-card-inline__avatar" :class="avatarGradient">
|
||||
<img v-if="contact.avatar_url" :src="contact.avatar_url" alt="" class="contact-card-inline__avatar-img" />
|
||||
<span v-else>{{ avatarText }}</span>
|
||||
</div>
|
||||
|
||||
<div class="contact-card-inline__name-row">
|
||||
<div class="contact-card-inline__name">
|
||||
{{ contact.name }}
|
||||
<span class="contact-card-inline__gender" :class="genderClass">{{ genderIcon }}</span>
|
||||
</div>
|
||||
<div class="contact-card-inline__dept">{{ contact.department }} · {{ contact.position }}</div>
|
||||
</div>
|
||||
|
||||
<el-tag size="small" type="info" effect="plain" class="contact-card-inline__badge">
|
||||
员工名片
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 卡片信息区 -->
|
||||
<div class="contact-card-inline__body">
|
||||
<div class="contact-card-inline__info-item">
|
||||
<span class="contact-card-inline__info-label">负责业务</span>
|
||||
<span class="contact-card-inline__info-value contact-card-inline__info-value--highlight">
|
||||
{{ contact.responsibility }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contact.extension" class="contact-card-inline__info-item">
|
||||
<span class="contact-card-inline__info-label">分机号</span>
|
||||
<span class="contact-card-inline__info-value">{{ contact.extension }}</span>
|
||||
</div>
|
||||
<div v-if="contact.service_area" class="contact-card-inline__info-item">
|
||||
<span class="contact-card-inline__info-label">{{ serviceAreaLabel }}</span>
|
||||
<span class="contact-card-inline__info-value">{{ contact.service_area }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐原因(仅 AI 推荐时显示) -->
|
||||
<div v-if="routingReason" class="contact-card-inline__reason">
|
||||
<el-icon :size="12"><InfoFilled /></el-icon>
|
||||
<span>{{ routingReason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ContactCardInline 坐席端名片卡片组件
|
||||
*
|
||||
* 在坐席端消息流中展示联系人名片信息 + 推荐原因。
|
||||
* 不显示「联系TA」按钮(坐席无需跳转企微单聊)。
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
|
||||
/** 联系人信息接口(与 H5 端一致) */
|
||||
export interface ContactInfo {
|
||||
id: number
|
||||
name: string
|
||||
gender: string
|
||||
department: string
|
||||
position: string
|
||||
responsibility: string
|
||||
extension: string
|
||||
service_area: string
|
||||
wecom_userid: string
|
||||
avatar_url: string
|
||||
business_category: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** 联系人信息 */
|
||||
contact: ContactInfo
|
||||
/** 推荐原因(AI 自动推荐时由后端传入) */
|
||||
routingReason?: string
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
const avatarText = computed(() => {
|
||||
return props.contact.name ? props.contact.name.charAt(0) : '?'
|
||||
})
|
||||
|
||||
const avatarGradient = computed(() => {
|
||||
const gradients = [
|
||||
'gradient-1', 'gradient-2', 'gradient-3', 'gradient-4', 'gradient-5',
|
||||
]
|
||||
if (!props.contact.name) return gradients[0]
|
||||
const charCode = props.contact.name.charCodeAt(0)
|
||||
return gradients[charCode % gradients.length]
|
||||
})
|
||||
|
||||
const genderIcon = computed(() => {
|
||||
return props.contact.gender === 'female' ? '♀' : '♂'
|
||||
})
|
||||
|
||||
const genderClass = computed(() => {
|
||||
return props.contact.gender === 'female' ? 'gender-female' : 'gender-male'
|
||||
})
|
||||
|
||||
const serviceAreaLabel = computed(() => {
|
||||
return props.contact.business_category === '行政-物业' ? '办公地点' : '服务区域'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 名片卡片容器(Element Plus 风格)
|
||||
// ============================================================================ */
|
||||
.contact-card-inline {
|
||||
width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 卡片头部
|
||||
// ============================================================================ */
|
||||
.contact-card-inline__header {
|
||||
padding: 12px 14px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.contact-card-inline__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contact-card-inline__avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.gradient-1 { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.gradient-2 { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||||
.gradient-3 { background: linear-gradient(135deg, #4facfe, #00f2fe); }
|
||||
.gradient-4 { background: linear-gradient(135deg, #43e97b, #38f9d7); }
|
||||
.gradient-5 { background: linear-gradient(135deg, #fa709a, #fee140); }
|
||||
|
||||
.contact-card-inline__name-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-card-inline__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.contact-card-inline__gender {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.gender-male { color: #4facfe; }
|
||||
.gender-female { color: #f5576c; }
|
||||
|
||||
.contact-card-inline__dept {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-card-inline__badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 卡片信息区
|
||||
// ============================================================================ */
|
||||
.contact-card-inline__body {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.contact-card-inline__info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.contact-card-inline__info-label {
|
||||
color: var(--text-placeholder);
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-card-inline__info-value {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.contact-card-inline__info-value--highlight {
|
||||
color: #07C160;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 推荐原因
|
||||
// ============================================================================ */
|
||||
.contact-card-inline__reason {
|
||||
padding: 6px 14px 10px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -64,6 +64,10 @@
|
||||
⚡
|
||||
<span class="tb-tip">快速回复</span>
|
||||
</button>
|
||||
<button class="tb-btn" title="发名片" @click="handleToolbarClick('contactCard')">
|
||||
👤
|
||||
<span class="tb-tip">发名片</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 输入行 -->
|
||||
@@ -107,6 +111,94 @@
|
||||
@success="onInviteSuccess"
|
||||
/>
|
||||
|
||||
<!-- 发名片弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showContactCardDialog"
|
||||
title="发送业务联系人名片"
|
||||
width="460px"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<div class="contact-picker">
|
||||
<!-- 搜索框 -->
|
||||
<el-input
|
||||
v-model="contactSearchKeyword"
|
||||
placeholder="搜索姓名/部门..."
|
||||
:prefix-icon="SearchIcon"
|
||||
clearable
|
||||
size="default"
|
||||
style="margin-bottom: 12px"
|
||||
@input="onContactSearchInput"
|
||||
/>
|
||||
|
||||
<!-- 业务类别筛选 -->
|
||||
<div class="contact-picker__categories">
|
||||
<el-tag
|
||||
v-for="cat in businessCategories"
|
||||
:key="cat"
|
||||
:type="contactFilterCategory === cat ? '' : 'info'"
|
||||
:effect="contactFilterCategory === cat ? 'dark' : 'plain'"
|
||||
size="small"
|
||||
class="contact-picker__category-tag"
|
||||
@click="onCategoryFilter(cat)"
|
||||
>
|
||||
{{ cat }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 联系人列表 -->
|
||||
<div class="contact-picker__list">
|
||||
<div v-if="contactLoading" class="contact-picker__loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="filteredContacts.length === 0" class="contact-picker__empty">
|
||||
暂无联系人
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="contact in filteredContacts"
|
||||
:key="contact.id"
|
||||
class="contact-picker__item"
|
||||
:class="{ 'contact-picker__item--selected': selectedContactId === contact.id }"
|
||||
@click="selectedContactId = contact.id"
|
||||
>
|
||||
<div class="contact-picker__item-avatar" :class="getAvatarGradient(contact.name)">
|
||||
{{ contact.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="contact-picker__item-info">
|
||||
<div class="contact-picker__item-name">
|
||||
{{ contact.name }}
|
||||
<span class="contact-picker__item-cat">{{ contact.business_category }}</span>
|
||||
</div>
|
||||
<div class="contact-picker__item-dept">{{ contact.department }} · {{ contact.position }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐原因输入 -->
|
||||
<el-input
|
||||
v-model="contactCardReason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="推荐原因(可选,如:员工咨询报销问题,为您推荐财务联系人)"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showContactCardDialog = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!selectedContactId || sendingContactCard"
|
||||
:loading="sendingContactCard"
|
||||
@click="handleSendContactCard"
|
||||
>
|
||||
发送名片
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 隐藏的文件输入框 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
@@ -134,6 +226,7 @@
|
||||
*/
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Loading } from '@element-plus/icons-vue'
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
|
||||
@@ -141,6 +234,11 @@ import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
import { sendMessage } from '@/api/message'
|
||||
import type { Message } from '@/api/message'
|
||||
import { getContacts, sendContactCard } from '@/api/routing'
|
||||
import type { BusinessContactInfo } from '@/api/routing'
|
||||
|
||||
// Element Plus 图标组件引用(模板中通过 :prefix-icon 和 <Loading /> 使用)
|
||||
const SearchIcon = Search
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
@@ -207,6 +305,30 @@ const showEmojiPicker = ref(false)
|
||||
/** 邀请弹窗是否可见 */
|
||||
const showInviteDialog = ref(false)
|
||||
|
||||
/** 发名片弹窗是否可见 */
|
||||
const showContactCardDialog = ref(false)
|
||||
|
||||
/** 联系人搜索关键词 */
|
||||
const contactSearchKeyword = ref('')
|
||||
|
||||
/** 联系人筛选业务类别(空字符串表示全部) */
|
||||
const contactFilterCategory = ref('')
|
||||
|
||||
/** 联系人列表(从后端加载) */
|
||||
const contacts = ref<BusinessContactInfo[]>([])
|
||||
|
||||
/** 联系人列表加载中 */
|
||||
const contactLoading = ref(false)
|
||||
|
||||
/** 选中的联系人ID */
|
||||
const selectedContactId = ref<number | null>(null)
|
||||
|
||||
/** 推荐原因 */
|
||||
const contactCardReason = ref('')
|
||||
|
||||
/** 名片发送中 */
|
||||
const sendingContactCard = ref(false)
|
||||
|
||||
/** 截图编辑器是否可见 */
|
||||
const showScreenshotEditor = ref(false)
|
||||
|
||||
@@ -244,6 +366,33 @@ const existingParticipantIds = computed(() => {
|
||||
return ids
|
||||
})
|
||||
|
||||
/** 业务类别列表(从联系人数据中动态提取,去重) */
|
||||
const businessCategories = computed(() => {
|
||||
const categories = contacts.value.map(c => c.business_category)
|
||||
return Array.from(new Set(categories))
|
||||
})
|
||||
|
||||
/** 过滤后的联系人列表(按搜索关键词 + 业务类别筛选) */
|
||||
const filteredContacts = computed(() => {
|
||||
let result = contacts.value
|
||||
|
||||
// 按业务类别筛选
|
||||
if (contactFilterCategory.value) {
|
||||
result = result.filter(c => c.business_category === contactFilterCategory.value)
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选(姓名/部门)
|
||||
const keyword = contactSearchKeyword.value.trim().toLowerCase()
|
||||
if (keyword) {
|
||||
result = result.filter(c =>
|
||||
c.name.toLowerCase().includes(keyword) ||
|
||||
c.department.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
@@ -454,6 +603,10 @@ function handleToolbarClick(action: string): void {
|
||||
handleScreenshot()
|
||||
break
|
||||
|
||||
case 'contactCard':
|
||||
handleOpenContactCardDialog()
|
||||
break
|
||||
|
||||
default:
|
||||
const actionMap: Record<string, string> = {
|
||||
voice: '语音消息功能开发中',
|
||||
@@ -519,6 +672,117 @@ function onScreenshotCancel(): void {
|
||||
screenshotCanvas = null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 发名片功能
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 打开发名片弹窗,加载联系人列表。
|
||||
* 每次打开都重新拉取最新联系人数据。
|
||||
*/
|
||||
async function handleOpenContactCardDialog(): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
showContactCardDialog.value = true
|
||||
contactSearchKeyword.value = ''
|
||||
contactFilterCategory.value = ''
|
||||
selectedContactId.value = null
|
||||
contactCardReason.value = ''
|
||||
contactLoading.value = true
|
||||
|
||||
try {
|
||||
const data = await getContacts()
|
||||
contacts.value = data.items || []
|
||||
} catch (error: any) {
|
||||
console.error('[InputBox] 加载联系人列表失败:', error)
|
||||
ElMessage.error(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'加载联系人列表失败'
|
||||
)
|
||||
contacts.value = []
|
||||
} finally {
|
||||
contactLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 联系人搜索输入事件。
|
||||
* 使用 computed filteredContacts 自动响应,此处仅用于触发 UI 反馈。
|
||||
*/
|
||||
function onContactSearchInput(): void {
|
||||
// filteredContacts 是 computed,自动响应 contactSearchKeyword 变化
|
||||
// 此函数保留用于后续可能的防抖优化
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换业务类别筛选。
|
||||
* 点击已选中的类别时取消筛选(设为空字符串)。
|
||||
*/
|
||||
function onCategoryFilter(cat: string): void {
|
||||
if (contactFilterCategory.value === cat) {
|
||||
contactFilterCategory.value = ''
|
||||
} else {
|
||||
contactFilterCategory.value = cat
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据姓名首字返回头像渐变色块样式类。
|
||||
* 与 ContactCardInline.vue 中的 avatarGradient 逻辑一致。
|
||||
*/
|
||||
function getAvatarGradient(name: string): string {
|
||||
const gradients = [
|
||||
'gradient-1', 'gradient-2', 'gradient-3', 'gradient-4', 'gradient-5',
|
||||
]
|
||||
if (!name) return gradients[0]
|
||||
const charCode = name.charCodeAt(0)
|
||||
return gradients[charCode % gradients.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送名片卡片到当前会话。
|
||||
* 调用后端 POST /conversations/{id}/send-contact-card 接口,
|
||||
* 后端创建 contact_card 消息并 WS 双通道推送。
|
||||
*/
|
||||
async function handleSendContactCard(): Promise<void> {
|
||||
if (selectedContactId.value === null) {
|
||||
ElMessage.warning('请先选择一位联系人')
|
||||
return
|
||||
}
|
||||
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
sendingContactCard.value = true
|
||||
try {
|
||||
const result = await sendContactCard(
|
||||
convId,
|
||||
selectedContactId.value,
|
||||
contactCardReason.value.trim() || undefined
|
||||
)
|
||||
ElMessage.success(`已发送${result.business_category}联系人名片:${result.contact_name}`)
|
||||
showContactCardDialog.value = false
|
||||
} catch (error: any) {
|
||||
console.error('[InputBox] 发送名片失败:', error)
|
||||
ElMessage.error(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'发送名片失败,请重试'
|
||||
)
|
||||
} finally {
|
||||
sendingContactCard.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onEmojiSelect(emoji: string): void {
|
||||
inputText.value += emoji
|
||||
showEmojiPicker.value = false
|
||||
@@ -802,4 +1066,139 @@ function onInviteSuccess(): void {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 发名片弹窗 — 联系人选择面板
|
||||
// ============================================================================ */
|
||||
.contact-picker {
|
||||
max-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 业务类别筛选标签 */
|
||||
.contact-picker__categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.contact-picker__category-tag {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.contact-picker__category-tag:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 联系人列表 */
|
||||
.contact-picker__list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #e4e7ed);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.contact-picker__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.contact-picker__empty {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
}
|
||||
|
||||
/* 联系人列表项 */
|
||||
.contact-picker__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||||
}
|
||||
|
||||
.contact-picker__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.contact-picker__item:hover {
|
||||
background: var(--accent-soft, #f5f7fa);
|
||||
}
|
||||
|
||||
.contact-picker__item--selected {
|
||||
background: var(--accent-soft, #ecf5ff);
|
||||
}
|
||||
|
||||
.contact-picker__item--selected::after {
|
||||
content: '✓';
|
||||
margin-left: auto;
|
||||
color: var(--accent, #409eff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 联系人头像 */
|
||||
.contact-picker__item-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gradient-1 { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.gradient-2 { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||||
.gradient-3 { background: linear-gradient(135deg, #4facfe, #00f2fe); }
|
||||
.gradient-4 { background: linear-gradient(135deg, #43e97b, #38f9d7); }
|
||||
.gradient-5 { background: linear-gradient(135deg, #fa709a, #fee140); }
|
||||
|
||||
/* 联系人信息 */
|
||||
.contact-picker__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-picker__item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #303133);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.contact-picker__item-cat {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
background: var(--bg-tertiary, #f0f0f0);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.contact-picker__item-dept {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -94,6 +94,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 业务联系人名片卡片:内联展示(msg_type === 'contact_card') -->
|
||||
<template v-else-if="message.msg_type === 'contact_card'">
|
||||
<ContactCardInline
|
||||
:contact="message.extra_data?.contact ?? {}"
|
||||
:routing-reason="message.extra_data?.routing_reason ?? ''"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 其他非文本消息(语音/视频/位置等):显示通用媒体卡片 -->
|
||||
<template v-else>
|
||||
<div class="media-card">
|
||||
@@ -151,6 +159,7 @@ import type { Message } from '@/api/message'
|
||||
import type { DraftResult } from '@/api/wingman'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import AiDraftBubble from './AiDraftBubble.vue'
|
||||
import ContactCardInline from './ContactCardInline.vue'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
|
||||
@@ -22,10 +22,14 @@
|
||||
:class="`message-row-${message.sender_type}`"
|
||||
@contextmenu.prevent="showContextMenu"
|
||||
>
|
||||
<!-- 发送者名称 -->
|
||||
<!-- 发送者名称 + 头像(头像在名字前面) -->
|
||||
<div v-if="message.sender_type !== 'agent'" class="message-sender-name">
|
||||
<!-- AI 头像 -->
|
||||
<img v-if="message.sender_type === 'ai'" src="/duckula.webp" class="sender-avatar" alt="Duckula" />
|
||||
<!-- 员工头像(后端返回) -->
|
||||
<img v-else-if="message.sender_type === 'employee' && senderAvatar" :src="senderAvatar" class="sender-avatar" alt="员工头像" />
|
||||
<!-- 发送者名字 -->
|
||||
{{ senderLabel }}
|
||||
<img v-if="message.sender_type === 'ai'" src="/duckula.webp" class="duckula-avatar" alt="Duckula" />
|
||||
</div>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
@@ -203,6 +207,19 @@ const senderLabel = computed(() => {
|
||||
return labelMap[props.message.sender_type] || '未知'
|
||||
})
|
||||
|
||||
/** 发送者头像 */
|
||||
const senderAvatar = computed(() => {
|
||||
// AI 头像直接用静态图
|
||||
if (props.message.sender_type === 'ai') {
|
||||
return '/duckula.webp'
|
||||
}
|
||||
// 员工头像从后端返回的数据中获取
|
||||
if (props.message.sender_type === 'employee') {
|
||||
return props.message.sender_avatar || ''
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatMessageTime = computed(() => {
|
||||
if (!props.message.created_at) return ''
|
||||
@@ -404,6 +421,14 @@ function previewImage(): void {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* 发送者头像 */
|
||||
.sender-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ai-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 待发送图片缩略图预览条
|
||||
// =============================================================================
|
||||
// 说明:在 ReplyBox 输入框上方显示待发送图片的缩略图,支持删除单张图片
|
||||
// 架构文档:docs/03-技术架构/坐席端截图拍照功能-架构设计.md
|
||||
//
|
||||
// Props: images — PendingImage[] 待发送图片列表
|
||||
// Emits: remove(id: string) — 删除指定图片
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div v-if="images.length > 0" class="pending-images-bar">
|
||||
<div class="thumbnail" v-for="img in images" :key="img.id">
|
||||
<img :src="img.previewUrl" class="thumb-img" :alt="img.source === 'screenshot' ? '截图' : '拍照'" />
|
||||
<!-- 来源标识 -->
|
||||
<span class="thumb-source" :title="img.source === 'screenshot' ? '截图' : '拍照'">
|
||||
{{ img.source === 'screenshot' ? '✂' : '📷' }}
|
||||
</span>
|
||||
<!-- 删除按钮 -->
|
||||
<button class="thumb-remove" title="删除" @click="$emit('remove', img.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// PendingImagePreview.vue — 待发送图片缩略图预览条
|
||||
// =============================================================================
|
||||
|
||||
import type { PendingImage } from '@/types/screenshot'
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 待发送图片列表 */
|
||||
images: PendingImage[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
/** 删除指定图片 */
|
||||
(e: 'remove', id: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pending-images-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-tertiary, #f5f5f5);
|
||||
border-bottom: 1px solid var(--border, #e5e5e5);
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保持可滚动 */
|
||||
.pending-images-bar::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.pending-images-bar::-webkit-scrollbar-thumb {
|
||||
background: var(--border, #ccc);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 来源标识(左下角小图标) */
|
||||
.thumb-source {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
font-size: 10px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* 删除按钮(右上角 ×) */
|
||||
.thumb-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.thumb-remove:hover {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -30,8 +30,27 @@
|
||||
<button class="reply-preview-close" title="取消回复" @click="$emit('cancelReply')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 待发送图片预览区(截图/拍照后、发送前显示) -->
|
||||
<PendingImagePreview
|
||||
:images="pendingImages.images.value"
|
||||
@remove="pendingImages.removeImage"
|
||||
/>
|
||||
|
||||
<!-- 快捷工具栏 -->
|
||||
<div class="chat-toolbar">
|
||||
<!-- 截图按钮 -->
|
||||
<button class="tb-btn" title="截图" @click="handleScreenshot">
|
||||
✂️
|
||||
<span class="tb-tip">截图</span>
|
||||
</button>
|
||||
<!-- 拍照按钮 -->
|
||||
<button class="tb-btn" title="拍照" @click="handleCamera">
|
||||
📷
|
||||
<span class="tb-tip">拍照</span>
|
||||
</button>
|
||||
|
||||
<div class="tb-sep"></div>
|
||||
|
||||
<div class="emoji-wrapper">
|
||||
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
|
||||
😊
|
||||
@@ -68,20 +87,35 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 输入行(圆角卡片:textarea + 发送按钮) -->
|
||||
<!-- 输入行(圆角卡片:textarea + 语音按钮 + 发送按钮) -->
|
||||
<div class="chat-input-card">
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
v-model="inputText"
|
||||
class="chat-input"
|
||||
:class="{ 'chat-input--listening': speechState.isListening }"
|
||||
placeholder="输入回复内容... (Enter发送,Shift+Enter换行)"
|
||||
:style="{ height: textareaHeight + 'px' }"
|
||||
@keydown="handleKeydown"
|
||||
@paste="handlePaste"
|
||||
></textarea>
|
||||
|
||||
<!-- 语音按钮(发送按钮左边,仅支持的浏览器显示) -->
|
||||
<!-- 做什么:点击开始识别,再次点击停止识别,结果填入输入框 -->
|
||||
<!-- 为什么用 v-if 而不是 v-show:不支持的浏览器完全移除,不占位 -->
|
||||
<button
|
||||
v-if="speechState.isSupported"
|
||||
class="btn-voice"
|
||||
:class="{ 'btn-voice--listening': speechState.isListening }"
|
||||
:title="speechState.isListening ? '点击停止识别' : '语音输入 (Ctrl+D)'"
|
||||
@click="handleVoiceToggle"
|
||||
>
|
||||
{{ speechState.isListening ? '⏹' : '🎤' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn-send"
|
||||
:disabled="!inputText.trim()"
|
||||
:disabled="!inputText.trim() && !pendingImages.hasImages.value"
|
||||
@click="handleSend"
|
||||
>
|
||||
发 送
|
||||
@@ -112,12 +146,20 @@
|
||||
@cancel="onScreenshotCancel"
|
||||
/>
|
||||
|
||||
<!-- 框选截图模式 -->
|
||||
<!-- 框选截图模式(接收 screenshotCanvas 作为编辑底图) -->
|
||||
<ScreenCapture
|
||||
v-if="showScreenCapture"
|
||||
v-if="showScreenCapture && screenshotCanvasRef"
|
||||
:screenshot-canvas="screenshotCanvasRef"
|
||||
@confirm="onBoxCaptureConfirm"
|
||||
@cancel="onBoxCaptureCancel"
|
||||
/>
|
||||
|
||||
<!-- 拍照弹窗 -->
|
||||
<CameraCapture
|
||||
v-model="showCameraDialog"
|
||||
@confirm="onCameraConfirm"
|
||||
@cancel="onCameraCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -125,17 +167,21 @@
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, watch, nextTick, onUnmounted, computed } from 'vue'
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
|
||||
import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||
import ScreenCapture from './ScreenCapture.vue'
|
||||
import CameraCapture from './CameraCapture.vue'
|
||||
import PendingImagePreview from './PendingImagePreview.vue'
|
||||
import { captureScreenAsCanvas } from '@/composables/useScreenCapture'
|
||||
import { usePendingImages } from '@/composables/usePendingImages'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
import { sendMessage } from '@/api/message'
|
||||
import type { Message } from '@/api/message'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数:安全提取错误详情(防止 [object Object])
|
||||
@@ -222,6 +268,52 @@ const showScreenCapture = ref(false)
|
||||
let screenshotCanvas: HTMLCanvasElement | null = null
|
||||
const showEmojiPicker = ref(false)
|
||||
|
||||
/** getDisplayMedia 截图的 Canvas(传给 ScreenCapture 编辑器作为底图) */
|
||||
const screenshotCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
/** 拍照弹窗是否可见 */
|
||||
const showCameraDialog = ref(false)
|
||||
|
||||
/** 待发送图片队列(截图/拍照后、发送前暂存) */
|
||||
const pendingImages = usePendingImages()
|
||||
|
||||
// ============================================================================
|
||||
// 语音识别功能(Web Speech API 实时转写)
|
||||
// ============================================================================
|
||||
/**
|
||||
* Web Speech API 语音识别 composable
|
||||
*
|
||||
* 做什么:封装浏览器原生的语音转文字功能,提供实时转写
|
||||
*
|
||||
* 交互方式:点击语音按钮 → 开始识别 → 实时转写 → 再次点击 → 停止 → 结果填入输入框
|
||||
* 快捷键:Ctrl+D 触发语音开始/停止
|
||||
*
|
||||
* 为什么用 composable:将复杂的 Web Speech API 逻辑封装在独立文件中,组件只关心 UI
|
||||
*/
|
||||
const {
|
||||
state: speechState,
|
||||
start: startSpeech,
|
||||
stop: stopSpeech,
|
||||
reset: resetSpeech,
|
||||
} = useSpeechRecognition()
|
||||
|
||||
/**
|
||||
* 开始语音识别前输入框中已有的文字
|
||||
*
|
||||
* 做什么:记录开始识别时输入框的内容,识别过程中实时追加转写结果
|
||||
*
|
||||
* 为什么需要这个变量:
|
||||
* 识别过程中 textarea 显示 = 已有文字 + 最终结果 + 临时结果
|
||||
* 停止后 textarea 显示 = 已有文字 + 最终结果
|
||||
* 需要保存"已有文字"才能正确拼接
|
||||
*/
|
||||
const textBeforeListening = ref('')
|
||||
|
||||
/** 识别结果是否需要追加前缀空格(如果输入框已有内容,加空格分隔) */
|
||||
function shouldAddSpaceBefore(text: string): boolean {
|
||||
return text.length > 0 && !text.endsWith(' ') && !text.endsWith('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用表情列表(8行x8列 = 64 个常用表情)
|
||||
* 覆盖日常沟通场景,与 H5 端保持一致
|
||||
@@ -301,6 +393,117 @@ watch(inputText, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 语音识别交互
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 监听语音识别状态,实时更新输入框内容
|
||||
*
|
||||
* 做什么:当 finalText 或 interimText 变化时,更新输入框显示
|
||||
*
|
||||
* 为什么用 watch:
|
||||
* Web Speech API 的 onresult 回调在 composable 内部,
|
||||
* 组件通过响应式 state 感知变化,用 watch 桥接到 inputText
|
||||
*
|
||||
* 实时显示逻辑:
|
||||
* 识别中:inputText = 已有文字 + 最终结果 + 临时结果(临时结果实时变化)
|
||||
* (停止后的处理在 handleVoiceToggle 中完成)
|
||||
*/
|
||||
watch(
|
||||
() => speechState.finalText + '|' + speechState.interimText,
|
||||
() => {
|
||||
if (speechState.isListening) {
|
||||
// 识别中:实时显示 已有文字 + 分隔空格 + 最终结果 + 临时结果
|
||||
const baseText = textBeforeListening.value
|
||||
const separator = shouldAddSpaceBefore(baseText) ? ' ' : ''
|
||||
inputText.value = baseText + separator + speechState.finalText + speechState.interimText
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 监听 isListening 从 true → false 的变化
|
||||
*
|
||||
* 做什么:识别停止后,将最终结果写入输入框并重置 composable 状态
|
||||
*
|
||||
* 为什么用 watch 而不是在 stop() 后直接处理:
|
||||
* stop() 调用后,浏览器可能还会触发最后一次 onresult(将临时结果转为最终结果)。
|
||||
* 通过监听 isListening 变化,确保在 onend 完全结束后才最终处理。
|
||||
*/
|
||||
watch(
|
||||
() => speechState.isListening,
|
||||
(listening, wasListening) => {
|
||||
// 仅在从"聆听中"变为"已停止"时触发
|
||||
if (wasListening && !listening) {
|
||||
// 将最终结果写入输入框(保留已有文字 + 分隔空格 + 最终结果)
|
||||
const baseText = textBeforeListening.value
|
||||
const separator = shouldAddSpaceBefore(baseText) ? ' ' : ''
|
||||
inputText.value = baseText + separator + speechState.finalText
|
||||
// 重置 composable 状态(清空 finalText 和 interimText,为下次识别准备)
|
||||
resetSpeech()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 处理语音按钮点击 / Ctrl+D 快捷键
|
||||
*
|
||||
* 做什么:
|
||||
* - 如果未在聆听 → 开始语音识别,ElMessage 提示"正在聆听..."
|
||||
* - 如果在聆听中 → 停止识别,最终结果通过 watch 自动写入输入框
|
||||
*
|
||||
* 交互流程:点击开始 → 实时转写 → 再次点击 → 停止 → 结果填入输入框(不自动发送)
|
||||
*
|
||||
* 错误处理:ElMessage 提示,不阻塞 UI
|
||||
*/
|
||||
function handleVoiceToggle(): void {
|
||||
// 浏览器不支持时不处理(按钮也不显示,这里做二次保护)
|
||||
if (!speechState.isSupported) {
|
||||
ElMessage.warning('当前浏览器不支持语音识别,请使用 Chrome 或 Edge')
|
||||
return
|
||||
}
|
||||
|
||||
if (speechState.isListening) {
|
||||
// ---- 停止识别 ----
|
||||
// stop() 后 watch(isListening) 会自动处理:写入 finalText + reset
|
||||
stopSpeech()
|
||||
ElMessage.success('语音识别已停止')
|
||||
} else {
|
||||
// ---- 开始识别 ----
|
||||
// 保存当前输入框内容(识别过程中实时追加转写结果)
|
||||
textBeforeListening.value = inputText.value
|
||||
startSpeech()
|
||||
|
||||
if (speechState.isListening) {
|
||||
ElMessage.info('正在聆听...')
|
||||
} else if (speechState.error) {
|
||||
ElMessage.error(speechState.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局键盘快捷键处理(Ctrl+D 语音识别)
|
||||
*
|
||||
* 做什么:监听 Ctrl+D 快捷键,触发语音开始/停止
|
||||
*
|
||||
* 为什么用 preventDefault:
|
||||
* Ctrl+D 是浏览器的"添加书签"快捷键,必须阻止默认行为
|
||||
*
|
||||
* 为什么挂载在 document 上:
|
||||
* 快捷键应全局生效,即使焦点不在输入框也能触发
|
||||
*
|
||||
* @param event - 键盘事件
|
||||
*/
|
||||
function handleGlobalKeydown(event: KeyboardEvent): void {
|
||||
// Ctrl+D(不区分大小写)
|
||||
if (event.ctrlKey && (event.key === 'd' || event.key === 'D')) {
|
||||
event.preventDefault() // 阻止浏览器默认行为(添加书签)
|
||||
handleVoiceToggle()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 拖拽调整输入区高度
|
||||
// ============================================================================
|
||||
@@ -356,19 +559,91 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* 发送消息(支持图文组合发送)
|
||||
*
|
||||
* 做什么:
|
||||
* - 有图片:逐张上传+发送图片消息,最后清空队列
|
||||
* - 有文字:走原有 emit('send', content) 逻辑
|
||||
* - 图文都有:先发图片,再发文字(两条独立消息)
|
||||
* - 都没有:不发送
|
||||
*/
|
||||
async function handleSend(): Promise<void> {
|
||||
const content = inputText.value.trim()
|
||||
if (!content) return
|
||||
const hasImages = pendingImages.hasImages.value
|
||||
const hasText = content.length > 0
|
||||
|
||||
try {
|
||||
// 无内容不发送
|
||||
if (!hasImages && !hasText) return
|
||||
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
|
||||
// 有图片:逐张上传+发送图片消息
|
||||
if (hasImages) {
|
||||
await sendPendingImages(convId)
|
||||
}
|
||||
|
||||
// 有文字:发送文字消息(走原有 emit 逻辑)
|
||||
if (hasText) {
|
||||
emit('send', content)
|
||||
inputText.value = ''
|
||||
// 恢复 textarea 高度(3行默认)
|
||||
textareaHeight.value = 60
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
}
|
||||
|
||||
// 清空输入框,恢复高度
|
||||
inputText.value = ''
|
||||
textareaHeight.value = 60
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送待发送图片队列中的所有图片
|
||||
*
|
||||
* 做什么:逐张串行上传+发送图片消息,避免并发请求过多
|
||||
* 为什么串行:架构文档 A1 决策,逐张上传避免并发过多
|
||||
* 错误处理:某张失败时 ElMessage.error 提示,继续发送剩余图片(A2 决策)
|
||||
*
|
||||
* @param convId - 会话ID
|
||||
*/
|
||||
async function sendPendingImages(convId: string): Promise<void> {
|
||||
ElMessage.info('发送中...')
|
||||
const images = [...pendingImages.images.value]
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const img of images) {
|
||||
try {
|
||||
// 1. 上传图片到服务器
|
||||
const result = await uploadFile(img.blob)
|
||||
|
||||
// 2. 发送图片消息
|
||||
const newMsg = await sendMessage(convId, '[图片]', 'image', {
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
|
||||
// 3. 把新消息加入 store,让界面立即刷新显示
|
||||
conversationStore.messages.push(newMsg)
|
||||
successCount++
|
||||
} catch (error: any) {
|
||||
failCount++
|
||||
console.error('[ReplyBox] 图片发送失败:', error)
|
||||
const errMsg =
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'未知错误'
|
||||
ElMessage.error(`图片发送失败:${errMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空图片队列(释放所有 ObjectURL)
|
||||
pendingImages.clearAll()
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
ElMessage.success('发送成功')
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
ElMessage.warning(`${successCount} 张发送成功,${failCount} 张发送失败`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,9 +892,8 @@ function handleToolbarClick(action: string): void {
|
||||
break
|
||||
|
||||
default:
|
||||
// 其他功能暂未实现
|
||||
// 其他功能暂未实现(语音功能已移至发送按钮旁的独立按钮)
|
||||
const actionMap: Record<string, string> = {
|
||||
voice: '语音消息功能开发中',
|
||||
remote: '远程协助功能开发中',
|
||||
}
|
||||
ElMessage.info(actionMap[action] || '功能开发中')
|
||||
@@ -629,14 +903,14 @@ function handleToolbarClick(action: string): void {
|
||||
/**
|
||||
* 处理截图功能
|
||||
*
|
||||
* 做什么:使用 html2canvas 截取当前页面内容,弹出预览,确认后上传发送
|
||||
* 做什么:使用 getDisplayMedia 截取屏幕画面,返回 Canvas 传给 ScreenCapture 编辑器
|
||||
*
|
||||
* 为什么用 html2canvas:
|
||||
* - Screen Capture API(getDisplayMedia)在企微桌面端被限制,不稳定
|
||||
* - html2canvas 纯前端渲染,不依赖任何浏览器 API,所有环境都可用
|
||||
* - 适合客服场景:坐席想截取当前对话内容发给用户
|
||||
* 为什么用 captureScreenAsCanvas(而非 html2canvas):
|
||||
* - getDisplayMedia 可截取整个屏幕/窗口/标签页(不仅仅是当前页面)
|
||||
* - 返回 Canvas 直接作为编辑底图,无需 Blob→Image 异步转换
|
||||
* - 企微桌面端基于 Chromium,完全支持
|
||||
*
|
||||
* 交互流程:点击截图按钮 → 截取页面 → 弹出预览对话框 → 确认发送 / 取消
|
||||
* 交互流程:点击截图按钮 → 浏览器弹出屏幕选择器 → 框选编辑 → 加入待发送队列
|
||||
*/
|
||||
async function handleScreenshot(): Promise<void> {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
@@ -645,122 +919,68 @@ async function handleScreenshot(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 提示用户在弹窗中选择"屏幕"
|
||||
// 注意:ElMessage.info 本身不会触发异步等待,但为防御性编程,用 try-catch 包裹避免异常打断用户手势
|
||||
try {
|
||||
ElMessage.info('正在截取页面...')
|
||||
|
||||
// 1. 截取整个页面
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
})
|
||||
|
||||
// 2. 保存 canvas 并显示截图编辑器(对标微信/企微)
|
||||
screenshotCanvas = canvas
|
||||
showScreenshotEditor.value = true
|
||||
} catch (error) {
|
||||
console.error('截图失败:', error)
|
||||
ElMessage.error('截图失败,请重试')
|
||||
ElMessage.info('请在弹窗中选择「屏幕」以截取完整画面', 3)
|
||||
} catch {
|
||||
// ElMessage 异常不应阻断后续截图流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 框选截图 - 直接在屏幕上框选区域
|
||||
*/
|
||||
function startBoxCapture(): void {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
// 直接调用 captureScreenAsCanvas 获取屏幕截图 Canvas
|
||||
// 关键:不能在此前 await requestFullscreen()——await 会消耗按钮点击的用户手势(user gesture),
|
||||
// 而 getDisplayMedia() 必须在用户手势上下文中同步调用,否则屏幕选择器不会弹出。
|
||||
const canvas = await captureScreenAsCanvas()
|
||||
if (!canvas) {
|
||||
// captureScreenAsCanvas 返回 null 有两种情况:用户取消 / 选了非屏幕源
|
||||
// 两种情况都不需要额外提示(取消是正常行为,非屏幕源已在 console 警告)
|
||||
return
|
||||
}
|
||||
|
||||
// 将 Canvas 传给 ScreenCapture 编辑器
|
||||
screenshotCanvasRef.value = canvas
|
||||
showScreenCapture.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 框选截图确认
|
||||
* 框选截图确认 — 加入待发送图片队列(不直接发送)
|
||||
*/
|
||||
async function onBoxCaptureConfirm(blob: Blob): Promise<void> {
|
||||
function onBoxCaptureConfirm(blob: Blob): void {
|
||||
showScreenCapture.value = false
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) return
|
||||
|
||||
try {
|
||||
ElMessage.info('截图上传中...')
|
||||
const result = await uploadFile(blob)
|
||||
const newMsg = await sendMessage(convId, '[截图]', 'image', {
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
conversationStore.messages.push(newMsg)
|
||||
ElMessage.success('截图发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('[ReplyBox] 框选截图发送失败:', error)
|
||||
ElMessage.error(`截图发送失败:${error?.message || '未知错误'}`)
|
||||
}
|
||||
screenshotCanvasRef.value = null
|
||||
pendingImages.addImage(blob, 'screenshot')
|
||||
}
|
||||
|
||||
function onBoxCaptureCancel(): void {
|
||||
showScreenCapture.value = false
|
||||
screenshotCanvasRef.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨屏截图 - 使用系统屏幕捕获API
|
||||
* 打开拍照弹窗
|
||||
*/
|
||||
async function handleScreenCapture(): Promise<void> {
|
||||
function handleCamera(): void {
|
||||
const convId = conversationStore.currentConversation?.id
|
||||
if (!convId) {
|
||||
ElMessage.warning('请先选择一个会话')
|
||||
return
|
||||
}
|
||||
showCameraDialog.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
ElMessage.info('选择要截取的屏幕...')
|
||||
|
||||
// 使用系统屏幕捕获API
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
displaySurface: 'monitor', // 优先捕获整个屏幕
|
||||
},
|
||||
audio: false,
|
||||
})
|
||||
/**
|
||||
* 拍照确认 — 加入待发送图片队列
|
||||
*/
|
||||
function onCameraConfirm(blob: Blob): void {
|
||||
showCameraDialog.value = false
|
||||
pendingImages.addImage(blob, 'camera')
|
||||
}
|
||||
|
||||
// 获取视频轨道
|
||||
const videoTrack = stream.getVideoTracks()[0]
|
||||
const settings = videoTrack.getSettings()
|
||||
|
||||
// 创建视频元素来捕获帧
|
||||
const video = document.createElement('video')
|
||||
video.srcObject = new MediaStream([videoTrack])
|
||||
await video.play()
|
||||
|
||||
// 创建 canvas 捕获帧
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = settings.width || video.videoWidth
|
||||
canvas.height = settings.height || video.videoHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
// 停止屏幕共享
|
||||
videoTrack.stop()
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
|
||||
// 转换为图片
|
||||
screenshotCanvas = canvas
|
||||
showScreenshotEditor.value = true
|
||||
ElMessage.success('截取成功')
|
||||
} catch (error: any) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
ElMessage.info('已取消跨屏截图')
|
||||
} else {
|
||||
console.error('跨屏截图失败:', error)
|
||||
ElMessage.error('跨屏截图失败,请重试')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 拍照取消
|
||||
*/
|
||||
function onCameraCancel(): void {
|
||||
showCameraDialog.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -807,10 +1027,23 @@ function onScreenshotCancel(): void {
|
||||
screenshotCanvas = null
|
||||
}
|
||||
|
||||
// 组件卸载时清理拖拽事件
|
||||
// 组件挂载时注册 Ctrl+D 快捷键监听
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
|
||||
// 组件卸载时清理事件监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeEnd)
|
||||
// 移除 Ctrl+D 快捷键监听
|
||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||
// 停止语音识别(防止组件卸载后仍在聆听)
|
||||
if (speechState.isListening) {
|
||||
stopSpeech()
|
||||
}
|
||||
// 释放待发送图片的 ObjectURL 资源
|
||||
pendingImages.clearAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -964,6 +1197,60 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
语音按钮(发送按钮左边)
|
||||
========================================================================== */
|
||||
/* 默认状态:与输入卡片融合,低调灰色 */
|
||||
.btn-voice {
|
||||
padding: 8px 10px;
|
||||
margin: 4px 2px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-voice:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-voice:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* 聆听中状态:红色背景 + 脉冲动画 */
|
||||
.btn-voice--listening {
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
animation: voice-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.btn-voice--listening:hover {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@keyframes voice-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 识别中 textarea 样式:微弱高亮提示用户正在转写 */
|
||||
.chat-input--listening {
|
||||
background: rgba(239, 68, 68, 0.03);
|
||||
}
|
||||
.btn-send:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,9 @@
|
||||
// 企微IT智能服务台 — 任务详情视图组件
|
||||
// =============================================================================
|
||||
// 说明:点击左侧待办事项时,中间栏从聊天视图切换为任务类型专属页面
|
||||
// 功能:根据 todoItem.type 渲染三种子视图:
|
||||
// 功能:根据 todoItem.type 渲染两种子视图:
|
||||
// 1. 📋 运维工单页 (ticket)
|
||||
// 2. 📝 审批单页 (approval)
|
||||
// 3. 🖥 设备异常页 (device)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
@@ -51,13 +50,6 @@
|
||||
@action="handleAction"
|
||||
/>
|
||||
|
||||
<!-- 🖥 设备异常页 -->
|
||||
<DeviceDetail
|
||||
v-else-if="todoItem.type === 'device'"
|
||||
:todo-item="todoItem"
|
||||
@action="handleAction"
|
||||
/>
|
||||
|
||||
<!-- 未知类型 fallback -->
|
||||
<div v-else class="tdv-unknown">
|
||||
<p>未知的任务类型:{{ todoItem.type }}</p>
|
||||
@@ -75,7 +67,6 @@ import { useConversationStore } from '@/stores/conversation'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
import TicketDetail from './task/TicketDetail.vue'
|
||||
import ApprovalDetail from './task/ApprovalDetail.vue'
|
||||
import DeviceDetail from './task/DeviceDetail.vue'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
@@ -102,7 +93,6 @@ const conversationStore = useConversationStore()
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
ticket: '📋 运维工单',
|
||||
approval: '📝 审批单',
|
||||
device: '🖥 设备异常',
|
||||
}
|
||||
|
||||
/** 优先级标签映射 */
|
||||
@@ -215,11 +205,6 @@ function handleAction(action: string): void {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.tdv-type-device {
|
||||
background-color: rgba(245, 108, 108, 0.1);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tdv-header-right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
// 企微IT智能服务台 — 审批单详情子视图
|
||||
// =============================================================================
|
||||
// 说明:TaskDetailView 中 type=approval 的子视图
|
||||
// 数据来源:企微审批 API(getapprovaldetail,通过后端 TodoAggregatorService 聚合)
|
||||
// description 字段结构(企微审批真实数据):
|
||||
// {
|
||||
// sp_no: string, // 审批单号
|
||||
// template_name: string, // 审批模板名称
|
||||
// template_id: string, // 审批模板ID
|
||||
// applicant: string, // 申请人userid
|
||||
// apply_time: number, // 申请时间(秒级时间戳)
|
||||
// sp_status: number, // 审批状态(1-审批中)
|
||||
// current_approver: string,// 当前审批人userid
|
||||
// }
|
||||
// 功能:
|
||||
// 1. 审批内容卡片(申请人/类型/预算/附件)
|
||||
// 2. 审批意见输入区(textarea)
|
||||
// 3. 底部操作按钮(审批通过/拒绝审批/转交审批)
|
||||
// 1. 审批内容卡片(审批单号/模板名称/申请人/申请时间/当前审批人)
|
||||
// 2. 审批意见输入区(textarea,仅供参考)
|
||||
// 3. 底部操作按钮(在企微审批中打开 — 跳转到原系统操作)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
@@ -15,37 +26,38 @@
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-card">
|
||||
<div class="tic-card-title">📝 审批内容</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">审批单号</span>
|
||||
<span class="tic-value">{{ todoItem.description?.sp_no || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">审批类型</span>
|
||||
<span class="tic-value">{{ todoItem.description?.template_name || todoItem.title || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">申请人</span>
|
||||
<span class="tic-value">{{ todoItem.description?.applicant || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">类型</span>
|
||||
<span class="tic-value">{{ todoItem.description?.approval_type || '—' }}</span>
|
||||
<span class="tic-label">申请时间</span>
|
||||
<span class="tic-value">{{ formatApplyTime(todoItem.description?.apply_time) }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">预算</span>
|
||||
<span class="tic-value">{{ todoItem.description?.budget ? `¥${todoItem.description.budget}` : '—' }}</span>
|
||||
<span class="tic-label">当前审批人</span>
|
||||
<span class="tic-value">{{ todoItem.description?.current_approver || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">附件</span>
|
||||
<span class="tic-label">审批状态</span>
|
||||
<span class="tic-value">
|
||||
<span v-if="todoItem.description?.attachments?.length">
|
||||
<span v-for="(att, idx) in todoItem.description.attachments" :key="idx" class="apv-attachment">
|
||||
📎 {{ att }}
|
||||
</span>
|
||||
<span class="apv-status-badge" :class="apvStatusClass">
|
||||
{{ apvStatusText }}
|
||||
</span>
|
||||
<span v-else>无</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">说明</span>
|
||||
<span class="tic-value tic-desc">{{ todoItem.description?.detail || todoItem.description?.description || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 审批意见输入区 -->
|
||||
<!-- 审批意见输入区(仅供参考,实际审批需在企微审批中操作) -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-card">
|
||||
<div class="tic-card-title">✏️ 审批意见</div>
|
||||
@@ -53,25 +65,24 @@
|
||||
v-model="approvalComment"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入审批意见..."
|
||||
placeholder="请输入审批意见(实际审批需在企微审批中操作)..."
|
||||
resize="none"
|
||||
class="apv-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 底部操作按钮 -->
|
||||
<!-- 底部操作按钮 — 跳转到企微审批原系统操作 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-actions">
|
||||
<button class="tic-action-btn tic-action-success" @click="$emit('action', '审批通过')">
|
||||
✅ 审批通过
|
||||
</button>
|
||||
<button class="tic-action-btn tic-action-danger" @click="$emit('action', '拒绝审批')">
|
||||
❌ 拒绝审批
|
||||
</button>
|
||||
<button class="tic-action-btn" @click="$emit('action', '转交审批')">
|
||||
🔄 转交审批
|
||||
</button>
|
||||
<a
|
||||
class="tic-action-btn tic-action-primary"
|
||||
:href="wecomApprovalUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🔗 在企微审批中打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,7 +91,7 @@
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
|
||||
// ============================================================================
|
||||
@@ -92,7 +103,7 @@ interface Props {
|
||||
todoItem: TodoItemData
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
@@ -111,6 +122,84 @@ defineEmits<Emits>()
|
||||
|
||||
/** 审批意见文本 */
|
||||
const approvalComment = ref<string>('')
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 企微审批跳转链接 */
|
||||
const wecomApprovalUrl = computed<string>(() => {
|
||||
const spNo = props.todoItem.description?.sp_no
|
||||
const templateId = props.todoItem.description?.template_id
|
||||
const params = new URLSearchParams()
|
||||
if (templateId) params.set('template_id', templateId)
|
||||
if (spNo) params.set('sp_id', spNo)
|
||||
params.set('from', 'template_list')
|
||||
return `https://app.work.weixin.qq.com/wework_admin/approval_v3#/?${params.toString()}`
|
||||
})
|
||||
|
||||
/** 审批状态 CSS 类 */
|
||||
const apvStatusClass = computed<string>(() => {
|
||||
const status = props.todoItem.description?.sp_status
|
||||
// sp_status: 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付
|
||||
if (status === 1) return 'apv-status-pending'
|
||||
if (status === 2) return 'apv-status-approved'
|
||||
if (status === 3) return 'apv-status-rejected'
|
||||
if (status === 4 || status === 6) return 'apv-status-revoked'
|
||||
return 'apv-status-pending'
|
||||
})
|
||||
|
||||
/** 审批状态文本 */
|
||||
const apvStatusText = computed<string>(() => {
|
||||
const status = props.todoItem.description?.sp_status
|
||||
const statusMap: Record<number, string> = {
|
||||
1: '审批中',
|
||||
2: '已通过',
|
||||
3: '已驳回',
|
||||
4: '已撤销',
|
||||
6: '通过后撤销',
|
||||
7: '已删除',
|
||||
10: '已支付',
|
||||
}
|
||||
return statusMap[status as number] || '审批中'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 格式化企微申请时间(秒级时间戳 → 可读时间)
|
||||
*
|
||||
* @param applyTime - 秒级 Unix 时间戳,或已格式化的字符串
|
||||
* @returns 格式化后的时间字符串
|
||||
*/
|
||||
function formatApplyTime(applyTime: any): string {
|
||||
if (!applyTime) return '—'
|
||||
try {
|
||||
if (typeof applyTime === 'number') {
|
||||
const date = new Date(applyTime * 1000)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
// 如果已经是字符串,尝试解析
|
||||
const date = new Date(applyTime)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return String(applyTime)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -144,7 +233,7 @@ const approvalComment = ref<string>('')
|
||||
}
|
||||
|
||||
.tic-label {
|
||||
width: 70px;
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
@@ -157,23 +246,33 @@ const approvalComment = ref<string>('')
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tic-desc {
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ---- 附件标签 ---- */
|
||||
.apv-attachment {
|
||||
/* ---- 审批状态徽标 ---- */
|
||||
.apv-status-badge {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.apv-attachment:hover {
|
||||
text-decoration: underline;
|
||||
.apv-status-pending {
|
||||
background-color: rgba(230, 162, 60, 0.1);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.apv-status-approved {
|
||||
background-color: rgba(103, 194, 58, 0.1);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.apv-status-rejected {
|
||||
background-color: rgba(245, 108, 108, 0.1);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.apv-status-revoked {
|
||||
background-color: rgba(144, 147, 153, 0.1);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ---- 审批意见输入区 ---- */
|
||||
@@ -210,6 +309,7 @@ const approvalComment = ref<string>('')
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tic-action-btn:hover {
|
||||
@@ -218,25 +318,14 @@ const approvalComment = ref<string>('')
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tic-action-success {
|
||||
background-color: var(--color-success);
|
||||
.tic-action-primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
border-color: var(--color-success);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tic-action-success:hover {
|
||||
opacity: 0.9;
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tic-action-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: var(--bg-secondary);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tic-action-danger:hover {
|
||||
opacity: 0.9;
|
||||
.tic-action-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 设备异常详情子视图
|
||||
// =============================================================================
|
||||
// 说明:TaskDetailView 中 type=device 的子视图
|
||||
// ⚠️ 已废弃 — v1.0 移除 device 类型
|
||||
// 此组件保留但不再被任何组件引用。代办面板已移除 device 类型,
|
||||
// TaskDetailView 不再渲染 DeviceDetail 分支。
|
||||
// 如需恢复设备异常待办功能,需重新设计数据源并恢复引用。
|
||||
// =============================================================================
|
||||
// 说明:TaskDetailView 中 type=device 的子视图(已废弃)
|
||||
// 功能:
|
||||
// 1. 设备状态网格(2×3 grid: 设备名称/型号/在线状态/最后在线/IP/告警次数)
|
||||
// 2. 处理记录卡片
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
// 企微IT智能服务台 — 运维工单详情子视图
|
||||
// =============================================================================
|
||||
// 说明:TaskDetailView 中 type=ticket 的子视图
|
||||
// 数据来源:ITSM 运维平台 OpenAPI(通过后端 TodoAggregatorService 聚合)
|
||||
// description 字段结构(ITSM 真实数据):
|
||||
// {
|
||||
// process_instance_id: string, // 工单流程实例ID
|
||||
// executor: string, // 执行人(坐席userid)
|
||||
// status: string, // ITSM原始状态
|
||||
// creator: string, // 创建人
|
||||
// itsm_priority: string, // ITSM原始优先级
|
||||
// title: string, // 工单标题
|
||||
// }
|
||||
// 功能:
|
||||
// 1. 工单描述卡片(标题/类型/优先级/上报人/上报时间/描述)
|
||||
// 2. 处理进度卡片(状态/接单人/SLA倒计时)
|
||||
// 3. 底部操作按钮(接单/开始处理/结单/转派)
|
||||
// 1. 工单描述卡片(标题/类型/优先级/创建人/创建时间/执行人)
|
||||
// 2. 处理进度卡片(状态/接单人)
|
||||
// 3. 底部操作按钮(在 ITSM 中打开 — 跳转到原系统操作)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
@@ -20,8 +30,8 @@
|
||||
<span class="tic-value">{{ todoItem.title }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">类型</span>
|
||||
<span class="tic-value">{{ todoItem.description?.ticket_type || '运维' }}</span>
|
||||
<span class="tic-label">工单号</span>
|
||||
<span class="tic-value">{{ todoItem.description?.process_instance_id || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">优先级</span>
|
||||
@@ -32,16 +42,16 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">上报人</span>
|
||||
<span class="tic-value">{{ todoItem.description?.reporter || '—' }}</span>
|
||||
<span class="tic-label">创建人</span>
|
||||
<span class="tic-value">{{ todoItem.description?.creator || '—' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">上报时间</span>
|
||||
<span class="tic-label">创建时间</span>
|
||||
<span class="tic-value">{{ formatTime(todoItem.created_at) }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">描述</span>
|
||||
<span class="tic-value tic-desc">{{ todoItem.description?.detail || todoItem.description?.description || '—' }}</span>
|
||||
<span class="tic-value tic-desc">{{ todoItem.description?.title || todoItem.title || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,25 +69,23 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">接单人</span>
|
||||
<span class="tic-value">{{ todoItem.assigned_agent_id ? `坐席 ${todoItem.assigned_agent_id.slice(0, 8)}` : '未接单' }}</span>
|
||||
</div>
|
||||
<div class="tic-row">
|
||||
<span class="tic-label">SLA</span>
|
||||
<span class="tic-value">
|
||||
<span class="tic-sla" :class="slaLevel">{{ slaText }}</span>
|
||||
</span>
|
||||
<span class="tic-label">执行人</span>
|
||||
<span class="tic-value">{{ todoItem.description?.executor || todoItem.assigned_agent_id || '未分配' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 底部操作按钮 -->
|
||||
<!-- 底部操作按钮 — 跳转到 ITSM 原系统操作 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="tic-actions">
|
||||
<button class="tic-action-btn" @click="$emit('action', '接单')">📥 接单</button>
|
||||
<button class="tic-action-btn" @click="$emit('action', '开始处理')">🔧 开始处理</button>
|
||||
<button class="tic-action-btn tic-action-primary" @click="$emit('action', '结单')">✅ 结单</button>
|
||||
<button class="tic-action-btn" @click="$emit('action', '转派')">🔄 转派</button>
|
||||
<a
|
||||
class="tic-action-btn tic-action-primary"
|
||||
:href="itsmUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🔗 在 ITSM 中打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -89,6 +97,11 @@
|
||||
import { computed } from 'vue'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
|
||||
// ============================================================================
|
||||
// ITSM 运维平台基址(用于构建跳转链接)
|
||||
// ============================================================================
|
||||
const ITSM_BASE_URL = 'https://devops.dc.servyou-it.com/ITSM'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
@@ -126,27 +139,13 @@ const statusLabelMap: Record<string, string> = {
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** SLA 倒计时(Mock) */
|
||||
const slaText = computed<string>(() => {
|
||||
const desc = props.todoItem.description
|
||||
if (desc?.sla_remaining) return desc.sla_remaining
|
||||
// Mock: 根据 priority 生成 SLA
|
||||
if (props.todoItem.priority === 'urgent') return '0h 32m'
|
||||
if (props.todoItem.priority === 'high') return '2h 15m'
|
||||
return '23h 45m'
|
||||
})
|
||||
|
||||
/** SLA 等级(颜色指示) */
|
||||
const slaLevel = computed<string>(() => {
|
||||
const text = slaText.value
|
||||
if (text.startsWith('0h') || text.includes('超时')) return 'sla-overdue'
|
||||
// 提取小时数判断
|
||||
const hourMatch = text.match(/(\d+)h/)
|
||||
if (hourMatch) {
|
||||
const hours = parseInt(hourMatch[1], 10)
|
||||
if (hours <= 1) return 'sla-warning'
|
||||
/** ITSM 工单跳转链接 */
|
||||
const itsmUrl = computed<string>(() => {
|
||||
const processInstanceId = props.todoItem.description?.process_instance_id
|
||||
if (processInstanceId) {
|
||||
return `${ITSM_BASE_URL}/workflow/allTickets/detail?process_instance_id=${processInstanceId}`
|
||||
}
|
||||
return 'sla-normal'
|
||||
return ITSM_BASE_URL
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
@@ -254,16 +253,6 @@ function formatTime(isoString: string): string {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* ---- SLA ---- */
|
||||
.tic-sla {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tic-sla.sla-normal { color: var(--color-success); }
|
||||
.tic-sla.sla-warning { color: var(--color-warning); }
|
||||
.tic-sla.sla-overdue { color: var(--color-danger); }
|
||||
|
||||
/* ---- 操作按钮 ---- */
|
||||
.tic-actions {
|
||||
display: flex;
|
||||
@@ -285,6 +274,7 @@ function formatTime(isoString: string): string {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tic-action-btn:hover {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
// =============================================================================
|
||||
// 说明:主责坐席点击「邀请」后弹出,选择要邀请的员工或部门
|
||||
// 功能:
|
||||
// 1. 搜索框:按姓名/工号模糊搜索员工
|
||||
// 2. 已选列表:显示已选中的被邀请人
|
||||
// 3. 历史共享模式选择:最近10条/全部/不共享
|
||||
// 4. 确认邀请:发送企微卡片通知给被邀请人
|
||||
// 1. 搜索 Tab:按姓名/工号模糊搜索员工(对接真实 API,debounce 300ms)
|
||||
// 2. 组织架构 Tab:按部门树形浏览员工,支持多选
|
||||
// 3. 已选列表:两个 Tab 共享,显示已选中的被邀请人
|
||||
// 4. 历史共享模式选择:最近10条/全部/不共享
|
||||
// 5. 确认邀请:发送企微卡片通知给被邀请人
|
||||
// 区别:和「摇人」不同,摇人选的是坐席,这里选的是员工/部门
|
||||
// ============================================================================= -->
|
||||
|
||||
@@ -18,64 +19,105 @@
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<!-- 搜索框 -->
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索员工姓名或工号..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
style="margin-bottom: 12px;"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<!-- Tab 切换:搜索 / 组织架构 -->
|
||||
<el-tabs v-model="activeTab" class="invite-tabs">
|
||||
<!-- ==================== 搜索 Tab ==================== -->
|
||||
<el-tab-pane label="搜索" name="search">
|
||||
<!-- 搜索框(输入时 debounce 300ms 自动搜索,Enter 立即搜索) -->
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索员工姓名或工号..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
style="margin-bottom: 12px;"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
|
||||
<!-- 搜索结果 / 手动输入 -->
|
||||
<div class="search-area">
|
||||
<div v-if="searchResults.length === 0 && searchText" class="empty-hint">
|
||||
未找到匹配的员工,可直接输入添加
|
||||
</div>
|
||||
<div v-if="searchResults.length === 0 && !searchText" class="empty-hint">
|
||||
请输入姓名或工号搜索,或直接手动添加
|
||||
</div>
|
||||
<div
|
||||
v-for="person in searchResults"
|
||||
:key="person.id"
|
||||
class="person-item"
|
||||
:class="{ selected: isSelected(person.id) }"
|
||||
@click="toggleSelect(person)"
|
||||
>
|
||||
<div class="person-avatar">
|
||||
<img
|
||||
v-if="person.avatar && !failedIds[person.id]"
|
||||
:src="person.avatar"
|
||||
:alt="person.name"
|
||||
class="avatar-img"
|
||||
@error="failedIds[person.id] = true"
|
||||
/>
|
||||
<span v-else class="avatar-letter">{{ getAvatar(person.name) }}</span>
|
||||
<!-- 搜索结果区域 -->
|
||||
<div class="search-area" v-loading="searching">
|
||||
<!-- 空状态:有搜索词但无结果 -->
|
||||
<div v-if="!searching && searchResults.length === 0 && searchText" class="empty-hint">
|
||||
未找到匹配的员工,可直接输入添加
|
||||
</div>
|
||||
<!-- 空状态:无搜索词 -->
|
||||
<div v-if="!searching && searchResults.length === 0 && !searchText" class="empty-hint">
|
||||
请输入姓名或工号搜索,或直接手动添加
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<div
|
||||
v-for="person in searchResults"
|
||||
:key="person.id"
|
||||
class="person-item"
|
||||
:class="{ selected: isSelected(person.id) }"
|
||||
@click="toggleSelect(person)"
|
||||
>
|
||||
<div class="person-avatar">
|
||||
<img
|
||||
v-if="person.avatar && !failedIds[person.id]"
|
||||
:src="person.avatar"
|
||||
:alt="person.name"
|
||||
class="avatar-img"
|
||||
@error="failedIds[person.id] = true"
|
||||
/>
|
||||
<span v-else class="avatar-letter">{{ getAvatar(person.name) }}</span>
|
||||
</div>
|
||||
<div class="person-info">
|
||||
<div class="person-name">{{ person.name }}</div>
|
||||
<div class="person-dept">{{ person.department || '未知部门' }}</div>
|
||||
</div>
|
||||
<el-icon v-if="isSelected(person.id)" class="check-icon" color="var(--accent)">
|
||||
<Check />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 手动添加(搜索不到时可直接添加) -->
|
||||
<div class="manual-add" v-if="searchText && searchResults.length === 0 && !searching">
|
||||
<el-button type="primary" link @click="addManualPerson">
|
||||
<el-icon><Plus /></el-icon>
|
||||
手动添加「{{ searchText }}」
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="person-info">
|
||||
<div class="person-name">{{ person.name }}</div>
|
||||
<div class="person-dept">{{ person.department || '未知部门' }}</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ==================== 组织架构 Tab ==================== -->
|
||||
<el-tab-pane label="组织架构" name="org">
|
||||
<div class="org-tree-area" v-loading="orgTreeLoading">
|
||||
<!-- 组织架构树(首次切换到此 Tab 时懒加载) -->
|
||||
<el-tree
|
||||
v-if="orgTreeData.length > 0"
|
||||
ref="treeRef"
|
||||
:data="orgTreeData"
|
||||
:props="treeProps"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
:default-expanded-keys="defaultExpandedKeys"
|
||||
:expand-on-click-node="false"
|
||||
@check="handleTreeCheck"
|
||||
>
|
||||
<!-- 自定义节点内容:员工节点显示部门信息 -->
|
||||
<template #default="{ data }">
|
||||
<span class="tree-node">
|
||||
<span class="tree-node-label">{{ data.label }}</span>
|
||||
<span v-if="data.isLeaf && data.department" class="tree-node-dept">{{ data.department }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!orgTreeLoading && orgTreeData.length === 0" class="empty-hint">
|
||||
暂无组织架构数据
|
||||
</div>
|
||||
</div>
|
||||
<el-icon v-if="isSelected(person.id)" class="check-icon" color="var(--accent)">
|
||||
<Check />
|
||||
</el-icon>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 手动添加 -->
|
||||
<div class="manual-add" v-if="searchText && searchResults.length === 0">
|
||||
<el-button type="primary" link @click="addManualPerson">
|
||||
<el-icon><Plus /></el-icon>
|
||||
手动添加「{{ searchText }}」
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选列表 -->
|
||||
<!-- 已选列表(两个 Tab 共享) -->
|
||||
<div class="selected-area" v-if="selectedPeople.length > 0">
|
||||
<div class="selected-header">
|
||||
<span>已选 {{ selectedPeople.length }} 人</span>
|
||||
<el-button type="primary" link size="small" @click="selectedPeople = []">清空</el-button>
|
||||
<el-button type="primary" link size="small" @click="clearSelection">清空</el-button>
|
||||
</div>
|
||||
<div class="selected-tags">
|
||||
<el-tag
|
||||
@@ -121,10 +163,12 @@
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import { Check, Plus } from '@element-plus/icons-vue'
|
||||
import { inviteParticipant } from '@/api/conversation'
|
||||
import type { ParticipantInfo } from '@/api/conversation'
|
||||
import { searchEmployees, getOrgTree } from '@/api/employee'
|
||||
import type { DirectoryEmployee, OrgTreeNode } from '@/api/employee'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ============================================================================
|
||||
@@ -156,16 +200,22 @@ const emit = defineEmits<{
|
||||
/** 弹窗可见性 */
|
||||
const visible = ref(props.modelValue)
|
||||
|
||||
/** 头像加载失败标记:按员工 id 记录(各自 URL 独立,可能单独过期,要求 B 兜底) */
|
||||
/** 头像加载失败标记:按员工 id 记录(各自 URL 独立,可能单独过期,要求兜底) */
|
||||
const failedIds = ref<Record<string, boolean>>({})
|
||||
|
||||
/** 当前激活的 Tab:search(搜索)/ org(组织架构) */
|
||||
const activeTab = ref<'search' | 'org'>('search')
|
||||
|
||||
/** 搜索关键词 */
|
||||
const searchText = ref('')
|
||||
|
||||
/** 搜索结果(Mock 数据,阶段二对接企微通讯录API后替换) */
|
||||
/** 搜索结果列表 */
|
||||
const searchResults = ref<ParticipantInfo[]>([])
|
||||
|
||||
/** 已选人员列表 */
|
||||
/** 搜索中加载状态(显示 loading 遮罩) */
|
||||
const searching = ref(false)
|
||||
|
||||
/** 已选人员列表(两个 Tab 共享,确认时统一提交) */
|
||||
const selectedPeople = ref<ParticipantInfo[]>([])
|
||||
|
||||
/** 历史共享模式 */
|
||||
@@ -174,64 +224,205 @@ const historyMode = ref<'recent10' | 'all' | 'none'>('recent10')
|
||||
/** 是否正在提交 */
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 组织架构树数据 */
|
||||
const orgTreeData = ref<OrgTreeNode[]>([])
|
||||
|
||||
/** 组织架构树是否已加载(懒加载标记,首次切换到 org Tab 时加载) */
|
||||
const orgTreeLoaded = ref(false)
|
||||
|
||||
/** 组织架构树加载中 */
|
||||
const orgTreeLoading = ref(false)
|
||||
|
||||
/** el-tree 组件引用(用于调用 getCheckedNodes / setCheckedKeys) */
|
||||
const treeRef = ref<any>(null)
|
||||
|
||||
/** 搜索 debounce 定时器(300ms 防频繁请求) */
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ============================================================================
|
||||
// Mock 员工数据(阶段二对接企微通讯录API后替换)
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
const mockEmployees: ParticipantInfo[] = [
|
||||
{ id: 'zhangsan', name: '张三', department: '研发一部', type: 'employee', avatar: '' },
|
||||
{ id: 'lisi', name: '李四', department: '市场部', type: 'employee', avatar: '' },
|
||||
{ id: 'wangwu', name: '王五', department: '运维部', type: 'employee', avatar: '' },
|
||||
{ id: 'zhaoliu', name: '赵六', department: '人力资源部', type: 'employee', avatar: '' },
|
||||
{ id: 'qianqi', name: '钱七', department: '财务部', type: 'employee', avatar: '' },
|
||||
{ id: 'sunba', name: '孙八', department: '产品部', type: 'employee', avatar: '' },
|
||||
{ id: 'zhoujiu', name: '周九', department: '行政部', type: 'employee', avatar: '' },
|
||||
{ id: 'wushi', name: '吴十', department: '法务部', type: 'employee', avatar: '' },
|
||||
]
|
||||
/** 已在参与者列表中的 ID 集合(用于搜索排除 + 树节点禁用) */
|
||||
const existingIdSet = computed(() => new Set(props.existingParticipantIds))
|
||||
|
||||
/** el-tree 默认展开的节点:展开第一层(所有部门),让用户直接看到员工列表 */
|
||||
const defaultExpandedKeys = computed(() => orgTreeData.value.map(n => n.id))
|
||||
|
||||
/** el-tree props 配置:label/children 字段映射 + 已参与者禁用勾选 */
|
||||
const treeProps = {
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
// 已在参与者列表中的员工节点禁用勾选(避免重复邀请)
|
||||
// 仅对叶子节点(isLeaf=true,即员工)生效,部门节点不受影响
|
||||
disabled: (data: OrgTreeNode) => data.isLeaf === true && existingIdSet.value.has(data.id),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
/** 弹窗打开/关闭时重置状态 */
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
// 打开弹窗时重置
|
||||
// 打开弹窗时重置所有状态
|
||||
activeTab.value = 'search'
|
||||
searchText.value = ''
|
||||
searchResults.value = []
|
||||
selectedPeople.value = []
|
||||
historyMode.value = 'recent10'
|
||||
failedIds.value = {}
|
||||
// 清除搜索 debounce 定时器(避免关闭后还触发请求)
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
// 重置组织架构树勾选状态(数据保留缓存,下次打开不重新请求)
|
||||
nextTick(() => {
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCheckedKeys([])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** 弹窗可见性变化时同步到父组件 */
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
/** 搜索关键词变化时 debounce 300ms 自动搜索(避免逐字频繁请求后端) */
|
||||
watch(searchText, () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
doSearch()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
/** 切换到组织架构 Tab 时懒加载数据(首次切换才请求,后续用缓存) */
|
||||
watch(activeTab, (val) => {
|
||||
if (val === 'org' && !orgTreeLoaded.value) {
|
||||
loadOrgTree()
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 头像文字(取姓名最后一个字) */
|
||||
/** 头像文字(取姓名最后一个字,无头像时的降级显示) */
|
||||
function getAvatar(name: string): string {
|
||||
if (!name) return '?'
|
||||
return name.charAt(name.length - 1)
|
||||
}
|
||||
|
||||
/** 头像加载失败时隐藏 img,降级显示首字母 */
|
||||
/** 搜索员工(阶段一用 Mock 数据,阶段二替换为企微通讯录API) */
|
||||
function handleSearch(): void {
|
||||
const keyword = searchText.value.trim().toLowerCase()
|
||||
/** 执行员工搜索(调用真实 API) */
|
||||
async function doSearch(): Promise<void> {
|
||||
const keyword = searchText.value.trim()
|
||||
if (!keyword) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
// Mock: 按姓名/工号模糊匹配
|
||||
const existingSet = new Set(props.existingParticipantIds)
|
||||
searchResults.value = mockEmployees.filter(
|
||||
e => !existingSet.has(e.id) &&
|
||||
(e.name.toLowerCase().includes(keyword) || e.id.toLowerCase().includes(keyword))
|
||||
)
|
||||
|
||||
searching.value = true
|
||||
try {
|
||||
// 调用后端搜索 API(已排除当前坐席自己)
|
||||
const employees = await searchEmployees(keyword)
|
||||
|
||||
// 转换为 ParticipantInfo 格式,并排除已在参与者列表中的人员
|
||||
searchResults.value = employees
|
||||
.filter(e => !existingIdSet.value.has(e.id))
|
||||
.map((e: DirectoryEmployee): ParticipantInfo => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
type: 'employee',
|
||||
}))
|
||||
} catch (err) {
|
||||
// 错误提示已由 axios 响应拦截器统一处理(ElMessage.error)
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索(Enter 键触发,跳过 debounce 立即搜索) */
|
||||
function handleSearch(): void {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
doSearch()
|
||||
}
|
||||
|
||||
/** 加载组织架构树(首次切换到 org Tab 时调用) */
|
||||
async function loadOrgTree(): Promise<void> {
|
||||
orgTreeLoading.value = true
|
||||
try {
|
||||
orgTreeData.value = await getOrgTree()
|
||||
orgTreeLoaded.value = true
|
||||
|
||||
// 数据加载后,将搜索 Tab 已选的人同步到树勾选状态
|
||||
// (如果在搜索 Tab 选了人,切到组织架构 Tab 时这些人在树中也应显示已勾选)
|
||||
await nextTick()
|
||||
if (treeRef.value) {
|
||||
const allLeafIds = collectLeafIds(orgTreeData.value)
|
||||
// 只勾选树中存在的叶子节点(搜索/手动添加的 ID 可能不在树中)
|
||||
const selectedInTree = selectedPeople.value
|
||||
.filter(p => allLeafIds.has(p.id))
|
||||
.map(p => p.id)
|
||||
treeRef.value.setCheckedKeys(selectedInTree)
|
||||
}
|
||||
} catch (err) {
|
||||
// 错误提示已由 axios 拦截器统一处理
|
||||
orgTreeData.value = []
|
||||
} finally {
|
||||
orgTreeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 收集树中所有叶子节点(员工)的 ID 集合 */
|
||||
function collectLeafIds(nodes: OrgTreeNode[]): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
function walk(node: OrgTreeNode): void {
|
||||
if (node.isLeaf) {
|
||||
ids.add(node.id)
|
||||
return
|
||||
}
|
||||
node.children?.forEach(walk)
|
||||
}
|
||||
nodes.forEach(walk)
|
||||
return ids
|
||||
}
|
||||
|
||||
/** el-tree 勾选变化时同步到 selectedPeople */
|
||||
function handleTreeCheck(): void {
|
||||
if (!treeRef.value) return
|
||||
|
||||
// 获取所有勾选的叶子节点(员工),过滤掉非 isLeaf 的空部门节点
|
||||
// getCheckedNodes(leafOnly=true, includeHalfChecked=false)
|
||||
const checkedLeaves = (treeRef.value.getCheckedNodes(true, false) as OrgTreeNode[])
|
||||
.filter(n => n.isLeaf === true)
|
||||
|
||||
// 树中所有叶子 ID(用于区分"树来源的选择"和"搜索/手动添加的选择")
|
||||
const allLeafIds = collectLeafIds(orgTreeData.value)
|
||||
|
||||
// 保留非树来源的选择(搜索 Tab 点击的、手动添加的 — 这些 ID 不在树中)
|
||||
const nonTreeSelections = selectedPeople.value.filter(p => !allLeafIds.has(p.id))
|
||||
|
||||
// 将勾选的树叶子节点转为 ParticipantInfo
|
||||
const treeSelections: ParticipantInfo[] = checkedLeaves.map(n => ({
|
||||
id: n.id,
|
||||
name: n.label,
|
||||
department: n.department || '',
|
||||
type: 'employee',
|
||||
}))
|
||||
|
||||
// 合并:非树选择 + 树选择
|
||||
selectedPeople.value = [...nonTreeSelections, ...treeSelections]
|
||||
}
|
||||
|
||||
/** 判断是否已选中 */
|
||||
@@ -239,7 +430,7 @@ function isSelected(id: string): boolean {
|
||||
return selectedPeople.value.some(p => p.id === id)
|
||||
}
|
||||
|
||||
/** 切换选中状态 */
|
||||
/** 切换选中状态(搜索 Tab 点击员工时调用) */
|
||||
function toggleSelect(person: ParticipantInfo): void {
|
||||
const idx = selectedPeople.value.findIndex(p => p.id === person.id)
|
||||
if (idx >= 0) {
|
||||
@@ -247,6 +438,11 @@ function toggleSelect(person: ParticipantInfo): void {
|
||||
} else {
|
||||
selectedPeople.value.push(person)
|
||||
}
|
||||
|
||||
// 如果组织架构树已加载,同步勾选状态到树(双向同步)
|
||||
if (orgTreeLoaded.value && treeRef.value) {
|
||||
syncTreeCheckedKeys()
|
||||
}
|
||||
}
|
||||
|
||||
/** 从已选列表移除 */
|
||||
@@ -255,13 +451,37 @@ function removeSelected(id: string): void {
|
||||
if (idx >= 0) {
|
||||
selectedPeople.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
// 同步组织架构树勾选状态
|
||||
if (orgTreeLoaded.value && treeRef.value) {
|
||||
syncTreeCheckedKeys()
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空所有选择(同时清空树勾选) */
|
||||
function clearSelection(): void {
|
||||
selectedPeople.value = []
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCheckedKeys([])
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 selectedPeople 的选择状态同步到组织架构树 */
|
||||
function syncTreeCheckedKeys(): void {
|
||||
if (!treeRef.value) return
|
||||
const allLeafIds = collectLeafIds(orgTreeData.value)
|
||||
// 只勾选树中存在的叶子节点
|
||||
const selectedInTree = selectedPeople.value
|
||||
.filter(p => allLeafIds.has(p.id))
|
||||
.map(p => p.id)
|
||||
treeRef.value.setCheckedKeys(selectedInTree)
|
||||
}
|
||||
|
||||
/** 手动添加搜索不到的员工 */
|
||||
function addManualPerson(): void {
|
||||
const name = searchText.value.trim()
|
||||
if (!name) return
|
||||
// 用搜索词作为姓名和ID
|
||||
// 用搜索词作为姓名,生成临时 ID(manual_ 前缀避免与真实 UserID 冲突)
|
||||
const person: ParticipantInfo = {
|
||||
id: `manual_${Date.now()}`,
|
||||
name,
|
||||
@@ -275,7 +495,7 @@ function addManualPerson(): void {
|
||||
searchResults.value = []
|
||||
}
|
||||
|
||||
/** 确认邀请 */
|
||||
/** 确认邀请(两个 Tab 共用) */
|
||||
async function handleConfirm(): Promise<void> {
|
||||
if (selectedPeople.value.length === 0) return
|
||||
|
||||
@@ -304,12 +524,19 @@ async function handleConfirm(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Tab 区域 */
|
||||
.invite-tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 搜索结果区域 */
|
||||
.search-area {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.person-item {
|
||||
@@ -397,6 +624,36 @@ async function handleConfirm(): Promise<void> {
|
||||
border-top: 1px dashed var(--border-light);
|
||||
}
|
||||
|
||||
/* 组织架构树区域 */
|
||||
.org-tree-area {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-bottom: 12px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* 树节点自定义内容 */
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.tree-node-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tree-node-dept {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 已选列表 */
|
||||
.selected-area {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px;
|
||||
@@ -420,6 +677,7 @@ async function handleConfirm(): Promise<void> {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 历史共享模式 */
|
||||
.history-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -433,6 +691,7 @@ async function handleConfirm(): Promise<void> {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 底部按钮 */
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,89 +1,88 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 参与者面板组件
|
||||
// 企微IT智能服务台 — 坐席端参与者面板组件
|
||||
// =============================================================================
|
||||
// 说明:在聊天区顶部展示当前会话的参与者列表,提供邀请和移除入口
|
||||
// 说明:在聊天区顶部展示当前会话的参与者,支持缩略/展开双模式
|
||||
// 功能:
|
||||
// 1. 显示参与者列表(坐席 + 协作坐席 + 被邀请员工)含头像
|
||||
// 2. 主责坐席可点击「邀请」按钮打开邀请弹窗
|
||||
// 3. 主责坐席可移除参与者
|
||||
// 4. 参与者加入/退出状态实时更新
|
||||
// 位置:放在 UserInfoBar 下方,聊天消息区上方
|
||||
// 1. 缩略模式:一行横条 [N人参与:] + 头像列表(最多6个,20px) + +N + 邀请 + ▼
|
||||
// 2. 角色边框:主责蓝/协作绿/发起人橙/被邀请人无框
|
||||
// 3. 自己头像高亮环 + scale(1.1)
|
||||
// 4. 超员不滚动,显示+N文本
|
||||
// 5. 点击横条主体展开/收起就地详情面板
|
||||
// 6. 邀请按钮独立点击不触发展开
|
||||
// 7. P1: 展开状态按会话ID记忆
|
||||
// 位置:放在 TroubleshootBar 下方,聊天消息区上方
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="participant-bar" v-if="hasParticipants">
|
||||
<!-- 参与者列表 -->
|
||||
<div class="participant-bar__list">
|
||||
<!-- 缩略横条主体(点击展开/收起) -->
|
||||
<div
|
||||
class="participant-bar__main"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<span class="participant-bar__label">
|
||||
{{ totalParticipantCount }}人参与:
|
||||
{{ totalCount }}人参与:
|
||||
</span>
|
||||
|
||||
<!-- 主责坐席(始终第一个显示) -->
|
||||
<div class="participant-item participant-item--primary">
|
||||
<div class="participant-avatar participant-avatar--primary">
|
||||
<span class="avatar-letter">{{ agentName ? agentName.charAt(agentName.length - 1) : '席' }}</span>
|
||||
</div>
|
||||
<span class="participant-name">{{ agentName }}(主责)</span>
|
||||
</div>
|
||||
|
||||
<!-- 协作坐席 -->
|
||||
<div
|
||||
v-for="aid in collaboratingAgentIds"
|
||||
:key="'collab-' + aid"
|
||||
class="participant-item participant-item--collab"
|
||||
>
|
||||
<div class="participant-avatar participant-avatar--collab">
|
||||
<span class="avatar-letter">{{ getAgentName(aid).charAt(getAgentName(aid).length - 1) }}</span>
|
||||
</div>
|
||||
<span class="participant-name">{{ getAgentName(aid) }}(协作)</span>
|
||||
</div>
|
||||
|
||||
<!-- 被邀请参与者 -->
|
||||
<div
|
||||
v-for="p in participants"
|
||||
:key="'p-' + p.id"
|
||||
class="participant-item"
|
||||
:class="{ 'participant-item--pending': !p.joined }"
|
||||
>
|
||||
<!-- 头像:有 avatar 用 img,无则首字母降级 -->
|
||||
<div class="participant-avatar" :class="p.joined ? '' : 'participant-avatar--pending'">
|
||||
<!-- 头像列表(最多 MAX_AVATARS 个) -->
|
||||
<div class="participant-bar__avatars">
|
||||
<div
|
||||
v-for="p in visibleAvatars"
|
||||
:key="p.id"
|
||||
class="participant-bar__avatar"
|
||||
:class="{ 'participant-bar__avatar--self': p.isSelf }"
|
||||
:style="{ borderColor: ROLE_BORDER_COLORS[p.role] }"
|
||||
>
|
||||
<!-- 有头像URL:渲染<img>,加载失败降级显示末字 -->
|
||||
<img
|
||||
v-if="p.avatar && !failedIds[p.id]"
|
||||
:src="p.avatar"
|
||||
:alt="p.name"
|
||||
class="avatar-img"
|
||||
class="participant-bar__avatar-img"
|
||||
@error="failedIds[p.id] = true"
|
||||
/>
|
||||
<span v-else class="avatar-letter">{{ p.name.charAt(p.name.length - 1) }}</span>
|
||||
<span v-else class="participant-bar__avatar-letter">
|
||||
{{ avatarLetter(p.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="participant-name">
|
||||
{{ p.name }}{{ p.type === 'employee' ? '' : `(${p.type})` }}
|
||||
<span v-if="!p.joined" class="pending-hint">待加入</span>
|
||||
|
||||
<!-- 超员溢出文本 -->
|
||||
<span v-if="overflowCount > 0" class="participant-bar__overflow">
|
||||
+{{ overflowCount }}
|
||||
</span>
|
||||
<!-- 移除按钮(仅主责坐席可见) -->
|
||||
<el-icon
|
||||
v-if="isPrimaryAgent"
|
||||
class="remove-icon"
|
||||
@click.stop="handleRemove(p.id)"
|
||||
>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 展开箭头 -->
|
||||
<span
|
||||
class="participant-bar__arrow"
|
||||
:class="{ 'participant-bar__arrow--open': expanded }"
|
||||
>▼</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="participant-bar__actions">
|
||||
<!-- 邀请按钮(仅主责坐席可见) -->
|
||||
<!-- 操作按钮区(邀请按钮独立,不触发展开) -->
|
||||
<div class="participant-bar__actions" @click.stop>
|
||||
<el-button
|
||||
v-if="isPrimaryAgent"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="$emit('invite')"
|
||||
@click.stop="$emit('invite')"
|
||||
>
|
||||
+ 邀请
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 展开详情面板(就地展开,CSS transition) -->
|
||||
<Transition name="expand">
|
||||
<div v-if="expanded" class="participant-bar__expanded-wrapper">
|
||||
<ParticipantExpandedPanel
|
||||
:participants="normalizedParticipants"
|
||||
:is-primary-agent="isPrimaryAgent"
|
||||
@remove="handleRemove"
|
||||
@collapse="collapseExpand"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -91,9 +90,13 @@
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { computed, ref } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ParticipantInfo } from '@/api/conversation'
|
||||
import {
|
||||
useParticipantDisplay,
|
||||
ROLE_BORDER_COLORS,
|
||||
} from '@/composables/useParticipantDisplay'
|
||||
import ParticipantExpandedPanel from './ParticipantExpandedPanel.vue'
|
||||
|
||||
// ============================================================================
|
||||
// Props & Emits
|
||||
@@ -104,17 +107,30 @@ interface Props {
|
||||
participants: ParticipantInfo[]
|
||||
/** 主责坐席姓名 */
|
||||
agentName: string
|
||||
/** 主责坐席ID(用于 isSelf 判断) */
|
||||
agentId?: string
|
||||
/** 发起人姓名 */
|
||||
employeeName: string
|
||||
/** 发起人ID */
|
||||
employeeId: string
|
||||
/** 当前登录坐席是否为主责坐席 */
|
||||
isPrimaryAgent: boolean
|
||||
/** 协作坐席ID列表 */
|
||||
collaboratingAgentIds?: string[]
|
||||
/** 协作坐席姓名映射(agent_id → name) */
|
||||
collaboratingAgentNames?: Record<string, string>
|
||||
/** 当前登录坐席ID(用于 isSelf 判断) */
|
||||
currentAgentId?: string
|
||||
/** 会话ID(用于展开状态记忆) */
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
agentId: '',
|
||||
collaboratingAgentIds: () => [],
|
||||
collaboratingAgentNames: () => ({}),
|
||||
currentAgentId: '',
|
||||
conversationId: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -124,9 +140,28 @@ const emit = defineEmits<{
|
||||
'remove': [userId: string]
|
||||
}>()
|
||||
|
||||
/** 头像加载失败标记:按参与者 id 记录(各自 URL 独立,可能单独过期,要求 B 兜底) */
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
|
||||
/** 头像加载失败标记:按参与者 id 记录 */
|
||||
const failedIds = ref<Record<string, boolean>>({})
|
||||
|
||||
/** 超员阈值 N=6 */
|
||||
const MAX_AVATARS = 6
|
||||
|
||||
/** 展开状态(本地 ref) */
|
||||
const expanded = ref(false)
|
||||
|
||||
/** P1: 展开状态记忆(按会话ID) */
|
||||
const expandedStateMap = ref<Map<string, boolean>>(new Map())
|
||||
|
||||
// ============================================================================
|
||||
// composable 获取规范化数据
|
||||
// ============================================================================
|
||||
|
||||
const { normalizedParticipants, totalCount } = useParticipantDisplay(props)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
@@ -136,26 +171,77 @@ const hasParticipants = computed(() => {
|
||||
return props.participants.length > 0 || props.collaboratingAgentIds.length > 0
|
||||
})
|
||||
|
||||
/** 总参与者数量(主责 + 协作 + 被邀请) */
|
||||
const totalParticipantCount = computed(() => {
|
||||
// 主责坐席 1人 + 协作坐席 + 被邀请参与者
|
||||
return 1 + props.collaboratingAgentIds.length + props.participants.length
|
||||
})
|
||||
/** 可见头像(前 N 个) */
|
||||
const visibleAvatars = computed(() =>
|
||||
normalizedParticipants.value.slice(0, MAX_AVATARS)
|
||||
)
|
||||
|
||||
/** 溢出数量 */
|
||||
const overflowCount = computed(() =>
|
||||
Math.max(0, normalizedParticipants.value.length - MAX_AVATARS)
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 获取协作坐席姓名(从映射表查找,找不到则显示ID) */
|
||||
function getAgentName(agentId: string): string {
|
||||
return props.collaboratingAgentNames?.[agentId] || agentId
|
||||
/**
|
||||
* 获取姓名末字作为降级头像
|
||||
* 坐席端使用末字 charAt(length-1),与现有逻辑一致
|
||||
* @param name - 姓名
|
||||
*/
|
||||
function avatarLetter(name: string): string {
|
||||
const n = name || '?'
|
||||
return n.charAt(n.length - 1)
|
||||
}
|
||||
|
||||
/** 移除参与者(仅主责坐席可操作) */
|
||||
/**
|
||||
* 切换展开/收起
|
||||
* P1: 同步写入状态记忆 map
|
||||
*/
|
||||
function toggleExpand(): void {
|
||||
expanded.value = !expanded.value
|
||||
// P1: 记忆状态
|
||||
if (props.conversationId) {
|
||||
expandedStateMap.value.set(props.conversationId, expanded.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收起面板(通过 ExpandedPanel 的 collapse 事件触发)
|
||||
*/
|
||||
function collapseExpand(): void {
|
||||
expanded.value = false
|
||||
if (props.conversationId) {
|
||||
expandedStateMap.value.set(props.conversationId, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除参与者(透传给父组件)
|
||||
*/
|
||||
function handleRemove(userId: string): void {
|
||||
if (!props.isPrimaryAgent) return
|
||||
emit('remove', userId)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// P1: 会话切换时恢复展开状态记忆
|
||||
// ============================================================================
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
// 从记忆中恢复该会话的展开状态
|
||||
const remembered = expandedStateMap.value.get(newId)
|
||||
expanded.value = remembered ?? false
|
||||
} else {
|
||||
expanded.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -163,25 +249,28 @@ function handleRemove(userId: string): void {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-tertiary, #f5f7fa);
|
||||
border-bottom: 1px solid var(--border-light, #e4e7ed);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.participant-bar__list {
|
||||
/* 缩略横条主体(可点击展开) */
|
||||
.participant-bar__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留滚动功能 */
|
||||
.participant-bar__list::-webkit-scrollbar {
|
||||
display: none;
|
||||
.participant-bar__main:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.participant-bar__label {
|
||||
@@ -191,109 +280,102 @@ function handleRemove(userId: string): void {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 参与者条目 */
|
||||
.participant-item {
|
||||
/* 头像列表区(不横向滚动,溢出显示+N) */
|
||||
.participant-bar__avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary, #fff);
|
||||
border: 1px solid var(--border-light, #e4e7ed);
|
||||
transition: background 0.15s;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.participant-item:hover {
|
||||
background: var(--bg-tertiary, #f5f7fa);
|
||||
}
|
||||
|
||||
.participant-item--primary {
|
||||
border-color: var(--accent, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.participant-item--collab {
|
||||
border-color: var(--success, #67c23a);
|
||||
background: rgba(103, 194, 58, 0.06);
|
||||
}
|
||||
|
||||
.participant-item--pending {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 头像容器 */
|
||||
.participant-avatar {
|
||||
/* 单个头像(20px 圆形 + 角色边框) */
|
||||
.participant-bar__avatar {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
background: var(--accent, #3b82f6);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
background: var(--accent, #3b82f6);
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.participant-avatar--primary {
|
||||
background: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
.participant-avatar--collab {
|
||||
background: var(--success, #67c23a);
|
||||
}
|
||||
|
||||
.participant-avatar--pending {
|
||||
background: var(--text-tertiary, #c0c4cc);
|
||||
}
|
||||
|
||||
/* 头像图片 */
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 真实头像图片 */
|
||||
.participant-bar__avatar-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 头像首字降级 */
|
||||
.avatar-letter {
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
/* 首字降级 */
|
||||
.participant-bar__avatar-letter {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 参与者姓名 */
|
||||
.participant-name {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary, #303133);
|
||||
/* 自己的高亮环 */
|
||||
.participant-bar__avatar--self {
|
||||
box-shadow: 0 0 0 2px #07C160;
|
||||
transform: scale(1.1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pending-hint {
|
||||
font-size: 10px;
|
||||
/* 超员溢出文本 */
|
||||
.participant-bar__overflow {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #c0c4cc);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* 移除图标 */
|
||||
.remove-icon {
|
||||
font-size: 12px;
|
||||
/* 展开箭头 */
|
||||
.participant-bar__arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #c0c4cc);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
transition: transform 0.2s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.remove-icon:hover {
|
||||
color: var(--danger, #f56c6c);
|
||||
.participant-bar__arrow--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 操作按钮区 */
|
||||
.participant-bar__actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 展开面板展开/收起动画(≤200ms) */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: max-height 0.2s ease, opacity 0.2s ease;
|
||||
overflow: hidden;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.participant-bar__expanded-wrapper {
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端参与者展开详情面板
|
||||
// =============================================================================
|
||||
// 说明:点击 ParticipantBar 横条后就地展开的详情面板
|
||||
// - 头部:标题"参与者详情" + 收起按钮
|
||||
// - 内容区:max-height 150px + overflow-y auto
|
||||
// - 每行:头像(28px) + 姓名(+角色标签) + 部门 + 加入状态 + [移除图标]
|
||||
// - 角色标签:主责/协作/发起人,被邀请人显示加入状态
|
||||
// - 移除图标:仅主责坐席且 role=invitee 时显示
|
||||
// - P1:姓名被截断时悬停头像显示 el-tooltip
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="expanded-panel">
|
||||
<!-- 头部 -->
|
||||
<div class="expanded-panel__header">
|
||||
<span class="expanded-panel__title">参与者详情</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="emit('collapse')"
|
||||
>
|
||||
收起
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="expanded-panel__list">
|
||||
<div
|
||||
v-for="p in participants"
|
||||
:key="p.id"
|
||||
class="expanded-panel__item"
|
||||
:class="{ 'expanded-panel__item--self': p.isSelf }"
|
||||
>
|
||||
<!-- 头像(含角色边框) -->
|
||||
<el-tooltip
|
||||
:content="`${p.name}${p.department ? ' · ' + p.department : ''}`"
|
||||
:disabled="!truncatedMap[p.id]"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
class="expanded-panel__avatar"
|
||||
:style="{ borderColor: ROLE_BORDER_COLORS[p.role] }"
|
||||
:class="{ 'expanded-panel__avatar--self': p.isSelf }"
|
||||
@mouseenter="checkTruncation(p.id, $event)"
|
||||
>
|
||||
<!-- 有头像URL:渲染<img>,加载失败降级显示末字 -->
|
||||
<img
|
||||
v-if="p.avatar && !failedIds[p.id]"
|
||||
:src="p.avatar"
|
||||
:alt="p.name"
|
||||
class="expanded-panel__avatar-img"
|
||||
@error="failedIds[p.id] = true"
|
||||
/>
|
||||
<span v-else class="expanded-panel__avatar-letter">
|
||||
{{ avatarLetter(p.name) }}
|
||||
</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 信息区 -->
|
||||
<div class="expanded-panel__info">
|
||||
<span class="expanded-panel__name" :ref="el => setNameRef(p.id, el)">
|
||||
{{ p.name }}
|
||||
<span v-if="p.role === 'primary_agent'" class="expanded-panel__role-tag expanded-panel__role-tag--primary">主责</span>
|
||||
<span v-else-if="p.role === 'collaborator'" class="expanded-panel__role-tag expanded-panel__role-tag--collab">协作</span>
|
||||
<span v-else-if="p.role === 'owner'" class="expanded-panel__role-tag expanded-panel__role-tag--owner">发起人</span>
|
||||
</span>
|
||||
<span v-if="p.department" class="expanded-panel__dept">{{ p.department }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 加入状态(仅被邀请人显示) -->
|
||||
<span
|
||||
v-if="p.role === 'invitee'"
|
||||
class="expanded-panel__status"
|
||||
:class="p.joined ? 'expanded-panel__status--joined' : 'expanded-panel__status--pending'"
|
||||
>
|
||||
{{ p.joined ? '已加入' : '待加入' }}
|
||||
</span>
|
||||
|
||||
<!-- 移除图标(仅主责坐席 + 被邀请人可移除) -->
|
||||
<el-icon
|
||||
v-if="isPrimaryAgent && p.role === 'invitee'"
|
||||
class="expanded-panel__remove-icon"
|
||||
@click.stop="emit('remove', p.id)"
|
||||
>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ParticipantExpandedPanel 展开详情面板
|
||||
* 在 ParticipantBar 下方就地展开,显示参与者详情
|
||||
*/
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import {
|
||||
ROLE_BORDER_COLORS,
|
||||
type NormalizedParticipant,
|
||||
} from '@/composables/useParticipantDisplay'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props & Emits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Props {
|
||||
/** 规范化参与者列表 */
|
||||
participants: NormalizedParticipant[]
|
||||
/** 当前坐席是否为主责 */
|
||||
isPrimaryAgent: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 移除参与者 */
|
||||
'remove': [userId: string]
|
||||
/** 收起面板 */
|
||||
'collapse': []
|
||||
}>()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 头像降级
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 头像加载失败标记:按 id 记录 */
|
||||
const failedIds = ref<Record<string, boolean>>({})
|
||||
|
||||
/**
|
||||
* 获取姓名末字作为降级头像
|
||||
* 坐席端使用末字 charAt(length-1),与现有逻辑一致
|
||||
* @param name - 姓名
|
||||
*/
|
||||
function avatarLetter(name: string): string {
|
||||
const n = name || '?'
|
||||
return n.charAt(n.length - 1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1: 姓名 tooltip(仅在截断时显示)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 姓名元素引用映射(id → DOM 元素) */
|
||||
const nameRefs: Record<string, HTMLElement | null> = {}
|
||||
|
||||
/** 姓名是否被截断的映射 */
|
||||
const truncatedMap = ref<Record<string, boolean>>({})
|
||||
|
||||
/**
|
||||
* 设置姓名元素引用
|
||||
*/
|
||||
function setNameRef(id: string, el: any): void {
|
||||
nameRefs[id] = el as HTMLElement | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查姓名是否被 CSS text-overflow: ellipsis 截断
|
||||
* 通过 scrollWidth > clientWidth 判断
|
||||
*/
|
||||
function checkTruncation(id: string, _event: MouseEvent): void {
|
||||
nextTick(() => {
|
||||
const el = nameRefs[id]
|
||||
if (el) {
|
||||
truncatedMap.value[id] = el.scrollWidth > el.clientWidth
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 展开面板容器 */
|
||||
.expanded-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary, #fff);
|
||||
border-bottom: 1px solid var(--border-light, #e4e7ed);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.expanded-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-tertiary, #f5f7fa);
|
||||
border-bottom: 1px solid var(--border-light, #e4e7ed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expanded-panel__title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #909399);
|
||||
}
|
||||
|
||||
/* 内容区(固定 150px + 滚动) */
|
||||
.expanded-panel__list {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 单个参与者行 */
|
||||
.expanded-panel__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.expanded-panel__item:hover {
|
||||
background: var(--bg-tertiary, #f5f7fa);
|
||||
}
|
||||
|
||||
/* 自己的高亮行 */
|
||||
.expanded-panel__item--self {
|
||||
background: rgba(7, 193, 96, 0.06);
|
||||
}
|
||||
|
||||
/* 头像容器(28px 圆形 + 角色边框) */
|
||||
.expanded-panel__avatar {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: var(--accent, #3b82f6);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 真实头像图片 */
|
||||
.expanded-panel__avatar-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 首字降级 */
|
||||
.expanded-panel__avatar-letter {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 自己的高亮环 */
|
||||
.expanded-panel__avatar--self {
|
||||
box-shadow: 0 0 0 2px #07C160;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 信息区 */
|
||||
.expanded-panel__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* 姓名(支持 ellipsis 截断) */
|
||||
.expanded-panel__name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #303133);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 角色标签 */
|
||||
.expanded-panel__role-tag {
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expanded-panel__role-tag--primary {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.expanded-panel__role-tag--collab {
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
color: #07C160;
|
||||
}
|
||||
|
||||
.expanded-panel__role-tag--owner {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
/* 部门 */
|
||||
.expanded-panel__dept {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #c0c4cc);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 加入状态 */
|
||||
.expanded-panel__status {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expanded-panel__status--joined {
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
color: #07C160;
|
||||
}
|
||||
|
||||
.expanded-panel__status--pending {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
/* 移除图标 */
|
||||
.expanded-panel__remove-icon {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary, #c0c4cc);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.expanded-panel__remove-icon:hover {
|
||||
color: var(--danger, #f56c6c);
|
||||
}
|
||||
</style>
|
||||
@@ -3,23 +3,62 @@
|
||||
// =============================================================================
|
||||
// 说明:左栏底部挂载的待办面板
|
||||
// 功能:
|
||||
// 1. 显示待办列表(优先级圆点 + 文本 + 类型标签 + 时间)
|
||||
// 2. 点击条目 → todoStore.selectTodoItem + workspaceView = 'task'
|
||||
// 3. 底部显示坐席在线统计
|
||||
// 1. 显示待办列表(优先级圆点 + 文本 + 类型标签 + 时间 + 上报人头像)
|
||||
// 2. 类型筛选 Tab(全部/审批/工单)— 切换时更新 activeType 并刷新列表
|
||||
// 3. 手动刷新按钮 — 跳过后端缓存重新查询
|
||||
// 4. 定时自动刷新 — 每 60 秒自动刷新,组件卸载时清除定时器
|
||||
// 5. 点击条目 → todoStore.selectTodoItem + workspaceView = 'task'
|
||||
// 6. 底部显示坐席在线统计
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="todo-panel">
|
||||
<!-- 标题行 -->
|
||||
<div class="todo-header">
|
||||
<span class="todo-title">📋 待办事项</span>
|
||||
<span v-if="todoStore.urgentCount > 0" class="todo-urgent-badge">
|
||||
{{ todoStore.urgentCount }}
|
||||
</span>
|
||||
<div class="todo-header-left">
|
||||
<span class="todo-title">📋 待办事项</span>
|
||||
<span v-if="todoStore.urgentCount > 0" class="todo-urgent-badge">
|
||||
{{ todoStore.urgentCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="todo-header-right">
|
||||
<!-- 类型筛选 Tab -->
|
||||
<div class="todo-tabs">
|
||||
<button
|
||||
class="todo-tab"
|
||||
:class="{ 'todo-tab-active': todoStore.activeType === 'all' }"
|
||||
@click="handleTabChange('all')"
|
||||
>全部</button>
|
||||
<button
|
||||
class="todo-tab"
|
||||
:class="{ 'todo-tab-active': todoStore.activeType === 'approval' }"
|
||||
@click="handleTabChange('approval')"
|
||||
>审批</button>
|
||||
<button
|
||||
class="todo-tab"
|
||||
:class="{ 'todo-tab-active': todoStore.activeType === 'ticket' }"
|
||||
@click="handleTabChange('ticket')"
|
||||
>工单</button>
|
||||
</div>
|
||||
<!-- 刷新按钮 -->
|
||||
<button
|
||||
class="todo-refresh-btn"
|
||||
:class="{ 'todo-refreshing': todoStore.loading }"
|
||||
:disabled="todoStore.loading"
|
||||
title="刷新待办列表"
|
||||
@click="handleRefresh"
|
||||
>🔄</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待办列表 -->
|
||||
<div class="todo-list">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="todoStore.loading && todoStore.pendingTodos.length === 0" class="todo-loading">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<!-- 待办条目 -->
|
||||
<div
|
||||
v-for="item in todoStore.pendingTodos"
|
||||
:key="item.id"
|
||||
@@ -40,7 +79,7 @@
|
||||
</span>
|
||||
<!-- 时间 -->
|
||||
<span class="todo-time">{{ formatTodoTime(item.created_at) }}</span>
|
||||
<!-- v5.4: 上报人缩略头像 -->
|
||||
<!-- 上报人缩略头像 -->
|
||||
<div
|
||||
class="ki-avatar"
|
||||
:class="todoAvatarColor(item)"
|
||||
@@ -78,7 +117,7 @@
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useTodoStore } from '@/stores/todo'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { getAgentStats } from '@/mock/data'
|
||||
@@ -98,16 +137,38 @@ const onlineAgents = ref(stats.onlineAgents)
|
||||
const busyAgents = ref(stats.busyAgents)
|
||||
const offlineAgents = ref(stats.offlineAgents)
|
||||
|
||||
// ============================================================================
|
||||
// 定时刷新定时器(每 60 秒自动刷新一次)
|
||||
// ============================================================================
|
||||
const AUTO_REFRESH_INTERVAL = 60000 // 60秒
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// 组件挂载时获取待办列表
|
||||
todoStore.fetchTodoList()
|
||||
// 启动定时自动刷新
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
todoStore.fetchTodoList()
|
||||
}, AUTO_REFRESH_INTERVAL)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 组件卸载时清除定时器,避免内存泄漏
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 类型标签文字映射 */
|
||||
/** 类型标签文字映射(仅 ticket/approval,已移除 device) */
|
||||
function typeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ticket: '工单',
|
||||
approval: '审批',
|
||||
device: '设备',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
@@ -134,6 +195,16 @@ function handleTodoClick(item: TodoItemData): void {
|
||||
conversationStore.workspaceView = 'task'
|
||||
}
|
||||
|
||||
/** 切换筛选类型 Tab */
|
||||
function handleTabChange(type: 'all' | 'approval' | 'ticket'): void {
|
||||
todoStore.setActiveType(type)
|
||||
}
|
||||
|
||||
/** 手动刷新待办列表 */
|
||||
function handleRefresh(): void {
|
||||
todoStore.refreshList()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v5.4: 待办缩略头像辅助函数
|
||||
// ============================================================================
|
||||
@@ -181,7 +252,7 @@ function todoAvatarTitle(item: TodoItemData): string {
|
||||
/* 面板容器 */
|
||||
.todo-panel {
|
||||
border-top: 1px solid var(--border-color);
|
||||
max-height: 220px;
|
||||
max-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
@@ -192,12 +263,25 @@ function todoAvatarTitle(item: TodoItemData): string {
|
||||
.todo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.todo-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.todo-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -218,6 +302,76 @@ function todoAvatarTitle(item: TodoItemData): string {
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 类型筛选 Tab */
|
||||
.todo-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.todo-tab {
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-tab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.todo-tab-active {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.todo-tab-active:hover {
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 刷新按钮 */
|
||||
.todo-refresh-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-refresh-btn:hover:not(:disabled) {
|
||||
background-color: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.todo-refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.todo-refreshing {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 待办列表 */
|
||||
.todo-list {
|
||||
flex: 1;
|
||||
@@ -296,13 +450,6 @@ function todoAvatarTitle(item: TodoItemData): string {
|
||||
border-color: color-mix(in srgb, var(--purple) 30%, transparent);
|
||||
}
|
||||
|
||||
/* 设备 — 橙色 */
|
||||
.todo-type-tag.type-device {
|
||||
color: var(--color-warning);
|
||||
background-color: var(--warning-soft);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
|
||||
}
|
||||
|
||||
/* 时间 */
|
||||
.todo-time {
|
||||
font-size: 10px;
|
||||
@@ -311,6 +458,14 @@ function todoAvatarTitle(item: TodoItemData): string {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.todo-loading {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.todo-empty {
|
||||
padding: 12px;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- 左侧:Logo + 标题 -->
|
||||
<div class="top-bar-left">
|
||||
<span class="logo-block">IT</span>
|
||||
<span class="title-gradient">IT智能服务台</span>
|
||||
<span class="title-gradient">智能IT服务台</span>
|
||||
<span class="subtitle">· 坐席工作台 — AI驱动 · 多系统对接 · 一站式处理</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* 坐席端 useParticipantDisplay composable 单元测试
|
||||
*
|
||||
* 测试维度:
|
||||
* 1. 数据规范化:空数据、单人、多人、超员、混合角色
|
||||
* 2. 角色映射:primary_agent / collaborator / owner / invitee
|
||||
* 3. 排列顺序:主责坐席 → 发起人 → 协作坐席 → 被邀请人(按 joinedAt 排序)
|
||||
* 4. isSelf 判定:主责 agentId===currentAgentId,协作 aid===currentAgentId
|
||||
* 5. 头像降级:坐席和发起人 avatar 为空字符串
|
||||
* 6. 边界情况:name 为空、collaboratingAgentNames 缺失、joinedAt 缺失
|
||||
* 7. 计算属性:totalCount、hasPending
|
||||
* 8. 常量:ROLE_BORDER_COLORS、SELF_HIGHLIGHT_COLOR、ROLE_LABELS
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { reactive, nextTick } from 'vue'
|
||||
|
||||
import {
|
||||
useParticipantDisplay,
|
||||
ROLE_BORDER_COLORS,
|
||||
SELF_HIGHLIGHT_COLOR,
|
||||
ROLE_LABELS,
|
||||
type UseParticipantDisplayProps,
|
||||
} from '@/composables/useParticipantDisplay'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 创建默认 props */
|
||||
function createProps(overrides: Partial<UseParticipantDisplayProps> = {}): UseParticipantDisplayProps {
|
||||
return {
|
||||
participants: [],
|
||||
agentName: '',
|
||||
agentId: '',
|
||||
employeeName: '',
|
||||
employeeId: '',
|
||||
collaboratingAgentIds: [],
|
||||
collaboratingAgentNames: {},
|
||||
currentAgentId: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取 normalizedParticipants 的值 */
|
||||
function getParticipants(props: UseParticipantDisplayProps) {
|
||||
const { normalizedParticipants } = useParticipantDisplay(props)
|
||||
return normalizedParticipants.value
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Agent useParticipantDisplay — 常量定义', () => {
|
||||
it('ROLE_BORDER_COLORS 应包含 4 种角色的正确色值', () => {
|
||||
expect(ROLE_BORDER_COLORS.primary_agent).toBe('#3b82f6')
|
||||
expect(ROLE_BORDER_COLORS.collaborator).toBe('#07C160')
|
||||
expect(ROLE_BORDER_COLORS.owner).toBe('#FF9800')
|
||||
expect(ROLE_BORDER_COLORS.invitee).toBe('transparent')
|
||||
})
|
||||
|
||||
it('SELF_HIGHLIGHT_COLOR 应为 #07C160', () => {
|
||||
expect(SELF_HIGHLIGHT_COLOR).toBe('#07C160')
|
||||
})
|
||||
|
||||
it('ROLE_LABELS 应包含 4 种角色的标签', () => {
|
||||
expect(ROLE_LABELS.primary_agent).toBe('主责')
|
||||
expect(ROLE_LABELS.collaborator).toBe('协作')
|
||||
expect(ROLE_LABELS.owner).toBe('发起人')
|
||||
expect(ROLE_LABELS.invitee).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — 空数据', () => {
|
||||
it('所有字段为空时返回空数组', () => {
|
||||
const props = createProps()
|
||||
const result = getParticipants(props)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('无坐席姓名时不含 primary_agent', () => {
|
||||
const props = createProps({
|
||||
employeeName: '员工A',
|
||||
employeeId: 'emp_001',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].role).toBe('owner')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — 单人场景', () => {
|
||||
it('仅有主责坐席时返回 1 个 primary_agent', () => {
|
||||
const props = createProps({
|
||||
agentName: '坐席A',
|
||||
agentId: 'agent_001',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].role).toBe('primary_agent')
|
||||
expect(result[0].name).toBe('坐席A')
|
||||
expect(result[0].id).toBe('agent_001')
|
||||
expect(result[0].joined).toBe(true)
|
||||
expect(result[0].avatar).toBe('')
|
||||
expect(result[0].department).toBe('IT坐席')
|
||||
})
|
||||
|
||||
it('主责坐席 agentId 为空时使用默认 ID "agent_primary"', () => {
|
||||
const props = createProps({
|
||||
agentName: '坐席A',
|
||||
agentId: '',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].id).toBe('agent_primary')
|
||||
})
|
||||
|
||||
it('仅有发起人时返回 1 个 owner', () => {
|
||||
const props = createProps({
|
||||
employeeName: '员工B',
|
||||
employeeId: 'emp_001',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].role).toBe('owner')
|
||||
expect(result[0].name).toBe('员工B')
|
||||
expect(result[0].id).toBe('emp_001')
|
||||
expect(result[0].joined).toBe(true)
|
||||
expect(result[0].isSelf).toBe(false) // 坐席不是员工
|
||||
expect(result[0].avatar).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — 多人场景 & 排列顺序', () => {
|
||||
it('完整顺序:主责 → 发起人 → 协作坐席 → 被邀请人', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责坐席',
|
||||
agentId: 'agent_001',
|
||||
employeeName: '发起员工',
|
||||
employeeId: 'emp_001',
|
||||
collaboratingAgentIds: ['agent_002'],
|
||||
collaboratingAgentNames: { agent_002: '协作坐席' },
|
||||
participants: [
|
||||
{ id: 'emp_002', name: '被邀请人A', joined: true, joined_at: '2026-01-01T10:00:00Z' },
|
||||
],
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0].role).toBe('primary_agent')
|
||||
expect(result[1].role).toBe('owner')
|
||||
expect(result[2].role).toBe('collaborator')
|
||||
expect(result[3].role).toBe('invitee')
|
||||
})
|
||||
|
||||
it('多个协作坐席按 collaboratingAgentIds 顺序排列', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
collaboratingAgentIds: ['agent_003', 'agent_002'],
|
||||
collaboratingAgentNames: { agent_002: '协作B', agent_003: '协作C' },
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[1].id).toBe('agent_003') // 第一个协作
|
||||
expect(result[2].id).toBe('agent_002') // 第二个协作
|
||||
})
|
||||
|
||||
it('被邀请人按 joinedAt 升序排列(已加入在前)', () => {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
{ id: 'emp_003', name: '后加入', joined: true, joined_at: '2026-01-01T11:00:00Z' },
|
||||
{ id: 'emp_002', name: '先加入', joined: true, joined_at: '2026-01-01T10:00:00Z' },
|
||||
],
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].id).toBe('emp_002')
|
||||
expect(result[1].id).toBe('emp_003')
|
||||
})
|
||||
|
||||
it('未加入的被邀请人排在已加入的后面', () => {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
{ id: 'emp_003', name: '未加入', joined: false },
|
||||
{ id: 'emp_002', name: '已加入', joined: true, joined_at: '2026-01-01T10:00:00Z' },
|
||||
],
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].id).toBe('emp_002')
|
||||
expect(result[1].id).toBe('emp_003')
|
||||
})
|
||||
|
||||
it('多个未加入的被邀请人保持原始相对顺序', () => {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
{ id: 'emp_a', name: 'A', joined: false },
|
||||
{ id: 'emp_b', name: 'B', joined: false },
|
||||
],
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].id).toBe('emp_a')
|
||||
expect(result[1].id).toBe('emp_b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — isSelf 判定', () => {
|
||||
it('主责坐席 agentId === currentAgentId 时 isSelf=true', () => {
|
||||
const props = createProps({
|
||||
agentName: '坐席A',
|
||||
agentId: 'agent_001',
|
||||
currentAgentId: 'agent_001',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].isSelf).toBe(true)
|
||||
})
|
||||
|
||||
it('主责坐席 agentId !== currentAgentId 时 isSelf=false', () => {
|
||||
const props = createProps({
|
||||
agentName: '坐席A',
|
||||
agentId: 'agent_001',
|
||||
currentAgentId: 'agent_002',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].isSelf).toBe(false)
|
||||
})
|
||||
|
||||
it('协作坐席 aid === currentAgentId 时 isSelf=true', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
collaboratingAgentIds: ['agent_002', 'agent_003'],
|
||||
collaboratingAgentNames: { agent_002: '协作B', agent_003: '协作C' },
|
||||
currentAgentId: 'agent_003',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
const collab = result.find(p => p.role === 'collaborator' && p.id === 'agent_003')!
|
||||
expect(collab.isSelf).toBe(true)
|
||||
})
|
||||
|
||||
it('协作坐席 aid !== currentAgentId 时 isSelf=false', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
collaboratingAgentIds: ['agent_002'],
|
||||
collaboratingAgentNames: { agent_002: '协作B' },
|
||||
currentAgentId: 'agent_001', // 主责是自己,协作不是
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
const collab = result.find(p => p.role === 'collaborator')!
|
||||
expect(collab.isSelf).toBe(false)
|
||||
})
|
||||
|
||||
it('发起人 isSelf 始终为 false(坐席不是员工)', () => {
|
||||
const props = createProps({
|
||||
employeeName: '员工A',
|
||||
employeeId: 'emp_001',
|
||||
currentAgentId: 'emp_001', // 即使ID相同也不是自己
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].isSelf).toBe(false)
|
||||
})
|
||||
|
||||
it('被邀请人 isSelf 始终为 false', () => {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
{ id: 'agent_001', name: 'A', joined: true },
|
||||
],
|
||||
currentAgentId: 'agent_001',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].isSelf).toBe(false)
|
||||
})
|
||||
|
||||
it('主责坐席 agentId 和 currentAgentId 都为空字符串时不误判为 isSelf', () => {
|
||||
// 注意:agentId 和 currentAgentId 都为 '' 时,'' === '' 为 true
|
||||
// 这是一个潜在的边界问题,当前实现会返回 isSelf=true
|
||||
const props = createProps({
|
||||
agentName: '坐席A',
|
||||
agentId: '',
|
||||
currentAgentId: '',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
// 当前实现:'' === '' → true,标记为 isSelf
|
||||
// 这是一个已知的边界情况
|
||||
expect(result[0].isSelf).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — 协作坐席姓名降级', () => {
|
||||
it('collaboratingAgentNames 中有对应姓名时使用映射姓名', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
collaboratingAgentIds: ['agent_002'],
|
||||
collaboratingAgentNames: { agent_002: '张协作' },
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[1].name).toBe('张协作')
|
||||
})
|
||||
|
||||
it('collaboratingAgentNames 中无对应姓名时使用 agent ID 作为姓名', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
collaboratingAgentIds: ['agent_002'],
|
||||
collaboratingAgentNames: {}, // 无映射
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[1].name).toBe('agent_002')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — 头像降级', () => {
|
||||
it('主责坐席 avatar 始终为空字符串', () => {
|
||||
const props = createProps({
|
||||
agentName: '坐席A',
|
||||
agentId: 'agent_001',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].avatar).toBe('')
|
||||
})
|
||||
|
||||
it('发起人 avatar 始终为空字符串', () => {
|
||||
const props = createProps({
|
||||
employeeName: '员工A',
|
||||
employeeId: 'emp_001',
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].avatar).toBe('')
|
||||
})
|
||||
|
||||
it('协作坐席 avatar 始终为空字符串', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
collaboratingAgentIds: ['agent_002'],
|
||||
collaboratingAgentNames: { agent_002: '协作B' },
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[1].avatar).toBe('')
|
||||
})
|
||||
|
||||
it('被邀请人有 avatar 时保留', () => {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
{ id: 'emp_002', name: 'C', avatar: 'https://example.com/p.jpg', joined: true },
|
||||
],
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].avatar).toBe('https://example.com/p.jpg')
|
||||
})
|
||||
|
||||
it('被邀请人 avatar 为 null/undefined 时返回空字符串', () => {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
{ id: 'emp_002', name: 'C', avatar: null, joined: true },
|
||||
{ id: 'emp_003', name: 'D', joined: true },
|
||||
],
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].avatar).toBe('')
|
||||
expect(result[1].avatar).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — 计算属性', () => {
|
||||
it('totalCount 等于 normalizedParticipants 长度', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
employeeName: '员工',
|
||||
employeeId: 'emp_001',
|
||||
collaboratingAgentIds: ['agent_002'],
|
||||
collaboratingAgentNames: { agent_002: '协作' },
|
||||
participants: [
|
||||
{ id: 'emp_002', name: '被邀请', joined: true },
|
||||
],
|
||||
})
|
||||
const { totalCount, normalizedParticipants } = useParticipantDisplay(props)
|
||||
expect(totalCount.value).toBe(4)
|
||||
expect(totalCount.value).toBe(normalizedParticipants.value.length)
|
||||
})
|
||||
|
||||
it('totalCount 为 0 当无任何参与者', () => {
|
||||
const props = createProps()
|
||||
const { totalCount } = useParticipantDisplay(props)
|
||||
expect(totalCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('hasPending 为 true 当有未加入的参与者', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
participants: [
|
||||
{ id: 'emp_002', name: 'C', joined: false },
|
||||
],
|
||||
})
|
||||
const { hasPending } = useParticipantDisplay(props)
|
||||
expect(hasPending.value).toBe(true)
|
||||
})
|
||||
|
||||
it('hasPending 为 false 当所有参与者都已加入', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
employeeName: '员工',
|
||||
employeeId: 'emp_001',
|
||||
participants: [
|
||||
{ id: 'emp_002', name: 'C', joined: true },
|
||||
],
|
||||
})
|
||||
const { hasPending } = useParticipantDisplay(props)
|
||||
expect(hasPending.value).toBe(false)
|
||||
})
|
||||
|
||||
it('hasPending 为 false 当无被邀请人', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
employeeName: '员工',
|
||||
employeeId: 'emp_001',
|
||||
})
|
||||
const { hasPending } = useParticipantDisplay(props)
|
||||
expect(hasPending.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — 边界情况', () => {
|
||||
it('被邀请人 joined 字段缺失时默认为 false', () => {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
{ id: 'emp_002', name: 'C' }, // 无 joined
|
||||
],
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].joined).toBe(false)
|
||||
})
|
||||
|
||||
it('被邀请人 department 缺失时返回空字符串', () => {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
{ id: 'emp_002', name: 'C', joined: true },
|
||||
],
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result[0].department).toBe('')
|
||||
})
|
||||
|
||||
it('participants 为 null 时不报错', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
participants: null as any,
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result).toHaveLength(1) // 仅主责
|
||||
})
|
||||
|
||||
it('collaboratingAgentIds 为空数组时不添加协作坐席', () => {
|
||||
const props = createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
collaboratingAgentIds: [],
|
||||
collaboratingAgentNames: {},
|
||||
})
|
||||
const result = getParticipants(props)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].role).toBe('primary_agent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent useParticipantDisplay — 响应式更新', () => {
|
||||
it('当 props 数据变化时 normalizedParticipants 自动更新', async () => {
|
||||
const props = reactive(createProps({
|
||||
agentName: '主责',
|
||||
agentId: 'agent_001',
|
||||
employeeName: '员工',
|
||||
employeeId: 'emp_001',
|
||||
participants: [],
|
||||
})) as any
|
||||
|
||||
const { normalizedParticipants, totalCount } = useParticipantDisplay(props)
|
||||
expect(totalCount.value).toBe(2)
|
||||
|
||||
// 添加新参与者
|
||||
props.participants = [
|
||||
{ id: 'emp_002', name: 'C', joined: true },
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(totalCount.value).toBe(3)
|
||||
expect(normalizedParticipants.value[2].id).toBe('emp_002')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 摄像头流管理组合函数
|
||||
// =============================================================================
|
||||
// 说明:封装 getUserMedia API,提供摄像头预览、拍照、切换前后摄像头等功能
|
||||
// 架构文档:docs/03-技术架构/坐席端截图拍照功能-架构设计.md
|
||||
//
|
||||
// 使用方式:
|
||||
// const { isActive, error, stream, startCamera, stopCamera, captureFrame, switchCamera }
|
||||
// = useCamera()
|
||||
// - startCamera(facingMode?):启动摄像头,返回是否成功
|
||||
// - stopCamera():停止所有视频轨道,释放摄像头
|
||||
// - captureFrame():从 video 当前帧捕获图片,返回 PNG Blob
|
||||
// - switchCamera():切换前置/后置摄像头
|
||||
// =============================================================================
|
||||
|
||||
import { ref } from 'vue'
|
||||
import type { CameraError } from '@/types/screenshot'
|
||||
|
||||
/** 摄像头是否已激活 */
|
||||
const isActive = ref(false)
|
||||
|
||||
/** 摄像头错误信息 */
|
||||
const error = ref<CameraError | null>(null)
|
||||
|
||||
/** 当前摄像头流(赋值到 video.srcObject) */
|
||||
const stream = ref<MediaStream | null>(null)
|
||||
|
||||
/** 当前使用的 facingMode(用于 switchCamera 切换) */
|
||||
let currentFacingMode: 'user' | 'environment' = 'user'
|
||||
|
||||
/**
|
||||
* 获取摄像头错误类型的友好提示文本
|
||||
* @param err - 错误对象
|
||||
* @returns CameraError 类型枚举
|
||||
*/
|
||||
function classifyCameraError(err: any): CameraError {
|
||||
if (err?.name === 'NotAllowedError' || err?.name === 'SecurityError') {
|
||||
return 'permission-denied'
|
||||
}
|
||||
if (err?.name === 'NotFoundError' || err?.name === 'DevicesNotFoundError') {
|
||||
return 'no-device'
|
||||
}
|
||||
if (err?.name === 'NotReadableError' || err?.name === 'TrackStartError') {
|
||||
return 'in-use'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取摄像头错误的友好提示文本
|
||||
* @param errType - 错误类型
|
||||
* @returns 错误提示文本
|
||||
*/
|
||||
export function getCameraErrorMessage(errType: CameraError | null): string {
|
||||
if (!errType) return ''
|
||||
const ua = navigator.userAgent
|
||||
const isDesktop = !/Mobile|Android|iPhone|iPad|iPod|Windows Phone/i.test(ua)
|
||||
const fallback = isDesktop ? '选择本地图片' : '使用系统相机'
|
||||
const messages: Record<CameraError, string> = {
|
||||
'permission-denied': `摄像头权限被拒绝,请使用下方"${fallback}",或在浏览器设置中允许访问摄像头`,
|
||||
'no-device': `当前环境不支持直接访问摄像头,请使用下方"${fallback}"`,
|
||||
'in-use': `摄像头正在被其他应用使用,请关闭后重试或使用"${fallback}"`,
|
||||
'unknown': `摄像头启动失败,请使用下方"${fallback}"或重试`,
|
||||
}
|
||||
return messages[errType]
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动摄像头
|
||||
*
|
||||
* 做什么:调用 getUserMedia 获取摄像头视频流,赋值到响应式 stream
|
||||
* 为什么:CameraCapture.vue 通过 video.srcObject = stream 实现实时预览
|
||||
*
|
||||
* @param facingMode - 摄像头方向:'user'(前置/默认)或 'environment'(后置)
|
||||
* @returns 是否启动成功
|
||||
*/
|
||||
export async function startCamera(facingMode: 'user' | 'environment' = 'user'): Promise<boolean> {
|
||||
// 清除之前的错误
|
||||
error.value = null
|
||||
|
||||
// 如果已有流在运行,先停止
|
||||
if (stream.value) {
|
||||
stopCamera()
|
||||
}
|
||||
|
||||
currentFacingMode = facingMode
|
||||
|
||||
// 检测是否支持 getUserMedia
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
error.value = 'no-device'
|
||||
console.warn('[useCamera] 浏览器不支持 getUserMedia')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 请求摄像头权限,请求 1280×720 分辨度
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: facingMode },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
audio: false,
|
||||
})
|
||||
|
||||
stream.value = mediaStream
|
||||
isActive.value = true
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = classifyCameraError(err)
|
||||
console.error('[useCamera] 启动摄像头失败:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止摄像头
|
||||
*
|
||||
* 做什么:停止所有视频轨道,释放摄像头资源,清空 stream
|
||||
* 为什么:避免摄像头指示灯一直亮着,释放设备给其他应用使用
|
||||
*/
|
||||
export function stopCamera(): void {
|
||||
if (stream.value) {
|
||||
stream.value.getTracks().forEach((track) => track.stop())
|
||||
stream.value = null
|
||||
}
|
||||
isActive.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 从视频流当前帧捕获图片
|
||||
*
|
||||
* 做什么:将 video 当前帧 drawImage 到临时 Canvas,导出 PNG Blob
|
||||
* 为什么:拍照功能需要从实时视频流中捕获一帧静态画面
|
||||
*
|
||||
* @param video - video 元素(srcObject 已绑定 stream)
|
||||
* @returns PNG 格式的 Blob,失败返回 null
|
||||
*/
|
||||
export async function captureFrame(video: HTMLVideoElement): Promise<Blob | null> {
|
||||
if (!isActive.value || !stream.value) {
|
||||
console.warn('[useCamera] 摄像头未启动,无法拍照')
|
||||
return null
|
||||
}
|
||||
|
||||
// 确保视频已加载且有有效尺寸
|
||||
if (!video.videoWidth || !video.videoHeight) {
|
||||
console.warn('[useCamera] 视频尚未就绪')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
|
||||
// 绘制当前帧到 Canvas
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 导出为 PNG Blob
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b), 'image/png')
|
||||
})
|
||||
|
||||
return blob
|
||||
} catch (err) {
|
||||
console.error('[useCamera] 拍照失败:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换前后摄像头
|
||||
*
|
||||
* 做什么:停止当前摄像头,用相反的 facingMode 重新启动
|
||||
* 为什么:移动端用户可能需要在前置/后置摄像头之间切换(P1 功能)
|
||||
*
|
||||
* @returns 是否切换成功
|
||||
*/
|
||||
export async function switchCamera(): Promise<boolean> {
|
||||
const newFacingMode = currentFacingMode === 'user' ? 'environment' : 'user'
|
||||
return await startCamera(newFacingMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 摄像头管理组合函数
|
||||
*
|
||||
* 返回响应式状态和操作方法:
|
||||
* - state: isActive, error, stream
|
||||
* - actions: startCamera, stopCamera, captureFrame, switchCamera
|
||||
*/
|
||||
export function useCamera() {
|
||||
return {
|
||||
/** 摄像头是否已激活 */
|
||||
isActive,
|
||||
/** 摄像头错误(null 表示无错误) */
|
||||
error,
|
||||
/** 当前摄像头流 */
|
||||
stream,
|
||||
/** 启动摄像头 */
|
||||
startCamera,
|
||||
/** 停止摄像头 */
|
||||
stopCamera,
|
||||
/** 从视频帧拍照 */
|
||||
captureFrame,
|
||||
/** 切换前后摄像头 */
|
||||
switchCamera,
|
||||
/** 获取错误提示文本 */
|
||||
getCameraErrorMessage,
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ interface UseKeyboardShortcutsOptions {
|
||||
onQuickReplyConfirm?: () => void
|
||||
/** 聚焦搜索框(/) */
|
||||
onFocusSearch?: () => void
|
||||
/** 截图快捷键(Ctrl+Shift+A,P2 预留) */
|
||||
onScreenshot?: () => void
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -138,6 +140,16 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}):
|
||||
if (options.onFocusSearch) {
|
||||
shortcuts.push({ key: '/', handler: () => options.onFocusSearch!() })
|
||||
}
|
||||
|
||||
// Ctrl+Shift+A: 截图快捷键(P2 预留)
|
||||
if (options.onScreenshot) {
|
||||
shortcuts.push({
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: 'a',
|
||||
handler: () => options.onScreenshot!(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* =============================================================================
|
||||
* 企微IT智能服务台 — 坐席端参与者数据规范化 composable
|
||||
* =============================================================================
|
||||
* 从 Props 读取异构数据,输出统一的 NormalizedParticipant[]
|
||||
*
|
||||
* 排列顺序:主责坐席 → 发起人 → 协作坐席 → 被邀请人(按 joinedAt 排序,未加入排末尾)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { ParticipantInfo } from '@/api/conversation'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 参与者角色枚举 */
|
||||
export type ParticipantRole = 'primary_agent' | 'collaborator' | 'owner' | 'invitee'
|
||||
|
||||
/** 规范化参与者 — 统一内部数据模型 */
|
||||
export interface NormalizedParticipant {
|
||||
/** 唯一标识(employee_id 或 agent_id) */
|
||||
id: string
|
||||
/** 显示姓名(当前用户显示"我") */
|
||||
name: string
|
||||
/** 头像URL(空字符串 = 无头像,使用首字母降级) */
|
||||
avatar: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
/** 角色 */
|
||||
role: ParticipantRole
|
||||
/** 是否已加入会话 */
|
||||
joined: boolean
|
||||
/** 是否为当前登录用户 */
|
||||
isSelf: boolean
|
||||
/** 加入时间(ISO 格式,用于排序,可选) */
|
||||
joinedAt?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 常量
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 角色边框色常量(硬编码,不走 CSS 变量,因两端 --accent 在深色模式下会变色) */
|
||||
export const ROLE_BORDER_COLORS: Record<ParticipantRole, string> = {
|
||||
primary_agent: '#3b82f6', // 蓝色 — 主责坐席
|
||||
collaborator: '#07C160', // 绿色 — 协作坐席
|
||||
owner: '#FF9800', // 橙色 — 发起人
|
||||
invitee: 'transparent', // 无边框 — 被邀请人
|
||||
}
|
||||
|
||||
/** 自己的高亮环色 */
|
||||
export const SELF_HIGHLIGHT_COLOR = '#07C160'
|
||||
|
||||
/** 角色显示标签 */
|
||||
export const ROLE_LABELS: Record<ParticipantRole, string> = {
|
||||
primary_agent: '主责',
|
||||
collaborator: '协作',
|
||||
owner: '发起人',
|
||||
invitee: '', // 被邀请人不显示角色标签,显示加入状态
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composable 入参类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** useParticipantDisplay 入参 */
|
||||
export interface UseParticipantDisplayProps {
|
||||
/** 参与者列表(不含主责坐席) */
|
||||
participants: ParticipantInfo[]
|
||||
/** 主责坐席姓名 */
|
||||
agentName: string
|
||||
/** 主责坐席ID */
|
||||
agentId?: string
|
||||
/** 发起人姓名 */
|
||||
employeeName: string
|
||||
/** 发起人ID */
|
||||
employeeId: string
|
||||
/** 协作坐席ID列表 */
|
||||
collaboratingAgentIds: string[]
|
||||
/** 协作坐席姓名映射 */
|
||||
collaboratingAgentNames: Record<string, string>
|
||||
/** 当前登录坐席ID,用于 isSelf 判断 */
|
||||
currentAgentId: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composable 实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 坐席端参与者数据规范化 composable
|
||||
* 从 Props 读取异构数据,输出统一的 NormalizedParticipant[]
|
||||
*
|
||||
* 排列顺序:主责坐席 → 发起人 → 协作坐席 → 被邀请人(按 joinedAt 排序)
|
||||
*/
|
||||
export function useParticipantDisplay(props: UseParticipantDisplayProps) {
|
||||
/**
|
||||
* 规范化参与者列表
|
||||
* 将 agent/owner/collaborator/invited 拼装为统一列表,推断角色,标记 isSelf
|
||||
*/
|
||||
const normalizedParticipants = computed<NormalizedParticipant[]>(() => {
|
||||
const list: NormalizedParticipant[] = []
|
||||
|
||||
// 1. 主责坐席(第一位)
|
||||
if (props.agentName) {
|
||||
list.push({
|
||||
id: props.agentId || 'agent_primary',
|
||||
name: props.agentName,
|
||||
avatar: '', // 坐席无头像接口
|
||||
department: 'IT坐席',
|
||||
role: 'primary_agent',
|
||||
joined: true,
|
||||
isSelf: props.agentId === props.currentAgentId,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 发起人(原始员工)
|
||||
if (props.employeeName) {
|
||||
list.push({
|
||||
id: props.employeeId,
|
||||
name: props.employeeName,
|
||||
avatar: '', // 坐席端暂无员工头像接口
|
||||
department: '',
|
||||
role: 'owner',
|
||||
joined: true,
|
||||
isSelf: false, // 坐席不是员工
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 协作坐席
|
||||
for (const aid of props.collaboratingAgentIds) {
|
||||
const name = props.collaboratingAgentNames[aid] || aid
|
||||
list.push({
|
||||
id: aid,
|
||||
name,
|
||||
avatar: '',
|
||||
department: 'IT坐席',
|
||||
role: 'collaborator',
|
||||
joined: true,
|
||||
isSelf: aid === props.currentAgentId,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 被邀请参与者(按 joinedAt 排序,已加入在前)
|
||||
const invited = (props.participants || [])
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.joined !== b.joined) return a.joined ? -1 : 1
|
||||
if (a.joined_at && b.joined_at) {
|
||||
return new Date(a.joined_at).getTime() - new Date(b.joined_at).getTime()
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const p of invited) {
|
||||
list.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
avatar: p.avatar || '',
|
||||
department: p.department || '',
|
||||
role: 'invitee',
|
||||
joined: p.joined ?? false,
|
||||
isSelf: false,
|
||||
joinedAt: p.joined_at,
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
/** 总参与人数 */
|
||||
const totalCount = computed(() => normalizedParticipants.value.length)
|
||||
|
||||
/** 是否有待加入参与者(用于角标提示) */
|
||||
const hasPending = computed(() =>
|
||||
normalizedParticipants.value.some(p => !p.joined)
|
||||
)
|
||||
|
||||
return { normalizedParticipants, totalCount, hasPending }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 待发送图片队列管理组合函数
|
||||
// =============================================================================
|
||||
// 说明:管理 ReplyBox 中的待发送图片队列,提供添加/删除/清空功能,
|
||||
// 自动管理 URL.createObjectURL / revokeObjectURL 的生命周期。
|
||||
// 架构文档:docs/03-技术架构/坐席端截图拍照功能-架构设计.md
|
||||
//
|
||||
// 为什么用 Composable 而非 Pinia Store:
|
||||
// - 待发送图片是 ReplyBox 的局部 UI 状态,不需要跨组件共享
|
||||
// - 避免污染全局 store
|
||||
// - composable 可在 ReplyBox 卸载时自动清理 ObjectURL 资源
|
||||
//
|
||||
// 使用方式:
|
||||
// const { images, hasImages, count, addImage, removeImage, clearAll } = usePendingImages()
|
||||
// - addImage(blob, source):添加图片到队列
|
||||
// - removeImage(id):删除指定图片
|
||||
// - clearAll():清空队列(ReplyBox 卸载时调用)
|
||||
// =============================================================================
|
||||
|
||||
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
||||
import type { PendingImage } from '@/types/screenshot'
|
||||
|
||||
/**
|
||||
* 待发送图片队列管理组合函数
|
||||
*
|
||||
* 每张图片通过 URL.createObjectURL(blob) 生成 previewUrl 用于缩略图显示。
|
||||
* 删除图片或清空队列时必须调用 URL.revokeObjectURL(previewUrl) 释放内存。
|
||||
*
|
||||
* @returns 响应式图片队列和操作方法
|
||||
*/
|
||||
export function usePendingImages() {
|
||||
/** 待发送图片列表 */
|
||||
const images = ref<PendingImage[]>([]) as Ref<PendingImage[]>
|
||||
|
||||
/** 是否有待发送图片 */
|
||||
const hasImages = computed(() => images.value.length > 0) as ComputedRef<boolean>
|
||||
|
||||
/** 待发送图片数量 */
|
||||
const count = computed(() => images.value.length) as ComputedRef<number>
|
||||
|
||||
/**
|
||||
* 生成唯一 ID(基于时间戳 + 随机数)
|
||||
* @returns 唯一 ID 字符串
|
||||
*/
|
||||
function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加图片到待发送队列
|
||||
*
|
||||
* 做什么:为 Blob 生成预览 URL,创建 PendingImage 对象,push 到队列
|
||||
* 为什么:截图/拍照完成后不直接发送,而是先加入队列,让用户可以组合多张图片+文字一起发送
|
||||
*
|
||||
* @param blob - 图片 Blob 数据(用于上传)
|
||||
* @param source - 图片来源:'screenshot' 或 'camera'
|
||||
*/
|
||||
function addImage(blob: Blob, source: 'screenshot' | 'camera'): void {
|
||||
const previewUrl = URL.createObjectURL(blob)
|
||||
const item: PendingImage = {
|
||||
id: generateId(),
|
||||
blob,
|
||||
previewUrl,
|
||||
source,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
images.value.push(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定的待发送图片
|
||||
*
|
||||
* 做什么:根据 ID 找到图片项,revokeObjectURL 释放内存,从数组移除
|
||||
* 为什么:用户点 × 删除某张图片时,必须释放预览 URL 避免内存泄漏
|
||||
*
|
||||
* @param id - 图片项的唯一 ID
|
||||
*/
|
||||
function removeImage(id: string): void {
|
||||
const index = images.value.findIndex((img) => img.id === id)
|
||||
if (index !== -1) {
|
||||
// 释放 ObjectURL 内存
|
||||
URL.revokeObjectURL(images.value[index].previewUrl)
|
||||
images.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有待发送图片
|
||||
*
|
||||
* 做什么:遍历所有图片项,revokeObjectURL 释放内存,清空数组
|
||||
* 为什么:发送完成后或 ReplyBox 卸载时,必须释放所有预览 URL 避免内存泄漏
|
||||
*/
|
||||
function clearAll(): void {
|
||||
// 释放所有 ObjectURL
|
||||
images.value.forEach((img) => {
|
||||
URL.revokeObjectURL(img.previewUrl)
|
||||
})
|
||||
images.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
/** 待发送图片列表 */
|
||||
images,
|
||||
/** 是否有待发送图片 */
|
||||
hasImages,
|
||||
/** 待发送图片数量 */
|
||||
count,
|
||||
/** 添加图片 */
|
||||
addImage,
|
||||
/** 删除指定图片 */
|
||||
removeImage,
|
||||
/** 清空所有图片 */
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,10 @@
|
||||
// 然后 Ctrl+V 粘贴到输入框(已有 handlePaste 实现)
|
||||
//
|
||||
// 使用方式:
|
||||
// const { captureScreen, isCapturing, isScreenCaptureSupported, captureFallback }
|
||||
// const { captureScreen, captureScreenAsCanvas, isCapturing, isScreenCaptureSupported, captureFallback }
|
||||
// = useScreenCapture()
|
||||
// - captureScreen():尝试方案A,失败时不自动降级(由调用方决定是否提示)
|
||||
// - captureScreen():尝试方案A,返回 PNG Blob(向后兼容)
|
||||
// - captureScreenAsCanvas():尝试方案A,返回 HTMLCanvasElement(供 ScreenCapture.vue 编辑底图)
|
||||
// - isScreenCaptureSupported():检测是否支持方案A
|
||||
// - 调用方在 captureScreen() 返回 null 时,自行提示用户用系统截图
|
||||
// =============================================================================
|
||||
@@ -58,10 +59,13 @@ export async function captureScreen(): Promise<Blob | null> {
|
||||
// ------------------------------------------------------------------
|
||||
// 1. 请求屏幕共享权限(浏览器弹出选择器:屏幕/窗口/标签页)
|
||||
// ------------------------------------------------------------------
|
||||
// video: true — 只请求视频流(不需要音频)
|
||||
// @ts-ignore — Chrome 支持 preferLabel 等非标准选项
|
||||
// video.displaySurface: 'monitor' — 只允许选择屏幕/显示器,
|
||||
// 不弹出标签页/窗口选择器(Edge/Chrome 会简化选择器界面,只保留屏幕选项)
|
||||
// @ts-ignore — displaySurface 属于 MediaTrackConstraints,部分 TS 版本类型定义不完整
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
video: {
|
||||
displaySurface: 'monitor',
|
||||
} as MediaTrackConstraints,
|
||||
audio: false,
|
||||
})
|
||||
|
||||
@@ -70,10 +74,22 @@ export async function captureScreen(): Promise<Blob | null> {
|
||||
// ------------------------------------------------------------------
|
||||
const track = stream.getVideoTracks()[0]
|
||||
if (!track) {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
console.warn('[useScreenCapture] 没有获取到视频轨道')
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- 检查用户是否选择了"屏幕"而非"窗口"/"标签页" ----
|
||||
// displaySurface: 'monitor' 约束只是首选提示,浏览器仍允许用户选窗口/标签页
|
||||
// 如果选了非屏幕,截取的画面只有窗口大小,无法选区全屏内容
|
||||
const settings = track.getSettings()
|
||||
if (settings.displaySurface && settings.displaySurface !== 'monitor') {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
console.warn('[useScreenCapture] 用户选择了非屏幕源:', settings.displaySurface)
|
||||
// 返回 null,调用方会提示用户重新选择
|
||||
return null
|
||||
}
|
||||
|
||||
// 使用 ImageCapture API(Chrome 59+)获取高质量截图
|
||||
// 如果不支持 ImageCapture,则回退到 Canvas 绘制方案
|
||||
let blob: Blob | null = null
|
||||
@@ -154,6 +170,123 @@ export async function captureScreen(): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截取屏幕/窗口/标签页,返回 Canvas(供截图编辑器作为底图使用)
|
||||
*
|
||||
* 做什么:与 captureScreen() 逻辑类似,但不做 toBlob 转换,
|
||||
* 直接返回捕获了屏幕画面的 HTMLCanvasElement。
|
||||
* 为什么返回 Canvas 而非 Blob:
|
||||
* - ScreenCapture.vue 需要原始 Canvas 作为编辑底图,直接 drawImage 渲染
|
||||
* - 避免 Blob→Image 的异步转换,提升加载速度
|
||||
* - 最终确认时再从选区 Canvas 导出 Blob
|
||||
*
|
||||
* @returns 捕获了屏幕画面的 Canvas 元素,失败返回 null
|
||||
*/
|
||||
export async function captureScreenAsCanvas(): Promise<HTMLCanvasElement | null> {
|
||||
// 不支持 Screen Capture API → 返回 null
|
||||
if (!isScreenCaptureSupported()) {
|
||||
console.warn('[useScreenCapture] 浏览器不支持 Screen Capture API')
|
||||
return null
|
||||
}
|
||||
|
||||
isCapturing.value = true
|
||||
|
||||
try {
|
||||
// 1. 请求屏幕共享权限
|
||||
// displaySurface: 'monitor' — 只允许选择屏幕/显示器,
|
||||
// 不弹出标签页/窗口选择器(Edge/Chrome 会简化选择器界面,只保留屏幕选项)
|
||||
// @ts-ignore — displaySurface 属于 MediaTrackConstraints,部分 TS 版本类型定义不完整
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
displaySurface: 'monitor',
|
||||
} as MediaTrackConstraints,
|
||||
audio: false,
|
||||
})
|
||||
|
||||
const track = stream.getVideoTracks()[0]
|
||||
if (!track) {
|
||||
console.warn('[useScreenCapture] 没有获取到视频轨道')
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- 检查用户是否选择了"屏幕"而非"窗口"/"标签页" ----
|
||||
// displaySurface: 'monitor' 约束只是首选提示,浏览器仍允许用户选窗口/标签页
|
||||
// 如果选了非屏幕,截取的画面只有窗口大小,无法选区全屏内容
|
||||
const settings = track.getSettings()
|
||||
if (settings.displaySurface && settings.displaySurface !== 'monitor') {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
console.warn('[useScreenCapture] 用户选择了非屏幕源:', settings.displaySurface)
|
||||
// 返回 null,调用方会提示用户重新选择
|
||||
return null
|
||||
}
|
||||
|
||||
let canvas: HTMLCanvasElement | null = null
|
||||
|
||||
// 优先使用 ImageCapture API 获取高质量截图
|
||||
if ('ImageCapture' in window) {
|
||||
try {
|
||||
const imageCapture = new ImageCapture(track)
|
||||
// @ts-ignore — grabFrame 是 ImageCapture 标准方法
|
||||
const bitmap = await imageCapture.grabFrame()
|
||||
|
||||
canvas = document.createElement('canvas')
|
||||
canvas.width = bitmap.width
|
||||
canvas.height = bitmap.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(bitmap, 0, 0)
|
||||
}
|
||||
bitmap.close()
|
||||
} catch (imageCaptureError) {
|
||||
console.warn('[useScreenCapture] ImageCapture 失败,回退到 Canvas 方案:', imageCaptureError)
|
||||
}
|
||||
}
|
||||
|
||||
// ImageCapture 失败或不可用 → Canvas 方案
|
||||
if (!canvas) {
|
||||
const video = document.createElement('video')
|
||||
video.srcObject = stream
|
||||
video.muted = true
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadedmetadata = () => resolve()
|
||||
video.onerror = () => reject(new Error('视频加载失败'))
|
||||
video.play()
|
||||
})
|
||||
|
||||
// 短暂等待确保有画面帧可用
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
|
||||
canvas = document.createElement('canvas')
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0)
|
||||
}
|
||||
|
||||
video.pause()
|
||||
video.srcObject = null
|
||||
}
|
||||
|
||||
// 2. 停止屏幕共享
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
|
||||
return canvas
|
||||
} catch (error: any) {
|
||||
// 用户取消屏幕选择 → 静默处理
|
||||
if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') {
|
||||
console.log('[useScreenCapture] 用户取消了屏幕选择')
|
||||
return null
|
||||
}
|
||||
console.error('[useScreenCapture] 截图失败:', error)
|
||||
return null
|
||||
} finally {
|
||||
isCapturing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合函数返回值
|
||||
*/
|
||||
@@ -161,8 +294,10 @@ export function useScreenCapture() {
|
||||
return {
|
||||
/** 是否正在截图 */
|
||||
isCapturing,
|
||||
/** 截取屏幕 */
|
||||
/** 截取屏幕(返回 Blob,向后兼容) */
|
||||
captureScreen,
|
||||
/** 截取屏幕(返回 Canvas,供编辑器使用) */
|
||||
captureScreenAsCanvas,
|
||||
/** 检测浏览器支持 */
|
||||
isScreenCaptureSupported,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — Web Speech API 语音识别 composable(坐席端)
|
||||
// =============================================================================
|
||||
// 说明:封装浏览器 Web Speech API 的实时语音转文字功能,提供:
|
||||
// 1. start():开始语音识别(实时转写)
|
||||
// 2. stop():停止语音识别
|
||||
// 3. reset():重置状态(清空已识别的文字)
|
||||
//
|
||||
// 交互流程:
|
||||
// 用户点击语音按钮 → start() → 实时转写显示在 textarea 中...
|
||||
// 用户再次点击 → stop() → 最终文字填入输入框 → reset()
|
||||
//
|
||||
// 关键陷阱(已在代码中处理):
|
||||
// 1. resultIndex 遍历:onresult 必须从 event.resultIndex 开始,否则重复处理
|
||||
// 2. onend 自动重启:用户未主动停止时(如超时停止),自动重启识别
|
||||
// 3. 实例不能重复 start:需等待 onend 后再重启,否则抛错
|
||||
// 4. 错误类型处理:not-allowed/no-speech/network/aborted 各有不同处理方式
|
||||
//
|
||||
// 限制:
|
||||
// - 仅支持 Chrome/Edge(webkitSpeechRecognition)
|
||||
// - 不支持 Firefox(坐席端限定 Chrome/Edge)
|
||||
// =============================================================================
|
||||
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* composable 返回的响应式状态
|
||||
*/
|
||||
interface SpeechRecognitionState {
|
||||
/** 是否正在聆听(识别中) */
|
||||
isListening: boolean
|
||||
/** 临时识别文字(还在变化中的中间结果,尚未确认) */
|
||||
interimText: string
|
||||
/** 最终识别文字(已确认的结果,不会再变化) */
|
||||
finalText: string
|
||||
/** 最近一次错误信息(null 表示无错误) */
|
||||
error: string | null
|
||||
/** 浏览器是否支持 Web Speech API */
|
||||
isSupported: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* composable 返回的完整接口
|
||||
*/
|
||||
interface UseSpeechRecognitionReturn {
|
||||
/** 响应式状态对象 */
|
||||
state: SpeechRecognitionState
|
||||
/** 开始语音识别 */
|
||||
start: () => void
|
||||
/** 停止语音识别 */
|
||||
stop: () => void
|
||||
/** 重置状态(清空 finalText 和 interimText) */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composable 实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Web Speech API 语音识别 composable
|
||||
*
|
||||
* 做什么:封装浏览器原生的语音识别功能,提供响应式状态和简单 API
|
||||
*
|
||||
* 为什么用 composable 模式:
|
||||
* - 遵循 Vue3 组合式 API,与项目其他 composable 风格一致
|
||||
* - 将复杂的 Web Speech API 逻辑封装在独立文件中,组件只关心 UI
|
||||
* - 状态是响应式的,组件可直接绑定到模板
|
||||
*
|
||||
* 使用示例:
|
||||
* ```typescript
|
||||
* const { state, start, stop, reset } = useSpeechRecognition()
|
||||
*
|
||||
* // 点击语音按钮
|
||||
* function handleVoiceToggle() {
|
||||
* if (state.isListening) {
|
||||
* stop()
|
||||
* // state.finalText 包含全部识别结果
|
||||
* inputText.value += state.finalText
|
||||
* reset()
|
||||
* } else {
|
||||
* start()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSpeechRecognition(): UseSpeechRecognitionReturn {
|
||||
// 响应式状态:组件可绑定到模板
|
||||
const state = reactive<SpeechRecognitionState>({
|
||||
isListening: false,
|
||||
interimText: '',
|
||||
finalText: '',
|
||||
error: null,
|
||||
// 检测浏览器是否支持 Web Speech API
|
||||
// Chrome/Edge 使用 webkitSpeechRecognition,部分浏览器使用标准 SpeechRecognition
|
||||
isSupported:
|
||||
typeof window !== 'undefined' &&
|
||||
(!!window.SpeechRecognition || !!window.webkitSpeechRecognition),
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 内部变量(非响应式,组件不需要感知)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** SpeechRecognition 实例(通过构造器创建) */
|
||||
let recognition: SpeechRecognition | null = null
|
||||
|
||||
/**
|
||||
* 标记用户是否主动要求继续聆听
|
||||
*
|
||||
* 做什么:区分"用户主动停止"和"浏览器自动停止(如超时)"
|
||||
*
|
||||
* 为什么需要这个标记:
|
||||
* Web Speech API 会在一段时间无语音后自动触发 onend。
|
||||
* 如果用户没有主动点停止,我们应该自动重启识别(保持持续聆听)。
|
||||
* 如果用户主动点了停止,shouldKeepListening 设为 false,onend 中不再重启。
|
||||
*/
|
||||
let shouldKeepListening = false
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 内部方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 创建 SpeechRecognition 实例并绑定事件回调
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 获取构造器(Chrome 用 webkit 前缀)
|
||||
* 2. 创建实例
|
||||
* 3. 设置识别参数(语言、连续模式、中间结果等)
|
||||
* 4. 绑定 onresult / onerror / onend 回调
|
||||
*
|
||||
* 识别参数说明:
|
||||
* - lang = 'zh-CN':中文识别
|
||||
* - continuous = true:持续识别(不会说一句就停)
|
||||
* - interimResults = true:返回中间结果(实时显示)
|
||||
* - maxAlternatives = 1:只返回最佳结果(不需要多个候选项)
|
||||
*/
|
||||
function createRecognition(): void {
|
||||
// 获取构造器(Chrome/Edge 用 webkit 前缀,标准浏览器用无前缀)
|
||||
const SpeechRecognitionClass =
|
||||
window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
|
||||
if (!SpeechRecognitionClass) {
|
||||
state.error = '当前浏览器不支持语音识别,请使用 Chrome 或 Edge'
|
||||
return
|
||||
}
|
||||
|
||||
// 创建实例
|
||||
recognition = new SpeechRecognitionClass()
|
||||
|
||||
// 设置识别参数
|
||||
recognition.lang = 'zh-CN' // 中文识别
|
||||
recognition.continuous = true // 持续识别模式
|
||||
recognition.interimResults = true // 返回中间结果(实时转写)
|
||||
recognition.maxAlternatives = 1 // 只返回最佳结果
|
||||
|
||||
// 绑定 onresult:处理识别结果
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let interim = ''
|
||||
|
||||
// 【关键】从 event.resultIndex 开始遍历,不是从 0
|
||||
// 为什么:event.results 包含从开始到现在的所有结果,
|
||||
// 但之前的结果已经处理过了,只有 resultIndex 之后的是新增的。
|
||||
// 如果从 0 开始遍历,会导致 finalText 重复累加!
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
const transcript = result[0].transcript
|
||||
|
||||
if (result.isFinal) {
|
||||
// 最终结果(已确认):追加到 finalText
|
||||
state.finalText += transcript
|
||||
} else {
|
||||
// 临时结果(还在变化):收集到 interim
|
||||
interim += transcript
|
||||
}
|
||||
}
|
||||
|
||||
// 更新临时文字(每次 onresult 都会覆盖上一次的临时结果)
|
||||
state.interimText = interim
|
||||
}
|
||||
|
||||
// 绑定 onerror:处理识别错误
|
||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
switch (event.error) {
|
||||
case 'not-allowed':
|
||||
// 麦克风权限被拒绝(用户在浏览器弹窗中点了"拒绝")
|
||||
state.error = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风'
|
||||
shouldKeepListening = false // 权限被拒,不再自动重启
|
||||
state.isListening = false
|
||||
break
|
||||
|
||||
case 'service-not-allowed':
|
||||
// 语音识别服务不可用(可能是浏览器策略限制)
|
||||
state.error = '语音识别服务不可用,请检查浏览器设置'
|
||||
shouldKeepListening = false
|
||||
state.isListening = false
|
||||
break
|
||||
|
||||
case 'network':
|
||||
// 网络错误(Web Speech API 需要联网,因为识别在云端进行)
|
||||
state.error = '网络错误,语音识别服务不可用,请检查网络连接'
|
||||
shouldKeepListening = false
|
||||
state.isListening = false
|
||||
break
|
||||
|
||||
case 'no-speech':
|
||||
// 没有检测到语音输入(用户长时间没说话)
|
||||
// 不报错,onend 会自动处理重启
|
||||
break
|
||||
|
||||
case 'aborted':
|
||||
// 识别被中止(主动调用 abort/stop 导致)
|
||||
// 静默处理,不显示错误
|
||||
break
|
||||
|
||||
case 'audio-capture':
|
||||
// 音频采集失败(可能没有麦克风设备)
|
||||
state.error = '音频采集失败,请检查麦克风设备'
|
||||
shouldKeepListening = false
|
||||
state.isListening = false
|
||||
break
|
||||
|
||||
default:
|
||||
// 其他未知错误
|
||||
state.error = `语音识别错误: ${event.error}`
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定 onend:识别结束回调
|
||||
recognition.onend = () => {
|
||||
// 清除临时文字(onend 表示一段识别结束了)
|
||||
state.interimText = ''
|
||||
|
||||
// 判断是否需要自动重启
|
||||
if (shouldKeepListening) {
|
||||
// 用户未主动停止(可能是超时或 no-speech 导致的自动结束)
|
||||
// 自动重启识别,保持持续聆听
|
||||
try {
|
||||
recognition!.start()
|
||||
} catch {
|
||||
// start() 可能失败(实例还在 stopping 状态)
|
||||
// 延迟 100ms 后重试一次
|
||||
setTimeout(() => {
|
||||
if (shouldKeepListening && recognition) {
|
||||
try {
|
||||
recognition.start()
|
||||
} catch (retryErr) {
|
||||
console.error('[SpeechRecognition] 自动重启失败:', retryErr)
|
||||
state.isListening = false
|
||||
state.error = '语音识别自动恢复失败,请重新点击语音按钮'
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
} else {
|
||||
// 用户已主动停止,不再重启
|
||||
state.isListening = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 公开方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 开始语音识别
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 检查是否已支持、是否已在聆听
|
||||
* 2. 清空之前的状态(error, finalText, interimText)
|
||||
* 3. 创建(或复用)SpeechRecognition 实例
|
||||
* 4. 调用 start() 开始识别
|
||||
*
|
||||
* 陷阱处理:
|
||||
* - 不能重复 start():如果实例正在运行,直接返回
|
||||
* - start() 可能抛错(上一次还没完全停止):延迟 200ms 重试
|
||||
*/
|
||||
function start(): void {
|
||||
// 防止重复开始
|
||||
if (state.isListening) {
|
||||
return
|
||||
}
|
||||
|
||||
// 浏览器不支持则不操作
|
||||
if (!state.isSupported) {
|
||||
state.error = '当前浏览器不支持语音识别,请使用 Chrome 或 Edge'
|
||||
return
|
||||
}
|
||||
|
||||
// 清空状态,准备新一轮识别
|
||||
state.error = null
|
||||
state.finalText = ''
|
||||
state.interimText = ''
|
||||
// 标记用户要求持续聆听(onend 中检查此标记决定是否重启)
|
||||
shouldKeepListening = true
|
||||
|
||||
// 创建实例(如果还没创建过)
|
||||
if (!recognition) {
|
||||
createRecognition()
|
||||
}
|
||||
|
||||
if (!recognition) {
|
||||
state.error = '语音识别器创建失败'
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 start() 开始识别
|
||||
try {
|
||||
recognition.start()
|
||||
state.isListening = true
|
||||
} catch {
|
||||
// start() 失败:可能上一次识别还没完全停止(onend 未触发)
|
||||
// 延迟 200ms 后重试一次
|
||||
setTimeout(() => {
|
||||
if (shouldKeepListening && recognition && !state.isListening) {
|
||||
try {
|
||||
recognition.start()
|
||||
state.isListening = true
|
||||
} catch (retryErr) {
|
||||
console.error('[SpeechRecognition] 启动失败:', retryErr)
|
||||
state.error = '语音识别启动失败,请重试'
|
||||
shouldKeepListening = false
|
||||
}
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止语音识别
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 标记用户已主动停止(shouldKeepListening = false)
|
||||
* 2. 调用 recognition.stop() 停止识别
|
||||
* 3. 清除临时文字
|
||||
*
|
||||
* 注意:
|
||||
* - stop() 后 onend 会异步触发,isListening 会在 onend 中设为 false
|
||||
* - 但为了 UI 立即响应,这里也同步设置 isListening = false
|
||||
* - finalText 保留(组件需要读取最终结果)
|
||||
*/
|
||||
function stop(): void {
|
||||
// 标记用户主动停止(onend 中不再自动重启)
|
||||
shouldKeepListening = false
|
||||
|
||||
if (recognition) {
|
||||
try {
|
||||
recognition.stop()
|
||||
} catch (err) {
|
||||
// stop() 失败通常可以忽略(可能实例已经停止了)
|
||||
console.warn('[SpeechRecognition] stop 警告:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 同步更新状态(UI 立即响应)
|
||||
state.isListening = false
|
||||
// 清除临时文字(最终文字保留,供组件读取)
|
||||
state.interimText = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*
|
||||
* 做什么:清空 finalText 和 interimText,清除 error
|
||||
*
|
||||
* 什么时候调用:
|
||||
* 组件在读取完 finalText 并填入输入框后调用,为下一次识别准备干净状态
|
||||
*/
|
||||
function reset(): void {
|
||||
state.finalText = ''
|
||||
state.interimText = ''
|
||||
state.error = null
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
start,
|
||||
stop,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import router from './router'
|
||||
// Pinia 状态管理
|
||||
import { createPinia } from 'pinia'
|
||||
// ElementPlus 组件库
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessage, ElNotification, ElMessageBox, ElLoading } from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
// ElementPlus 中文语言包
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
@@ -48,6 +48,20 @@ app.use(router)
|
||||
// ElementPlus: UI 组件库(表格、表单、对话框等)+ 中文语言包
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 防御性修复:显式设置 ElMessage/ElNotification/ElMessageBox/ElLoading 的 _context
|
||||
// --------------------------------------------------------------------------
|
||||
// 问题:Vue 3.5.x + Element Plus 2.14.x 在某些构建环境下,
|
||||
// app.use(ElementPlus) 的 install 链可能未正确传递 app._context 到 message._context,
|
||||
// 导致 ElMessage.info() 调用时 vnode.appContext 为 null,
|
||||
// 进而 createComponentInstance 中 Object.create(appContext.provides) 抛出
|
||||
// "TypeError: Object prototype may only be an Object or null: undefined"
|
||||
// 修复:在 app.use(ElementPlus) 之后,手动确保所有服务函数的 _context 已正确设置
|
||||
;(ElMessage as any)._context = app._context
|
||||
;(ElNotification as any)._context = app._context
|
||||
;(ElMessageBox as any)._context = app._context
|
||||
;(ElLoading as any)._context = app._context
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 挂载应用到 DOM
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import type { Conversation, ConversationListData } from '../api/conversation'
|
||||
import type { Message, MessageListData } from '../api/message'
|
||||
import type { TodoItemData, TodoItemListData } from '../api/todo'
|
||||
import type { Agent, AgentListData, LoginData } from '../api/agent'
|
||||
import type { DraftResult, SummaryResult, TagsResult } from '../api/wingman'
|
||||
|
||||
@@ -592,132 +591,9 @@ export function getAgentStats() {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. 待办列表 mock — 5条,覆盖 3 种类型 + 3 种优先级
|
||||
// 4. 待办列表 mock — 已移除(v1.0 去mock改造,数据改为真实数据源)
|
||||
// =========================================================================
|
||||
|
||||
export const mockTodos: TodoItemData[] = [
|
||||
{
|
||||
id: 'todo-001',
|
||||
type: 'ticket',
|
||||
title: 'CEO办公室 — 投屏设备故障',
|
||||
priority: 'urgent',
|
||||
description: {
|
||||
ticket_id: 'TKT-20260606001',
|
||||
reporter: '王莉',
|
||||
reporter_title: '张总秘书',
|
||||
reporter_dept: 'CEO办公室',
|
||||
issue_type: '硬件故障',
|
||||
detail: 'CEO办公室会议室的投屏设备无法连接,下午2点有重要客户演示。已尝试重启设备3次,仍无法投屏。急需IT人员到现场处理。',
|
||||
sla_deadline: new Date(now.getTime() + 12 * 60000).toISOString(),
|
||||
location: '12楼 CEO会议室',
|
||||
},
|
||||
status: 'pending',
|
||||
assigned_agent_id: null,
|
||||
corp_id: 'corp-001',
|
||||
created_at: min(5).toString(),
|
||||
updated_at: min(5).toString(),
|
||||
},
|
||||
{
|
||||
id: 'todo-002',
|
||||
type: 'ticket',
|
||||
title: '财务部 — 税控系统无法登录',
|
||||
priority: 'urgent',
|
||||
description: {
|
||||
ticket_id: 'TKT-20260606002',
|
||||
reporter: '王磊',
|
||||
reporter_title: '财务主管',
|
||||
reporter_dept: '财务部',
|
||||
issue_type: '系统登录',
|
||||
detail: '财务部税控系统(eTax)自今早9点起无法登录,提示"证书验证失败"。今天是报税截止日,下午3点前必须完成申报,否则会产生滞纳金。已联系税局客服,确认非税局端问题。',
|
||||
sla_deadline: new Date(now.getTime() + 180 * 60000).toISOString(),
|
||||
affected_users: 8,
|
||||
},
|
||||
status: 'pending',
|
||||
assigned_agent_id: null,
|
||||
corp_id: 'corp-001',
|
||||
created_at: min(12).toString(),
|
||||
updated_at: min(12).toString(),
|
||||
},
|
||||
{
|
||||
id: 'todo-003',
|
||||
type: 'approval',
|
||||
title: 'IT采购申请 — 研发部笔记本电脑',
|
||||
priority: 'high',
|
||||
description: {
|
||||
approval_id: 'APP-20260606003',
|
||||
applicant: '李研发经理',
|
||||
applicant_dept: '研发一部',
|
||||
approval_type: 'IT设备采购',
|
||||
budget: 86400,
|
||||
budget_currency: 'CNY',
|
||||
items: [
|
||||
{ name: 'MacBook Pro 14" M4 Pro', qty: 6, unit_price: 12999, subtotal: 77994 },
|
||||
{ name: 'USB-C 扩展坞', qty: 6, unit_price: 899, subtotal: 5394 },
|
||||
{ name: '内胆包', qty: 6, unit_price: 169, subtotal: 1014 },
|
||||
],
|
||||
reason: '新入职 Python 后端团队标配开发机,替代旧的 ThinkPad T480(已服役4年)',
|
||||
attachments: ['采购清单_20260606.xlsx', '报价单_Apple授权经销商.pdf'],
|
||||
approver_chain: ['直属上级', 'IT总监', '财务审批'],
|
||||
},
|
||||
status: 'pending',
|
||||
assigned_agent_id: 'agent-1',
|
||||
corp_id: 'corp-001',
|
||||
created_at: min(28).toString(),
|
||||
updated_at: min(28).toString(),
|
||||
},
|
||||
{
|
||||
id: 'todo-004',
|
||||
type: 'device',
|
||||
title: '会议室A — 投影仪离线告警',
|
||||
priority: 'high',
|
||||
description: {
|
||||
device_id: 'DEV-20260606007',
|
||||
device_name: '会议室A-投影仪',
|
||||
model: 'Epson CB-W42',
|
||||
serial_number: 'X3KP-78901234',
|
||||
location: '6楼 会议室A',
|
||||
ip: '192.168.10.88',
|
||||
mac: '00:1B:44:11:3A:B7',
|
||||
status: 'offline',
|
||||
last_online: hour(2).toString(),
|
||||
alert_count: 3,
|
||||
alert_message: '设备连续3次 ping 不通,疑似网络或电源问题',
|
||||
},
|
||||
status: 'pending',
|
||||
assigned_agent_id: 'agent-1',
|
||||
corp_id: 'corp-001',
|
||||
created_at: min(45).toString(),
|
||||
updated_at: min(30).toString(),
|
||||
},
|
||||
{
|
||||
id: 'todo-005',
|
||||
type: 'approval',
|
||||
title: '新员工入职 — 设备申领审批',
|
||||
priority: 'normal',
|
||||
description: {
|
||||
approval_id: 'APP-20260606008',
|
||||
applicant: '赵敏',
|
||||
applicant_dept: '人力资源部',
|
||||
approval_type: '新员工设备申领',
|
||||
budget: 15000,
|
||||
budget_currency: 'CNY',
|
||||
items: [
|
||||
{ name: 'ThinkPad X1 Carbon Gen 12', qty: 1, unit_price: 10999, subtotal: 10999 },
|
||||
{ name: 'Dell 27" 4K 显示器 U2723QE', qty: 1, unit_price: 3599, subtotal: 3599 },
|
||||
{ name: '罗技 MX Keys 键盘 + MX Master 3S 鼠标', qty: 1, unit_price: 1299, subtotal: 1299 },
|
||||
],
|
||||
reason: '下周一(6月9日)新入职市场总监,需提前准备办公设备',
|
||||
new_hire_name: '林峰',
|
||||
new_hire_position: '市场总监',
|
||||
onboard_date: '2026-06-09',
|
||||
},
|
||||
status: 'pending',
|
||||
assigned_agent_id: 'agent-1',
|
||||
corp_id: 'corp-001',
|
||||
created_at: min(35).toString(),
|
||||
updated_at: min(35).toString(),
|
||||
},
|
||||
]
|
||||
|
||||
// =========================================================================
|
||||
// 5. 用户画像 mock — 张伟完整信息
|
||||
@@ -835,11 +711,6 @@ export const mockMessageListData: MessageListData = {
|
||||
has_more: false,
|
||||
}
|
||||
|
||||
export const mockTodoListData: TodoItemListData = {
|
||||
items: mockTodos,
|
||||
total: mockTodos.length,
|
||||
}
|
||||
|
||||
export const mockAgentListData: AgentListData = {
|
||||
items: mockAgentList,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ const routes = [
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { title: '坐席登录', requiresAuth: false },
|
||||
},
|
||||
// 账号绑定页(互联企业用户)
|
||||
{
|
||||
path: '/bind',
|
||||
name: 'Bind',
|
||||
component: () => import('@/views/Bind.vue'),
|
||||
meta: { title: '账号绑定', requiresAuth: false },
|
||||
},
|
||||
{
|
||||
// 坐席工作台主页面
|
||||
path: '/workspace',
|
||||
@@ -90,7 +97,7 @@ const router = createRouter({
|
||||
router.beforeEach((to, _from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - IT智能服务台`
|
||||
document.title = `${to.meta.title} - 智能IT服务台`
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@@ -112,7 +119,8 @@ router.beforeEach((to, _from, next) => {
|
||||
|
||||
// v0.5.4 BC/DR 应急页(agent-preview)不需 Portal token
|
||||
// 它的鉴权由 /emergency 入口的企微 JS-SDK 完成
|
||||
if (to.name === 'AgentPreview') {
|
||||
// 账号绑定页不需要认证
|
||||
if (to.name === 'AgentPreview' || to.name === 'Bind') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
CreateSessionPayload,
|
||||
ApprovalPayload,
|
||||
TakeoverPayload,
|
||||
InformationItem,
|
||||
PausedSession,
|
||||
} from '@/api/automation'
|
||||
import {
|
||||
createAutomationSession,
|
||||
@@ -18,6 +20,10 @@ import {
|
||||
getAutomationSession,
|
||||
approveAutomationSession,
|
||||
takeoverAutomationSession,
|
||||
listPausedSessions,
|
||||
agentResumeSession,
|
||||
agentCloseSession,
|
||||
getInfoItems,
|
||||
} from '@/api/automation'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -45,6 +51,10 @@ export const useAutomationStore = defineStore('automation', () => {
|
||||
const ws = ref<WebSocket | null>(null)
|
||||
const wsSessionId = ref<string | null>(null)
|
||||
const wsConnected = ref(false)
|
||||
/** 暂停会话列表(复杂场景重构) */
|
||||
const pausedSessions = ref<PausedSession[]>([])
|
||||
/** 当前会话信息项列表(复杂场景重构) */
|
||||
const infoItems = ref<InformationItem[]>([])
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 计算属性
|
||||
@@ -91,6 +101,25 @@ export const useAutomationStore = defineStore('automation', () => {
|
||||
currentSession.value = await takeoverAutomationSession(sessionId, payload)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 复杂场景重构:暂停会话管理 / 信息项
|
||||
// ------------------------------------------------------------------------
|
||||
async function fetchPausedSessions(employeeId: string): Promise<void> {
|
||||
pausedSessions.value = await listPausedSessions(employeeId)
|
||||
}
|
||||
|
||||
async function agentResume(sessionId: string, note?: string): Promise<void> {
|
||||
currentSession.value = await agentResumeSession(sessionId, { note })
|
||||
}
|
||||
|
||||
async function agentClose(sessionId: string, note?: string): Promise<void> {
|
||||
currentSession.value = await agentCloseSession(sessionId, { note })
|
||||
}
|
||||
|
||||
async function fetchInfoItems(sessionId: string): Promise<void> {
|
||||
infoItems.value = await getInfoItems(sessionId)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// WebSocket
|
||||
// ------------------------------------------------------------------------
|
||||
@@ -146,6 +175,38 @@ export const useAutomationStore = defineStore('automation', () => {
|
||||
fetchSession(wsSessionId.value)
|
||||
}
|
||||
break
|
||||
// --- 复杂场景重构:新增 WS 事件 ---
|
||||
case 'automation.paused':
|
||||
// 会话暂停:刷新详情
|
||||
if (wsSessionId.value) {
|
||||
fetchSession(wsSessionId.value)
|
||||
}
|
||||
break
|
||||
case 'automation.resumed':
|
||||
// 会话恢复:刷新详情和信息项
|
||||
if (wsSessionId.value) {
|
||||
fetchSession(wsSessionId.value)
|
||||
fetchInfoItems(wsSessionId.value)
|
||||
}
|
||||
break
|
||||
case 'automation.info_corrected':
|
||||
// 信息更正:刷新信息项
|
||||
if (wsSessionId.value) {
|
||||
fetchInfoItems(wsSessionId.value)
|
||||
}
|
||||
break
|
||||
case 'automation.info_supplemented':
|
||||
// 信息补充:刷新信息项
|
||||
if (wsSessionId.value) {
|
||||
fetchInfoItems(wsSessionId.value)
|
||||
}
|
||||
break
|
||||
case 'automation.timeout_closed':
|
||||
// 超时关闭:刷新详情
|
||||
if (wsSessionId.value) {
|
||||
fetchSession(wsSessionId.value)
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -157,11 +218,17 @@ export const useAutomationStore = defineStore('automation', () => {
|
||||
loading,
|
||||
wsConnected,
|
||||
pendingSessions,
|
||||
pausedSessions,
|
||||
infoItems,
|
||||
fetchSessions,
|
||||
fetchSession,
|
||||
createSession,
|
||||
approve,
|
||||
takeover,
|
||||
fetchPausedSessions,
|
||||
agentResume,
|
||||
agentClose,
|
||||
fetchInfoItems,
|
||||
connectWs,
|
||||
disconnectWs,
|
||||
}
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
// =============================================================================
|
||||
// 说明:管理坐席工作台的待办事项列表、当前选中的待办、加载状态
|
||||
// 核心功能:
|
||||
// 1. 获取待办列表
|
||||
// 1. 获取待办列表(支持按类型筛选)
|
||||
// 2. 选中待办事项(联动 conversationStore.workspaceView = 'task')
|
||||
// 3. 更新待办状态
|
||||
// 3. 手动刷新待办列表(跳过缓存)
|
||||
// 4. 更新待办状态
|
||||
// 数据来源:后端聚合企微审批 + ITSM 工单,无 mock fallback
|
||||
// =============================================================================
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TodoItemData } from '@/api/todo'
|
||||
import { getTodoItems, updateTodoStatus } from '@/api/todo'
|
||||
import { mockTodoListData } from '@/mock/data'
|
||||
import { getTodoItems, refreshTodoItems, updateTodoStatus } from '@/api/todo'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Store 定义
|
||||
@@ -31,6 +32,9 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
/** 是否正在加载 */
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
/** 当前筛选类型:all/approval/ticket */
|
||||
const activeType = ref<'all' | 'approval' | 'ticket'>('all')
|
||||
|
||||
// ==========================================================================
|
||||
// 计算属性
|
||||
// ==========================================================================
|
||||
@@ -56,25 +60,56 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
|
||||
/**
|
||||
* 获取待办列表
|
||||
* 调用后端 API 获取当前坐席的待办事项
|
||||
* 调用后端 API 获取当前坐席的待办事项,按 activeType 筛选
|
||||
* API 失败时显示空列表 + 错误日志,不回退 mock 数据
|
||||
*/
|
||||
async function fetchTodoList(): Promise<void> {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await getTodoItems()
|
||||
const params = activeType.value !== 'all'
|
||||
? { type: activeType.value as 'ticket' | 'approval' }
|
||||
: undefined
|
||||
const data = await getTodoItems(params)
|
||||
todoList.value = data.items
|
||||
} catch (error) {
|
||||
console.error('获取待办列表失败:', error)
|
||||
// 使用 mock 数据作为 fallback(开发/演示用)
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Mock] 使用模拟待办数据')
|
||||
todoList.value = mockTodoListData.items
|
||||
}
|
||||
// 不回退 mock 数据,直接清空列表
|
||||
todoList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新待办列表(跳过后端缓存)
|
||||
* 通过 refreshTodoItems 传递 _force=1 参数,后端跳过 Redis 缓存重新查询
|
||||
*/
|
||||
async function refreshList(): Promise<void> {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = activeType.value !== 'all'
|
||||
? { type: activeType.value as 'ticket' | 'approval' }
|
||||
: undefined
|
||||
const data = await refreshTodoItems(params)
|
||||
todoList.value = data.items
|
||||
} catch (error) {
|
||||
console.error('刷新待办列表失败:', error)
|
||||
todoList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换筛选类型并刷新列表
|
||||
*
|
||||
* @param type - 筛选类型:all/approval/ticket
|
||||
*/
|
||||
async function setActiveType(type: 'all' | 'approval' | 'ticket'): Promise<void> {
|
||||
activeType.value = type
|
||||
await fetchTodoList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中待办事项
|
||||
* 设置 currentTodoItem,并触发 workspaceView 切换为 'task'
|
||||
@@ -109,6 +144,7 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
todoList,
|
||||
currentTodoItem,
|
||||
loading,
|
||||
activeType,
|
||||
|
||||
// 计算属性
|
||||
urgentCount,
|
||||
@@ -117,6 +153,8 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
|
||||
// 方法
|
||||
fetchTodoList,
|
||||
refreshList,
|
||||
setActiveType,
|
||||
selectTodoItem,
|
||||
updateTodoItemStatus,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 截图/拍照功能共享 TypeScript 类型定义
|
||||
// =============================================================================
|
||||
// 说明:定义截图编辑器、摄像头拍照、待发送图片队列所需的类型
|
||||
// 架构文档:docs/03-技术架构/坐席端截图拍照功能-架构设计.md
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 待发送图片项
|
||||
*
|
||||
* 用于 ReplyBox 中的待发送图片队列,每张图片包含 Blob 数据和预览 URL。
|
||||
* 预览 URL 通过 URL.createObjectURL(blob) 生成,删除时需 revokeObjectURL 释放。
|
||||
*/
|
||||
export interface PendingImage {
|
||||
/** 唯一标识(基于时间戳 + 随机数生成) */
|
||||
id: string
|
||||
/** 图片 Blob 数据(用于上传到服务器) */
|
||||
blob: Blob
|
||||
/** 缩略图预览 URL(URL.createObjectURL 生成,删除时需 revokeObjectURL) */
|
||||
previewUrl: string
|
||||
/** 图片来源:截图编辑器 or 摄像头拍照 */
|
||||
source: 'screenshot' | 'camera'
|
||||
/** 创建时间戳(Date.now()) */
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图选区
|
||||
*
|
||||
* 表示用户在截图编辑器中框选的矩形区域,坐标基于 CSS 像素。
|
||||
*/
|
||||
export interface SelectionBox {
|
||||
/** 选区左上角 X 坐标 */
|
||||
x: number
|
||||
/** 选区左上角 Y 坐标 */
|
||||
y: number
|
||||
/** 选区宽度 */
|
||||
w: number
|
||||
/** 选区高度 */
|
||||
h: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图编辑工具类型
|
||||
*
|
||||
* - mosaic: 马赛克工具(区域像素化)
|
||||
* - text: 文字工具(内联 input → fillText)
|
||||
* - arrow: 箭头工具(拖拽绘制带箭头的线段)
|
||||
* - rect: 矩形工具(拖拽绘制矩形边框)
|
||||
*/
|
||||
export type ScreenshotTool = 'mosaic' | 'text' | 'arrow' | 'rect'
|
||||
|
||||
/**
|
||||
* 编辑历史记录条目
|
||||
*
|
||||
* 用于撤销功能,每次编辑操作前保存当前 Canvas 状态(ImageData 快照)。
|
||||
* 撤销时通过 putImageData 恢复到上一状态。
|
||||
*/
|
||||
export interface EditHistoryEntry {
|
||||
/** Canvas 像素快照(通过 ctx.getImageData 获取) */
|
||||
imageData: ImageData
|
||||
/** 操作时间戳(Date.now()) */
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图工具配置
|
||||
*
|
||||
* 用于工具栏按钮渲染,每个工具对应一个图标和标签。
|
||||
*/
|
||||
export interface ScreenshotToolConfig {
|
||||
/** 工具名称 */
|
||||
name: ScreenshotTool
|
||||
/** 图标(Emoji 或 SVG 字符) */
|
||||
icon: string
|
||||
/** 工具标签(tooltip 用) */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 摄像头错误类型
|
||||
*
|
||||
* 映射 getUserMedia 可能抛出的错误,提供友好的错误提示。
|
||||
*/
|
||||
export type CameraError = 'permission-denied' | 'no-device' | 'in-use' | 'unknown'
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — Web Speech API TypeScript 类型声明(坐席端)
|
||||
// =============================================================================
|
||||
// 说明:Web Speech API 是浏览器原生的语音识别接口(Chrome/Edge 内置)。
|
||||
// 本文件为 SpeechRecognition 及相关事件提供完整的类型定义,
|
||||
// 让 TypeScript 能正确识别 window.SpeechRecognition 等全局对象。
|
||||
//
|
||||
// 使用方式:
|
||||
// 1. 本文件放在 src/types/ 下,tsconfig.json 已配置 "src/**/*.d.ts" 自动加载
|
||||
// 2. 在代码中可直接使用 window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
// 3. 需要引用接口类型时,使用 import type { SpeechRecognition, SpeechRecognitionEvent } from '@/types/speech-recognition'
|
||||
//
|
||||
// 注意:
|
||||
// - Chrome 使用 webkit 前缀(window.webkitSpeechRecognition)
|
||||
// - Firefox 不支持此 API(坐席端限定 Chrome/Edge,无需兼容 Firefox)
|
||||
// =============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 语音识别结果中的单个候选项
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 语音识别候选项(一个识别结果可能包含多个候选项)
|
||||
*
|
||||
* 做什么:表示语音识别返回的一个可能结果
|
||||
*
|
||||
* 字段说明:
|
||||
* - transcript: 识别出的文字
|
||||
* - confidence: 置信度(0~1,1 表示最确信)
|
||||
*/
|
||||
export interface SpeechRecognitionAlternative {
|
||||
/** 识别出的文字内容 */
|
||||
transcript: string
|
||||
/** 识别置信度(0~1,1 表示最确信) */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 语音识别结果(一个结果可包含多个候选项)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 语音识别结果(包含一个或多个候选项)
|
||||
*
|
||||
* 做什么:表示一段语音的识别结果,可能包含多个候选项
|
||||
*
|
||||
* 字段说明:
|
||||
* - isFinal: 是否为最终结果(true=已确认,false=临时/中间结果)
|
||||
* - length: 候选项数量
|
||||
*
|
||||
* 索引访问:results[0] 返回第一个候选项(SpeechRecognitionAlternative)
|
||||
*/
|
||||
export interface SpeechRecognitionResult {
|
||||
/** 是否为最终结果(true=已确认,false=还在变化中) */
|
||||
readonly isFinal: boolean
|
||||
/** 候选项数量 */
|
||||
readonly length: number
|
||||
/** 按索引获取候选项 */
|
||||
item(index: number): SpeechRecognitionAlternative
|
||||
/** 索引访问:results[0] 等价于 results.item(0) */
|
||||
[index: number]: SpeechRecognitionAlternative
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 语音识别结果列表
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 语音识别结果列表(onresult 事件中的 results 属性)
|
||||
*
|
||||
* 做什么:包含从识别开始到当前的所有结果(包括临时和最终结果)
|
||||
*
|
||||
* 使用方式:
|
||||
* for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
* const result = event.results[i]
|
||||
* if (result.isFinal) { ... } else { ... }
|
||||
* }
|
||||
*/
|
||||
export interface SpeechRecognitionResultList {
|
||||
/** 结果数量 */
|
||||
readonly length: number
|
||||
/** 按索引获取结果 */
|
||||
item(index: number): SpeechRecognitionResult
|
||||
/** 索引访问 */
|
||||
[index: number]: SpeechRecognitionResult
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onresult 事件
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 语音识别结果事件(onresult 回调的参数)
|
||||
*
|
||||
* 做什么:包含本次识别的所有结果和结果索引
|
||||
*
|
||||
* 关键字段:
|
||||
* - resultIndex: 本次新增结果的起始索引(从此处遍历避免重复处理)
|
||||
* - results: 所有结果列表(从开始到现在的全部)
|
||||
*
|
||||
* 陷阱:
|
||||
* 必须从 event.resultIndex 开始遍历,否则会重复处理之前已处理的结果
|
||||
*/
|
||||
export interface SpeechRecognitionEvent extends Event {
|
||||
/** 本次新增结果的起始索引(从此处遍历避免重复处理) */
|
||||
readonly resultIndex: number
|
||||
/** 所有识别结果(包括之前的和新增加的) */
|
||||
readonly results: SpeechRecognitionResultList
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onerror 事件
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 语音识别错误事件(onerror 回调的参数)
|
||||
*
|
||||
* 做什么:包含错误类型和错误信息
|
||||
*
|
||||
* 常见 error 值:
|
||||
* - 'no-speech': 没有检测到语音输入
|
||||
* - 'aborted': 识别被中止(主动调用 abort/stop)
|
||||
* - 'audio-capture': 音频采集失败
|
||||
* - 'network': 网络错误(识别服务不可达)
|
||||
* - 'not-allowed': 麦克风权限被拒绝
|
||||
* - 'service-not-allowed': 语音识别服务不可用
|
||||
*/
|
||||
export interface SpeechRecognitionErrorEvent extends Event {
|
||||
/** 错误类型字符串 */
|
||||
readonly error: string
|
||||
/** 错误详细描述(可能为空) */
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpeechRecognition 核心接口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Web Speech API 语音识别器
|
||||
*
|
||||
* 做什么:提供浏览器原生的语音转文字能力
|
||||
*
|
||||
* 使用流程:
|
||||
* 1. new SpeechRecognition() 创建实例
|
||||
* 2. 设置 lang / continuous / interimResults 等属性
|
||||
* 3. 绑定 onresult / onerror / onend 等回调
|
||||
* 4. 调用 start() 开始识别
|
||||
* 5. 调用 stop() 或 abort() 停止识别
|
||||
*
|
||||
* 注意:
|
||||
* - 不能在已运行时重复调用 start(),需等待 onend 后再重启
|
||||
* - onend 会自动触发(包括超时停止、调用 stop/abort 后)
|
||||
*/
|
||||
export interface SpeechRecognition extends EventTarget {
|
||||
/** 识别语言(BCP-47 标签,如 'zh-CN'、'en-US') */
|
||||
lang: string
|
||||
/** 是否连续识别(true=持续识别,false=单次识别后自动停止) */
|
||||
continuous: boolean
|
||||
/** 是否返回中间结果(true=实时返回临时结果,false=只返回最终结果) */
|
||||
interimResults: boolean
|
||||
/** 最大候选项数量(1=只返回最佳结果) */
|
||||
maxAlternatives: number
|
||||
|
||||
/** 开始语音识别 */
|
||||
start(): void
|
||||
/** 停止语音识别(会触发 onend,可能还有最终的 onresult) */
|
||||
stop(): void
|
||||
/** 中止语音识别(立即停止,不触发最终 onresult) */
|
||||
abort(): void
|
||||
|
||||
/** 识别开始回调 */
|
||||
onstart: ((event: Event) => void) | null
|
||||
/** 识别结果回调(包含临时结果和最终结果) */
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||
/** 识别错误回调 */
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||
/** 识别结束回调(正常停止或超时都会触发) */
|
||||
onend: ((event: Event) => void) | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpeechRecognition 构造器接口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SpeechRecognition 构造器(静态接口)
|
||||
*
|
||||
* 做什么:用于 new SpeechRecognition() 创建实例
|
||||
*
|
||||
* 使用方式:
|
||||
* const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
* const recognition = new SR()
|
||||
*/
|
||||
export interface SpeechRecognitionStatic {
|
||||
/** 构造新实例 */
|
||||
new (): SpeechRecognition
|
||||
/** 原型对象 */
|
||||
prototype: SpeechRecognition
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 全局 Window 接口扩展
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 扩展 Window 接口,添加 SpeechRecognition 全局对象
|
||||
*
|
||||
* 做什么:让 TypeScript 识别 window.SpeechRecognition 和 window.webkitSpeechRecognition
|
||||
*
|
||||
* 注意:
|
||||
* - 标准接口名:SpeechRecognition(部分浏览器支持)
|
||||
* - Chrome 前缀:webkitSpeechRecognition(Chrome/Edge 实际使用这个)
|
||||
* - 两者都声明为可选属性(可能不存在,需运行时检查)
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
/** 标准 SpeechRecognition 构造器(部分浏览器支持) */
|
||||
SpeechRecognition?: SpeechRecognitionStatic
|
||||
/** Chrome/Edge 前缀版本的构造器(实际使用这个) */
|
||||
webkitSpeechRecognition?: SpeechRecognitionStatic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 坐席端账号绑定页
|
||||
// =============================================================================
|
||||
// 说明:互联企业用户扫码后无本地记录时,跳转绑定页面
|
||||
// - 绑定已有账号
|
||||
// - 申请新账号
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="bind-page">
|
||||
<div class="bind-card">
|
||||
<!-- 标题区 -->
|
||||
<div class="bind-header">
|
||||
<div class="bind-icon">🔗</div>
|
||||
<h1 class="bind-title">账号绑定</h1>
|
||||
<p class="bind-subtitle">请绑定您的企业账号</p>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div v-if="wecomUserInfo" class="wecom-user-info">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="企业微信">{{ wecomUserInfo.name }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="wecomUserInfo.corp_name" label="企业">{{ wecomUserInfo.corp_name }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 绑定方式选择 -->
|
||||
<div v-if="!showBindForm" class="bind-options">
|
||||
<el-radio-group v-model="bindType">
|
||||
<el-radio value="existing" border>绑定已有账号</el-radio>
|
||||
<el-radio value="new" border>申请新账号</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="next-btn"
|
||||
@click="handleNext"
|
||||
>
|
||||
下一步
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 绑定表单 -->
|
||||
<div v-else class="bind-form">
|
||||
<!-- 绑定已有账号 -->
|
||||
<el-form v-if="bindType === 'existing'" :model="bindForm" label-position="top" @submit.prevent="handleBindExisting">
|
||||
<el-form-item label="员工号" required>
|
||||
<el-input
|
||||
v-model="bindForm.employee_no"
|
||||
placeholder="请输入您的员工号"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button size="large" @click="handleBack">返回</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
native-type="submit"
|
||||
:loading="binding"
|
||||
>
|
||||
绑定
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 申请新账号 -->
|
||||
<el-form v-if="bindType === 'new'" :model="bindForm" label-position="top" @submit.prevent="handleBindNew">
|
||||
<el-form-item label="姓名" required>
|
||||
<el-input
|
||||
v-model="bindForm.real_name"
|
||||
placeholder="请输入您的姓名"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门" required>
|
||||
<el-input
|
||||
v-model="bindForm.department"
|
||||
placeholder="请输入部门"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="手机" required>
|
||||
<el-input
|
||||
v-model="bindForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button size="large" @click="handleBack">返回</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
native-type="submit"
|
||||
:loading="binding"
|
||||
>
|
||||
申请绑定
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bindAccount } from '@/api/auth'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 状态
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 企微用户信息 */
|
||||
const wecomUserInfo = ref<{
|
||||
userid: string
|
||||
name: string
|
||||
corp_name?: string
|
||||
} | null>(null)
|
||||
|
||||
/** 绑定方式 */
|
||||
const bindType = ref<'existing' | 'new'>('existing')
|
||||
|
||||
/** 是否显示绑定表单 */
|
||||
const showBindForm = ref(false)
|
||||
|
||||
/** 绑定中状态 */
|
||||
const binding = ref(false)
|
||||
|
||||
/** 绑定表单 */
|
||||
const bindForm = reactive({
|
||||
employee_no: '',
|
||||
real_name: '',
|
||||
department: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 下一步
|
||||
*/
|
||||
function handleNext(): void {
|
||||
showBindForm.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回
|
||||
*/
|
||||
function handleBack(): void {
|
||||
showBindForm.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定已有账号
|
||||
*/
|
||||
async function handleBindExisting(): Promise<void> {
|
||||
if (!wecomUserInfo.value) {
|
||||
ElMessage.error('用户信息无效')
|
||||
return
|
||||
}
|
||||
|
||||
if (!bindForm.employee_no.trim()) {
|
||||
ElMessage.error('请输入员工号')
|
||||
return
|
||||
}
|
||||
|
||||
binding.value = true
|
||||
|
||||
try {
|
||||
const result = await bindAccount({
|
||||
employee_id: wecomUserInfo.value.userid,
|
||||
bind_type: 'existing',
|
||||
employee_no: bindForm.employee_no,
|
||||
})
|
||||
|
||||
// 绑定成功,登录
|
||||
localStorage.setItem('agent_token', result.token)
|
||||
agentStore.token = result.token
|
||||
localStorage.setItem('agent_user_id', result.user_info.userid)
|
||||
agentStore.agentUserId = result.user_info.userid
|
||||
agentStore.agentInfo = {
|
||||
user_id: result.user_info.userid,
|
||||
name: result.user_info.name,
|
||||
status: 'online',
|
||||
}
|
||||
|
||||
ElMessage.success('绑定成功')
|
||||
router.replace('/workspace')
|
||||
} catch (error: any) {
|
||||
console.error('绑定失败:', error)
|
||||
const msg = error?.message || '绑定失败,请重试'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请新账号
|
||||
*/
|
||||
async function handleBindNew(): Promise<void> {
|
||||
if (!wecomUserInfo.value) {
|
||||
ElMessage.error('用户信息无效')
|
||||
return
|
||||
}
|
||||
|
||||
if (!bindForm.real_name.trim() || !bindForm.department.trim() || !bindForm.phone.trim()) {
|
||||
ElMessage.error('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
binding.value = true
|
||||
|
||||
try {
|
||||
const result = await bindAccount({
|
||||
employee_id: wecomUserInfo.value.userid,
|
||||
bind_type: 'new',
|
||||
real_name: bindForm.real_name,
|
||||
department: bindForm.department,
|
||||
phone: bindForm.phone,
|
||||
})
|
||||
|
||||
// 绑定成功,登录
|
||||
localStorage.setItem('agent_token', result.token)
|
||||
agentStore.token = result.token
|
||||
localStorage.setItem('agent_user_id', result.user_info.userid)
|
||||
agentStore.agentUserId = result.user_info.userid
|
||||
agentStore.agentInfo = {
|
||||
user_id: result.user_info.userid,
|
||||
name: result.user_info.name,
|
||||
status: 'online',
|
||||
}
|
||||
|
||||
ElMessage.success('申请成功')
|
||||
router.replace('/workspace')
|
||||
} catch (error: any) {
|
||||
console.error('申请绑定失败:', error)
|
||||
const msg = error?.message || '申请失败,请重试'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 生命周期
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
onMounted(() => {
|
||||
// 从路由参数获取企微用户信息
|
||||
const userid = route.query.userid as string
|
||||
const name = route.query.name as string
|
||||
const corpName = route.query.corp_name as string
|
||||
|
||||
if (userid && name) {
|
||||
wecomUserInfo.value = {
|
||||
userid,
|
||||
name,
|
||||
corp_name: corpName,
|
||||
}
|
||||
} else {
|
||||
// 缺少必要参数,返回登录页
|
||||
ElMessage.error('缺少用户信息,请重新登录')
|
||||
router.replace('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 绑定页面容器 */
|
||||
.bind-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 绑定卡片 */
|
||||
.bind-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-secondary, #ffffff);
|
||||
border-radius: 16px;
|
||||
padding: 40px 32px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 标题区 */
|
||||
.bind-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.bind-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bind-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.bind-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary, #909399);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 企微用户信息 */
|
||||
.wecom-user-info {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 绑定选项 */
|
||||
.bind-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bind-options :deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bind-options :deep(.el-radio) {
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 绑定表单 */
|
||||
.bind-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.form-actions :deep(.el-button) {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
+270
-396
@@ -1,41 +1,38 @@
|
||||
<!-- =============================================================================
|
||||
// IT智能服务台 — 坐席登录页 (三端认证重构 AUTH-09 + CTRT)
|
||||
// 企微IT智能服务台 — 坐席端统一登录页
|
||||
// =============================================================================
|
||||
// 说明: 坐席工作台登录,仅保留两种受控登录方式(决策见 system_design.md):
|
||||
// - ① 企微扫码登录(/auth_qrcode/create + /auth_qrcode/poll 轮询)
|
||||
// - ② 账号密码 + OTP 二次验证(POST /agents/login,所有登录强制 OTP)
|
||||
//
|
||||
// 已移除(AUTH-09):
|
||||
// - 企微 JS-SDK "免密登录" 分支(checkWecomClient / handleWecomQuickLogin / autoLoginWithWecomUser)
|
||||
// - "智能检测自动跳转" 分支(isInWecom 自动跳转 sso/init)
|
||||
// 理由:统一安全水位,所有登录均需经过扫码或账密+OTP,不再有免密直入通道。
|
||||
//
|
||||
// 响应契约(CTRT-01/02):apiClient 拦截器已统一返回 inner data,
|
||||
// 故此页面直接读取 response 字段,不再访问 response.data / response.data.data。
|
||||
// 说明:统一认证登录页面
|
||||
// - 企微内:自动跳转 OAuth2 授权
|
||||
// - 企微外:展示扫码登录页面,轮询扫码状态
|
||||
// - 支持账号绑定(互联企业用户)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 测试环境标识 - 右上角覆盖层 -->
|
||||
<!-- 测试环境标识 -->
|
||||
<div v-if="isTestEnv" class="test-env-badge">
|
||||
🔧 测试环境
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<!-- 标题区 -->
|
||||
<!-- 标题区:logo-block + 渐变文字(与 TopBar 统一) -->
|
||||
<div class="login-title">
|
||||
<h1>🛠️ IT智能服务台</h1>
|
||||
<div class="login-logo-row">
|
||||
<span class="logo-block">IT</span>
|
||||
<h1 class="title-gradient">智能IT服务台</h1>
|
||||
</div>
|
||||
<p>坐席工作台</p>
|
||||
</div>
|
||||
|
||||
<!-- 扫码登录面板(默认展示) -->
|
||||
<div v-if="showQrLoginPanel" class="qr-login">
|
||||
<!-- 扫码登录面板 -->
|
||||
<div v-if="showQrPanel" class="qr-login">
|
||||
<div class="qr-container">
|
||||
<div v-if="qrLoading" class="qr-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
<div v-else-if="qrCode" class="qr-code">
|
||||
<img :src="qrCode" alt="企微扫码登录" />
|
||||
<div v-else-if="qrCodeUrl" class="qr-code">
|
||||
<img :src="qrCodeUrl" alt="企微扫码登录" />
|
||||
</div>
|
||||
<div v-else class="qr-error">
|
||||
<p>获取二维码失败</p>
|
||||
@@ -43,250 +40,134 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="qr-hint">请使用企业微信扫码登录</p>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="divider">
|
||||
<span>其他登录方式</span>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
size="large"
|
||||
class="password-login-btn"
|
||||
@click="showPasswordLogin = true"
|
||||
>
|
||||
<span>🔐</span>
|
||||
账号密码登录
|
||||
</el-button>
|
||||
<p v-if="scanStatus === 'scanned'" class="scan-tip">
|
||||
<el-icon><CircleCheckFilled /></el-icon> 已扫码,请在手机确认
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录表单(与二维码同时展示,无需返回按钮) -->
|
||||
<div v-if="showPasswordLogin" class="password-login">
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="loginForm"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item label="账号" prop="userId">
|
||||
<el-input
|
||||
v-model="loginForm.userId"
|
||||
placeholder="请输入账号(如 sxn)"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- OTP 输入区: 仅在 require_otp 时显示 -->
|
||||
<el-form-item v-if="requireOtp" label="OTP 验证码" prop="otpCode">
|
||||
<el-input
|
||||
v-model="loginForm.otpCode"
|
||||
placeholder="请输入 Google Authenticator 验证码"
|
||||
size="large"
|
||||
:prefix-icon="Key"
|
||||
maxlength="6"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="logging"
|
||||
:disabled="logging"
|
||||
@click="handleLogin"
|
||||
class="login-btn"
|
||||
>
|
||||
{{ logging ? '登录中...' : (requireOtp ? '验证 OTP' : '账号密码登录') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- OTP 首次绑定面板 -->
|
||||
<OtpBindPanel
|
||||
v-if="requireOtpBind"
|
||||
:user-id="otpBindUser.user_id"
|
||||
:name="otpBindUser.name"
|
||||
@bind-success="onBindSuccess"
|
||||
@cancel="onBindCancel"
|
||||
/>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="errorMsg = ''"
|
||||
style="margin-top: 16px"
|
||||
/>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="login-hint">
|
||||
<p>登录即表示同意《IT智能服务台使用规范》</p>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-icon class="is-loading" size="32"><Loading /></el-icon>
|
||||
<p>{{ loadingText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { User, Lock, Key, Loading } from '@element-plus/icons-vue'
|
||||
import { Loading, CircleCheckFilled } from '@element-plus/icons-vue'
|
||||
import { getQrcode, getScanStatus, handleOAuthCallback, type QrcodeResponse, type ScanStatusResponse } from '@/api/auth'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import apiClient from '@/api/index'
|
||||
import OtpBindPanel from '@/components/OtpBindPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { connect: connectWebSocket } = useWebSocket()
|
||||
const agentStore = useAgentStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 状态
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 测试环境标识 */
|
||||
const isTestEnv = import.meta.env.DEV || window.location.hostname.includes('localhost')
|
||||
|
||||
/** 登录面板显示控制 */
|
||||
const showQrLoginPanel = ref(true) // 默认展示扫码登录
|
||||
const showPasswordLogin = ref(false) // 是否显示账号密码登录
|
||||
/** 是否显示扫码面板 */
|
||||
const showQrPanel = ref(true)
|
||||
|
||||
/** 企微二维码 */
|
||||
const qrCode = ref('')
|
||||
/** 二维码 URL */
|
||||
const qrCodeUrl = ref('')
|
||||
|
||||
/** 二维码加载中 */
|
||||
const qrLoading = ref(false)
|
||||
|
||||
/** 登录表单数据 */
|
||||
const loginForm = reactive({
|
||||
userId: '',
|
||||
password: '',
|
||||
otpCode: '',
|
||||
})
|
||||
/** 扫码状态 */
|
||||
const scanStatus = ref<'waiting' | 'scanned' | ''>('')
|
||||
|
||||
/** 是否需要 OTP 验证 */
|
||||
const requireOtp = ref(false)
|
||||
/** 全局加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 是否需要 OTP 首次绑定 */
|
||||
const requireOtpBind = ref(false)
|
||||
|
||||
/** OTP 绑定用户信息(来自 require_otp_bind 响应) */
|
||||
const otpBindUser = ref<{ user_id: string; name: string; role: string } | null>(null)
|
||||
|
||||
/** 登录中状态 */
|
||||
const logging = ref(false)
|
||||
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref<string>('')
|
||||
/** 加载提示文字 */
|
||||
const loadingText = ref('请稍候...')
|
||||
|
||||
/** 轮询定时器 */
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 当前扫码登录票据 */
|
||||
let currentTicket = ''
|
||||
|
||||
/** 表单校验规则 */
|
||||
const rules: FormRules = {
|
||||
userId: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
otpCode: [
|
||||
{ required: true, message: '请输入 OTP 验证码', trigger: 'blur' },
|
||||
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' },
|
||||
],
|
||||
// --------------------------------------------------------------------------
|
||||
// 方法
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 检测是否在企微环境
|
||||
*/
|
||||
function isWecomEnv(): boolean {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
return /wxwork/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企微登录二维码(CTRT: apiClient 直接返回 inner data)
|
||||
* 获取扫码登录二维码
|
||||
*/
|
||||
async function fetchQrCode(): Promise<void> {
|
||||
qrLoading.value = true
|
||||
errorMsg.value = ''
|
||||
scanStatus.value = 'waiting'
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/auth_qrcode/create')
|
||||
const result = response
|
||||
|
||||
if (result?.qrcode_url || result?.qrcode_png_base64) {
|
||||
qrCode.value = result.qrcode_png_base64
|
||||
? `data:image/png;base64,${result.qrcode_png_base64}`
|
||||
: result.qrcode_url
|
||||
currentTicket = result.ticket
|
||||
startPolling()
|
||||
} else {
|
||||
throw new Error('获取二维码失败:响应数据为空')
|
||||
}
|
||||
const data: QrcodeResponse = await getQrcode()
|
||||
qrCodeUrl.value = `data:image/png;base64,${data.qrcode_png_base64}`
|
||||
currentTicket = data.ticket
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
console.error('获取企微二维码失败:', error)
|
||||
if (!errorMsg.value) {
|
||||
errorMsg.value = error instanceof Error ? error.message : '获取二维码失败,请重试'
|
||||
}
|
||||
console.error('获取二维码失败:', error)
|
||||
ElMessage.error('获取二维码失败,请重试')
|
||||
} finally {
|
||||
qrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询扫码状态(CTRT: apiClient 直接返回 inner data)
|
||||
* 轮询扫码状态
|
||||
*/
|
||||
async function pollQrCode(): Promise<void> {
|
||||
async function pollScanStatus(): Promise<void> {
|
||||
if (!currentTicket) return
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/auth_qrcode/poll/${currentTicket}`)
|
||||
const result = response
|
||||
const result: ScanStatusResponse = await getScanStatus(currentTicket)
|
||||
|
||||
if (result) {
|
||||
const { status, token, employee_id, name } = result
|
||||
|
||||
if (status === 'confirmed' && token) {
|
||||
stopPolling()
|
||||
localStorage.setItem('agent_token', token)
|
||||
if (employee_id) {
|
||||
localStorage.setItem('agent_user_id', employee_id)
|
||||
}
|
||||
const agentStore = useAgentStore()
|
||||
agentStore.token = token
|
||||
if (employee_id) {
|
||||
agentStore.agentUserId = employee_id
|
||||
}
|
||||
if (name) {
|
||||
agentStore.agentInfo = { user_id: employee_id, name, status: 'online' }
|
||||
}
|
||||
ElMessage.success('登录成功')
|
||||
connectWebSocket()
|
||||
router.push('/workspace')
|
||||
} else if (status === 'expired') {
|
||||
stopPolling()
|
||||
ElMessage.warning('二维码已过期,请重新获取')
|
||||
fetchQrCode()
|
||||
}
|
||||
if (result.status === 'scanned') {
|
||||
scanStatus.value = 'scanned'
|
||||
} else if (result.status === 'confirmed' && result.token) {
|
||||
stopPolling()
|
||||
await handleLoginSuccess(result.token, result.user_info)
|
||||
} else if (result.status === 'expired') {
|
||||
stopPolling()
|
||||
ElMessage.warning('二维码已过期,请重新扫码')
|
||||
fetchQrCode()
|
||||
} else if (result.status === 'cancelled') {
|
||||
stopPolling()
|
||||
ElMessage.info('扫码已取消')
|
||||
scanStatus.value = 'waiting'
|
||||
fetchQrCode()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('轮询扫码状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动轮询 */
|
||||
/**
|
||||
* 启动轮询
|
||||
*/
|
||||
function startPolling(): void {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(pollQrCode, 2000)
|
||||
pollTimer = setInterval(pollScanStatus, 2000)
|
||||
}
|
||||
|
||||
/** 停止轮询 */
|
||||
/**
|
||||
* 停止轮询
|
||||
*/
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
@@ -294,164 +175,159 @@ function stopPolling(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回扫码登录 */
|
||||
function handleBackToQrCode(): void {
|
||||
showPasswordLogin.value = false
|
||||
showQrLoginPanel.value = true
|
||||
fetchQrCode()
|
||||
}
|
||||
|
||||
/** 显示扫码登录面板 */
|
||||
function showQrLogin(): void {
|
||||
showQrLoginPanel.value = true
|
||||
showPasswordLogin.value = false
|
||||
fetchQrCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号密码 + OTP 登录
|
||||
* 处理登录成功
|
||||
*/
|
||||
async function handleLogin(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
logging.value = true
|
||||
errorMsg.value = ''
|
||||
async function handleLoginSuccess(token: string, userInfo?: any): Promise<void> {
|
||||
loading.value = true
|
||||
loadingText.value = '登录中...'
|
||||
|
||||
try {
|
||||
const result = await agentStore.login(
|
||||
loginForm.userId.trim(),
|
||||
loginForm.password,
|
||||
loginForm.otpCode.trim() || undefined
|
||||
)
|
||||
|
||||
if (result && result.require_otp_bind) {
|
||||
// 首次登录需绑定 OTP:隐藏表单,展示绑定面板
|
||||
requireOtpBind.value = true
|
||||
otpBindUser.value = {
|
||||
user_id: result.user_id,
|
||||
name: result.name,
|
||||
role: result.role,
|
||||
}
|
||||
showQrLoginPanel.value = false
|
||||
showPasswordLogin.value = false
|
||||
logging.value = false
|
||||
// 检查是否需要账号绑定
|
||||
if (userInfo?.is_new_user) {
|
||||
// 跳转绑定页面,传递用户信息
|
||||
router.push({
|
||||
name: 'Bind',
|
||||
query: {
|
||||
userid: userInfo.userid,
|
||||
name: userInfo.name,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result && result.require_otp) {
|
||||
requireOtp.value = true
|
||||
loginForm.otpCode = ''
|
||||
ElMessage.info('请输入 OTP 验证码')
|
||||
logging.value = false
|
||||
return
|
||||
// 保存 token
|
||||
localStorage.setItem('agent_token', token)
|
||||
agentStore.token = token
|
||||
if (userInfo) {
|
||||
localStorage.setItem('agent_user_id', userInfo.userid)
|
||||
agentStore.agentUserId = userInfo.userid
|
||||
agentStore.agentInfo = {
|
||||
user_id: userInfo.userid,
|
||||
name: userInfo.name,
|
||||
status: 'online',
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
connectWebSocket()
|
||||
router.push('/workspace')
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message === 'require_otp') {
|
||||
requireOtp.value = true
|
||||
loginForm.otpCode = ''
|
||||
ElMessage.info('请输入 OTP 验证码')
|
||||
logging.value = false
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
ElMessage.error('登录失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 OAuth2 回调
|
||||
*/
|
||||
async function processOAuthCallback(code: string, state?: string): Promise<void> {
|
||||
loading.value = true
|
||||
loadingText.value = '授权中...'
|
||||
|
||||
try {
|
||||
const result = await handleOAuthCallback(code, state)
|
||||
|
||||
if (result.is_new_user) {
|
||||
// 需要账号绑定
|
||||
router.push({
|
||||
name: 'Bind',
|
||||
query: {
|
||||
userid: result.user_info.userid,
|
||||
name: result.user_info.name,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
|
||||
errorMsg.value = errMsg
|
||||
} finally {
|
||||
logging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OTP 绑定成功回调
|
||||
* 保存 token 并跳转到工作台
|
||||
*/
|
||||
function onBindSuccess(token: string, userId: string, name: string, role: string): void {
|
||||
localStorage.setItem('agent_token', token)
|
||||
localStorage.setItem('agent_user_id', userId)
|
||||
|
||||
agentStore.token = token
|
||||
agentStore.agentUserId = userId
|
||||
agentStore.agentInfo = { user_id: userId, name, status: 'online' }
|
||||
|
||||
ElMessage.success('OTP 绑定成功,已登录')
|
||||
connectWebSocket()
|
||||
router.push('/workspace')
|
||||
}
|
||||
|
||||
/**
|
||||
* OTP 绑定取消回调
|
||||
* 返回扫码登录面板
|
||||
*/
|
||||
function onBindCancel(): void {
|
||||
requireOtpBind.value = false
|
||||
otpBindUser.value = null
|
||||
showQrLoginPanel.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// === OAuth 重定向计数清除 ===
|
||||
const existingToken = localStorage.getItem('agent_token')
|
||||
if (existingToken) {
|
||||
console.log('[Login] 检测到已有 token,清除 OAuth 重定向计数')
|
||||
localStorage.removeItem('oauth_redirect_count')
|
||||
}
|
||||
|
||||
// === OAuth2.0 自动登录流程(参考ITSM系统)===
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const ssoToken = urlParams.get('sso_token')
|
||||
|
||||
// 1. 如果有 SSO token,说明已经通过 OAuth 登录成功(CTRT: 直接读取 inner data)
|
||||
if (ssoToken) {
|
||||
console.log('[Login] 检测到 SSO token,验证身份...')
|
||||
logging.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/auth_wecom/sso/verify', {
|
||||
params: { sso_token: ssoToken },
|
||||
})
|
||||
const result = response
|
||||
|
||||
// 拦截器已保证成功即有效数据
|
||||
if (result) {
|
||||
const { user_id, name, role } = result
|
||||
const token = ssoToken
|
||||
|
||||
localStorage.setItem('agent_token', token)
|
||||
localStorage.setItem('agent_user_id', user_id)
|
||||
|
||||
agentStore.token = token
|
||||
agentStore.agentUserId = user_id
|
||||
agentStore.agentInfo = { user_id, name, status: 'online' }
|
||||
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
connectWebSocket()
|
||||
router.push('/workspace')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SSO token 验证失败:', error)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
} finally {
|
||||
logging.value = false
|
||||
// 登录成功
|
||||
localStorage.setItem('agent_token', result.token)
|
||||
agentStore.token = result.token
|
||||
localStorage.setItem('agent_user_id', result.user_info.userid)
|
||||
agentStore.agentUserId = result.user_info.userid
|
||||
agentStore.agentInfo = {
|
||||
user_id: result.user_info.userid,
|
||||
name: result.user_info.name,
|
||||
status: 'online',
|
||||
}
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/workspace')
|
||||
} catch (error) {
|
||||
console.error('OAuth回调处理失败:', error)
|
||||
ElMessage.error('授权失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到 OAuth2 授权页面
|
||||
*/
|
||||
async function redirectToOAuth(): Promise<void> {
|
||||
const currentRedirectUri = window.location.origin + '/itagent/'
|
||||
|
||||
try {
|
||||
const { getOAuthAuthorizeUrl } = await import('@/api/auth')
|
||||
const data = await getOAuthAuthorizeUrl(currentRedirectUri)
|
||||
if (data.authorize_url) {
|
||||
window.location.href = data.authorize_url
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('从后端获取授权URL失败,尝试本地构造:', error)
|
||||
}
|
||||
|
||||
// 2. 如果有 code,跳转到后端 OAuth 回调
|
||||
if (code) {
|
||||
console.log('[Login] 检测到 OAuth code,跳转授权...')
|
||||
window.location.replace(`/api/auth_wecom/sso/callback?code=${code}&state=`)
|
||||
// 降级:本地构造授权URL
|
||||
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
|
||||
if (!corpId) {
|
||||
console.warn('未配置 VITE_WECOM_CORP_ID,无法跳转授权')
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 默认展示扫码登录(AUTH-09:移除 JS-SDK 免密与智能检测自动跳转)
|
||||
showQrLogin()
|
||||
const redirectUri = encodeURIComponent(currentRedirectUri)
|
||||
const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${corpId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect`
|
||||
window.location.href = oauthUrl
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 生命周期
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
// 检查 URL 中的 code 参数(OAuth2 回调)
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const state = urlParams.get('state')
|
||||
|
||||
if (code) {
|
||||
// 有 OAuth2 code,处理回调
|
||||
await processOAuthCallback(code, state || undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// 检测企微环境
|
||||
if (isWecomEnv()) {
|
||||
loading.value = true
|
||||
loadingText.value = '正在跳转企微授权...'
|
||||
|
||||
try {
|
||||
// 企微内,跳转 OAuth2 授权
|
||||
await redirectToOAuth()
|
||||
} catch (error) {
|
||||
console.error('跳转授权失败:', error)
|
||||
// 降级显示扫码登录
|
||||
showQrPanel.value = true
|
||||
fetchQrCode()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
// 企微外,显示扫码登录
|
||||
showQrPanel.value = true
|
||||
fetchQrCode()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -460,7 +336,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 测试环境标识 - 左上角覆盖层 */
|
||||
/* 测试环境标识 */
|
||||
.test-env-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
@@ -472,7 +348,7 @@ onUnmounted(() => {
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.login-page {
|
||||
@@ -481,7 +357,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: linear-gradient(135deg, #07C160 0%, #06AD56 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -499,11 +375,37 @@ onUnmounted(() => {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-title h1 {
|
||||
/* logo + 标题水平排列 */
|
||||
.login-logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* IT logo 方块 — 复用 TopBar 样式 */
|
||||
.logo-block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #07C160;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 渐变文字 — 复用 TopBar 样式 */
|
||||
.title-gradient {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #303133);
|
||||
margin: 0 0 8px 0;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-title p {
|
||||
@@ -512,6 +414,7 @@ onUnmounted(() => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 扫码登录 */
|
||||
.qr-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -524,9 +427,9 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
background: var(--bg-tertiary, #f5f5f5);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
@@ -534,7 +437,7 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #909399;
|
||||
color: var(--text-tertiary, #909399);
|
||||
}
|
||||
|
||||
.qr-code img {
|
||||
@@ -544,60 +447,31 @@ onUnmounted(() => {
|
||||
|
||||
.qr-error {
|
||||
text-align: center;
|
||||
color: #f56c6c;
|
||||
color: var(--error-color, #f56c6c);
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0 0 24px 0;
|
||||
color: var(--text-tertiary, #909399);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
.scan-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0 0 24px 0;
|
||||
color: #909399;
|
||||
gap: 4px;
|
||||
color: var(--success-color, #07c160);
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e4e7ed;
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.password-login-btn,
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.password-login {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
color: var(--text-placeholder, #c0c4cc);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
border-top: 1px solid var(--border-color-lighter, #ebeef5);
|
||||
padding-top: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-hint p {
|
||||
margin: 4px 0;
|
||||
/* 加载状态 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 40px 0;
|
||||
color: var(--text-secondary, #606266);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<!--
|
||||
压缩日志页面
|
||||
分页展示上下文压缩日志,含压缩率、耗时、级别、摘要。
|
||||
-->
|
||||
<template>
|
||||
<div class="compression-logs">
|
||||
<el-card shadow="never">
|
||||
<template #header>上下文压缩日志</template>
|
||||
<el-table :data="logs" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="#" width="60" />
|
||||
<el-table-column label="压缩前" width="100">
|
||||
<template #default="{ row }">{{ row.tokens_before }} tok</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="压缩后" width="100">
|
||||
<template #default="{ row }">{{ row.tokens_after }} tok</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="压缩率" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="ratioType(row.compression_ratio)" size="small">
|
||||
{{ (row.compression_ratio * 100).toFixed(0) }}%
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="级别" width="80">
|
||||
<template #default="{ row }">L{{ row.compression_level }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时" width="100">
|
||||
<template #default="{ row }">{{ row.duration_ms }}ms</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="task_node" label="任务节点" width="150" />
|
||||
<el-table-column label="摘要" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.summary || '无摘要'" placement="top" :show-after="300">
|
||||
<span class="summary-text">{{ row.summary || '—' }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadData"
|
||||
class="pagination"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCompressionLogs, type CompressionLog } from '@/api/automation_p2'
|
||||
|
||||
const props = defineProps<{ sessionId: string }>()
|
||||
|
||||
const logs = ref<CompressionLog[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
/** 加载压缩日志数据 */
|
||||
async function loadData(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getCompressionLogs(props.sessionId, page.value, pageSize)
|
||||
logs.value = result.logs
|
||||
total.value = result.total
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载压缩日志失败:' + (e.message || e))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化时间戳 */
|
||||
function formatTime(t: string | null): string {
|
||||
return t ? new Date(t).toLocaleString('zh-CN') : '—'
|
||||
}
|
||||
|
||||
/** 根据压缩率返回标签类型 */
|
||||
function ratioType(ratio: number): 'danger' | 'warning' | 'success' {
|
||||
if (ratio < 0.3) return 'danger'
|
||||
if (ratio < 0.5) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.compression-logs {
|
||||
padding: 16px;
|
||||
}
|
||||
.summary-text {
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!--
|
||||
更正历史页面
|
||||
以时间线展示每次更正的快照记录,点击可展开快照详情;
|
||||
顶部提供"撤销最近一次"按钮。
|
||||
-->
|
||||
<template>
|
||||
<div class="correction-history">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>更正历史</span>
|
||||
<el-button
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="!canUndo"
|
||||
@click="handleUndo"
|
||||
:loading="undoLoading"
|
||||
>
|
||||
撤销最近一次
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-timeline v-if="history.length > 0">
|
||||
<el-timeline-item
|
||||
v-for="item in history"
|
||||
:key="item.snapshot_id"
|
||||
:timestamp="formatTime(item.created_at)"
|
||||
:type="item.is_undone ? 'info' : 'primary'"
|
||||
placement="top"
|
||||
>
|
||||
<el-card shadow="hover" class="history-card" @click="toggleExpand(item.snapshot_id)">
|
||||
<div class="history-summary">
|
||||
<span class="trigger-field">触发字段:{{ item.trigger_item_key }}</span>
|
||||
<el-tag v-if="item.is_undone" size="small" type="info">已撤销</el-tag>
|
||||
<el-tag v-else size="small" type="success">生效中</el-tag>
|
||||
<el-icon class="expand-icon">
|
||||
<ArrowDown v-if="expanded !== item.snapshot_id" />
|
||||
<ArrowUp v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div v-if="expanded === item.snapshot_id" class="snapshot-detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item
|
||||
v-for="(data, key) in item.snapshot_data"
|
||||
:key="key"
|
||||
:label="String(key)"
|
||||
>
|
||||
{{ data.value }} (v{{ data.version }})
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无更正记录" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getCorrectionHistory,
|
||||
undoCorrection,
|
||||
type CorrectionHistoryItem,
|
||||
} from '@/api/automation_p2'
|
||||
|
||||
const props = defineProps<{ sessionId: string }>()
|
||||
|
||||
const history = ref<CorrectionHistoryItem[]>([])
|
||||
const expanded = ref<number | null>(null)
|
||||
const undoLoading = ref(false)
|
||||
|
||||
/** 存在未撤销的记录时才可撤销 */
|
||||
const canUndo = computed(() => history.value.some((h) => !h.is_undone))
|
||||
|
||||
/** 加载更正历史数据 */
|
||||
async function loadData(): Promise<void> {
|
||||
try {
|
||||
history.value = await getCorrectionHistory(props.sessionId)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载更正历史失败:' + (e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开/折叠快照详情 */
|
||||
function toggleExpand(id: number): void {
|
||||
expanded.value = expanded.value === id ? null : id
|
||||
}
|
||||
|
||||
/** 格式化时间戳 */
|
||||
function formatTime(t: string | null): string {
|
||||
if (!t) return ''
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/** 撤销最近一次更正 */
|
||||
async function handleUndo(): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定撤销最近一次更正?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
undoLoading.value = true
|
||||
try {
|
||||
const result = await undoCorrection(props.sessionId)
|
||||
ElMessage.success(
|
||||
`已撤销 ${result.undone_items.length} 个信息项,剩余可撤销 ${result.remaining_undo_count} 次`,
|
||||
)
|
||||
await loadData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('撤销失败:' + (e.message || e))
|
||||
} finally {
|
||||
undoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.correction-history {
|
||||
padding: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.history-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.history-card:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.history-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.trigger-field {
|
||||
font-weight: 600;
|
||||
}
|
||||
.expand-icon {
|
||||
margin-left: auto;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.snapshot-detail {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,25 @@
|
||||
class="wb-alert"
|
||||
/>
|
||||
|
||||
<!-- 暂停状态横幅(复杂场景重构) -->
|
||||
<el-alert
|
||||
v-if="session?.status === 'paused'"
|
||||
:type="pausedTimeoutWarning ? 'warning' : 'info'"
|
||||
:closable="false"
|
||||
class="wb-alert"
|
||||
>
|
||||
<template #title>
|
||||
会话已暂停 · {{ pausedDurationText }}
|
||||
<span v-if="pausedTimeoutWarning" class="timeout-warning">⚠ 接近24小时超时</span>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="paused-actions">
|
||||
<el-button size="small" type="primary" @click="onAgentResume">恢复任务</el-button>
|
||||
<el-button size="small" type="danger" plain @click="onAgentClose">关闭会话</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 加载态 -->
|
||||
<div v-if="store.loading" class="wb-loading">
|
||||
<el-skeleton :rows="8" animated />
|
||||
@@ -43,10 +62,27 @@
|
||||
<div class="ov-item"><span class="ov-label">模式</span><span>{{ modeLabel }}</span></div>
|
||||
<div class="ov-item"><span class="ov-label">置信度</span><span>{{ Math.round((session.confidence || 0) * 100) }}%</span></div>
|
||||
<div class="ov-item"><span class="ov-label">自动关单</span><span>{{ autoCloseText }}</span></div>
|
||||
<div v-if="session.paused_at" class="ov-item"><span class="ov-label">暂停时间</span><span>{{ formatTime(session.paused_at) }}</span></div>
|
||||
</div>
|
||||
<div v-if="session.intent" class="intent">意图:{{ intentText }}</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 恢复点信息(暂停状态时展示,复杂场景重构) -->
|
||||
<el-card v-if="session.status === 'paused'" class="wb-card" shadow="never">
|
||||
<template #header>恢复点信息</template>
|
||||
<div class="resume-info">
|
||||
<div class="ri-item"><span class="ri-label">会话标题</span><span>{{ session.title }}</span></div>
|
||||
<div class="ri-item"><span class="ri-label">当前步骤</span><span>{{ currentStepText }}</span></div>
|
||||
<div class="ri-item"><span class="ri-label">暂停时长</span><span>{{ pausedDurationText }}</span></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 信息项面板(复杂场景重构) -->
|
||||
<InformationItemPanel
|
||||
v-if="store.infoItems.length > 0"
|
||||
:items="store.infoItems"
|
||||
/>
|
||||
|
||||
<!-- 处置进展(时间线) -->
|
||||
<el-card class="wb-card" shadow="never">
|
||||
<template #header>处置进展</template>
|
||||
@@ -98,11 +134,12 @@
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAutomationStore } from '@/stores/automation'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import ActionApprovalCard from '@/components/automation/ActionApprovalCard.vue'
|
||||
import TakeoverPanel from '@/components/automation/TakeoverPanel.vue'
|
||||
import InformationItemPanel from '@/components/automation/InformationItemPanel.vue'
|
||||
import type { AutomationAction } from '@/api/automation'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -146,6 +183,12 @@ onMounted(async () => {
|
||||
if (!sessionId.value) return
|
||||
await store.fetchSession(sessionId.value)
|
||||
store.connectWs(sessionId.value)
|
||||
// 获取信息项列表(复杂场景重构)
|
||||
try {
|
||||
await store.fetchInfoItems(sessionId.value)
|
||||
} catch {
|
||||
// 信息项可能不存在,忽略错误
|
||||
}
|
||||
})
|
||||
onUnmounted(() => store.disconnectWs())
|
||||
|
||||
@@ -212,6 +255,65 @@ const autoCloseText = computed(() => {
|
||||
return t ? formatTime(t) : '无'
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 复杂场景重构:暂停状态 / 恢复点 / 信息项
|
||||
// --------------------------------------------------------------------------
|
||||
const pausedDurationText = computed(() => {
|
||||
if (!session.value?.paused_at) return ''
|
||||
const diff = Date.now() - new Date(session.value.paused_at).getTime()
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const minutes = Math.floor((diff % 3600000) / 60000)
|
||||
if (hours > 0) return `已暂停 ${hours}h ${minutes}min`
|
||||
return `已暂停 ${minutes}min`
|
||||
})
|
||||
|
||||
/** 接近24小时超时预警(超过20小时显示预警) */
|
||||
const pausedTimeoutWarning = computed(() => {
|
||||
if (!session.value?.paused_at) return false
|
||||
const diff = Date.now() - new Date(session.value.paused_at).getTime()
|
||||
return diff > 20 * 3600000 // 超过20小时
|
||||
})
|
||||
|
||||
const currentStepText = computed(() => {
|
||||
const actions = session.value?.actions || []
|
||||
const current = actions.find((a) => a.id === session.value?.current_action_id)
|
||||
if (current) return `${current.title}(${actionStatusLabel(current)})`
|
||||
if (actions.length > 0) return `已完成 ${actions.filter((a) => ['success', 'done', 'executed'].includes(a.status)).length}/${actions.length} 步`
|
||||
return '等待开始处置'
|
||||
})
|
||||
|
||||
async function onAgentResume(): Promise<void> {
|
||||
if (!sessionId.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认代替员工恢复此暂停会话?', '坐席代恢复', {
|
||||
confirmButtonText: '确认恢复',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await store.agentResume(sessionId.value, '坐席代恢复')
|
||||
await store.fetchSession(sessionId.value)
|
||||
ElMessage.success('已代恢复,任务继续执行')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
async function onAgentClose(): Promise<void> {
|
||||
if (!sessionId.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认关闭此暂停会话?关闭后不可恢复。', '坐席关闭会话', {
|
||||
confirmButtonText: '确认关闭',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await store.agentClose(sessionId.value, '坐席手动关闭')
|
||||
await store.fetchSession(sessionId.value)
|
||||
ElMessage.success('会话已关闭')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(s: string): string {
|
||||
switch (s) {
|
||||
case 'created':
|
||||
@@ -420,4 +522,30 @@ function formatTime(t: string): string {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.paused-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.timeout-warning {
|
||||
color: var(--el-color-warning);
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.resume-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ri-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ri-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
min-width: 70px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<!--
|
||||
版本对比页面
|
||||
选择信息项 + 两个版本号,展示对比结果与完整版本链。
|
||||
-->
|
||||
<template>
|
||||
<div class="version-diff">
|
||||
<el-card shadow="never">
|
||||
<template #header>版本对比</template>
|
||||
<el-form :inline="true" class="diff-form">
|
||||
<el-form-item label="信息项">
|
||||
<el-input v-model="itemName" placeholder="输入信息项名称" style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版本1">
|
||||
<el-input-number v-model="v1" :min="1" size="default" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版本2">
|
||||
<el-input-number v-model="v2" :min="1" size="default" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleDiff" :loading="loading">对比</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="diffResult" class="diff-result">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="version-header">v{{ diffResult.v1 }}</span>
|
||||
</template>
|
||||
<div class="version-value" :class="{ changed: diffResult.changed }">
|
||||
{{ diffResult.v1_value || '(空)' }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="version-header">v{{ diffResult.v2 }}</span>
|
||||
</template>
|
||||
<div class="version-value" :class="{ changed: diffResult.changed }">
|
||||
{{ diffResult.v2_value || '(空)' }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-alert
|
||||
v-if="diffResult.changed"
|
||||
type="success"
|
||||
:closable="false"
|
||||
class="diff-alert"
|
||||
>
|
||||
两个版本存在差异
|
||||
</el-alert>
|
||||
<el-alert v-else type="info" :closable="false" class="diff-alert">
|
||||
两个版本内容一致
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div v-if="versionChain.length > 0" class="version-chain">
|
||||
<el-divider>版本链</el-divider>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="item in versionChain"
|
||||
:key="item.version"
|
||||
:timestamp="formatTime(item.timestamp)"
|
||||
placement="top"
|
||||
>
|
||||
<span class="chain-version">v{{ item.version }}</span>
|
||||
<span class="chain-action">{{ actionText(item.action) }}</span>
|
||||
<span class="chain-value">{{ item.new_value || item.value }}</span>
|
||||
<span v-if="item.reason" class="chain-reason">({{ item.reason }})</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getVersionDiff,
|
||||
getVersionChain,
|
||||
type VersionDiffResult,
|
||||
type VersionChainItem,
|
||||
} from '@/api/automation_p2'
|
||||
|
||||
const props = defineProps<{ sessionId: string }>()
|
||||
|
||||
const itemName = ref('')
|
||||
const v1 = ref(1)
|
||||
const v2 = ref(2)
|
||||
const loading = ref(false)
|
||||
const diffResult = ref<VersionDiffResult | null>(null)
|
||||
const versionChain = ref<VersionChainItem[]>([])
|
||||
|
||||
/** 执行版本对比 */
|
||||
async function handleDiff(): Promise<void> {
|
||||
if (!itemName.value.trim()) {
|
||||
ElMessage.warning('请输入信息项名称')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
diffResult.value = await getVersionDiff(
|
||||
props.sessionId,
|
||||
itemName.value.trim(),
|
||||
v1.value,
|
||||
v2.value,
|
||||
)
|
||||
versionChain.value = await getVersionChain(props.sessionId, itemName.value.trim())
|
||||
} catch (e: any) {
|
||||
ElMessage.error('对比失败:' + (e.message || e))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化时间戳 */
|
||||
function formatTime(t: string | null): string {
|
||||
return t ? new Date(t).toLocaleString('zh-CN') : ''
|
||||
}
|
||||
|
||||
/** 动作类型转中文 */
|
||||
function actionText(action: string): string {
|
||||
const map: Record<string, string> = {
|
||||
collect: '收集',
|
||||
correct: '更正',
|
||||
supplement: '补充',
|
||||
current: '当前',
|
||||
}
|
||||
return map[action] || action
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-diff {
|
||||
padding: 16px;
|
||||
}
|
||||
.diff-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.version-header {
|
||||
font-weight: 600;
|
||||
}
|
||||
.version-value {
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
word-break: break-all;
|
||||
}
|
||||
.version-value.changed {
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
.diff-alert {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.version-chain {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.chain-version {
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.chain-action {
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-right: 8px;
|
||||
}
|
||||
.chain-value {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.chain-reason {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>文字工具测试</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { overflow: hidden; }
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background: #333;
|
||||
cursor: crosshair;
|
||||
}
|
||||
#canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #666; }
|
||||
#input {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
border: 2px solid #07C160;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 20px;
|
||||
font-family: sans-serif;
|
||||
background: rgba(255,255,255,0.9);
|
||||
outline: none;
|
||||
min-width: 60px;
|
||||
color: #333;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" id="container">
|
||||
<canvas id="canvas"></canvas>
|
||||
<input id="input" />
|
||||
</div>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const input = document.getElementById('input')
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
|
||||
ctx.fillStyle = '#666'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.fillStyle = 'red'
|
||||
ctx.font = '20px sans-serif'
|
||||
|
||||
let isSelecting = false
|
||||
let box = {x: 0, y: 0, w: 0, h: 0}
|
||||
let tool = 'arrow'
|
||||
|
||||
// 绘制选区框
|
||||
function drawBox() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.clearRect(box.x, box.y, box.w, box.h)
|
||||
ctx.strokeStyle = '#07C160'
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(box.x, box.y, box.w, box.h)
|
||||
}
|
||||
|
||||
// 切换工具(模拟点击工具栏)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 't') { tool = 'text'; console.log('tool=text') }
|
||||
if (e.key === 'a') { tool = 'arrow'; console.log('tool=arrow') }
|
||||
if (e.key === 'Escape') {
|
||||
input.style.display = 'none'
|
||||
input.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
console.log('mousedown', e.clientX, e.clientY, 'tool=', tool)
|
||||
if (box.w === 0) {
|
||||
isSelecting = true
|
||||
box = {x: e.clientX, y: e.clientY, w: 0, h: 0}
|
||||
return
|
||||
}
|
||||
// 在选区内
|
||||
if (e.clientX >= box.x && e.clientX <= box.x + box.w && e.clientY >= box.y && e.clientY <= box.y + box.h) {
|
||||
if (tool === 'text') {
|
||||
input.style.display = 'block'
|
||||
input.style.left = e.clientX + 'px'
|
||||
input.style.top = (e.clientY - 14) + 'px'
|
||||
input.value = ''
|
||||
input.focus()
|
||||
console.log('show input at', e.clientX, e.clientY - 14)
|
||||
return
|
||||
}
|
||||
// arrow 等...
|
||||
}
|
||||
})
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (isSelecting) {
|
||||
box.w = Math.abs(e.clientX - box.x)
|
||||
box.h = Math.abs(e.clientY - box.y)
|
||||
box.x = Math.min(box.x, e.clientX)
|
||||
box.y = Math.min(box.y, e.clientY)
|
||||
drawBox()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
isSelecting = false
|
||||
})
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
commitText()
|
||||
}
|
||||
})
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
console.log('blur')
|
||||
commitText()
|
||||
})
|
||||
|
||||
function commitText() {
|
||||
if (input.style.display === 'none') return
|
||||
const text = input.value.trim()
|
||||
console.log('commitText', text)
|
||||
if (text) {
|
||||
const rect = input.getBoundingClientRect()
|
||||
ctx.fillStyle = 'red'
|
||||
ctx.font = '20px sans-serif'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(text, rect.left, rect.top + rect.height / 2)
|
||||
}
|
||||
input.style.display = 'none'
|
||||
input.value = ''
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -50,6 +50,8 @@ export default defineConfig({
|
||||
build: {
|
||||
// 输出目录
|
||||
outDir: 'dist',
|
||||
// 不删除旧文件(避免安全沙箱阻止)
|
||||
emptyOutDir: false,
|
||||
// 静态资源内联阈值(小于4KB的资源会被base64内联)
|
||||
assetsInlineLimit: 4096,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user