接入客户登录和订单会话权限隔离
This commit is contained in:
@@ -42,6 +42,18 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; }
|
||||
.clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
|
||||
.clear-all-btn:hover { color: var(--danger); }
|
||||
.customer-account { display: block; width: 100%; min-height: 40px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 13px; }
|
||||
.customer-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; }
|
||||
.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; }
|
||||
.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.customer-login-dialog h2 { margin: 0 0 20px; font-size: 20px; }
|
||||
.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; }
|
||||
.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; }
|
||||
.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
|
||||
#customer-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
|
||||
.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
|
||||
.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); }
|
||||
#customer-submit { border-color: var(--brand); background: var(--brand); color: white; }
|
||||
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
|
||||
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
|
||||
|
||||
+45
-1
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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' : '';
|
||||
}};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user