增加工单模块
This commit is contained in:
1 parent
dc136e449e
commit
f9dcae4610
17 files changed
+4908
No files matched your search
@@ -0,0 +1,318 @@
|
||||
<!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>
|
||||
Reference in new issue
Block a user