319 lines
17 KiB
HTML
319 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>工单对话 Demo</title>
|
|
<style>
|
|
:root{
|
|
--bg:#eef1f4; --panel:#ffffff; --line:#dde2e8; --text:#1f2933; --sub:#6b7785;
|
|
--brand:#98638f; --brand-soft:#f3eaf2; --me:#98638f; --me-text:#fff; --other:#f1f3f6;
|
|
--ok:#2f7d4f; --err:#b3261e;
|
|
--waiting:#b45309; --waiting-bg:#fdf0da;
|
|
--replied:#1d5fa8; --replied-bg:#e2edf9;
|
|
--done:#5b6673; --done-bg:#e9ecf0;
|
|
}
|
|
@media (prefers-color-scheme: dark){
|
|
:root:not([data-theme="light"]){
|
|
--bg:#15191d; --panel:#1d2227; --line:#2f363d; --text:#e6eaee; --sub:#93a0ad;
|
|
--brand:#c792bd; --brand-soft:#3a2838; --me:#98638f; --me-text:#fff; --other:#272d33;
|
|
--ok:#6cc08b; --err:#f08a82;
|
|
--waiting:#f0b35a; --waiting-bg:#3a2d17;
|
|
--replied:#7db4ee; --replied-bg:#1b2d44;
|
|
--done:#a3adb8; --done-bg:#2b3138;
|
|
}
|
|
}
|
|
*{box-sizing:border-box}
|
|
html,body{height:100%;margin:0}
|
|
body{font-family:"Microsoft YaHei UI","PingFang SC","Segoe UI",sans-serif;font-size:14px;color:var(--text);background:var(--bg);display:flex;flex-direction:column;
|
|
padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
|
|
button{font:inherit;cursor:pointer}
|
|
button:focus-visible,textarea:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
|
|
|
|
main{flex:1;min-height:0;padding:16px}
|
|
.page{display:none;height:100%}
|
|
.page.on{display:block}
|
|
|
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:6px}
|
|
.btn{border:1px solid var(--brand);background:var(--brand);color:#fff;border-radius:4px;padding:6px 14px}
|
|
.btn.ghost{background:none;color:var(--brand)}
|
|
.btn:disabled{opacity:.45;cursor:not-allowed}
|
|
.tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;white-space:nowrap}
|
|
.s-waiting_reply{color:var(--waiting);background:var(--waiting-bg)}
|
|
.s-replied{color:var(--replied);background:var(--replied-bg)}
|
|
.s-completed{color:var(--done);background:var(--done-bg)}
|
|
|
|
/* 工单页 */
|
|
.tk{display:grid;grid-template-columns:300px 1fr;height:100%;overflow:hidden}
|
|
.left{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
|
|
.left-h{padding:12px 14px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);font-weight:600}
|
|
.list{overflow:auto;flex:1}
|
|
.item{padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer;border-left:3px solid transparent}
|
|
.item:hover{background:var(--bg)}
|
|
.item.on{background:var(--brand-soft);border-left-color:var(--brand)}
|
|
.item .r1,.item .r3{display:flex;justify-content:space-between;gap:8px;align-items:center}
|
|
.item .r1{margin-bottom:6px}
|
|
.item .r2{color:var(--sub);font-size:12px;margin-bottom:4px}
|
|
.item .r3{color:var(--sub);font-size:12px}
|
|
.item .pv{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
|
.right{display:flex;flex-direction:column;min-height:0;min-width:0}
|
|
.right-h{padding:12px 18px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}
|
|
.right-h .t{font-weight:600}
|
|
.right-h .d{color:var(--sub);font-size:12px;margin-top:2px}
|
|
.msgs{flex:1;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:14px;min-height:0}
|
|
.row{display:flex;flex-direction:column;max-width:70%}
|
|
.row.me{align-self:flex-end;align-items:flex-end}
|
|
.row.cs{align-self:flex-start}
|
|
.who{font-size:12px;color:var(--sub);margin-bottom:3px}
|
|
.bub{padding:9px 13px;border-radius:8px;line-height:1.55;white-space:pre-wrap;word-break:break-word}
|
|
.me .bub{background:var(--me);color:var(--me-text);border-top-right-radius:2px}
|
|
.cs .bub{background:var(--other);border-top-left-radius:2px}
|
|
.sys{align-self:center;font-size:12px;color:var(--sub);background:var(--done-bg);padding:3px 12px;border-radius:10px}
|
|
|
|
|
|
.input{border-top:1px solid var(--line);padding:12px 18px}
|
|
.composer{display:flex;flex-direction:column;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;background:var(--panel);transition:border-color .15s}
|
|
.composer:focus-within{border-color:var(--brand)}
|
|
.composer textarea{width:100%;height:64px;resize:none;border:0;outline:0;padding:0;font:inherit;line-height:1.5;background:transparent;color:var(--text)}
|
|
.composer .foot{display:flex;align-items:center;gap:12px;font-size:12px;color:var(--sub)}
|
|
.composer .cnt{margin-left:auto}
|
|
.composer .btn{height:32px;padding:0 20px;border-radius:6px}
|
|
.empty{margin:auto;color:var(--sub);text-align:center}
|
|
.order-box{margin:18px;padding:12px 14px;border:1px dashed var(--line);border-radius:6px;color:var(--sub);line-height:1.7}
|
|
.order-box b{color:var(--text)}
|
|
.order-box select{font:inherit;margin-left:4px;padding:3px 6px;border:1px solid var(--line);border-radius:4px;background:var(--panel);color:var(--text)}
|
|
|
|
/* 未读角标 + SSE 状态 */
|
|
.badge{background:var(--brand);color:#fff;border-radius:9px;min-width:18px;height:18px;padding:0 5px;font-size:11px;display:inline-flex;align-items:center;justify-content:center;margin-left:6px;vertical-align:1px}
|
|
.item.unread .pv{color:var(--text);font-weight:600}
|
|
.sse{border-top:1px solid var(--line);padding:9px 14px;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--sub);cursor:pointer;user-select:none}
|
|
.dot{width:8px;height:8px;border-radius:50%;background:var(--sub);flex:none}
|
|
.dot.connected{background:var(--ok)}
|
|
.dot.connecting,.dot.reconnecting{background:var(--waiting);animation:blink 1s infinite}
|
|
.dot.disconnected{background:var(--err)}
|
|
@keyframes blink{50%{opacity:.3}}
|
|
@media (prefers-reduced-motion:reduce){.dot{animation:none!important}}
|
|
@media (max-width:760px){.tk{grid-template-columns:1fr;grid-template-rows:200px 1fr}.left{border-right:0;border-bottom:1px solid var(--line)}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<section class="page on card" id="page-tickets"><div class="tk">
|
|
<aside class="left">
|
|
<div class="left-h"><span>历史工单</span><button class="btn ghost" onclick="newDraft()">新建</button></div>
|
|
<div class="list" id="tkList"></div>
|
|
<div class="sse" id="sse" onclick="onSseClick()"></div>
|
|
</aside>
|
|
<div class="right" id="tkRight"></div>
|
|
</div></section>
|
|
</main>
|
|
|
|
<script>
|
|
/* ========== 状态定义:对应 WPF 里的枚举 / 转换器 ========== */
|
|
const STATUS = {
|
|
waiting_reply:{text:'待客服回复'},
|
|
replied:{text:'客服已回复'},
|
|
completed:{text:'已完结'}
|
|
};
|
|
|
|
/* ========== 数据(WPF 里对应 ViewModel / 服务层) ========== */
|
|
const orders = [
|
|
{id:'SO20261012001',name:'无线降噪耳机 Pro',amount:'¥ 899.00',time:'2026-10-12 14:20'},
|
|
{id:'SO20261009017',name:'机械键盘 87键',amount:'¥ 459.00',time:'2026-10-09 09:41'},
|
|
{id:'SO20261003088',name:'27寸 4K 显示器',amount:'¥ 2,199.00',time:'2026-10-03 20:05'},
|
|
{id:'SO20260928052',name:'人体工学椅',amount:'¥ 1,580.00',time:'2026-09-28 11:12'}
|
|
];
|
|
let seq = 3;
|
|
const tickets = [
|
|
{id:'T0002',orderId:'SO20261009017',status:'replied',updated:'10-16 10:32',messages:[
|
|
{role:'me',text:'键盘空格键卡键,想问下能否换新?',time:'10-16 09:50'},
|
|
{role:'cs',text:'您好,已为您登记换货,请提供一下故障视频。',time:'10-16 10:32'}]},
|
|
{id:'T0003',orderId:'SO20261003088',status:'waiting_reply',updated:'10-17 09:12',messages:[
|
|
{role:'me',text:'显示器左下角有一块亮斑,请问怎么处理?',time:'10-17 09:12'}]},
|
|
{id:'T0001',orderId:'SO20260928052',status:'completed',updated:'10-05 16:08',messages:[
|
|
{role:'me',text:'椅子扶手有松动,怎么处理?',time:'10-05 15:20'},
|
|
{role:'cs',text:'已安排上门维修,师傅会在 2 天内联系您。',time:'10-05 15:48'},
|
|
{role:'sys',text:'客服已完结该工单',time:'10-05 16:08'}]}
|
|
].sort((a,b)=>b.id.localeCompare(a.id));
|
|
|
|
let selectedId=null, draftOrderId=null;
|
|
|
|
/* ========== 工具 ========== */
|
|
const $=id=>document.getElementById(id);
|
|
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
const now=()=>{const d=new Date(),p=n=>String(n).padStart(2,'0');return `${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`};
|
|
const order=id=>orders.find(o=>o.id===id);
|
|
const tag=s=>`<span class="tag s-${s}">${STATUS[s].text}</span>`;
|
|
|
|
/* ========== 工单页 ========== */
|
|
function renderTickets(){renderList();renderRight();}
|
|
function renderList(){
|
|
const draft = draftOrderId && !selectedId
|
|
? `<div class="item on"><div class="r1"><b>新工单(未提交)</b></div><div class="r2">订单 ${draftOrderId}</div><div class="r3">请在右侧描述问题</div></div>` : '';
|
|
$('tkList').innerHTML = draft + tickets.map(t=>{
|
|
const last=t.messages[t.messages.length-1];
|
|
return `<div class="item ${t.id===selectedId?'on':''} ${t.unread?'unread':''}" onclick="selectTicket('${t.id}')">
|
|
<div class="r1"><span><b>${t.id}</b>${t.unread?`<span class="badge">${t.unread}</span>`:''}</span>${tag(t.status)}</div>
|
|
<div class="r2">订单 ${t.orderId}</div>
|
|
<div class="r3"><span class="pv">${esc(last.text)}</span><span>${t.updated}</span></div></div>`;
|
|
}).join('');
|
|
if(!tickets.length&&!draft)$('tkList').innerHTML='<div class="empty" style="padding:24px">暂无工单</div>';}
|
|
function renderRight(){
|
|
const t=tickets.find(x=>x.id===selectedId);
|
|
if(t) renderChat(t);
|
|
else if(draftOrderId) renderDraft();
|
|
else $('tkRight').innerHTML='<div class="empty">在左侧选择工单,或点击“新建”提交新工单</div>';
|
|
}
|
|
/* 输入框:字数计数 + Ctrl+Enter 发送 */
|
|
function bindInput(ta,onSubmit){
|
|
const cnt=ta.parentNode.querySelector('.cnt');
|
|
const upd=()=>cnt.textContent=ta.value.length+' / 500';
|
|
ta.addEventListener('input',upd);
|
|
ta.addEventListener('keydown',e=>{if(e.key==='Enter'&&e.ctrlKey)onSubmit();});
|
|
upd();
|
|
}
|
|
function selectTicket(id){selectedId=id; draftOrderId=null; const t=tickets.find(x=>x.id===id); if(t)t.unread=0; renderTickets();}
|
|
|
|
/* 外部入口:订单页点“提交工单”时调用 openNewTicket(订单号),即可选中该订单 */
|
|
function openNewTicket(orderId){draftOrderId=orderId||orders[0].id; selectedId=null; renderTickets(); $('draftText').focus();}
|
|
function newDraft(){openNewTicket(draftOrderId);}
|
|
function pickOrder(id){ // 切换订单时保留已输入的问题
|
|
const v=$('draftText').value; draftOrderId=id; renderTickets();
|
|
$('draftText').value=v; $('draftText').dispatchEvent(new Event('input')); $('draftText').focus();
|
|
}
|
|
|
|
function renderDraft(){
|
|
const o=order(draftOrderId);
|
|
$('tkRight').innerHTML=`
|
|
<div class="right-h"><div><div class="t">新建工单</div><div class="d">提交后将生成工单并等待客服回复</div></div></div>
|
|
<div class="msgs">
|
|
<div class="order-box">关联订单:<select id="orderSel" onchange="pickOrder(this.value)">${orders.map(x=>`<option value="${x.id}" ${x.id===o.id?'selected':''}>${x.id}</option>`).join('')}</select><br>商品:${esc(o.name)}<br>金额:${o.amount} 下单时间:${o.time}</div>
|
|
</div>
|
|
<div class="input">
|
|
<div class="composer"><textarea id="draftText" maxlength="500" placeholder="请描述您遇到的问题"></textarea>
|
|
<div class="foot"><span class="hint">Ctrl+Enter 发送</span><span class="cnt">0 / 500</span><button class="btn" onclick="createTicket()">提交工单</button></div></div>
|
|
</div>`;
|
|
bindInput($('draftText'),createTicket);
|
|
}
|
|
function createTicket(){
|
|
const v=$('draftText').value.trim();
|
|
if(!v){$('draftText').focus();return;}
|
|
const t={id:'T'+String(++seq).padStart(4,'0'),orderId:draftOrderId,status:'waiting_reply',updated:now(),
|
|
messages:[{role:'me',text:v,time:now()}]};
|
|
tickets.unshift(t); // 新工单放在列表最上面
|
|
selectedId=t.id; draftOrderId=null; mockServerRespond(t.id);
|
|
renderTickets();
|
|
}
|
|
|
|
function renderChat(t){
|
|
const prev=$('chatText'), keep=(prev&&prev.dataset.tid===t.id)?prev.value:''; // 推送刷新时保留输入
|
|
const o=order(t.orderId);
|
|
$('tkRight').innerHTML=`
|
|
<div class="right-h"><div><div class="t">${t.id} ${tag(t.status)}</div>
|
|
<div class="d">订单 ${o.id} · ${esc(o.name)}</div></div></div>
|
|
<div class="msgs" id="msgs">${t.messages.map(m=>
|
|
m.role==='sys'?`<div class="sys">${esc(m.text)} · ${m.time}</div>`:
|
|
`<div class="row ${m.role}"><div class="who">${m.role==='me'?'我':'客服'} ${m.time}</div><div class="bub">${esc(m.text)}</div></div>`).join('')}</div>
|
|
<div class="input">
|
|
<div class="composer"><textarea id="chatText" maxlength="500" data-tid="${t.id}" placeholder="${t.status==='completed'?'工单已完结,继续留言将重新开启':'输入留言'}"></textarea>
|
|
<div class="foot"><span class="hint">Ctrl+Enter 发送</span><span class="cnt">0 / 500</span><button class="btn" onclick="sendMsg()">发送</button></div></div>
|
|
</div>`;
|
|
$('chatText').value=keep;
|
|
bindInput($('chatText'),sendMsg);
|
|
const m=$('msgs'); m.scrollTop=m.scrollHeight;
|
|
}
|
|
|
|
/* ========== 状态流转(对应状态表) ========== */
|
|
function sendMsg(){ // 客户留言(含完结后追问)→ waiting_reply
|
|
const t=tickets.find(x=>x.id===selectedId), v=$('chatText').value.trim();
|
|
if(!v)return;
|
|
t.messages.push({role:'me',text:v,time:now()});
|
|
$('chatText').value=''; t.status='waiting_reply'; touch(t); mockServerRespond(t.id);
|
|
}
|
|
function touch(t){
|
|
t.updated=now();
|
|
tickets.splice(tickets.indexOf(t),1); tickets.unshift(t); // 有新动态的工单移到最上
|
|
renderTickets();
|
|
}
|
|
|
|
/* ========== SSE 推送 ========== */
|
|
const SSE_URL='/api/tickets/stream'; // 真实 SSE 地址
|
|
const USE_MOCK=true; // 接真实服务器时改为 false
|
|
const SSE_TEXT={connecting:'连接中…',connected:'已连接',reconnecting:'连接断开,重连中…',disconnected:'已断开,点击重试'};
|
|
let sse=null, sseState='connecting', retries=0, retryTimer=null;
|
|
|
|
function setSse(state){
|
|
sseState=state;
|
|
const el=$('sse');
|
|
el.title=state==='disconnected'?'点击重试':(USE_MOCK&&state==='connected'?'Demo:点击模拟断线':'');
|
|
el.innerHTML=`<span class="dot ${state}"></span><span>实时推送:${SSE_TEXT[state]}</span>`;
|
|
}
|
|
function connectSSE(){
|
|
clearTimeout(retryTimer);
|
|
if(sse){sse.onerror=null;sse.close();}
|
|
setSse(retries?'reconnecting':'connecting');
|
|
sse=USE_MOCK?new MockEventSource(SSE_URL):new EventSource(SSE_URL);
|
|
sse.onopen=()=>{retries=0;setSse('connected');};
|
|
sse.onmessage=e=>{try{handlePush(JSON.parse(e.data));}catch(err){console.error(err);}};
|
|
sse.onerror=()=>{
|
|
sse.close();
|
|
if(++retries>5){setSse('disconnected');return;} // 连续失败 5 次后停止自动重连
|
|
setSse('reconnecting');
|
|
retryTimer=setTimeout(connectSSE,Math.min(1000*retries,5000));
|
|
};
|
|
}
|
|
function onSseClick(){
|
|
if(sseState==='disconnected'){retries=0;connectSSE();}
|
|
else if(USE_MOCK&&sseState==='connected')sse.drop(); // 仅 Demo:模拟断线
|
|
}
|
|
|
|
/* 推送数据格式:{ticketId, status?, message?:{role:'cs'|'sys', text, time}} */
|
|
function handlePush(d){
|
|
const t=tickets.find(x=>x.id===d.ticketId); if(!t)return;
|
|
if(d.message)t.messages.push(d.message);
|
|
if(d.status)t.status=d.status;
|
|
t.updated=d.message?d.message.time:now();
|
|
const viewing=t.id===selectedId;
|
|
if(!viewing&&d.message)t.unread=(t.unread||0)+1; // 非当前查看的工单 → 未读 +1
|
|
tickets.splice(tickets.indexOf(t),1); tickets.unshift(t);
|
|
renderList();
|
|
if(viewing)renderChat(t); // 新建界面不刷新右侧,避免丢输入
|
|
}
|
|
|
|
/* ----- 以下为 Demo 用的模拟服务器,接真实 SSE 后整段删除 ----- */
|
|
class MockEventSource{
|
|
static pending=[]; static current=null; static offline=false;
|
|
constructor(url){
|
|
this.url=url; this.closed=false; this.connected=false; MockEventSource.current=this;
|
|
setTimeout(()=>{
|
|
if(this.closed)return;
|
|
if(MockEventSource.offline){this.onerror&&this.onerror();return;}
|
|
this.connected=true; this.onopen&&this.onopen(); MockEventSource.flush();
|
|
},800);
|
|
}
|
|
close(){this.closed=true; this.connected=false;}
|
|
drop(){ // 断线 4 秒
|
|
MockEventSource.offline=true; setTimeout(()=>MockEventSource.offline=false,4000);
|
|
this.onerror&&this.onerror();
|
|
}
|
|
static push(data){MockEventSource.pending.push(data); MockEventSource.flush();} // 断线期间的消息,重连后补发
|
|
static flush(){
|
|
const c=MockEventSource.current; if(!c||!c.connected)return;
|
|
while(MockEventSource.pending.length)c.onmessage&&c.onmessage({data:JSON.stringify(MockEventSource.pending.shift())});
|
|
}
|
|
}
|
|
function mockServerRespond(id){
|
|
if(!USE_MOCK)return;
|
|
setTimeout(()=>MockEventSource.push({ticketId:id,status:'replied',
|
|
message:{role:'cs',text:'您好,已收到您的反馈,我们会尽快处理。',time:now()}}),2500);
|
|
}
|
|
|
|
selectedId=tickets[0].id;
|
|
renderTickets();
|
|
connectSSE();
|
|
if(USE_MOCK)setTimeout(()=>MockEventSource.push({ticketId:'T0002',status:'replied',
|
|
message:{role:'cs',text:'已收到您的故障视频,正在为您安排换货。',time:now()}}),6000);
|
|
</script>
|
|
</body>
|
|
</html>
|