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:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
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

+23
View File
@@ -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>
+1 -1
View File
@@ -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 图标 -->
+1889 -234
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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"
}
}
+1092
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -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 } })
}
+74
View File
@@ -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[]
}
+161
View File
@@ -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 }
}
+69
View File
@@ -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')
}
+23 -4
View File
@@ -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 })
+2
View File
@@ -51,6 +51,8 @@ export interface Message {
status?: string
/** 可撤回截止时间(M2 新增) */
recallable_until?: string
/** 发送者头像(AI用静态图,员工用企微API获取) */
sender_avatar?: string
}
/** 消息列表响应(对应后端 MessageListResponse */
+110
View File
@@ -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
}
+22 -1
View File
@@ -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>
+400 -113
View File
@@ -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 APIgetDisplayMedia在企微桌面端被限制不稳定
* - html2canvas 纯前端渲染不依赖任何浏览器 API所有环境都可用
* - 适合客服场景坐席想截取当前对话内容发给用户
* 为什么用 captureScreenAsCanvas而非 html2canvas
* - getDisplayMedia 可截取整个屏幕/窗口/标签页不仅仅是当前页面
* - 返回 Canvas 直接作为编辑底图无需 BlobImage 异步转换
* - 企微桌面端基于 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
// APIgetapprovaldetail 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/ APIdebounce 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>>({})
/** 当前激活的 Tabsearch(搜索)/ 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
// IDmanual_ 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. totalCounthasPending
* 8. ROLE_BORDER_COLORSSELF_HIGHLIGHT_COLORROLE_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')
})
})
+212
View File
@@ -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+AP2 预留) */
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 APIChrome 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
* - BlobImage
* - 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/EdgewebkitSpeechRecognition
// - 不支持 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 falseonend
*/
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,
}
}
+15 -1
View File
@@ -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
// --------------------------------------------------------------------------
+1 -130
View File
@@ -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,
}
+10 -2
View File
@@ -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
}
+67
View File
@@ -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,
}
+49 -11
View File
@@ -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,
}
+85
View File
@@ -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
/** 缩略图预览 URLURL.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
View File
@@ -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~11
*/
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 webkitSpeechRecognitionChrome/Edge 使
* -
*/
declare global {
interface Window {
/** 标准 SpeechRecognition 构造器(部分浏览器支持) */
SpeechRecognition?: SpeechRecognitionStatic
/** Chrome/Edge 前缀版本的构造器(实际使用这个) */
webkitSpeechRecognition?: SpeechRecognitionStatic
}
}
+367
View File
@@ -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
View File
@@ -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/02apiClient 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>
+137
View File
@@ -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>
+2
View File
@@ -50,6 +50,8 @@ export default defineConfig({
build: {
// 输出目录
outDir: 'dist',
// 不删除旧文件(避免安全沙箱阻止)
emptyOutDir: false,
// 静态资源内联阈值(小于4KB的资源会被base64内联)
assetsInlineLimit: 4096,
},
+16
View File
@@ -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,
},
})