2484 lines
82 KiB
HTML
2484 lines
82 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>柏悦智能物流客服系统</title>
|
||
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
body {
|
||
font-family: "Microsoft YaHei", sans-serif;
|
||
margin: 0;
|
||
padding: 40px;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #0c2042 0%, #12346b 50%, #174894 100%);
|
||
background-attachment: fixed;
|
||
overflow-x: hidden;
|
||
position: relative;
|
||
}
|
||
.bg-wrap{
|
||
position: fixed;
|
||
left:0;
|
||
top:0;
|
||
width:100%;
|
||
height:100%;
|
||
z-index:-1;
|
||
overflow:hidden;
|
||
}
|
||
.meteor{
|
||
position:absolute;
|
||
width:180px;
|
||
height:2px;
|
||
background:linear-gradient(90deg, rgba(255,255,255,0), #b8d0ff, #fff);
|
||
border-radius: 100px;
|
||
filter: drop-shadow(0 0 4px #72a6ff);
|
||
transform: rotate(30deg);
|
||
animation: shoot 7s linear infinite;
|
||
}
|
||
.meteor:nth-child(1){ top:-10%; left:-10%; animation-delay:0s; }
|
||
.meteor:nth-child(2){ top:-10%; left:-10%; animation-delay:1.8s; }
|
||
.meteor:nth-child(3){ top:-10%; left:-10%; animation-delay:3.2s; }
|
||
.meteor:nth-child(4){ top:-10%; left:-10%; animation-delay:4.5s; }
|
||
@keyframes shoot {
|
||
0% {
|
||
transform: rotate(30deg) translateX(0);
|
||
opacity:0;
|
||
}
|
||
10% { opacity: 1; }
|
||
90% { opacity: 1; }
|
||
100% {
|
||
transform: rotate(30deg) translateX(1400px);
|
||
opacity:0;
|
||
}
|
||
}
|
||
.ship-silhouette{
|
||
position:absolute;
|
||
bottom: 0px;
|
||
left:0;
|
||
width:100%;
|
||
height:120px;
|
||
opacity:0.22;
|
||
}
|
||
.chat-container {
|
||
width: 1100px;
|
||
max-width: 100%;
|
||
height: 680px;
|
||
display: flex;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||
border-radius:14px;
|
||
overflow:hidden;
|
||
background: rgba(255,255,255,0.92);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
|
||
|
||
.left-panel {
|
||
width: 260px;
|
||
flex-shrink: 0;
|
||
background: linear-gradient(160deg, #12356d 0%, #1a53b3 100%);
|
||
color:white;
|
||
padding:22px 16px;
|
||
display:flex;
|
||
flex-direction:column;
|
||
max-height:100%;
|
||
overflow-y:auto;
|
||
}
|
||
.new-chat-btn{
|
||
width:100%;
|
||
padding:10px;
|
||
border-radius:10px;
|
||
border:none;
|
||
background:rgba(255,255,255,0.2);
|
||
color:#fff;
|
||
cursor:pointer;
|
||
margin-bottom:16px;
|
||
}
|
||
.clear-all-btn{
|
||
width:100%;
|
||
padding:10px;
|
||
border-radius:10px;
|
||
border:none;
|
||
background:rgba(255,100,100,0.25);
|
||
color:#fff;
|
||
cursor:pointer;
|
||
margin-bottom:16px;
|
||
font-size:13px;
|
||
}
|
||
.clear-all-btn:hover{
|
||
background:rgba(255,80,80,0.45);
|
||
}
|
||
.history-wrap{
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
.history-item {
|
||
border: 1px solid rgba(255,255,255,.22);
|
||
border-radius: 12px;
|
||
padding: 11px 13px;
|
||
margin-bottom: 8px;
|
||
background: rgba(255,255,255,.14);
|
||
cursor: pointer;
|
||
backdrop-filter: blur(4px);
|
||
font-size:13px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap:8px;
|
||
position: relative;
|
||
transition: all 0.25s ease;
|
||
}
|
||
|
||
.history-text-wrap{
|
||
flex:1;
|
||
min-width:0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
transition: all 0.25s ease;
|
||
}
|
||
.history-time{
|
||
font-size:11px;
|
||
color:rgba(255,255,255,0.72);
|
||
margin-top:4px;
|
||
display:none;
|
||
}
|
||
.history-item:hover .history-time{
|
||
display:block;
|
||
}
|
||
.del-history-btn{
|
||
width:20px;
|
||
height:20px;
|
||
flex-shrink:0;
|
||
border:none;
|
||
background:rgba(255,255,255,0.22);
|
||
color:#fff;
|
||
border-radius:50%;
|
||
font-size:14px;
|
||
line-height:1;
|
||
cursor:pointer;
|
||
padding:0;
|
||
}
|
||
.del-history-btn:hover{
|
||
background:rgba(255,80,80,0.7);
|
||
}
|
||
/* 删除按钮默认隐藏,鼠标移到这一条上才淡入 */
|
||
.del-history-btn{
|
||
opacity:0;
|
||
pointer-events:none;
|
||
transition:opacity 0.15s ease, background 0.2s ease;
|
||
}
|
||
.history-item:hover .del-history-btn,
|
||
.history-item:focus-within .del-history-btn{
|
||
opacity:1;
|
||
pointer-events:auto;
|
||
}
|
||
/* 触屏设备没有 hover,保持常显,不然删不掉 */
|
||
@media (hover: none){
|
||
.del-history-btn{
|
||
opacity:0.85;
|
||
pointer-events:auto;
|
||
}
|
||
}
|
||
.history-time{
|
||
font-size:11px;
|
||
color:rgba(255,255,255,0.72);
|
||
margin-top:4px;
|
||
}
|
||
.panel-title {
|
||
font-size: 13px;
|
||
color: rgba(255,255,255,.85);
|
||
margin: 12px 0 8px 4px;
|
||
}
|
||
/* 历史搜索框 */
|
||
.history-search-wrap{
|
||
margin-bottom:10px;
|
||
}
|
||
.history-search{
|
||
width:100%;
|
||
padding:8px 12px;
|
||
border-radius:10px;
|
||
border:1px solid rgba(255,255,255,.25);
|
||
background:rgba(255,255,255,.14);
|
||
color:#fff;
|
||
font-size:13px;
|
||
outline:none;
|
||
transition:all .2s;
|
||
}
|
||
.history-search::placeholder{
|
||
color:rgba(255,255,255,.6);
|
||
}
|
||
.history-search:focus{
|
||
border-color:rgba(255,255,255,.65);
|
||
background:rgba(255,255,255,.22);
|
||
}
|
||
.history-empty{
|
||
font-size:12px;
|
||
color:rgba(255,255,255,.6);
|
||
text-align:center;
|
||
padding:14px 0;
|
||
}
|
||
/* 深色模式切换按钮 */
|
||
.theme-toggle{
|
||
width:34px;
|
||
height:34px;
|
||
border-radius:50%;
|
||
border:1px solid #dde7f3;
|
||
background:#f5f9ff;
|
||
font-size:16px;
|
||
line-height:1;
|
||
cursor:pointer;
|
||
transition:all .2s;
|
||
}
|
||
.title-actions{
|
||
display:flex;
|
||
align-items:center;
|
||
gap:8px;
|
||
}
|
||
.lang-toggle{
|
||
font-size:13px;
|
||
font-weight:600;
|
||
color:#18479c;
|
||
}
|
||
body.dark .lang-toggle{
|
||
color:#9dc0f5;
|
||
}
|
||
.theme-toggle:hover{
|
||
background:#e8f1ff;
|
||
border-color:#2978e8;
|
||
}
|
||
|
||
/* ===== 深色模式 ===== */
|
||
body.dark .main-chat{
|
||
background:#141a26;
|
||
}
|
||
body.dark .chat-title{
|
||
background:#1b2331;
|
||
border-bottom-color:#2a3547;
|
||
color:#e6ecf5;
|
||
}
|
||
body.dark .theme-toggle{
|
||
background:#26313f;
|
||
border-color:#3a4759;
|
||
}
|
||
body.dark #chatDom{
|
||
background-color:#141a26;
|
||
}
|
||
body.dark .ai-welcome-card,
|
||
body.dark .hot-questions,
|
||
body.dark .recent-chat{
|
||
background:#1b2331;
|
||
box-shadow:0 6px 20px rgba(0,0,0,.35);
|
||
}
|
||
body.dark .ai-welcome-top h3,
|
||
body.dark .hot-questions h4,
|
||
body.dark .recent-chat h4{
|
||
color:#e6ecf5;
|
||
}
|
||
body.dark .ai-welcome-top p{
|
||
color:#8b9ab0;
|
||
}
|
||
body.dark .suggest-item{
|
||
background:#222c3c;
|
||
border-color:#2f3b4e;
|
||
color:#9dc0f5;
|
||
}
|
||
body.dark .suggest-item:hover{
|
||
background:#2978e8;
|
||
color:#fff;
|
||
border-color:#2978e8;
|
||
}
|
||
body.dark .hot-capsule{
|
||
background:#222c3c;
|
||
border-color:#2f3b4e;
|
||
color:#c3cede;
|
||
}
|
||
body.dark .hot-capsule:hover{
|
||
border-color:#2978e8;
|
||
background:#24344f;
|
||
}
|
||
body.dark .recent-item{
|
||
color:#c3cede;
|
||
border-bottom-color:#2a3547;
|
||
}
|
||
body.dark .recent-item:hover{
|
||
background:#222c3c;
|
||
}
|
||
body.dark .robot-message{
|
||
background:#1f2937;
|
||
color:#dbe4f0;
|
||
box-shadow:0 4px 14px rgba(0,0,0,.3);
|
||
}
|
||
body.dark .robot-message.error-bubble{
|
||
background:#2a1d1d;
|
||
border-color:#5c3232;
|
||
}
|
||
body.dark .robot-message.hint-bubble,
|
||
body.dark .robot-message.track-card{
|
||
background:#1b2331;
|
||
border-color:#2f3b4e;
|
||
}
|
||
body.dark .track-no{
|
||
color:#dbe4f0;
|
||
}
|
||
body.dark .track-node{
|
||
color:#a8b5c8;
|
||
}
|
||
body.dark .track-node.active .track-text{
|
||
color:#7fb2ff;
|
||
}
|
||
body.dark .track-node::before{
|
||
background:#2f3b4e;
|
||
}
|
||
body.dark .track-dot{
|
||
background:#1b2331;
|
||
border-color:#42536b;
|
||
}
|
||
body.dark .track-head{
|
||
border-bottom-color:#2a3547;
|
||
}
|
||
body.dark .input-area{
|
||
background:#1b2331;
|
||
border-top-color:#2a3547;
|
||
}
|
||
body.dark #input-text{
|
||
background:#141a26;
|
||
border-color:#2f3b4e;
|
||
color:#e6ecf5;
|
||
}
|
||
body.dark #input-text::placeholder{
|
||
color:#6b7a90;
|
||
}
|
||
body.dark .retry-btn{
|
||
color:#9dc0f5;
|
||
background:#24344f;
|
||
border-color:#3a5578;
|
||
}
|
||
body.dark .retry-btn:hover{
|
||
background:#2978e8;
|
||
color:#fff;
|
||
}
|
||
|
||
.main-chat{
|
||
flex-grow:1;
|
||
display:flex;
|
||
flex-direction:column;
|
||
background:#eef3fb;
|
||
position:relative;
|
||
}
|
||
.chat-title{
|
||
padding:14px 20px;
|
||
font-size:18px;
|
||
border-bottom:solid 1px #dde7f3;
|
||
background:#ffffff;
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:space-between;
|
||
}
|
||
.chat-title .ai-icon{
|
||
width:28px;
|
||
height:28px;
|
||
border-radius:50%;
|
||
background:linear-gradient(135deg,#18479c,#2978e8);
|
||
display:inline-flex;
|
||
align-items:center;
|
||
justify-content:center;
|
||
color:white;
|
||
font-size:14px;
|
||
margin-right:8px;
|
||
}
|
||
#chatDom{
|
||
position:relative;
|
||
flex-grow:1;
|
||
padding:30px 24px;
|
||
overflow-y:auto;
|
||
background-color:#eef3fb;
|
||
}
|
||
.ai-welcome-card{
|
||
background:linear-gradient(135deg,#ffffff 0%,#f0f6ff 100%);
|
||
border-radius:18px;
|
||
padding:24px;
|
||
box-shadow:0 6px 20px rgba(199,216,245,.5);
|
||
border-top:4px solid #2978e8;
|
||
margin-bottom:22px;
|
||
}
|
||
.ai-welcome-top{
|
||
display:flex;
|
||
align-items:center;
|
||
margin-bottom:14px;
|
||
}
|
||
.ai-avatar{
|
||
width:46px;
|
||
height:46px;
|
||
border-radius:50%;
|
||
background:linear-gradient(135deg,#18479c,#2978e8);
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:center;
|
||
color:white;
|
||
font-size:20px;
|
||
margin-right:14px;
|
||
}
|
||
.ai-welcome-top h3{
|
||
font-size:18px;
|
||
color:#222;
|
||
}
|
||
.ai-welcome-top p{
|
||
margin:4px 0 0 0;
|
||
font-size:13px;
|
||
color:#7f8fa6;
|
||
}
|
||
.suggest-grid{
|
||
display:grid;
|
||
grid-template-columns:1fr 1fr;
|
||
gap:10px;
|
||
margin-bottom:18px;
|
||
}
|
||
.suggest-item{
|
||
background:#f5f9ff;
|
||
border:1px solid #dce8fb;
|
||
border-radius:12px;
|
||
padding:12px;
|
||
font-size:13px;
|
||
color:#18479c;
|
||
cursor:pointer;
|
||
transition:0.2s;
|
||
}
|
||
.suggest-item:hover{
|
||
background:#18479c;
|
||
color:white;
|
||
border-color:#18479c;
|
||
}
|
||
.hot-questions,.recent-chat{
|
||
background:#ffffff;
|
||
border-radius:18px;
|
||
padding:20px;
|
||
box-shadow:0 6px 20px rgba(199,216,245,.5);
|
||
margin-bottom:22px;
|
||
}
|
||
.hot-questions h4,.recent-chat h4{
|
||
font-size:15px;
|
||
color:#222;
|
||
margin-bottom:12px;
|
||
display:flex;
|
||
align-items:center;
|
||
}
|
||
.hot-questions h4::before,.recent-chat h4::before{
|
||
content:"";
|
||
width:4px;
|
||
height:14px;
|
||
background:#2978e8;
|
||
border-radius:2px;
|
||
margin-right:8px;
|
||
}
|
||
/* 胶囊标签样式 */
|
||
.hot-capsule-wrap{
|
||
display:flex;
|
||
flex-wrap:wrap;
|
||
gap:10px;
|
||
}
|
||
.hot-capsule{
|
||
padding:8px 14px;
|
||
background:#f7faff;
|
||
border:1px solid #e4efff;
|
||
border-radius:999px;
|
||
font-size:13px;
|
||
color:#333;
|
||
cursor:pointer;
|
||
transition:0.2s;
|
||
}
|
||
.hot-capsule:hover{
|
||
border-color:#2978e8;
|
||
background:#e8f1ff;
|
||
}
|
||
.hot-bar{
|
||
display:flex;
|
||
align-items:center;
|
||
gap:10px;
|
||
flex-wrap:wrap;
|
||
padding:10px 20px 0;
|
||
}
|
||
.send-order-btn{
|
||
padding:6px 14px;
|
||
border-radius:18px;
|
||
background:#fff;
|
||
border:1px solid #ffb347;
|
||
color:#e0882a;
|
||
font-size:13px;
|
||
cursor:pointer;
|
||
white-space:nowrap;
|
||
font-weight:500;
|
||
}
|
||
.send-order-btn:hover{
|
||
background:#fff4e0;
|
||
}
|
||
.hot-bar-label{
|
||
font-size:12px;
|
||
color:#9aa7ba;
|
||
flex-shrink:0;
|
||
}
|
||
.hot-bar .hot-capsule{
|
||
padding:4px 12px;
|
||
font-size:12px;
|
||
}
|
||
.disclaimer{
|
||
text-align:center;
|
||
font-size:11px;
|
||
color:#aab4c4;
|
||
padding:4px 0 6px;
|
||
}
|
||
.robot-mascot{
|
||
position:fixed;
|
||
right:20px;
|
||
bottom:80px;
|
||
width:110px;
|
||
height:55px;
|
||
overflow:hidden;
|
||
z-index:9999;
|
||
filter:drop-shadow(0 4px 10px rgba(80,110,200,0.3));
|
||
cursor:grab;
|
||
transition:height 0.3s ease;
|
||
}
|
||
.robot-mascot:hover{
|
||
height:110px;
|
||
}
|
||
.robot-mascot svg{
|
||
width:110px;
|
||
height:110px;
|
||
display:block;
|
||
}
|
||
@keyframes robotFloat{
|
||
0%,100%{ transform:translateY(0); }
|
||
50%{ transform:translateY(-8px); }
|
||
}
|
||
.eye-pupils{
|
||
animation:eyeLook 4s ease-in-out infinite;
|
||
}
|
||
@keyframes eyeLook{
|
||
0%,100%{ transform:translate(0,0); }
|
||
25%{ transform:translate(3px,1px); }
|
||
50%{ transform:translate(-3px,1px); }
|
||
75%{ transform:translate(0,-2px); }
|
||
}
|
||
.antenna-light{
|
||
animation:blink 1.5s ease-in-out infinite;
|
||
}
|
||
@keyframes blink{
|
||
0%,100%{ opacity:1; }
|
||
50%{ opacity:0.3; }
|
||
}
|
||
.wave-arm{
|
||
animation:wave 2s ease-in-out infinite;
|
||
}
|
||
@keyframes wave{
|
||
0%,100%{ transform:rotate(0deg); }
|
||
50%{ transform:rotate(-25deg); }
|
||
}
|
||
.recent-item{
|
||
display:flex;
|
||
justify-content:space-between;
|
||
padding:10px 12px;
|
||
border-bottom:1px dashed #e4ebf5;
|
||
font-size:13px;
|
||
color:#333;
|
||
cursor:pointer;
|
||
}
|
||
.recent-item:last-child{
|
||
border-bottom:none;
|
||
}
|
||
.recent-item:hover{
|
||
background:#f7faff;
|
||
}
|
||
.recent-date{
|
||
color:#9aa7bd;
|
||
font-size:12px;
|
||
}
|
||
.msg-row{
|
||
margin-bottom:22px;
|
||
}
|
||
.user-line{
|
||
display:flex;
|
||
justify-content:flex-end;
|
||
}
|
||
.robot-line{
|
||
display:flex;
|
||
justify-content:flex-start;
|
||
}
|
||
.user-message{
|
||
max-width:72%;
|
||
background:linear-gradient(135deg,#18479c,#2978e8);
|
||
color:white;
|
||
padding:14px 18px;
|
||
border-radius:14px 14px 0 14px;
|
||
box-shadow:0 4px 14px rgba(41,120,232,0.3);
|
||
line-height:1.6;
|
||
white-space:pre-wrap;
|
||
word-break:break-word;
|
||
overflow-wrap:anywhere;
|
||
}
|
||
.robot-message{
|
||
max-width:78%;
|
||
min-width:0;
|
||
background:#ffffff;
|
||
padding:14px 18px;
|
||
border-radius:14px 14px 14px 0;
|
||
box-shadow:0 4px 14px rgba(199,216,245,.4);
|
||
color:#333;
|
||
line-height:1.6;
|
||
/* 关键:长英文/长数字/无空格内容也要换行,不能用 nowrap */
|
||
white-space:normal;
|
||
word-break:break-word;
|
||
overflow-wrap:anywhere;
|
||
}
|
||
.msg-time{
|
||
font-size:11px;
|
||
color:#9aa7ba;
|
||
margin-top:4px;
|
||
opacity:0.7;
|
||
}
|
||
.user-line .msg-time{
|
||
text-align:right;
|
||
}
|
||
.input-area{
|
||
display:flex;
|
||
padding:16px 20px;
|
||
gap:12px;
|
||
border-top:solid 1px #dde7f3;
|
||
background:#ffffff;
|
||
align-items:flex-end;
|
||
}
|
||
#input-text:focus{
|
||
border-color:#2978e8;
|
||
box-shadow:0 0 0 3px rgba(41,120,232,0.15);
|
||
}
|
||
#send-button{
|
||
width:64px;
|
||
height:44px;
|
||
border:none;
|
||
border-radius:22px;
|
||
background:linear-gradient(135deg,#18479c,#2978e8);
|
||
color:white;
|
||
font-size:18px;
|
||
cursor:pointer;
|
||
transition:0.2s;
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:center;
|
||
}
|
||
#upload-btn{
|
||
width:44px;
|
||
height:44px;
|
||
border:none;
|
||
border-radius:50%;
|
||
background:#eef3fb;
|
||
font-size:18px;
|
||
cursor:pointer;
|
||
flex-shrink:0;
|
||
}
|
||
#upload-btn:hover{
|
||
background:#dde7f5;
|
||
}
|
||
#send-button:hover{
|
||
transform:scale(1.05);
|
||
box-shadow:0 4px 12px rgba(41,120,232,0.4);
|
||
}
|
||
.robot-message.typing::after{
|
||
content:"▍";
|
||
margin-left:2px;
|
||
animation: blink 1s steps(1) infinite;
|
||
}
|
||
@keyframes blink{
|
||
50%{ opacity:0; }
|
||
}
|
||
.robot-message.error-bubble{
|
||
background:#fff7f6;
|
||
border:1px solid #f3c9c4;
|
||
}
|
||
.err-line{
|
||
color:#c0392b;
|
||
font-size:14px;
|
||
line-height:1.6;
|
||
word-break:break-word;
|
||
}
|
||
.retry-btn{
|
||
margin-top:10px;
|
||
padding:4px 14px;
|
||
font-size:12px;
|
||
color:#18479c;
|
||
background:#eef4ff;
|
||
border:1px solid #c9dcff;
|
||
border-radius:999px;
|
||
cursor:pointer;
|
||
transition:0.2s;
|
||
}
|
||
.retry-btn:hover{
|
||
background:#18479c;
|
||
color:#fff;
|
||
border-color:#18479c;
|
||
}
|
||
.robot-message.hint-bubble{
|
||
background:#f7f9fd;
|
||
border:1px dashed #cfd9e8;
|
||
color:#6b7a90;
|
||
font-size:13px;
|
||
}
|
||
/* ===== 滚动条美化 ===== */
|
||
/* Firefox:只认这两个属性 */
|
||
.left-panel, .history-wrap{
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(255,255,255,0.28) transparent;
|
||
}
|
||
#chatDom{
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(24,71,156,0.28) transparent;
|
||
}
|
||
/* Chrome / Edge:webkit 伪元素 */
|
||
::-webkit-scrollbar{
|
||
width:8px;
|
||
height:8px;
|
||
}
|
||
::-webkit-scrollbar-track{
|
||
background:transparent;
|
||
}
|
||
::-webkit-scrollbar-thumb{
|
||
background:rgba(24,71,156,0.25);
|
||
border-radius:10px;
|
||
}
|
||
::-webkit-scrollbar-thumb:hover{
|
||
background:rgba(24,71,156,0.45);
|
||
}
|
||
.left-panel::-webkit-scrollbar-thumb,
|
||
.history-wrap::-webkit-scrollbar-thumb{
|
||
background:rgba(255,255,255,0.25);
|
||
}
|
||
.left-panel::-webkit-scrollbar-thumb:hover,
|
||
.history-wrap::-webkit-scrollbar-thumb:hover{
|
||
background:rgba(255,255,255,0.45);
|
||
}
|
||
/* ===== 运单轨迹卡片 ===== */
|
||
.robot-message.track-card{
|
||
padding:16px 18px;
|
||
min-width:320px;
|
||
}
|
||
.track-head{
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:space-between;
|
||
gap:16px;
|
||
padding-bottom:12px;
|
||
border-bottom:1px dashed #e4ebf5;
|
||
margin-bottom:14px;
|
||
}
|
||
.track-no{
|
||
font-size:14px;
|
||
font-weight:600;
|
||
color:#2b3a50;
|
||
}
|
||
.track-no span{
|
||
font-weight:400;
|
||
font-size:12px;
|
||
color:#8a97ab;
|
||
margin-right:8px;
|
||
}
|
||
.track-status{
|
||
flex-shrink:0;
|
||
font-size:12px;
|
||
color:#fff;
|
||
background:linear-gradient(135deg,#18479c,#2978e8);
|
||
padding:3px 10px;
|
||
border-radius:999px;
|
||
}
|
||
.track-node{
|
||
position:relative;
|
||
padding-left:22px;
|
||
padding-bottom:16px;
|
||
font-size:13px;
|
||
color:#5b6b80;
|
||
line-height:1.5;
|
||
}
|
||
.track-node:last-child{
|
||
padding-bottom:0;
|
||
}
|
||
.track-node::before{
|
||
content:"";
|
||
position:absolute;
|
||
left:4px;
|
||
top:16px;
|
||
bottom:0;
|
||
width:1px;
|
||
background:#e1e8f2;
|
||
}
|
||
.track-node:last-child::before{
|
||
display:none;
|
||
}
|
||
.track-dot{
|
||
position:absolute;
|
||
left:0;
|
||
top:5px;
|
||
width:8px;
|
||
height:8px;
|
||
border-radius:50%;
|
||
background:#fff;
|
||
border:1px solid #c3d1e6;
|
||
}
|
||
.track-node.active .track-dot{
|
||
background:#2978e8;
|
||
border-color:#2978e8;
|
||
box-shadow:0 0 0 3px rgba(41,120,232,.15);
|
||
}
|
||
.track-node.active .track-text{
|
||
color:#1b3f7a;
|
||
font-weight:600;
|
||
}
|
||
.track-time{
|
||
color:#9aa7bd;
|
||
font-size:12px;
|
||
margin-right:10px;
|
||
}
|
||
.track-place{
|
||
color:#9aa7bd;
|
||
font-size:12px;
|
||
margin-left:8px;
|
||
}
|
||
/* ===== 多行输入框 ===== */
|
||
#input-text{
|
||
flex:1;
|
||
min-height:48px;
|
||
max-height:140px;
|
||
padding:13px 18px;
|
||
border:1px solid #d5dce6;
|
||
border-radius:20px;
|
||
font-size:15px;
|
||
line-height:1.5;
|
||
font-family:inherit;
|
||
outline:none;
|
||
resize:none;
|
||
overflow-y:auto;
|
||
transition:border-color .2s, box-shadow .2s;
|
||
}
|
||
body.dark #input-text{
|
||
resize:none;
|
||
}
|
||
/* 发送按钮:生成中变成停止按钮 */
|
||
#send-button.stop{
|
||
background:#e5484d;
|
||
font-size:14px;
|
||
}
|
||
#send-button.stop:hover{
|
||
background:#d13b40;
|
||
}
|
||
/* 每条回答的操作条:复制 / 重答 */
|
||
.msg-actions{
|
||
display:flex;
|
||
gap:6px;
|
||
margin-top:8px;
|
||
opacity:0;
|
||
transition:opacity .15s;
|
||
}
|
||
/* 卡片里的操作条单独占一行 */
|
||
.track-card .msg-actions{
|
||
clear:both;
|
||
padding-top:6px;
|
||
border-top:1px dashed #e4ebf5;
|
||
}
|
||
.robot-line:hover .msg-actions{
|
||
opacity:1;
|
||
}
|
||
.msg-action-btn{
|
||
padding:3px 10px;
|
||
font-size:12px;
|
||
color:#5b6b80;
|
||
background:#f4f7fc;
|
||
border:1px solid #e1e8f2;
|
||
border-radius:999px;
|
||
cursor:pointer;
|
||
transition:all .2s;
|
||
}
|
||
.msg-action-btn:hover{
|
||
color:#18479c;
|
||
border-color:#2978e8;
|
||
background:#eef4ff;
|
||
}
|
||
body.dark .msg-action-btn{
|
||
color:#a8b5c8;
|
||
background:#222c3c;
|
||
border-color:#2f3b4e;
|
||
}
|
||
body.dark .msg-action-btn:hover{
|
||
color:#fff;
|
||
background:#2978e8;
|
||
border-color:#2978e8;
|
||
}
|
||
/* 👍/👎 选中态 */
|
||
.msg-action-btn.voted{
|
||
color:#fff;
|
||
background:#2978e8;
|
||
border-color:#2978e8;
|
||
}
|
||
/* 回答后的相关追问 */
|
||
.followup-list{
|
||
margin-top:10px;
|
||
border-top:1px solid #eef2f8;
|
||
padding-top:8px;
|
||
}
|
||
.followup-item{
|
||
display:flex;
|
||
justify-content:space-between;
|
||
align-items:center;
|
||
padding:9px 4px;
|
||
font-size:13px;
|
||
color:#445;
|
||
cursor:pointer;
|
||
border-bottom:1px solid #f2f5fa;
|
||
}
|
||
.followup-item:last-child{ border-bottom:none; }
|
||
.followup-item:hover{ color:#18479c; }
|
||
.followup-item .arrow{ color:#b6c2d4; }
|
||
body.dark .followup-item{ color:#c3cede; border-bottom-color:#2a3547; }
|
||
body.dark .followup-item:hover{ color:#fff; }
|
||
body.dark .followup-list{ border-top-color:#2a3547; }
|
||
/* 已停止生成提示 */
|
||
.stopped-hint{
|
||
margin-top:8px;
|
||
font-size:12px;
|
||
color:#9aa7bd;
|
||
}
|
||
.robot-message ul,
|
||
.robot-message ol{
|
||
margin:6px 0 6px 22px;
|
||
padding:0;
|
||
}
|
||
.robot-message li{
|
||
margin:3px 0;
|
||
line-height:1.7;
|
||
}
|
||
.robot-message code{
|
||
background:#eef2f8;
|
||
padding:1px 5px;
|
||
border-radius:4px;
|
||
font-family:Consolas, "Courier New", monospace;
|
||
font-size:13px;
|
||
}
|
||
body.dark .robot-message code{
|
||
background:#2a3547;
|
||
color:#cfe0f7;
|
||
}
|
||
/* 回到底部 */
|
||
.to-bottom{
|
||
position:absolute;
|
||
right:26px;
|
||
bottom:96px;
|
||
width:38px;
|
||
height:38px;
|
||
border-radius:50%;
|
||
border:1px solid #dde7f3;
|
||
background:#fff;
|
||
color:#18479c;
|
||
font-size:18px;
|
||
line-height:1;
|
||
cursor:pointer;
|
||
box-shadow:0 4px 14px rgba(24,71,156,.18);
|
||
display:none;
|
||
z-index:5;
|
||
transition:all .2s;
|
||
}
|
||
.to-bottom.show{
|
||
display:block;
|
||
}
|
||
.to-bottom:hover{
|
||
background:#2978e8;
|
||
color:#fff;
|
||
border-color:#2978e8;
|
||
}
|
||
body.dark .to-bottom{
|
||
background:#1b2331;
|
||
border-color:#2f3b4e;
|
||
color:#9dc0f5;
|
||
}
|
||
.toggle-divider{
|
||
width:14px;
|
||
background:#1a53b3;
|
||
color:#fff;
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:center;
|
||
cursor:pointer;
|
||
border-radius:0 6px 6px 0;
|
||
user-select:none;
|
||
}
|
||
.left-panel-hide{
|
||
width:0 !important;
|
||
overflow:hidden;
|
||
padding:0 !important;
|
||
}
|
||
/* ===== 移动端 / 窄屏适配(放在所有基础样式之后,确保覆盖生效) ===== */
|
||
@media (max-width: 1180px){
|
||
body{
|
||
padding: 16px;
|
||
}
|
||
.chat-container{
|
||
width: 100%;
|
||
height: calc(100vh - 32px);
|
||
}
|
||
}
|
||
@media (max-width: 900px){
|
||
html, body{
|
||
width: 100%;
|
||
height: 100%;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: block;
|
||
overflow: hidden;
|
||
}
|
||
.chat-container{
|
||
width: 100vw;
|
||
max-width: 100vw;
|
||
height: 100dvh;
|
||
border-radius: 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
/* 侧栏改成抽屉:绝对定位,不再占据文档流宽度 */
|
||
.chat-container .left-panel{
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
z-index: 20;
|
||
width: 260px;
|
||
max-width: 80vw;
|
||
max-height: none;
|
||
height: 100%;
|
||
box-shadow: 6px 0 24px rgba(0,0,0,.35);
|
||
transition: transform .25s ease;
|
||
}
|
||
.chat-container.side-closed .left-panel{
|
||
transform: translateX(-100%);
|
||
}
|
||
.toggle-divider{
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
z-index: 21;
|
||
width: 14px;
|
||
height: 56px;
|
||
border-radius: 0 8px 8px 0;
|
||
}
|
||
.chat-container .history-wrap{
|
||
max-height: none;
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
/* 主聊天区占满,且内部元素一律不超出 */
|
||
.main-chat{
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
.chat-title{
|
||
padding: 12px 14px;
|
||
font-size: 16px;
|
||
}
|
||
.chat-title > span{
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
#chatDom{
|
||
padding: 16px 12px;
|
||
width: 100%;
|
||
min-width: 0;
|
||
overflow-x: hidden;
|
||
}
|
||
.ai-welcome-card,
|
||
.hot-questions,
|
||
.recent-chat{
|
||
width: 100%;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.user-message, .robot-message{
|
||
max-width: 88%;
|
||
min-width: 0;
|
||
}
|
||
.input-area{
|
||
padding: 10px 12px;
|
||
gap: 8px;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
#input-text{
|
||
min-width: 0;
|
||
flex: 1 1 auto;
|
||
}
|
||
#send-button{
|
||
width: 68px;
|
||
flex: 0 0 68px;
|
||
border-radius: 18px;
|
||
}
|
||
.suggest-grid{
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.to-bottom{
|
||
right: 14px;
|
||
bottom: 84px;
|
||
}
|
||
.hot-bar{
|
||
padding: 8px 10px 0 !important;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.hot-capsule{
|
||
font-size: 12px;
|
||
padding: 5px 10px;
|
||
}
|
||
.robot-mascot{
|
||
right: 8px;
|
||
bottom:-40px;
|
||
transform: scale(0.7);
|
||
transform-origin: bottom right;
|
||
}
|
||
.robot-mascot:hover{
|
||
bottom:10px;
|
||
}
|
||
.ai-welcome-card{
|
||
padding: 16px !important;
|
||
}
|
||
.welcome-title{
|
||
font-size: 20px !important;
|
||
}
|
||
.suggest-grid{
|
||
gap: 8px !important;
|
||
}
|
||
.suggest-item{
|
||
padding: 10px 12px !important;
|
||
font-size: 13px !important;
|
||
}
|
||
.disclaimer{
|
||
font-size: 11px !important;
|
||
padding: 4px 12px !important;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
</head>
|
||
<body>
|
||
<div class="bg-wrap">
|
||
<div class="meteor"></div>
|
||
<div class="meteor"></div>
|
||
<div class="meteor"></div>
|
||
<div class="meteor"></div>
|
||
<svg class="ship-silhouette" viewBox="0 0 1200 120">
|
||
<path fill="#184482" d="M0,80 L120,80 L140,60 L260,60 L280,80 L400,80 L420,70 L550,70 L570,80 L700,80 L720,65 L860,65 L880,80 L1020,80 L1040,72 L1150,72 L1170,80 L1200,80 L1200,120 L0,120 Z"/>
|
||
</svg>
|
||
</div>
|
||
|
||
<div class="chat-container">
|
||
<div class="left-panel">
|
||
<button class="new-chat-btn">新建对话</button>
|
||
<button class="clear-all-btn">清空全部历史</button>
|
||
<div class="panel-title">历史对话</div>
|
||
<div class="history-search-wrap">
|
||
<input id="history-search" class="history-search" type="text" placeholder="搜索历史对话..." autocomplete="off">
|
||
</div>
|
||
<div class="history-wrap" id="historyWrap">
|
||
</div>
|
||
</div>
|
||
<div class="toggle-divider">◀</div>
|
||
|
||
<div class="main-chat">
|
||
<div class="chat-title">
|
||
<span><span class="ai-icon">🤖</span><span id="app-title">柏悦物流智能助手</span></span>
|
||
<span class="title-actions">
|
||
<button id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
|
||
<button id="font-toggle" class="theme-toggle" title="字体大小">A</button>
|
||
<button id="theme-toggle" class="theme-toggle" title="切换深色/浅色">🌙</button>
|
||
</span>
|
||
</div>
|
||
<div id="chatDom">
|
||
<div class="ai-welcome-card">
|
||
<div class="ai-welcome-top">
|
||
<div class="ai-avatar">🤖</div>
|
||
<div>
|
||
<h3>您好,我是柏悦物流智能助手</h3>
|
||
<p>很高兴为您提供物流查询、专线咨询、异常处理等服务。</p >
|
||
</div>
|
||
</div>
|
||
<p>您可以直接输入问题,也可以点击下面选项快速提问。</p >
|
||
<div class="suggest-grid">
|
||
<div class="suggest-item" onclick="askNewTopic('帮我查询物流轨迹')">🚚 查询物流轨迹</div>
|
||
<div class="suggest-item" onclick="askNewTopic('美国海运专线报价')">💰 美国海运专线</div>
|
||
<div class="suggest-item" onclick="askNewTopic('包裹异常处理')">⚠️ 包裹异常处理</div>
|
||
<div class="suggest-item" onclick="showHuman('转接人工')">👤 转接人工客服</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hot-questions">
|
||
<h4>热点问题</h4>
|
||
<div class="hot-capsule-wrap">
|
||
<div class="hot-capsule" onclick="askNewTopic('如何查询物流轨迹?')">如何查询物流轨迹?</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('美国海运专线时效是多久?')">美国海运专线时效是多久?</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('包裹显示异常签收怎么办?')">包裹显示异常签收怎么办?</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('如何申请理赔?')">如何申请理赔?</div>
|
||
</div>
|
||
</div>
|
||
<div class="recent-chat">
|
||
<h4>最近对话记录</h4>
|
||
<div id="recentChatWrap"></div>
|
||
</div>
|
||
</div>
|
||
<div class="hot-bar">
|
||
<div class="send-order-btn" onclick="sendOrder()">📦 发运单</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('怎么查我的物流轨迹?')">物流轨迹查询</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('欧洲海运要多久?')">欧洲海运时效</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('寄到美国怎么收费?')">美国海运报价</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('包裹丢了或破损怎么办?')">异常与理赔</div>
|
||
<div class="hot-capsule" onclick="showHuman()">联系人工</div>
|
||
</div>
|
||
<div class="input-area">
|
||
<button id="upload-btn" title="发送图片">📎</button>
|
||
<input type="file" id="upload-input" accept="image/*,.pdf" hidden>
|
||
<textarea id="input-text" rows="1" placeholder="请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)"></textarea>
|
||
<button id="send-button">➤</button>
|
||
</div>
|
||
<div class="disclaimer">AI 生成内容仅供参考,具体以人工客服回复为准</div>
|
||
<div class="robot-mascot" title="柏悦物流小助手">
|
||
<svg viewBox="0 0 160 160" width="110" height="110">
|
||
<!-- 天线 -->
|
||
<line x1="80" y1="8" x2="80" y2="22" stroke="#6b8ce0" stroke-width="3"/>
|
||
<circle cx="80" cy="7" r="5" fill="#ffd166" class="antenna-light"/>
|
||
<!-- 耳朵 -->
|
||
<circle cx="34" cy="62" r="9" fill="#8aa5f0"/>
|
||
<circle cx="126" cy="62" r="9" fill="#8aa5f0"/>
|
||
<circle cx="34" cy="62" r="4" fill="#dfe8ff"/>
|
||
<circle cx="126" cy="62" r="4" fill="#dfe8ff"/>
|
||
<!-- 头 -->
|
||
<rect x="36" y="20" width="88" height="66" rx="30" fill="#8aa5f0"/>
|
||
<!-- 脸屏 -->
|
||
<rect x="48" y="34" width="64" height="40" rx="16" fill="#2b3a6b"/>
|
||
<!-- 眼睛(半圆月牙眼) -->
|
||
<g class="eye-pupils">
|
||
<path d="M60 52 Q66 46 72 52" stroke="#a8e6cf" stroke-width="3.5" fill="none" stroke-linecap="round"/>
|
||
<path d="M88 52 Q94 46 100 52" stroke="#a8e6cf" stroke-width="3.5" fill="none" stroke-linecap="round"/>
|
||
</g>
|
||
<!-- 嘴(微笑) -->
|
||
<path d="M72 62 Q80 68 88 62" stroke="#a8e6cf" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||
<!-- 腮红 -->
|
||
<ellipse cx="56" cy="62" rx="5" ry="3" fill="#ffb3c1" opacity="0.85"/>
|
||
<ellipse cx="104" cy="62" rx="5" ry="3" fill="#ffb3c1" opacity="0.85"/>
|
||
<!-- 身体 -->
|
||
<rect x="48" y="88" width="64" height="44" rx="18" fill="#8aa5f0"/>
|
||
<!-- 肚子 -->
|
||
<ellipse cx="80" cy="112" rx="20" ry="14" fill="#dfe8ff"/>
|
||
<!-- 胸口小箱子图标 -->
|
||
<rect x="70" y="106" width="20" height="14" rx="3" fill="#ffb347"/>
|
||
<line x1="70" y1="111" x2="90" y2="111" stroke="#e0882a" stroke-width="1.5"/>
|
||
<line x1="80" y1="106" x2="80" y2="120" stroke="#e0882a" stroke-width="1.5"/>
|
||
<!-- 招手的手(举着箱子) -->
|
||
<g class="wave-arm" style="transform-origin:120px 92px;">
|
||
<rect x="118" y="86" width="11" height="24" rx="5.5" fill="#8aa5f0"/>
|
||
<rect x="114" y="76" width="18" height="16" rx="3" fill="#ffb347"/>
|
||
<line x1="114" y1="82" x2="132" y2="82" stroke="#e0882a" stroke-width="1.5"/>
|
||
</g>
|
||
<!-- 左手 -->
|
||
<rect x="32" y="92" width="11" height="22" rx="5.5" fill="#8aa5f0"/>
|
||
<circle cx="37" cy="92" r="6" fill="#8aa5f0"/>
|
||
<!-- 脚 -->
|
||
<rect x="58" y="130" width="14" height="16" rx="6" fill="#6b8ce0"/>
|
||
<rect x="88" y="130" width="14" height="16" rx="6" fill="#6b8ce0"/>
|
||
</svg>
|
||
</div>
|
||
<button id="to-bottom" class="to-bottom" title="回到底部">↓</button>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function(){
|
||
const chatDom = document.getElementById("chatDom");
|
||
const inputDom = document.getElementById("input-text");
|
||
const sendBtn = document.getElementById("send-button");
|
||
const uploadBtn = document.getElementById("upload-btn");
|
||
const uploadInput = document.getElementById("upload-input");
|
||
uploadBtn.onclick = ()=> uploadInput.click();
|
||
uploadInput.onchange = function(){
|
||
const f = uploadInput.files[0];
|
||
if(!f) return;
|
||
const fd = new FormData();
|
||
fd.append("file", f);
|
||
uploadBtn.textContent = "⏳";
|
||
fetch("/api/upload", {method:"POST", body:fd})
|
||
.then(r=>r.json())
|
||
.then(data=>{
|
||
uploadBtn.textContent = "📎";
|
||
if(data.ok){
|
||
let text = "[附件] " + data.url;
|
||
if(data.is_image) text = "[图片] " + data.url;
|
||
inputDom.value = text;
|
||
inputDom.focus();
|
||
}else{
|
||
alert(data.error || "上传失败");
|
||
}
|
||
})
|
||
.catch(()=>{ uploadBtn.textContent = "📎"; alert("上传失败"); });
|
||
uploadInput.value = "";
|
||
};
|
||
const leftHistoryWrap = document.getElementById("historyWrap");
|
||
const recentChatWrap = document.getElementById("recentChatWrap");
|
||
const clearAllBtn = document.querySelector('.clear-all-btn');
|
||
const newChatBtn = document.querySelector('.new-chat-btn');
|
||
window.__busy = false; // 生成中状态,用 window 属性避免作用域问题
|
||
// 生成中状态(语言切换等会引用,需提前声明)
|
||
|
||
// ===== 深色模式 =====
|
||
(function initTheme(){
|
||
const KEY = "wuliu-theme";
|
||
const btn = document.getElementById("theme-toggle");
|
||
function apply(mode){
|
||
document.body.classList.toggle("dark", mode === "dark");
|
||
if(btn) btn.textContent = mode === "dark" ? "☀️" : "🌙";
|
||
}
|
||
let saved = null;
|
||
try{ saved = localStorage.getItem(KEY); }catch(e){}
|
||
if(!saved){
|
||
saved = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||
}
|
||
apply(saved);
|
||
if(btn){
|
||
btn.onclick = function(){
|
||
const next = document.body.classList.contains("dark") ? "light" : "dark";
|
||
apply(next);
|
||
try{ localStorage.setItem(KEY, next); }catch(e){}
|
||
}
|
||
}
|
||
})();
|
||
|
||
// ===== 字体大小切换 =====
|
||
(function(){
|
||
var KEY = "wuliu-fontsize";
|
||
var SIZES = [14, 16, 18, 20];
|
||
var idx = 1;
|
||
try{ var s = localStorage.getItem(KEY); if(s) idx = parseInt(s); }catch(e){}
|
||
var btn = document.getElementById("font-toggle");
|
||
function apply(){
|
||
document.body.style.fontSize = SIZES[idx] + "px";
|
||
if(btn) btn.textContent = SIZES[idx] + "px";
|
||
}
|
||
apply();
|
||
if(btn){
|
||
btn.onclick = function(){
|
||
idx = (idx + 1) % SIZES.length;
|
||
apply();
|
||
try{ localStorage.setItem(KEY, idx); }catch(e){}
|
||
};
|
||
}
|
||
})();
|
||
|
||
// ===== 中英文切换 =====
|
||
const I18N = {
|
||
zh: {
|
||
appTitle: "柏悦物流智能助手",
|
||
newChat: "新建对话",
|
||
clearAll: "清空全部历史",
|
||
history: "历史对话",
|
||
searchPh: "搜索历史对话...",
|
||
confirmClear: "确定清空全部历史对话吗?无法恢复!",
|
||
confirmDel: "确定要删除这条历史对话吗?",
|
||
emptySearch: "没有匹配的历史对话",
|
||
emptyHistory: "暂无历史对话",
|
||
thinking: "正在思考中...",
|
||
busyRetry: "服务繁忙,请稍后重试",
|
||
sessionFail: "会话创建失败,请确认服务已启动",
|
||
networkFail: "网络连接中断,请检查网络后重试",
|
||
stopped: "已停止生成",
|
||
stoppedMark: "(已停止生成)",
|
||
inputPh: "请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)",
|
||
send: "发送",
|
||
stop: "停止",
|
||
copy: "复制",
|
||
copied: "已复制",
|
||
retry: "重答",
|
||
clickRetry: "点击重试",
|
||
welcomeTitle: "您好,我是柏悦物流智能助手",
|
||
welcomeSub: "很高兴为您提供物流查询、专线咨询、异常处理等服务。",
|
||
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
|
||
hot: "热点问题",
|
||
recent: "最近对话记录",
|
||
sideCollapse: "收起侧栏",
|
||
sideExpand: "展开侧栏",
|
||
themeTip: "切换深色/浅色",
|
||
langTip: "中英文切换(Ctrl+L)",
|
||
toBottom: "回到底部",
|
||
quick: [
|
||
"帮我查询物流轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||
],
|
||
hotList: [
|
||
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
|
||
]
|
||
},
|
||
en: {
|
||
appTitle: "Baiyue Logistics AI Assistant",
|
||
newChat: "New Chat",
|
||
clearAll: "Clear All History",
|
||
history: "History",
|
||
searchPh: "Search history...",
|
||
confirmClear: "Clear all conversation history? This cannot be undone.",
|
||
confirmDel: "Delete this conversation?",
|
||
emptySearch: "No matching conversations",
|
||
emptyHistory: "No conversations yet",
|
||
thinking: "Thinking...",
|
||
busyRetry: "Service busy, please try again later",
|
||
sessionFail: "Failed to create session, please check the service is running",
|
||
networkFail: "Connection lost, please check your network and retry",
|
||
stopped: "Stopped",
|
||
stoppedMark: "(Generation stopped)",
|
||
inputPh: "Ask a question or enter a tracking number... (Enter to send, Shift+Enter for newline)",
|
||
send: "Send",
|
||
stop: "Stop",
|
||
copy: "Copy",
|
||
copied: "Copied",
|
||
retry: "Regenerate",
|
||
clickRetry: "Retry",
|
||
welcomeTitle: "Hi, I'm the Baiyue Logistics Assistant",
|
||
welcomeSub: "I can help with shipment tracking, route quotes, exceptions and more.",
|
||
welcomeTip: "Type your question, or pick a quick option below.",
|
||
hot: "Popular Questions",
|
||
recent: "Recent Conversations",
|
||
sideCollapse: "Collapse sidebar",
|
||
sideExpand: "Expand sidebar",
|
||
themeTip: "Toggle dark/light",
|
||
langTip: "Switch language (Ctrl+L)",
|
||
toBottom: "Back to bottom",
|
||
quick: [
|
||
"Track my shipment", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||
],
|
||
hotList: [
|
||
"How do I track my shipment?", "How long does Europe sea freight take?",
|
||
"My parcel shows delivered but I didn't get it", "How do I file a claim?"
|
||
]
|
||
}
|
||
};
|
||
let LANG = "zh";
|
||
function t(key){
|
||
return (I18N[LANG] || I18N.zh)[key];
|
||
}
|
||
|
||
function applyLanguage(lang){
|
||
LANG = lang === "en" ? "en" : "zh";
|
||
const dict = I18N[LANG];
|
||
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
|
||
try{ localStorage.setItem("wuliu-lang", LANG); }catch(e){}
|
||
|
||
const titleEl = document.getElementById("app-title");
|
||
if(titleEl) titleEl.textContent = dict.appTitle;
|
||
const langBtn = document.getElementById("lang-toggle");
|
||
if(langBtn){
|
||
langBtn.textContent = LANG === "en" ? "EN" : "中";
|
||
langBtn.title = dict.langTip;
|
||
}
|
||
const themeBtn = document.getElementById("theme-toggle");
|
||
if(themeBtn) themeBtn.title = dict.themeTip;
|
||
document.querySelectorAll(".new-chat-btn").forEach(function(b){ b.textContent = dict.newChat; });
|
||
document.querySelectorAll(".clear-all-btn").forEach(function(b){ b.textContent = dict.clearAll; });
|
||
document.querySelectorAll(".panel-title").forEach(function(e){ e.textContent = dict.history; });
|
||
const hs = document.getElementById("history-search");
|
||
if(hs) hs.placeholder = dict.searchPh;
|
||
if(window.innerWidth > 900 && inputDom) inputDom.placeholder = dict.inputPh;
|
||
sendBtn.textContent = window.__busy ? dict.stop : dict.send;
|
||
const tb = document.getElementById("to-bottom");
|
||
if(tb) tb.title = dict.toBottom;
|
||
const tg = document.querySelector(".toggle-divider");
|
||
if(tg) tg.title = document.querySelector('.chat-container').classList.contains('side-closed') ? dict.sideExpand : dict.sideCollapse;
|
||
|
||
// 欢迎页(若当前显示的是欢迎页则重绘)
|
||
if(chatDom.querySelector(".ai-welcome-card")){
|
||
const scroll = chatDom.scrollTop;
|
||
chatDom.innerHTML = welcomeHTML();
|
||
chatDom.scrollTop = scroll;
|
||
}
|
||
// 历史列表 / 最近对话(空状态与条目都要刷新)
|
||
if(typeof renderSideHistory === 'function'){
|
||
try{
|
||
renderSideHistory();
|
||
renderRecentChat();
|
||
}catch(e){ /* 列表尚未初始化时忽略 */ }
|
||
}
|
||
// 已有消息上的复制/重答按钮
|
||
document.querySelectorAll(".msg-action-btn").forEach(function(b){
|
||
const n = b.dataset.action;
|
||
if(n === "copy") b.textContent = dict.copy;
|
||
if(n === "retry") b.textContent = dict.retry;
|
||
});
|
||
document.querySelectorAll(".retry-btn").forEach(function(b){
|
||
if(!b.dataset.keep) b.textContent = dict.clickRetry;
|
||
});
|
||
}
|
||
|
||
(function initLang(){
|
||
let saved = null;
|
||
try{ saved = localStorage.getItem("wuliu-lang"); }catch(e){}
|
||
if(!saved){
|
||
saved = (navigator.language || "zh").toLowerCase().indexOf("zh") === 0 ? "zh" : "en";
|
||
}
|
||
applyLanguage(saved);
|
||
const btn = document.getElementById("lang-toggle");
|
||
if(btn){
|
||
btn.onclick = function(){ applyLanguage(LANG === "en" ? "zh" : "en"); };
|
||
}
|
||
// 快捷键:Ctrl+L 切换语言(不占用输入框)
|
||
document.addEventListener("keydown", function(e){
|
||
if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l"){
|
||
e.preventDefault();
|
||
applyLanguage(LANG === "en" ? "zh" : "en");
|
||
}
|
||
});
|
||
})();
|
||
|
||
// ===== 历史搜索 =====
|
||
const historySearch = document.getElementById("history-search");
|
||
if(historySearch){
|
||
historySearch.addEventListener("input", renderSideHistory);
|
||
}
|
||
|
||
let historyList = [];
|
||
let sseInstance = null;
|
||
let activeBubble = null; // 正在输出的气泡
|
||
let cancelActive = null; // 中止当前请求(切换对话/重复发送时用)
|
||
let currentConvId = null; // 当前会话 id,null 表示还没建
|
||
|
||
function formatTime(timeStr) {
|
||
const target = new Date(timeStr);
|
||
const y = target.getFullYear();
|
||
const m = String(target.getMonth() + 1).padStart(2, '0');
|
||
const d = String(target.getDate()).padStart(2, '0');
|
||
const h = String(target.getHours()).padStart(2, '0');
|
||
const min = String(target.getMinutes()).padStart(2, '0');
|
||
return `${y}-${m}-${d} ${h}:${min}`;
|
||
}
|
||
|
||
// 先转义再套格式,防止模型输出里的标签被浏览器执行
|
||
function escHtml(s){
|
||
return String(s).replace(/[&<>"]/g, function(c){
|
||
return {"&":"&", "<":"<", ">":">", '"':"""}[c];
|
||
});
|
||
}
|
||
|
||
// 轻量 Markdown:加粗、行内代码、无序/有序列表、换行
|
||
function mdToHtml(text){
|
||
const inline = function(s){
|
||
return s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||
};
|
||
const lines = escHtml(text).split("\n");
|
||
let html = "";
|
||
let list = null;
|
||
for (let i = 0; i < lines.length; i++){
|
||
const t = lines[i].trim();
|
||
let m;
|
||
if ((m = t.match(/^[-*•]\s+(.*)$/))){ // 无序列表
|
||
if (list !== "ul"){ if (list) html += "</" + list + ">"; html += "<ul>"; list = "ul"; }
|
||
html += "<li>" + inline(m[1]) + "</li>";
|
||
} else if ((m = t.match(/^\d+[.、)]\s*(.*)$/))){ // 有序列表
|
||
if (list !== "ol"){ if (list) html += "</" + list + ">"; html += "<ol>"; list = "ol"; }
|
||
html += "<li>" + inline(m[1]) + "</li>";
|
||
} else if (!t){ // 空行
|
||
if (list){ html += "</" + list + ">"; list = null; }
|
||
html += "<br>";
|
||
} else { // 普通段落
|
||
if (list){ html += "</" + list + ">"; list = null; }
|
||
html += inline(t) + "<br>";
|
||
}
|
||
}
|
||
if (list) html += "</" + list + ">";
|
||
return html;
|
||
}
|
||
|
||
function parseBold(text){
|
||
return mdToHtml(text);
|
||
}
|
||
|
||
// 空会话时的欢迎内容(新建对话 / 打开空会话都要用)
|
||
function welcomeHTML(){
|
||
const q = t("quick");
|
||
const h = t("hotList");
|
||
return `
|
||
<div class="ai-welcome-card">
|
||
<div class="ai-welcome-top">
|
||
<div class="ai-avatar">🤖</div>
|
||
<div>
|
||
<h3>${t("welcomeTitle")}</h3>
|
||
<p>${t("welcomeSub")}</p >
|
||
</div>
|
||
</div>
|
||
<p>${t("welcomeTip")}</p >
|
||
<div class="suggest-grid">
|
||
<div class="suggest-item" onclick="askNewTopic('${q[0]}')">🚚 ${q[0]}</div>
|
||
<div class="suggest-item" onclick="askNewTopic('${q[1]}')">💰 ${q[1]}</div>
|
||
<div class="suggest-item" onclick="askNewTopic('${q[2]}')">⚠️ ${q[2]}</div>
|
||
<div class="suggest-item" onclick="showHuman()">👤 ${q[3]}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hot-questions">
|
||
<h4>${t("hot")}</h4>
|
||
<div class="hot-capsule-wrap">
|
||
<div class="hot-capsule" onclick="askNewTopic('${h[0]}')">${h[0]}</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('${h[1]}')">${h[1]}</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('${h[2]}')">${h[2]}</div>
|
||
<div class="hot-capsule" onclick="askNewTopic('${h[3]}')">${h[3]}</div>
|
||
</div>
|
||
</div>
|
||
<div class="recent-chat">
|
||
<h4>${t("recent")}</h4>
|
||
<div id="recentChatWrap"></div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 清空全部历史
|
||
if(clearAllBtn){
|
||
clearAllBtn.onclick = function(){
|
||
if(!confirm(t("confirmClear"))) return;
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
fetch("/api/clear_all", {method:"DELETE"})
|
||
.then(()=>{
|
||
currentConvId = null;
|
||
chatDom.innerHTML = welcomeHTML();
|
||
loadHistory();
|
||
});
|
||
}
|
||
}
|
||
|
||
// 新建对话:只清界面,等用户开口时再建会话
|
||
if(newChatBtn){
|
||
newChatBtn.onclick = function(){
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
if(sseInstance){ sseInstance.close(); sseInstance = null; }
|
||
inputDom.value = "";
|
||
currentConvId = null;
|
||
chatDom.innerHTML = welcomeHTML();
|
||
loadHistory();
|
||
}
|
||
}
|
||
|
||
function loadHistory(){
|
||
fetch("/api/get_history")
|
||
.then(res=>res.json())
|
||
.then(data=>{
|
||
historyList = data;
|
||
renderSideHistory();
|
||
renderRecentChat();
|
||
})
|
||
}
|
||
|
||
function renderSideHistory(){
|
||
leftHistoryWrap.innerHTML = "";
|
||
const kw = (document.getElementById("history-search")?.value || "").trim().toLowerCase();
|
||
const pinned = JSON.parse(localStorage.getItem("pinned_conv") || "[]");
|
||
let list = kw
|
||
? historyList.filter(item => (item.title || "").toLowerCase().indexOf(kw) >= 0)
|
||
: historyList.slice();
|
||
// 置顶的排前面
|
||
list.sort((a,b)=>{
|
||
const ap = pinned.includes(a.id) ? 0 : 1;
|
||
const bp = pinned.includes(b.id) ? 0 : 1;
|
||
return ap - bp;
|
||
});
|
||
|
||
if(!list.length){
|
||
const empty = document.createElement("div");
|
||
empty.className = "history-empty";
|
||
empty.innerText = kw ? t("emptySearch") : t("emptyHistory");
|
||
leftHistoryWrap.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
list.forEach((item)=>{
|
||
let div = document.createElement('div');
|
||
div.className = "history-item";
|
||
div.dataset.fullText = item.title;
|
||
const isPinned = pinned.includes(item.id);
|
||
const textWrap = document.createElement("div");
|
||
textWrap.className = "history-text-wrap";
|
||
const titleSpan = document.createElement("div");
|
||
titleSpan.className = "history-title";
|
||
titleSpan.innerText = (isPinned ? "📌 " : "") + item.title;
|
||
let timeDiv = document.createElement('div');
|
||
timeDiv.className = "history-time";
|
||
timeDiv.innerText = formatTime(item.utime || item.ctime);
|
||
textWrap.appendChild(titleSpan);
|
||
textWrap.appendChild(timeDiv);
|
||
const pinBtn = document.createElement("button");
|
||
pinBtn.className = "del-history-btn";
|
||
pinBtn.innerText = isPinned ? "📍" : "📌";
|
||
pinBtn.title = isPinned ? "取消置顶" : "置顶";
|
||
pinBtn.style.marginRight = "4px";
|
||
pinBtn.onclick = (e)=>{
|
||
e.stopPropagation();
|
||
let p = JSON.parse(localStorage.getItem("pinned_conv") || "[]");
|
||
if(p.includes(item.id)){ p = p.filter(x=>x!==item.id); }
|
||
else { p.push(item.id); }
|
||
localStorage.setItem("pinned_conv", JSON.stringify(p));
|
||
renderSideHistory();
|
||
};
|
||
const delBtn = document.createElement("button");
|
||
delBtn.className = "del-history-btn";
|
||
delBtn.innerText = "×";
|
||
delBtn.onclick = (e)=>{
|
||
e.stopPropagation();
|
||
if(confirm(t("confirmDel"))){
|
||
fetch(`/api/del_history?id=${item.id}`, {method:"DELETE"})
|
||
.then(()=>{
|
||
if(item.id === currentConvId){
|
||
currentConvId = null;
|
||
chatDom.innerHTML = welcomeHTML();
|
||
}
|
||
loadHistory();
|
||
})
|
||
}
|
||
}
|
||
div.appendChild(textWrap);
|
||
div.appendChild(pinBtn);
|
||
div.appendChild(delBtn);
|
||
div.onclick = ()=> openConversation(item.id);
|
||
leftHistoryWrap.appendChild(div);
|
||
})
|
||
}
|
||
|
||
function renderRecentChat(){
|
||
const wrap = document.getElementById("recentChatWrap");
|
||
if(!wrap) return; // 切过对话后旧节点会失效,每次重新取
|
||
wrap.innerHTML = "";
|
||
historyList.slice(0,4).forEach(item=>{
|
||
const div = document.createElement('div');
|
||
div.className = "recent-item";
|
||
div.onclick = ()=> openConversation(item.id);
|
||
const titleSpan = document.createElement('span');
|
||
titleSpan.innerText = item.title;
|
||
const timeSpan = document.createElement('span');
|
||
timeSpan.className = "recent-date";
|
||
timeSpan.innerText = formatTime(item.utime || item.ctime);
|
||
div.appendChild(titleSpan);
|
||
div.appendChild(timeSpan);
|
||
wrap.appendChild(div);
|
||
})
|
||
}
|
||
|
||
// 没有会话就先建一个,拿到 conv_id
|
||
function ensureConv(){
|
||
if(currentConvId) return Promise.resolve(currentConvId);
|
||
return fetch("/api/new_chat", {method:"POST"})
|
||
.then(res=>res.json())
|
||
.then(data=>{
|
||
currentConvId = data.id;
|
||
return currentConvId;
|
||
});
|
||
}
|
||
|
||
// 打开历史会话:还原整段对话,而不是把标题重发一遍
|
||
let openingConv = null;
|
||
function openConversation(id){
|
||
if(openingConv === id) return; // 防连点
|
||
openingConv = id;
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
if(sseInstance){ sseInstance.close(); sseInstance = null; }
|
||
currentConvId = id;
|
||
fetch("/api/get_messages?conv_id=" + id)
|
||
.then(res=>res.json())
|
||
.then(msgs=>{
|
||
openingConv = null;
|
||
chatDom.innerHTML = "";
|
||
if(!msgs.length){
|
||
chatDom.innerHTML = welcomeHTML();
|
||
return;
|
||
}
|
||
msgs.forEach(m=>{
|
||
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
|
||
try{
|
||
addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)));
|
||
}catch(e){
|
||
addMsg(m.content, false);
|
||
}
|
||
}else{
|
||
const b = addMsg(m.content, m.role === "user");
|
||
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
|
||
}
|
||
});
|
||
// 等渲染完再滚到底部
|
||
requestAnimationFrame(function(){ chatDom.scrollTop = chatDom.scrollHeight; });
|
||
})["catch"](function(){ openingConv = null; });
|
||
}
|
||
|
||
const CARD_PREFIX = "__CARD__";
|
||
|
||
// 把轨迹数据画成时间轴
|
||
function fillTrackCard(el, card){
|
||
el.innerHTML = "";
|
||
const head = document.createElement("div");
|
||
head.className = "track-head";
|
||
const no = document.createElement("div");
|
||
no.className = "track-no";
|
||
no.innerHTML = "<span>运单号</span>" + escHtml(card.no);
|
||
const st = document.createElement("div");
|
||
st.className = "track-status";
|
||
st.textContent = card.status;
|
||
head.appendChild(no);
|
||
head.appendChild(st);
|
||
el.appendChild(head);
|
||
|
||
(card.nodes || []).forEach(function(n, i){
|
||
const node = document.createElement("div");
|
||
node.className = "track-node" + (i === 0 ? " active" : "");
|
||
const dot = document.createElement("span");
|
||
dot.className = "track-dot";
|
||
const text = document.createElement("div");
|
||
text.className = "track-text";
|
||
text.innerHTML = '<span class="track-time">' + escHtml(n.time) + "</span>" + escHtml(n.text) +
|
||
'<span class="track-place">' + escHtml(n.place || "") + "</span>";
|
||
node.appendChild(dot);
|
||
node.appendChild(text);
|
||
el.appendChild(node);
|
||
});
|
||
}
|
||
|
||
function addTrackCard(card){
|
||
const row = document.createElement("div");
|
||
row.className = "msg-row robot-line";
|
||
const bubble = document.createElement("div");
|
||
bubble.className = "robot-message track-card";
|
||
fillTrackCard(bubble, card);
|
||
row.appendChild(bubble);
|
||
chatDom.appendChild(row);
|
||
chatDom.scrollTop = chatDom.scrollHeight;
|
||
return bubble;
|
||
}
|
||
|
||
loadHistory();
|
||
|
||
window.fillInput = function(words){
|
||
inputDom.value = words;
|
||
doSend();
|
||
}
|
||
|
||
// 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史
|
||
window.askNewTopic = function(words){
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
currentConvId = null; // 下次发送时自动新建会话
|
||
// 保留欢迎页(原来就是这个样子);已经在对话里则先回到欢迎页
|
||
if(!chatDom.querySelector('.ai-welcome-card')){
|
||
chatDom.innerHTML = welcomeHTML();
|
||
}
|
||
inputDom.value = words;
|
||
doSend();
|
||
}
|
||
|
||
window.sendOrder = function(){
|
||
const no = prompt("请输入运单号:");
|
||
if(no && no.trim()){
|
||
inputDom.value = "我的运单号是 " + no.trim() + ",帮我查一下轨迹";
|
||
doSend();
|
||
}
|
||
}
|
||
|
||
window.showHuman = async function(){
|
||
if(window.humanSid){
|
||
addMsg("您已在人工客服会话中,请直接提问。", false);
|
||
return;
|
||
}
|
||
var now = new Date();
|
||
var day = now.getDay();
|
||
var h = now.getHours();
|
||
var work = (day >= 1 && day <= 5) && (h >= 9 && h < 19);
|
||
if(!work){
|
||
addMsg("人工客服工作时间:周一至周五 9:00-18:00\n当前非工作时间,留言后工作时间内会尽快回复。", false);
|
||
return;
|
||
}
|
||
addMsg("正在为您转接人工客服...", false);
|
||
if(!currentConvId){
|
||
try{ await ensureConv(); }catch(e){}
|
||
}
|
||
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId||0})})
|
||
.then(function(r){return r.json()})
|
||
.then(function(d){
|
||
if(d.ok){
|
||
window.humanSid = d.session_id;
|
||
addMsg("您已加入人工排队,请稍候...", false);
|
||
startHumanPoll();
|
||
}else{
|
||
addMsg("转接失败,请稍后再试。", false);
|
||
}
|
||
})
|
||
.catch(function(){
|
||
addMsg("网络错误,请稍后再试。", false);
|
||
});
|
||
}
|
||
window.humanSid = 0;
|
||
function startHumanPoll(){
|
||
let last=0;
|
||
const t=setInterval(()=>{
|
||
if(!window.humanSid){ clearInterval(t); return; }
|
||
fetch("/api/human/poll?sid="+window.humanSid+"&after="+last)
|
||
.then(r=>r.json())
|
||
.then(d=>{
|
||
if(d.status==="closed"){
|
||
clearInterval(t);
|
||
window.humanSid=0;
|
||
window.__busy=false;
|
||
if(typeof setBusy==="function") setBusy(false);
|
||
addMsg("人工客服已结束,您可以继续向 AI 提问。", false);
|
||
return;
|
||
}
|
||
d.messages.forEach(m=>{
|
||
last=m.id;
|
||
if(m.side==="agent") addMsg(m.content, false);
|
||
});
|
||
})
|
||
.catch(()=>{});
|
||
}, 1000);
|
||
}
|
||
|
||
function addMsg(content, isUser){
|
||
const rowDiv = document.createElement("div");
|
||
rowDiv.className = "msg-row";
|
||
let robotMsg;
|
||
// 检测图片附件
|
||
let imgUrl = null;
|
||
if(content.indexOf("[图片]") >= 0){
|
||
const m = content.match(/\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i);
|
||
if(m) imgUrl = m[0];
|
||
}
|
||
if(isUser){
|
||
rowDiv.classList.add("user-line");
|
||
const userMsg = document.createElement('div');
|
||
userMsg.className = "user-message";
|
||
if(imgUrl){
|
||
userMsg.innerHTML = '<img src="' + imgUrl + '" style="max-width:220px;border-radius:10px;display:block;padding:0;background:transparent;cursor:zoom-in;" onclick="window.open(this.src)">';
|
||
}else{
|
||
userMsg.innerText = content;
|
||
}
|
||
rowDiv.appendChild(userMsg);
|
||
}else{
|
||
rowDiv.classList.add("robot-line");
|
||
robotMsg = document.createElement('div');
|
||
robotMsg.className = "robot-message";
|
||
robotMsg.innerHTML = parseBold(content);
|
||
rowDiv.appendChild(robotMsg);
|
||
}
|
||
// 时间戳
|
||
const ts = document.createElement("div");
|
||
ts.className = "msg-time";
|
||
const d = new Date();
|
||
ts.textContent = String(d.getHours()).padStart(2,'0') + ":" + String(d.getMinutes()).padStart(2,'0');
|
||
rowDiv.appendChild(ts);
|
||
chatDom.appendChild(rowDiv);
|
||
chatDom.scrollTop = chatDom.scrollHeight;
|
||
if(!isUser){
|
||
return robotMsg;
|
||
}else{
|
||
return rowDiv;
|
||
}
|
||
}
|
||
|
||
function renderError(bubble, text, retry){
|
||
bubble.classList.remove("typing");
|
||
bubble.classList.add("error-bubble");
|
||
bubble.innerHTML = "";
|
||
const line = document.createElement("div");
|
||
line.className = "err-line";
|
||
line.textContent = "⚠️ " + text;
|
||
const btn = document.createElement("button");
|
||
btn.className = "retry-btn";
|
||
btn.textContent = "点击重试";
|
||
btn.onclick = retry;
|
||
bubble.appendChild(line);
|
||
bubble.appendChild(btn);
|
||
chatDom.scrollTop = chatDom.scrollHeight;
|
||
}
|
||
|
||
// 复制用:把 Markdown 标记去掉,得到干净纯文本
|
||
function mdToPlain(md){
|
||
if(md.indexOf(CARD_PREFIX) === 0){
|
||
try{
|
||
const card = JSON.parse(md.slice(CARD_PREFIX.length));
|
||
const lines = ["运单号 " + card.no + "(" + (card.status || "") + ")"];
|
||
(card.nodes || []).forEach(function(n){
|
||
lines.push(n.time + " " + n.text + (n.place ? " " + n.place : ""));
|
||
});
|
||
return lines.join("\n");
|
||
}catch(e){ /* 解析失败就走普通处理 */ }
|
||
}
|
||
return String(md)
|
||
.replace(/`([^`]+)`/g, "$1") // 行内代码
|
||
.replace(/\*\*(.+?)\*\*/g, "$1") // 加粗
|
||
.replace(/^[-*•]\s+/gm, "· ") // 列表符号换成 ·
|
||
.replace(/^\d+[.、)]\s*/gm, "") // 去掉编号前缀
|
||
.replace(/“|”/g, '"');
|
||
}
|
||
|
||
// 给一条回答挂上「复制 / 重答」按钮
|
||
function attachActions(bubble, rawText, question, showRetry){
|
||
const old = bubble.querySelector(".msg-actions");
|
||
if(old) old.remove();
|
||
if(!rawText) return;
|
||
const bar = document.createElement("div");
|
||
bar.className = "msg-actions";
|
||
|
||
const copyBtn = document.createElement("button");
|
||
copyBtn.className = "msg-action-btn";
|
||
copyBtn.dataset.action = "copy";
|
||
copyBtn.textContent = t("copy");
|
||
copyBtn.onclick = function(){
|
||
const plain = mdToPlain(rawText);
|
||
const done = ()=> {
|
||
copyBtn.textContent = t("copied");
|
||
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200);
|
||
};
|
||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||
navigator.clipboard.writeText(plain).then(done).catch(fallback);
|
||
}else{
|
||
fallback();
|
||
}
|
||
function fallback(){
|
||
const ta = document.createElement("textarea");
|
||
ta.value = plain;
|
||
ta.style.position = "fixed";
|
||
ta.style.opacity = "0";
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try{ document.execCommand("copy"); }catch(e){}
|
||
document.body.removeChild(ta);
|
||
done();
|
||
}
|
||
};
|
||
bar.appendChild(copyBtn);
|
||
|
||
if(showRetry){
|
||
const retryBtn = document.createElement("button");
|
||
retryBtn.className = "msg-action-btn";
|
||
retryBtn.dataset.action = "retry";
|
||
retryBtn.textContent = t("retry");
|
||
retryBtn.onclick = function(){
|
||
const row = bubble.closest(".msg-row");
|
||
if(row) row.remove();
|
||
doSend(question); // 传字符串 = 只重发,不重复插入提问气泡
|
||
};
|
||
bar.appendChild(retryBtn);
|
||
}
|
||
|
||
// 👍/👎 反馈
|
||
function addVote(vote, label){
|
||
const b = document.createElement("button");
|
||
b.className = "msg-action-btn";
|
||
b.textContent = label;
|
||
b.onclick = function(){
|
||
if(b.classList.contains("voted")) return;
|
||
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); });
|
||
b.classList.add("voted");
|
||
fetch("/api/feedback", {
|
||
method:"POST", headers:{"Content-Type":"application/json"},
|
||
body: JSON.stringify({question: question, answer: rawText, vote: vote})
|
||
}).catch(function(){});
|
||
};
|
||
bar.appendChild(b);
|
||
}
|
||
addVote("up", "👍");
|
||
addVote("down", "👎");
|
||
|
||
bubble.appendChild(bar);
|
||
}
|
||
|
||
// 回答下方的"相关追问":点一下就接着问
|
||
function renderFollowups(bubble){
|
||
let list = [];
|
||
try{ list = JSON.parse(bubble.dataset.suggest || "[]"); }catch(e){ list = []; }
|
||
if(!list.length) return;
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "followup-list";
|
||
list.forEach(function(q){
|
||
const item = document.createElement("div");
|
||
item.className = "followup-item";
|
||
item.innerHTML = "<span></span><span class='arrow'>›</span>";
|
||
item.firstChild.textContent = q;
|
||
item.onclick = function(){
|
||
wrap.remove();
|
||
inputDom.value = q;
|
||
doSend();
|
||
};
|
||
wrap.appendChild(item);
|
||
});
|
||
bubble.appendChild(wrap);
|
||
}
|
||
|
||
async function doSend(retryText){
|
||
const isRetry = typeof retryText === "string";
|
||
const sendWord = (isRetry ? retryText : inputDom.value).trim();
|
||
if(!sendWord){
|
||
inputDom.focus();
|
||
return;
|
||
}
|
||
|
||
// 人工客服模式:直接发给客服,不走AI
|
||
if(window.humanSid){
|
||
if(!isRetry) addMsg(sendWord, true);
|
||
inputDom.value = "";
|
||
inputDom.focus();
|
||
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:window.humanSid,side:"customer",content:sendWord})});
|
||
return;
|
||
}
|
||
|
||
if(window.__busy && !retryText) return; // 防连点
|
||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||
|
||
if(!isRetry){
|
||
addMsg(sendWord, true);
|
||
inputDom.value = "";
|
||
inputDom.focus();
|
||
}
|
||
|
||
// 没有会话就先建一个,后面所有消息都挂在它下面
|
||
try{
|
||
await ensureConv();
|
||
}catch(err){
|
||
const bubble = addMsg("", false);
|
||
renderError(bubble, t("sessionFail"), function(){
|
||
const row = bubble.closest(".msg-row");
|
||
if(row) row.remove();
|
||
doSend(sendWord);
|
||
});
|
||
return;
|
||
}
|
||
|
||
const aiBubble = addMsg(t("thinking"), false);
|
||
aiBubble.classList.add("typing");
|
||
activeBubble = aiBubble;
|
||
setBusy(true);
|
||
|
||
let es = null;
|
||
let firstChunk = true;
|
||
let settled = false; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开"
|
||
|
||
const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 15000);
|
||
const totalTimer = setTimeout(function(){ fail("生成时间过长,已中断,请重试"); }, 120000);
|
||
|
||
function cleanup(){
|
||
clearTimeout(firstTimer);
|
||
clearTimeout(totalTimer);
|
||
const cur = es;
|
||
es = null;
|
||
if(cur){
|
||
cur.close();
|
||
if(sseInstance === cur) sseInstance = null;
|
||
}
|
||
}
|
||
|
||
function finish(){
|
||
if(settled) return;
|
||
settled = true;
|
||
cleanup();
|
||
setBusy(false);
|
||
aiBubble.classList.remove("typing");
|
||
if(activeBubble === aiBubble) activeBubble = null;
|
||
inputDom.focus();
|
||
|
||
let raw = aiBubble.dataset.raw || "";
|
||
// 模型有时会把卡片标记原样输出,这里兜底转成时间轴
|
||
if(raw.indexOf(CARD_PREFIX) === 0){
|
||
try{
|
||
const card = JSON.parse(raw.slice(CARD_PREFIX.length));
|
||
aiBubble.classList.add("track-card");
|
||
fillTrackCard(aiBubble, card);
|
||
raw = "运单号 " + card.no + "(" + (card.status || "") + ")";
|
||
}catch(e){ /* 解析失败就按普通文本显示 */ }
|
||
}
|
||
attachActions(aiBubble, raw, sendWord, true);
|
||
renderFollowups(aiBubble);
|
||
updateToBottom();
|
||
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
|
||
}
|
||
|
||
function fail(text){
|
||
if(settled) return;
|
||
settled = true;
|
||
cleanup();
|
||
setBusy(false);
|
||
if(activeBubble === aiBubble) activeBubble = null;
|
||
renderError(aiBubble, text || t("busyRetry"), function(){
|
||
const row = aiBubble.closest(".msg-row");
|
||
if(row) row.remove();
|
||
doSend(sendWord);
|
||
});
|
||
}
|
||
|
||
// 静默中止(用户点了停止 / 开了新对话 / 问了新问题)
|
||
function stop(){
|
||
if(settled) return;
|
||
settled = true;
|
||
cleanup();
|
||
setBusy(false);
|
||
aiBubble.classList.remove("typing");
|
||
if(activeBubble === aiBubble) activeBubble = null;
|
||
const raw = aiBubble.dataset.raw || "";
|
||
if(raw){
|
||
// 已生成的部分保留,并标注已停止
|
||
const hint = document.createElement("div");
|
||
hint.className = "stopped-hint";
|
||
hint.textContent = t("stoppedMark");
|
||
aiBubble.appendChild(hint);
|
||
attachActions(aiBubble, raw, sendWord, false);
|
||
}else{
|
||
aiBubble.innerHTML = t("stopped");
|
||
aiBubble.classList.add("hint-bubble");
|
||
}
|
||
}
|
||
cancelActive = stop;
|
||
|
||
function handleFrame(dataText){
|
||
if(settled) return;
|
||
let obj;
|
||
try{
|
||
obj = JSON.parse(dataText);
|
||
}catch(err){
|
||
obj = {t: dataText}; // 兼容旧的纯文本帧
|
||
}
|
||
if(obj.e){ fail(obj.e); return; }
|
||
if(obj.card){
|
||
aiBubble.classList.add("track-card");
|
||
fillTrackCard(aiBubble, obj.card);
|
||
aiBubble.dataset.raw = "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
|
||
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
|
||
firstChunk = false;
|
||
chatDom.scrollTop = chatDom.scrollHeight;
|
||
return;
|
||
}
|
||
if(obj.replace){
|
||
aiBubble.dataset.raw = obj.replace;
|
||
aiBubble.innerHTML = parseBold(obj.replace);
|
||
chatDom.scrollTop = chatDom.scrollHeight;
|
||
return;
|
||
}
|
||
if(obj.suggest){
|
||
aiBubble.dataset.suggest = JSON.stringify(obj.suggest);
|
||
return;
|
||
}
|
||
if(obj.d){ finish(); return; }
|
||
if(typeof obj.t !== "string") return;
|
||
clearTimeout(firstTimer);
|
||
if(firstChunk){
|
||
aiBubble.innerHTML = "";
|
||
aiBubble.dataset.raw = "";
|
||
firstChunk = false;
|
||
}
|
||
const raw = (aiBubble.dataset.raw || "") + obj.t;
|
||
aiBubble.dataset.raw = raw;
|
||
aiBubble.innerHTML = parseBold(raw);
|
||
chatDom.scrollTop = chatDom.scrollHeight;
|
||
}
|
||
|
||
// 用 POST + fetch 流式读取:问题放在请求体里,不受 URL 长度限制
|
||
const controller = new AbortController();
|
||
es = { close: function(){ try{ controller.abort(); }catch(e){} } };
|
||
sseInstance = es;
|
||
// 保留 stop 里的 close 调用
|
||
const prevStop = cancelActive;
|
||
cancelActive = function(){ prevStop && prevStop(); try{ controller.abort(); }catch(e){} };
|
||
|
||
fetch("/stream_chat", {
|
||
method: "POST",
|
||
headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify({msg: sendWord, conv_id: currentConvId}),
|
||
signal: controller.signal
|
||
}).then(function(res){
|
||
if(!res.ok){ throw new Error("HTTP " + res.status); }
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
function pump(){
|
||
return reader.read().then(function(r){
|
||
if(r.done){ return; }
|
||
buffer += decoder.decode(r.value, {stream: true});
|
||
let idx;
|
||
// SSE 以空行分隔事件
|
||
while((idx = buffer.indexOf("\n\n")) >= 0){
|
||
const chunk = buffer.slice(0, idx);
|
||
buffer = buffer.slice(idx + 2);
|
||
chunk.split("\n").forEach(function(line){
|
||
if(line.indexOf("data:") === 0){
|
||
handleFrame(line.slice(5).trimStart());
|
||
}
|
||
});
|
||
}
|
||
return pump();
|
||
});
|
||
}
|
||
return pump();
|
||
}).catch(function(err){
|
||
if(err && err.name === "AbortError") return; // 用户主动停止
|
||
if(settled) return;
|
||
fail(t("networkFail"));
|
||
});
|
||
}
|
||
|
||
// ===== 发送 / 停止 按钮状态 =====
|
||
// 生成中按钮变成“停止”,点击走 cancelActive
|
||
function setBusy(on){
|
||
window.__busy = on;
|
||
sendBtn.textContent = on ? t("stop") : t("send");
|
||
sendBtn.classList.toggle("stop", on);
|
||
}
|
||
sendBtn.addEventListener("click", function(){
|
||
if(window.__busy && cancelActive){
|
||
cancelActive();
|
||
cancelActive = null;
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
doSend();
|
||
});
|
||
|
||
// ===== 多行输入框:自动增高 =====
|
||
function autoGrow(){
|
||
inputDom.style.height = "auto";
|
||
inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px";
|
||
}
|
||
inputDom.addEventListener("input", autoGrow);
|
||
|
||
inputDom.addEventListener("keydown",(e)=>{
|
||
// Enter 发送,Shift+Enter 换行
|
||
if(e.key === 'Enter' && !e.shiftKey && !e.isComposing){
|
||
e.preventDefault();
|
||
if(window.humanSid || !window.__busy) doSend();
|
||
}
|
||
});
|
||
|
||
// ===== 回到底部 =====
|
||
const toBottomBtn = document.getElementById("to-bottom");
|
||
function updateToBottom(){
|
||
const gap = chatDom.scrollHeight - chatDom.scrollTop - chatDom.clientHeight;
|
||
toBottomBtn.classList.toggle("show", gap > 160);
|
||
}
|
||
chatDom.addEventListener("scroll", updateToBottom);
|
||
toBottomBtn.onclick = ()=>{
|
||
chatDom.scrollTo({top: chatDom.scrollHeight, behavior:"smooth"});
|
||
};
|
||
|
||
const sidePanel = document.querySelector('.left-panel');
|
||
const toggleBtn = document.querySelector('.toggle-divider');
|
||
const chatContainer = document.querySelector('.chat-container');
|
||
let sideOpen = true;
|
||
|
||
function isNarrow(){
|
||
return window.matchMedia("(max-width: 900px)").matches;
|
||
}
|
||
|
||
// 窄屏默认收起侧栏
|
||
if(isNarrow()){
|
||
chatContainer.classList.add('side-closed');
|
||
toggleBtn.textContent = '▶';
|
||
sideOpen = false;
|
||
inputDom.placeholder = "请输入问题或运单号…";
|
||
}
|
||
|
||
toggleBtn.onclick = function(){
|
||
sideOpen = !sideOpen;
|
||
if(isNarrow()){
|
||
// 抽屉模式:只切换整个容器的 class,不动侧栏自身宽度
|
||
chatContainer.classList.toggle('side-closed', !sideOpen);
|
||
toggleBtn.textContent = sideOpen ? '◀' : '▶';
|
||
return;
|
||
}
|
||
if(sideOpen){
|
||
sidePanel.classList.remove('left-panel-hide');
|
||
toggleBtn.textContent = '◀';
|
||
}else{
|
||
sidePanel.classList.add('left-panel-hide');
|
||
toggleBtn.textContent = '▶';
|
||
}
|
||
}
|
||
|
||
// 从窄屏切到宽屏时,还原侧栏状态,避免卡在隐藏
|
||
window.addEventListener("resize", function(){
|
||
if(!isNarrow()){
|
||
chatContainer.classList.remove('side-closed');
|
||
}
|
||
});
|
||
|
||
// ===== 机器人拖拽(限制在聊天白色区域内)=====
|
||
const robot = document.querySelector('.robot-mascot');
|
||
let dragging = false, offX = 0, offY = 0;
|
||
robot.style.cursor = 'grab';
|
||
function chatBounds(){
|
||
const main = document.querySelector('.main-chat').getBoundingClientRect();
|
||
// 右边留出 20px,避免贴到边缘/滚动条
|
||
return { left: main.left + 10, top: main.top + 10, right: window.innerWidth - 20, bottom: main.bottom - 10 };
|
||
}
|
||
robot.addEventListener('mousedown', function(e){
|
||
dragging = true;
|
||
robot.style.transition = 'none';
|
||
robot.style.height = '110px';
|
||
robot.style.right = 'auto';
|
||
robot.style.bottom = 'auto';
|
||
offX = robot.offsetWidth / 2;
|
||
offY = robot.offsetHeight / 2;
|
||
robot.style.left = (e.clientX - offX) + 'px';
|
||
robot.style.top = (e.clientY - offY) + 'px';
|
||
e.preventDefault();
|
||
});
|
||
document.addEventListener('mousemove', function(e){
|
||
if(!dragging) return;
|
||
const b = chatBounds();
|
||
let x = e.clientX - offX;
|
||
let y = e.clientY - offY;
|
||
x = Math.max(b.left, Math.min(window.innerWidth - robot.offsetWidth - 15, x));
|
||
y = Math.max(b.top, Math.min(b.bottom - robot.offsetHeight, y));
|
||
robot.style.left = x + 'px';
|
||
robot.style.top = y + 'px';
|
||
});
|
||
document.addEventListener('mouseup', function(){
|
||
if(!dragging) return;
|
||
dragging = false;
|
||
// 松手后再校正一次位置
|
||
const b = chatBounds();
|
||
let x = parseFloat(robot.style.left) || 0;
|
||
let y = parseFloat(robot.style.top) || 0;
|
||
x = Math.max(b.left, Math.min(window.innerWidth - robot.offsetWidth - 15, x));
|
||
y = Math.max(b.top, Math.min(b.bottom - robot.offsetHeight, y));
|
||
robot.style.left = x + 'px';
|
||
robot.style.top = y + 'px';
|
||
});
|
||
robot.addEventListener('touchstart', function(e){
|
||
const t = e.touches[0];
|
||
const rect = robot.getBoundingClientRect();
|
||
dragging = true;
|
||
robot.style.transition = 'none';
|
||
robot.style.left = rect.left + 'px';
|
||
robot.style.top = rect.top + 'px';
|
||
robot.style.right = 'auto';
|
||
robot.style.bottom = 'auto';
|
||
offX = t.clientX - rect.left;
|
||
offY = t.clientY - rect.top;
|
||
}, {passive:true});
|
||
document.addEventListener('touchmove', function(e){
|
||
if(!dragging) return;
|
||
const t = e.touches[0];
|
||
const b = chatBounds();
|
||
let x = t.clientX - offX;
|
||
let y = t.clientY - offY;
|
||
x = Math.max(b.left, Math.min(b.right - robot.offsetWidth - 10, x));
|
||
y = Math.max(b.top, Math.min(b.bottom - robot.offsetHeight, y));
|
||
robot.style.left = x + 'px';
|
||
robot.style.top = y + 'px';
|
||
}, {passive:true});
|
||
document.addEventListener('touchend', function(){ dragging = false; });
|
||
})
|
||
</script>
|
||
</body>
|
||
</html> |