完善会话草稿恢复与附件保留,统一人工服务时区
This commit is contained in:
@@ -9,6 +9,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
|
||||
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
|
||||
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
|
||||
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
|
||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
@@ -54,7 +55,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
|
||||
|
||||
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
|
||||
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, and `visitor.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
|
||||
@@ -250,6 +250,8 @@ data: {"d": 1}
|
||||
|
||||
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
|
||||
|
||||
未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。
|
||||
|
||||
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
|
||||
|
||||
```powershell
|
||||
@@ -260,7 +262,7 @@ data: {"d": 1}
|
||||
|
||||
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||
|
||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||
|
||||
## 当前限制
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ tests/ # 按业务模块组织的回归
|
||||
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
||||
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
|
||||
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
|
||||
- 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。
|
||||
- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。
|
||||
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
|
||||
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
|
||||
|
||||
|
||||
@@ -2,21 +2,35 @@
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from urllib.parse import unquote
|
||||
|
||||
from .config import get_setting
|
||||
|
||||
|
||||
|
||||
def cleanup_uploads():
|
||||
"""删除 uploads/ 里 7 天前的图片"""
|
||||
"""Keep attachments referenced by chat history; expire unused uploads after 7 days."""
|
||||
try:
|
||||
udir = get_setting('UPLOAD_DIR')
|
||||
if not os.path.isdir(udir):
|
||||
return
|
||||
from .db import connect
|
||||
connection = connect()
|
||||
try:
|
||||
referenced = set()
|
||||
rows = connection.execute("SELECT content FROM messages WHERE content LIKE '%/uploads/%' "
|
||||
"UNION ALL SELECT content FROM human_messages WHERE content LIKE '%/uploads/%'")
|
||||
for (content,) in rows:
|
||||
referenced.update(unquote(name) for name in re.findall(r'/uploads/([A-Za-z0-9_.%-]+)', content or ''))
|
||||
finally:
|
||||
connection.close()
|
||||
cutoff = time.time() - 7 * 86400
|
||||
for f in os.listdir(udir):
|
||||
if f in referenced:
|
||||
continue
|
||||
fp = os.path.join(udir, f)
|
||||
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
|
||||
os.remove(fp)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Human conversation lifecycle; typing state is supplied by the app."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .db import connect
|
||||
|
||||
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def human_request(data):
|
||||
conv_id = data.get('conv_id')
|
||||
if not conv_id:
|
||||
return ({'ok': False, 'error': 'no conv'}, 400)
|
||||
if not 8 <= datetime.now().hour < 22:
|
||||
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
service_time = datetime.now(SERVICE_TIMEZONE)
|
||||
if not 8 <= service_time.hour < 22:
|
||||
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'}
|
||||
now = service_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
conn = connect()
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"""HTTP adapters for files; business logic lives in services."""
|
||||
|
||||
from flask import Blueprint, abort, request, send_from_directory
|
||||
from flask import Blueprint, render_template, request, send_from_directory
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from .. import file_service, visitor_service
|
||||
from ..config import get_setting
|
||||
|
||||
bp = Blueprint('files', __name__)
|
||||
|
||||
|
||||
def unavailable(staff=False):
|
||||
return render_template('attachment_unavailable.html', staff=staff), 404, {'Cache-Control':'private, no-store'}
|
||||
|
||||
@bp.route('/api/upload', methods=['POST'])
|
||||
def upload_file():
|
||||
return file_service.upload_file(request.files)
|
||||
@@ -15,14 +20,20 @@ def upload_file():
|
||||
@bp.route('/uploads/<path:name>')
|
||||
def uploaded_file(name):
|
||||
if not visitor_service.owns_upload(name):
|
||||
abort(404)
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
return unavailable()
|
||||
try:
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
except NotFound:
|
||||
return unavailable()
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
|
||||
@bp.route('/api/staff/uploads/<path:name>')
|
||||
def staff_uploaded_file(name):
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
try:
|
||||
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||
except NotFound:
|
||||
return unavailable(staff=True)
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
@@ -81,6 +81,7 @@ svg { display: block; flex-shrink: 0; }
|
||||
.emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
|
||||
.emoji-picker button:hover { background: var(--surface-hover); }
|
||||
.attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
|
||||
@@ -11,6 +11,7 @@ var typeTimer = null;
|
||||
var toolsOpen = false;
|
||||
var toolsOpener = null;
|
||||
var replyInput = document.getElementById("replyText");
|
||||
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
|
||||
|
||||
function escapeText(value){
|
||||
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
|
||||
|
||||
+77
-12
@@ -6,6 +6,28 @@ const sendBtn = document.getElementById("send-button");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
const uploadInput = document.getElementById("upload-input");
|
||||
const composerStatus = document.getElementById("composer-status");
|
||||
const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage});
|
||||
let draftTimer = null;
|
||||
let draftSnapshot = null;
|
||||
function flushDraft(){
|
||||
if(draftTimer !== null){ clearTimeout(draftTimer); draftTimer = null; }
|
||||
const saved = drafts.write(currentConvId, inputDom.value);
|
||||
drafts.active(currentConvId);
|
||||
if(!saved && inputDom.value && !composerStatus.textContent) setComposerStatus(t('draftSaveFailed'), true);
|
||||
}
|
||||
function restoreDraft(){
|
||||
inputDom.value = drafts.read(currentConvId);
|
||||
draftSnapshot = String(currentConvId) + '\n' + inputDom.value;
|
||||
drafts.active(currentConvId);
|
||||
autoGrow();
|
||||
}
|
||||
function queueDraft(){
|
||||
const snapshot = String(currentConvId) + '\n' + inputDom.value;
|
||||
if(snapshot === draftSnapshot) return;
|
||||
draftSnapshot = snapshot;
|
||||
if(draftTimer !== null) clearTimeout(draftTimer);
|
||||
draftTimer = setTimeout(flushDraft, 250);
|
||||
}
|
||||
function focusComposer(force){
|
||||
if(force || !isNarrow() || document.activeElement === inputDom){
|
||||
inputDom.focus({preventScroll:true});
|
||||
@@ -29,6 +51,7 @@ let humanSendingToken = null;
|
||||
let humanClosing = false;
|
||||
window.humanSid = 0;
|
||||
const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
|
||||
SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable'));
|
||||
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
|
||||
|
||||
function setComposerStatus(message, isError){
|
||||
@@ -217,7 +240,7 @@ const I18N = {
|
||||
busyRetry: "服务繁忙,请稍后重试",
|
||||
sessionFail: "会话创建失败,请确认服务已启动",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。",
|
||||
humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。",
|
||||
offlinePrompt: "请输入您的留言:",
|
||||
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
|
||||
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
|
||||
@@ -233,6 +256,10 @@ const I18N = {
|
||||
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
|
||||
humanReturnFailed: "暂时无法结束人工会话,请重试。",
|
||||
historyLoading: "正在加载这段会话,请稍候…",
|
||||
historyLoadFailed: "会话未加载成功,请重新选择该会话重试,或新建对话。",
|
||||
draftRestored: "已恢复这段会话的未发送草稿。",
|
||||
draftSaveFailed: "草稿暂时无法保存到当前浏览器,刷新前请保留输入内容。",
|
||||
attachmentUnavailable: "图片暂时无法访问,请重新发送附件。",
|
||||
recalled: "该消息已撤回",
|
||||
ratingQuestion: "本次服务满意吗?",
|
||||
ratingThanks: "感谢您的评价!",
|
||||
@@ -330,7 +357,7 @@ const I18N = {
|
||||
busyRetry: "Service busy, please try again later",
|
||||
sessionFail: "Failed to create session, please check the service is running",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support. Order lookup will be available after signing in and linking a customer account.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.",
|
||||
humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). Please leave a message and we will reply during service hours.",
|
||||
offlinePrompt: "Please leave your message:",
|
||||
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
|
||||
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
|
||||
@@ -346,6 +373,10 @@ const I18N = {
|
||||
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
|
||||
humanReturnFailed: "Could not end the support session. Please try again.",
|
||||
historyLoading: "Loading this conversation. Please wait…",
|
||||
historyLoadFailed: "Conversation could not load. Select it again to retry, or start a new chat.",
|
||||
draftRestored: "Your unsent draft has been restored.",
|
||||
draftSaveFailed: "Your browser could not save this draft. Keep a copy before refreshing.",
|
||||
attachmentUnavailable: "Image unavailable. Please upload the attachment again.",
|
||||
recalled: "This message was recalled",
|
||||
ratingQuestion: "How was your support experience?",
|
||||
ratingThanks: "Thank you for your feedback!",
|
||||
@@ -653,11 +684,12 @@ clearAllBtn.onclick = function(){
|
||||
if(newChatBtn){
|
||||
newChatBtn.onclick = function(){
|
||||
if(openingConv === 'clearing') return;
|
||||
const alreadyNew = currentConvId === null;
|
||||
resetConversation(null);
|
||||
inputDom.value = "";
|
||||
if(alreadyNew) inputDom.value = "";
|
||||
autoGrow();
|
||||
clearAttachment();
|
||||
setComposerStatus("");
|
||||
setComposerStatus(inputDom.value ? t('draftRestored') : '');
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
chatDom.scrollTop = 0;
|
||||
followLatest = true;
|
||||
@@ -673,10 +705,13 @@ function loadHistory(){
|
||||
historyList = data;
|
||||
renderSideHistory();
|
||||
renderRecentChat();
|
||||
}).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ });
|
||||
return data;
|
||||
}).catch(function(){ return null; /* 保留上次成功加载的历史。 */ });
|
||||
}
|
||||
|
||||
function removeCachedHistory(id){
|
||||
if(id === undefined){ drafts.clear(); flushDraft(); }
|
||||
else drafts.remove(id);
|
||||
historyRequestVersion += 1;
|
||||
historyList = id === undefined ? [] : historyList.filter(item => item.id !== id);
|
||||
renderSideHistory();
|
||||
@@ -823,6 +858,8 @@ function ensureConv(){
|
||||
throw error;
|
||||
}
|
||||
currentConvId = data.id;
|
||||
drafts.remove('new');
|
||||
flushDraft();
|
||||
if(data.welcome) addMsg(t('autoWelcome'), false);
|
||||
return currentConvId;
|
||||
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
|
||||
@@ -831,6 +868,7 @@ function ensureConv(){
|
||||
}
|
||||
|
||||
function resetConversation(id){
|
||||
flushDraft();
|
||||
emojiPicker.close();
|
||||
if(voiceInput) voiceInput.cancel();
|
||||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||||
@@ -839,6 +877,7 @@ function resetConversation(id){
|
||||
clearAttachment();
|
||||
stopHumanMode();
|
||||
currentConvId = id;
|
||||
restoreDraft();
|
||||
openingConv = null;
|
||||
creatingConv = null;
|
||||
sendPreparing = false;
|
||||
@@ -856,6 +895,7 @@ function openConversation(id){
|
||||
const version = resetConversation(id);
|
||||
if(isNarrow()){ sideOpen = false; renderSidebar(); }
|
||||
openingConv = id;
|
||||
chatDom.innerHTML = '';
|
||||
setComposerStatus(t('historyLoading'));
|
||||
renderSideHistory();
|
||||
requestJson("/api/get_messages?conv_id=" + id)
|
||||
@@ -863,7 +903,7 @@ function openConversation(id){
|
||||
if(version !== conversationVersion) return;
|
||||
if(!Array.isArray(msgs)) throw new Error('History failed');
|
||||
openingConv = null;
|
||||
setComposerStatus('');
|
||||
setComposerStatus(inputDom.value ? t('draftRestored') : '');
|
||||
chatDom.innerHTML = "";
|
||||
if(!msgs.length){
|
||||
chatDom.innerHTML = welcomeHTML();
|
||||
@@ -886,8 +926,8 @@ function openConversation(id){
|
||||
requestAnimationFrame(function(){ scrollChatToEnd(true); });
|
||||
}).catch(function(){
|
||||
if(version !== conversationVersion) return;
|
||||
openingConv = null;
|
||||
setComposerStatus(t('networkFail'), true);
|
||||
openingConv = 'failed';
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1047,7 +1087,30 @@ function addTrackCard(card, timeStr){
|
||||
return bubble;
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
const restoreId = drafts.active();
|
||||
currentConvId = restoreId === 'new' ? null : Number(restoreId);
|
||||
restoreDraft();
|
||||
if(currentConvId){
|
||||
const id = currentConvId, version = conversationVersion, initialDraft = inputDom.value;
|
||||
openingConv = 'restoring';
|
||||
loadHistory().then(function(history){
|
||||
if(version !== conversationVersion || currentConvId !== id || openingConv !== 'restoring') return;
|
||||
if(Array.isArray(history) && !history.some(item=>item.id === id)){
|
||||
if(inputDom.value !== initialDraft){
|
||||
openingConv = 'failed';
|
||||
flushDraft();
|
||||
setComposerStatus(t('historyLoadFailed'), true);
|
||||
return;
|
||||
}
|
||||
drafts.remove(id);
|
||||
currentConvId = null;
|
||||
restoreDraft();
|
||||
openingConv = null;
|
||||
return;
|
||||
}
|
||||
openConversation(id);
|
||||
});
|
||||
}else loadHistory();
|
||||
|
||||
window.fillInput = function(words){
|
||||
inputDom.value = words;
|
||||
@@ -1113,11 +1176,10 @@ async function humanRequest(path, data){
|
||||
window.showHuman = async function(){
|
||||
if(voiceInput) voiceInput.cancel();
|
||||
if(openingConv === 'clearing') return;
|
||||
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
|
||||
if(humanRequestToken || humanClosing) return;
|
||||
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
|
||||
if(window.humanSid) stopHumanMode();
|
||||
const hour = new Date().getHours();
|
||||
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
|
||||
const token = {version:conversationVersion};
|
||||
humanRequestToken = token;
|
||||
if(cancelActive){ cancelActive(); cancelActive = null; }
|
||||
@@ -1530,7 +1592,7 @@ async function doSend(retryText){
|
||||
var fullContent = sendWord;
|
||||
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
|
||||
setComposerStatus("");
|
||||
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; }
|
||||
if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; }
|
||||
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
|
||||
// 人工客服模式:直接发给客服,不走AI
|
||||
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
|
||||
@@ -1764,6 +1826,7 @@ sendBtn.addEventListener("click", function(){
|
||||
|
||||
// ===== 多行输入框:自动增高 =====
|
||||
function autoGrow(){
|
||||
queueDraft();
|
||||
if(!inputDom.value){
|
||||
inputDom.style.height = '';
|
||||
return;
|
||||
@@ -1775,6 +1838,8 @@ function autoGrow(){
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
|
||||
}
|
||||
inputDom.addEventListener("input", autoGrow);
|
||||
window.addEventListener('pagehide', flushDraft);
|
||||
document.addEventListener('visibilitychange', function(){ if(document.hidden) flushDraft(); });
|
||||
|
||||
inputDom.addEventListener("keydown",(e)=>{
|
||||
// Enter 发送,Shift+Enter 换行
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Text drafts stay in this visitor's browser, independently for each conversation.
|
||||
var ChatDrafts = {
|
||||
create(options){
|
||||
const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null;
|
||||
const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null;
|
||||
const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null;
|
||||
const memory = new Map();
|
||||
const dirty = new Set();
|
||||
let current = 'new';
|
||||
function id(value){
|
||||
if(value === null || value === 'new') return 'new';
|
||||
const key = String(value);
|
||||
return /^[1-9][0-9]{0,18}$/.test(key) ? key : null;
|
||||
}
|
||||
function storage(name){
|
||||
try{ return options[name](); }catch(error){ return null; }
|
||||
}
|
||||
function read(value){
|
||||
const key = id(value);
|
||||
if(!key) return '';
|
||||
if(dirty.has(key)) return memory.get(key) || '';
|
||||
if(prefix){
|
||||
try{
|
||||
const saved = storage('storage').getItem(prefix + key);
|
||||
if(saved !== null){
|
||||
const data = JSON.parse(saved);
|
||||
if(data && data.version === 1 && typeof data.text === 'string'){
|
||||
memory.set(key, data.text);
|
||||
return data.text;
|
||||
}
|
||||
}
|
||||
memory.delete(key);
|
||||
return '';
|
||||
}catch(error){}
|
||||
}
|
||||
return memory.get(key) || '';
|
||||
}
|
||||
function write(value, text){
|
||||
const key = id(value);
|
||||
if(!key || typeof text !== 'string') return false;
|
||||
if(text) memory.set(key, text); else memory.delete(key);
|
||||
dirty.add(key);
|
||||
if(!prefix) return false;
|
||||
try{
|
||||
const target = storage('storage');
|
||||
if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text}));
|
||||
else target.removeItem(prefix + key);
|
||||
dirty.delete(key);
|
||||
return true;
|
||||
}catch(error){ return false; }
|
||||
}
|
||||
function active(value){
|
||||
if(arguments.length){
|
||||
current = id(value) || 'new';
|
||||
if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){}
|
||||
}else if(activeKey){
|
||||
try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
function clear(){
|
||||
memory.clear();
|
||||
dirty.clear();
|
||||
if(prefix){
|
||||
try{
|
||||
const target = storage('storage');
|
||||
for(let index = target.length - 1; index >= 0; index--){
|
||||
const key = target.key(index);
|
||||
if(key && key.startsWith(prefix)) target.removeItem(key);
|
||||
}
|
||||
}catch(error){}
|
||||
}
|
||||
active('new');
|
||||
}
|
||||
return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear};
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,17 @@
|
||||
// Shared controls for the customer page and service workspace.
|
||||
var SupportUI = {
|
||||
initAttachments(container, label){
|
||||
container.addEventListener('error', function(event){
|
||||
const image = event.target;
|
||||
if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return;
|
||||
image.dataset.unavailable = '1';
|
||||
image.hidden = true;
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'attachment-unavailable';
|
||||
hint.textContent = typeof label === 'function' ? label() : label;
|
||||
image.after(hint);
|
||||
}, true);
|
||||
},
|
||||
async enableNotifications(button){
|
||||
if(!('Notification' in window)) return;
|
||||
try{
|
||||
|
||||
@@ -45,5 +45,5 @@ var VisitorSession = (function(){
|
||||
}
|
||||
return established ? send() : ensure().then(send);
|
||||
}
|
||||
return {fetch:request};
|
||||
return {fetch:request,localKey:localKey};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<!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="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<style>
|
||||
body { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
|
||||
main { width: min(420px, 100%); padding: 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
|
||||
h1 { font-size: 22px; margin-bottom: 14px; }
|
||||
p { color: var(--secondary); margin: 10px 0; }
|
||||
.english { font-size: 13px; }
|
||||
a { display: inline-block; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>附件暂时无法访问</h1>
|
||||
<p>文件可能已过期、被删除或当前无法访问。请返回会话重新发送,或联系人工客服。</p>
|
||||
<p lang="en" class="english">This attachment is unavailable. Please return to your conversation to upload it again or contact support.</p>
|
||||
<a href="{{ url_for('admin.admin_dash') if staff else url_for('pages.index') }}">{{ '返回客服工作台' if staff else '返回聊天' }}</a>
|
||||
</main></body>
|
||||
</html>
|
||||
@@ -74,6 +74,7 @@
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||
</body>
|
||||
|
||||
@@ -6,6 +6,14 @@ const vm = require('node:vm');
|
||||
const flush = () => new Promise(setImmediate);
|
||||
|
||||
async function customerPage(pageOptions = {}){
|
||||
const storageValues = pageOptions.storage || {};
|
||||
if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64);
|
||||
const tabValues = pageOptions.tabStorage || {};
|
||||
function testStorage(values){return {
|
||||
getItem:key=>values[key] ?? null,setItem:(key,value)=>{values[key]=String(value);},
|
||||
removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null,
|
||||
get length(){return Object.keys(values).length;}
|
||||
};}
|
||||
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
|
||||
let intervalId = 0, timerId = 0, nextConvId = 1;
|
||||
let voiceOptions;
|
||||
@@ -71,7 +79,7 @@ async function customerPage(pageOptions = {}){
|
||||
document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true,
|
||||
FormData:class { append(){} },
|
||||
requestAnimationFrame:callback => callback(),
|
||||
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
|
||||
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
|
||||
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
@@ -85,6 +93,7 @@ async function customerPage(pageOptions = {}){
|
||||
fetch: async (url, options) => {
|
||||
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
|
||||
if(hooks[url]) return hooks[url](options);
|
||||
if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch();
|
||||
if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url);
|
||||
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
|
||||
if(url === '/stream_chat'){
|
||||
@@ -97,14 +106,14 @@ async function customerPage(pageOptions = {}){
|
||||
}
|
||||
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
|
||||
: url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)}
|
||||
: url === '/api/visitor/human/request' ? {ok:true, session_id:7}
|
||||
: url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7})
|
||||
: url.startsWith('/api/visitor/human/poll') ? state
|
||||
: ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
|
||||
: url === '/api/get_history' ? (pageOptions.history || []) : [];
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){
|
||||
for(const file of ['support.js', 'visitor.js', 'drafts.js', 'voice.js', 'chat.js']){
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
|
||||
if(file === 'voice.js'){
|
||||
const createVoice=context.ChatVoice.create;
|
||||
@@ -115,7 +124,7 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
if(pageOptions.human !== false) await context.window.showHuman();
|
||||
await flush();
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues,
|
||||
documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); },
|
||||
resize:async height => {
|
||||
context.window.innerHeight=height;
|
||||
@@ -134,6 +143,7 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
},
|
||||
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
|
||||
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
|
||||
setState:value => { state=value; },
|
||||
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
|
||||
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||
@@ -488,9 +498,9 @@ test('repeated contact clicks create one human request and show a visible queue
|
||||
});
|
||||
|
||||
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
|
||||
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
|
||||
const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1);
|
||||
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
|
||||
assert.match(app.element('chatDom').textContent, /留言已提交/);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
@@ -505,6 +515,13 @@ test('a failed human send preserves the text and makes retry possible', async ()
|
||||
assert.equal(app.element('send-button').disabled, false);
|
||||
});
|
||||
|
||||
test('a visitor whose phone says late at night can still reach an open support service', async () => {
|
||||
const app=await customerPage({human:false,hour:23});
|
||||
await app.context.window.showHuman();
|
||||
assert.equal(app.context.window.humanSid,7);
|
||||
assert.equal(app.calls.filter(call=>call.url==='/api/ticket').length,0);
|
||||
});
|
||||
|
||||
test('a duplicate human send cannot submit an in-flight message twice', async () => {
|
||||
const app = await customerPage();
|
||||
let resolve;
|
||||
@@ -841,10 +858,10 @@ test('successful clipboard fallback reports copied and keeps text focus stable',
|
||||
});
|
||||
|
||||
test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => {
|
||||
const empty=await customerPage({human:false,hour:23,offlineMessage:' '});
|
||||
const empty=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:' '});
|
||||
await empty.context.window.showHuman();await flush();
|
||||
assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0);
|
||||
const app=await customerPage({human:false,hour:23,offlineMessage:'需要保留的留言'});
|
||||
const app=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:'需要保留的留言'});
|
||||
app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400);
|
||||
await app.context.window.showHuman();await flush();
|
||||
assert.equal(app.element('input-text').value,'需要保留的留言');
|
||||
@@ -879,3 +896,99 @@ test('emoji panel dismisses outside or on Escape, stays open for its own control
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
assert.equal(panel.hidden,true);
|
||||
});
|
||||
|
||||
test('drafts stay with their own conversation and restore across a page reload', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
|
||||
const app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A的未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[1]);
|
||||
assert.equal(app.element('input-text').value,'');
|
||||
app.element('input-text').value='B的未发送草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
assert.equal(app.element('input-text').value,'A的未发送草稿');
|
||||
await app.pageHide();
|
||||
const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage});
|
||||
await flush();
|
||||
assert.equal(restored.element('input-text').value,'A的未发送草稿');
|
||||
assert.ok(restored.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
|
||||
await restored.click(restored.element('historyWrap').children[1]);
|
||||
assert.equal(restored.element('input-text').value,'B的未发送草稿');
|
||||
});
|
||||
|
||||
test('a new conversation draft restores after refresh and moves to the created conversation on send', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.element('input-text').value='尚未发送';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage});
|
||||
assert.equal(restored.element('input-text').value,'尚未发送');
|
||||
await restored.context.window.fillInput('尚未发送');await restored.pageHide();
|
||||
const store=restored.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>restored.context.localStorage,tabStorage:()=>restored.context.sessionStorage});
|
||||
assert.equal(store.read(null),'');assert.equal(store.read(1),'');assert.equal(store.active(),'1');
|
||||
});
|
||||
|
||||
test('restoring an old conversation cannot replace a newer user-selected draft', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
let release;
|
||||
const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
|
||||
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
|
||||
await restored.click(restored.element('.new-chat-btn'));
|
||||
restored.element('input-text').value='新话题草稿';restored.element('input-text').listeners.input();
|
||||
release(response([{id:9,title:'A',ctime:'now'}]));await flush();
|
||||
assert.equal(restored.element('input-text').value,'新话题草稿');
|
||||
});
|
||||
|
||||
test('failed history loading cannot send a question under a hidden conversation context', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.context.window.fillInput('原会话');await flush();
|
||||
app.hooks.messages=()=>response({ok:false},503);
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
const before=app.calls.filter(call=>call.url==='/stream_chat').length;
|
||||
await app.context.window.fillInput('禁止发到未加载会话');
|
||||
assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before);
|
||||
assert.match(app.element('composer-status').textContent,/会话未加载成功/);
|
||||
assert.doesNotMatch(app.element('chatDom').textContent,/原会话/);
|
||||
});
|
||||
|
||||
test('startup removes a deleted active conversation draft without erasing a newer edit', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='旧会话草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
const noEdit=await customerPage({human:false,storage:{...app.storage},tabStorage:{...app.tabStorage}});
|
||||
assert.equal(noEdit.element('input-text').value,'');
|
||||
assert.ok(!noEdit.calls.some(call=>call.url==='/api/get_messages?conv_id=9'));
|
||||
let release;
|
||||
const edited=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage,
|
||||
historyFetch:()=>new Promise(resolve=>{release=resolve;})});
|
||||
edited.element('input-text').value='加载期间的新输入';edited.element('input-text').listeners.input();
|
||||
release(response([]));await flush();
|
||||
assert.equal(edited.element('input-text').value,'加载期间的新输入');
|
||||
await edited.context.window.fillInput('加载期间的新输入');
|
||||
assert.ok(!edited.calls.some(call=>call.url==='/stream_chat'));
|
||||
assert.match(edited.element('composer-status').textContent,/会话未加载成功/);
|
||||
});
|
||||
|
||||
test('deleting a conversation removes only its saved draft', async () => {
|
||||
const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}];
|
||||
const app=await customerPage({human:false,history});
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
app.element('input-text').value='A草稿';app.element('input-text').listeners.input();
|
||||
await app.click(app.element('historyWrap').children[1]);
|
||||
app.element('input-text').value='B草稿';app.element('input-text').listeners.input();await app.pageHide();
|
||||
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
|
||||
const store=app.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>app.context.localStorage,tabStorage:()=>app.context.sessionStorage});
|
||||
assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿');
|
||||
});
|
||||
|
||||
test('an unavailable image gets one readable replacement instead of a broken image', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
let shown;
|
||||
const image={tagName:'IMG',classList:{contains:name=>name==='message-image'},dataset:{},after:hint=>{shown=hint;}};
|
||||
app.element('chatDom').listeners.error({target:image});
|
||||
assert.equal(image.hidden,true);
|
||||
assert.match(shown.textContent,/图片暂时无法访问/);
|
||||
const first=shown;
|
||||
app.element('chatDom').listeners.error({target:image});
|
||||
assert.equal(shown,first);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const vm=require('node:vm');
|
||||
const path=require('node:path');
|
||||
const context=vm.createContext({});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context);
|
||||
function storage(){
|
||||
const data=new Map();
|
||||
return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null,
|
||||
getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)};
|
||||
}
|
||||
function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){
|
||||
return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab});
|
||||
}
|
||||
test('conversation drafts and active tab restore independently of another visitor or tab',()=>{
|
||||
const local=storage(),tab=storage(),a=drafts(undefined,local,tab);
|
||||
a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9);
|
||||
const restored=drafts(undefined,local,tab);
|
||||
assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW');
|
||||
assert.equal(restored.active(),'9');
|
||||
const otherTab=drafts(undefined,local);otherTab.write(10,'B updated');
|
||||
assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A');
|
||||
otherTab.remove(10);assert.equal(restored.read(10),'');
|
||||
assert.equal(drafts('2'.repeat(64),local,tab).read(9),'');
|
||||
});
|
||||
test('malformed records and invalid identifiers cannot become restored drafts',()=>{
|
||||
const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9';
|
||||
for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){
|
||||
local.setItem(key,value);assert.equal(a.read(9),'');
|
||||
}
|
||||
assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),'');
|
||||
});
|
||||
test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{
|
||||
const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local);
|
||||
local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE');
|
||||
a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B');
|
||||
a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark');
|
||||
});
|
||||
test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{
|
||||
const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
|
||||
const a=drafts(undefined,broken,broken);
|
||||
assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9');
|
||||
const local=storage(),b=drafts(undefined,local);b.write(9,'old');
|
||||
local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new');
|
||||
const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only');
|
||||
assert.equal(drafts(null,local).read(9),'');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Remote dev behavior on the modular backend and the light workspace."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
@@ -50,6 +50,19 @@ class DevIntegrationTests(unittest.TestCase):
|
||||
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
|
||||
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
|
||||
|
||||
def test_service_hours_use_china_time_even_when_the_host_clock_is_utc(self):
|
||||
cid = self.client.post('/api/new_chat').json['id']
|
||||
for utc_hour, expected in ((0, True),(13, True),(14, False),(23, False)):
|
||||
instant = datetime(2026,10,31,utc_hour,tzinfo=timezone.utc)
|
||||
class HostUtcClock(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
|
||||
with self.subTest(utc_hour=utc_hour), patch('logistics_chat.human_service.datetime', HostUtcClock):
|
||||
result = self.client.post('/api/visitor/human/request',json={'conv_id':cid}).json
|
||||
self.assertEqual(result['ok'],expected)
|
||||
if not expected: self.assertTrue(result['closed'])
|
||||
|
||||
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
|
||||
sid = self.session()
|
||||
self.client.post('/api/human/accept', json={'sid': sid})
|
||||
|
||||
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
|
||||
def test_reply_workflows(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs'], cwd=root,
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
|
||||
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
@@ -123,6 +123,20 @@ class StorageTests(unittest.TestCase):
|
||||
self.assertNotIn('..', name)
|
||||
self.assertFalse(result['is_image'])
|
||||
|
||||
def test_old_attachments_referenced_by_ai_or_human_history_survive_cleanup(self):
|
||||
uploads=self.root/'uploads'; uploads.mkdir()
|
||||
for name in ('ai.png','human.pdf','unused.txt'):
|
||||
file=uploads/name;file.write_bytes(b'fixture')
|
||||
os.utime(file,(time.time()-20*86400,time.time()-20*86400))
|
||||
connection=db.connect()
|
||||
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(1,'user','[图片]/uploads/ai.png','now')")
|
||||
connection.execute("INSERT INTO human_messages(session_id,side,content,ctime) VALUES(1,'customer','[文件]/uploads/human.pdf','now')")
|
||||
connection.commit();connection.close()
|
||||
file_service.cleanup_uploads()
|
||||
self.assertTrue((uploads/'ai.png').exists())
|
||||
self.assertTrue((uploads/'human.pdf').exists())
|
||||
self.assertFalse((uploads/'unused.txt').exists())
|
||||
|
||||
def test_invalid_and_oversized_uploads_are_rejected(self):
|
||||
self.assertEqual(file_service.upload_file({})[1], 400)
|
||||
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
|
||||
|
||||
@@ -125,6 +125,17 @@ class VisitorTests(unittest.TestCase):
|
||||
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
|
||||
lookup.assert_not_called();model.assert_not_called()
|
||||
|
||||
def test_missing_attachments_show_a_readable_private_page_without_disclosing_ownership(self):
|
||||
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'fixture'),'missing.txt')})
|
||||
url=response.json['url']
|
||||
(Path(self.app.config['UPLOAD_DIR'])/url.rsplit('/',1)[1]).unlink()
|
||||
own=self.a.get(url);other=self.b.get(url)
|
||||
self.assertEqual(own.status_code,404)
|
||||
self.assertIn('附件暂时无法访问',own.get_data(as_text=True))
|
||||
self.assertIn('no-store',own.headers['Cache-Control'])
|
||||
self.assertEqual(own.data,other.data)
|
||||
self.assertNotIn(url,own.get_data(as_text=True))
|
||||
|
||||
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
|
||||
cid=self.conversation(self.a,'A_PRIVATE')
|
||||
for key in ('short','x'*64,'1'*65,[],{},123):
|
||||
|
||||
Reference in New Issue
Block a user