接入客户登录和订单会话权限隔离

This commit is contained in:
2026-10-30 18:27:40 +08:00
parent 689d55bcf9
commit 783f930380
25 changed files with 695 additions and 31 deletions
+45 -1
View File
@@ -40,6 +40,7 @@ let uploadController = null;
let historyRequestVersion = 0;
let followLatest = true;
let voiceInput = null;
let customerLogin = null;
let creatingConv = null;
let sendPreparing = false;
let conversationVersion = 0;
@@ -227,6 +228,10 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域
const I18N = {
zh: {
appTitle: "柏悦物流智能助手",
customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消",
customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询",
customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。",
customerLogoutFailed: "退出失败,请重试。",
newChat: "新建对话",
clearAll: "清空全部历史",
history: "历史对话",
@@ -333,6 +338,10 @@ const I18N = {
},
en: {
appTitle: "Baiyue Logistics AI Assistant",
customerLogin: "Sign in", customerLogout: "Sign out", customerCancel: "Cancel",
customerUsername: "Username", customerPassword: "Password", customerQuery: "Sign in to view orders",
customerSigningIn: "Signing in…", customerLoginFailed: "Could not sign in. Please try again.",
customerLogoutFailed: "Could not sign out. Please try again.",
newChat: "New Chat",
clearAll: "Clear All History",
history: "History",
@@ -449,6 +458,7 @@ function t(key){
function applyLanguage(lang){
LANG = lang === "en" ? "en" : "zh";
const dict = I18N[LANG];
if(customerLogin) customerLogin.render();
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
document.querySelectorAll("[data-i18n]").forEach(function(element){
const value = dict[element.dataset.i18n];
@@ -885,7 +895,9 @@ function openConversation(id){
chatDom.innerHTML = welcomeHTML();
return;
}
let previousQuestion = '';
msgs.forEach(m=>{
if(m.role === 'user') previousQuestion = m.content;
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
try{
const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime);
@@ -896,6 +908,9 @@ function openConversation(id){
}else{
const b = addMsg(m.content, m.role === "user", undefined, m.ctime);
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
if(m.role === 'assistant' && b && (/^请先使用客户账号登录并关联订单/.test(m.content) || /^订单查询将在客户账号登录并关联后开放/.test(m.content))){
attachCustomerLogin(b, previousQuestion);
}
}
});
// 等渲染完再滚到底部
@@ -1409,6 +1424,19 @@ function mdToPlain(md){
.replace(/“|”/g, '"');
}
function attachCustomerLogin(bubble, question){
const button = document.createElement('button');
button.type = 'button'; button.className = 'customer-login-cta';
button.textContent = t('customerQuery');
const version = conversationVersion;
button.onclick = function(){
customerLogin.require(async function(){
if(version === conversationVersion && question) await retryAnswer(bubble, question);
});
};
bubble.appendChild(button);
}
async function retryAnswer(bubble, question){
if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; }
if(await doSend(question)){
@@ -1629,7 +1657,8 @@ async function doSend(retryText){
raw = "运单号 " + card.no + "(" + (card.status || "") + ")";
}catch(e){ /* 解析失败就按普通文本显示 */ }
}
attachActions(aiBubble, raw, fullContent, true);
attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired);
if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent);
renderFollowups(aiBubble);
updateToBottom();
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
@@ -1679,6 +1708,7 @@ async function doSend(retryText){
}
if(!obj || typeof obj !== 'object') return;
if(obj.e){ fail(obj.e); return; }
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
if(obj.card){
clearTimeout(firstTimer);
aiBubble.classList.add("track-card");
@@ -1876,6 +1906,7 @@ function syncChatViewport(){
chatContainer.style.height = '';
chatContainer.style.top = '';
}
if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top);
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
autoGrow();
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
@@ -1952,6 +1983,19 @@ voiceInput = ChatVoice.create({
if(!await doSend()) voiceInput.setMode(false);
}
});
customerLogin = CustomerLogin.create({fetch:fetch, text:t,
onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); },
onChange:function(user,previous){
if(previous === undefined) return;
if(previous && (!user || user.id !== previous.id)){
resetConversation(null); chatDom.innerHTML=welcomeHTML();
}
loadHistory();
},
onError:function(message){ setComposerStatus(message,true); }
});
customerLogin.viewport(chatContainer.style.height,chatContainer.style.top);
customerLogin.refresh();
window.addEventListener('resize', function(){
if(!isNarrow()) voiceInput.setMode(false);
});
+121
View File
@@ -0,0 +1,121 @@
// Customer login has no relationship to the protected staff workspace.
var CustomerLogin = {
create(options){
const overlay=document.getElementById('customer-login');
const form=document.getElementById('customer-login-form');
const username=document.getElementById('customer-username');
const password=document.getElementById('customer-password');
const submit=document.getElementById('customer-submit');
const cancel=document.getElementById('customer-cancel');
const error=document.getElementById('customer-error');
const account=document.getElementById('customer-account');
let user, pending=null, opener=null, busy=false, version=0;
overlay.hidden=true;
function status(message){ error.textContent=message || ''; error.hidden=!message; }
function render(){
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerLogin');
submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin');
cancel.textContent=options.text('customerCancel');
document.getElementById('customer-login-title').textContent=options.text('customerLogin');
document.getElementById('customer-username-label').textContent=options.text('customerUsername');
document.getElementById('customer-password-label').textContent=options.text('customerPassword');
}
function update(next){
const previous=user;
user=next;
render();
if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){
options.onChange(user,previous);
}
}
async function request(path, data){
const controller=new AbortController();
const timer=setTimeout(()=>controller.abort(),10000);
try{
const response=await options.fetch(path,{method:data === undefined ? 'GET' : 'POST',
headers:{'Content-Type':'application/json'},body:data === undefined ? undefined : JSON.stringify(data),signal:controller.signal});
const result=await response.json();
if(!response.ok || !result.ok){
const failure=new Error(result.error || result.e || options.text('customerLoginFailed'));
failure.publicMessage=true;throw failure;
}
return result;
}finally{ clearTimeout(timer); }
}
async function refresh(){
if(busy) return;
const token=++version;
try{
const result=await request('/api/customer/session');
if(token === version) update(result.authenticated && result.user ? result.user : null);
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
}
function close(){
overlay.hidden=true;
password.value='';
pending=null;
if(opener && opener.isConnected) opener.focus({preventScroll:true});
opener=null;
}
function open(onSuccess){
if(busy) return;
pending=typeof onSuccess === 'function' ? onSuccess : null;
opener=document.activeElement;
status(''); password.value=''; overlay.hidden=false;
if(options.onOpen) options.onOpen();
render(); username.focus({preventScroll:true});
}
function setBusy(value){
busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value;
username.disabled=value; password.disabled=value; render();
}
form.addEventListener('submit',async function(event){
event.preventDefault();
if(busy) return;
const token=++version;
setBusy(true);status('');
try{
const result=await request('/api/customer/login',{username:username.value,password:password.value});
if(token !== version) return;
update(result.user);
const resume=pending;close();
if(resume) await resume();
}catch(failure){
if(token === version) status(failure.publicMessage ? failure.message : options.text('customerLoginFailed'));
}finally{ if(token === version) setBusy(false); }
});
cancel.onclick=function(){ if(!busy) close(); };
overlay.addEventListener('click',function(event){ if(event.target === overlay && !busy) close(); });
overlay.addEventListener('keydown',function(event){
if(event.key === 'Escape' && !busy){ event.preventDefault();close(); }
if(event.key === 'Tab'){
const controls=[username,password,submit,cancel].filter(control=>!control.disabled);
if(!controls.length){ event.preventDefault();return; }
const index=controls.indexOf(document.activeElement);
const next=(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length;
event.preventDefault();controls[next].focus({preventScroll:true});
}
});
account.onclick=async function(){
if(busy) return;
if(!user){ open(); return; }
const token=++version;setBusy(true);
try{
await request('/api/customer/logout',{});
if(token === version) update(null);
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
finally{ if(token === version) setBusy(false); }
};
window.addEventListener('focus',refresh);
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
render();
return {open:open,refresh:refresh,render:render,require:async function(resume){
if(busy) return;
await refresh();
if(user){ await resume(); }else open(resume);
},viewport:function(height,top){
overlay.style.height=height || ''; overlay.style.top=top || '';
overlay.style.bottom=height ? 'auto' : '';
}};
}
};