const state = {
apiBase: localStorage.getItem("financeAiApiBase") || "http://127.0.0.1:8000",
token: localStorage.getItem("financeAiToken") || "",
currentUser: null,
currentSessionId: null,
selectedDocumentFile: null,
stats: null,
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const elements = {
apiBaseInput: $("#apiBaseInput"),
phoneInput: $("#phoneInput"),
loginForm: $("#loginForm"),
loginState: $("#loginState"),
logoutBtn: $("#logoutBtn"),
healthBadge: $("#healthBadge"),
pageTitle: $("#pageTitle"),
pageSubtitle: $("#pageSubtitle"),
sessionList: $("#sessionList"),
messages: $("#messages"),
questionInput: $("#questionInput"),
documentRows: $("#documentRows"),
documentModal: $("#documentModal"),
docFileInput: $("#docFileInput"),
dropZone: $("#dropZone"),
dropTitle: $("#dropTitle"),
dropHint: $("#dropHint"),
auditRows: $("#auditRows"),
toast: $("#toast"),
};
function toast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("show");
window.setTimeout(() => elements.toast.classList.remove("show"), 2600);
}
function apiUrl(path) {
return `${state.apiBase.replace(/\/$/, "")}${path}`;
}
async function request(path, options = {}) {
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
};
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const response = await fetch(apiUrl(path), { ...options, headers });
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
if (response.status === 204) {
return null;
}
return response.json();
}
function setApiBase(value) {
state.apiBase = value.trim() || "http://127.0.0.1:8000";
localStorage.setItem("financeAiApiBase", state.apiBase);
}
async function checkHealth() {
try {
const response = await fetch(apiUrl("/health"));
if (!response.ok) throw new Error("health failed");
elements.healthBadge.textContent = "已连接";
elements.healthBadge.classList.remove("muted");
} catch (error) {
elements.healthBadge.textContent = "未连接";
elements.healthBadge.classList.add("muted");
}
}
function setView(view) {
$$(".nav-item").forEach((item) => item.classList.toggle("active", item.dataset.view === view));
$$(".view").forEach((panel) => panel.classList.remove("is-visible"));
$(`#${view}View`).classList.add("is-visible");
const titles = {
chat: ["智能问答工作台", "基于正式制度文档回答,答案保留来源引用和问答留痕。"],
documents: ["知识文档", "实时拉取 ADP 知识库(默认知识库)下的文档列表,增删改请到 ADP 控制台操作。"],
audit: ["审计与用量", "查看登录、文档、问答和接口调用留痕。"],
};
elements.pageTitle.textContent = titles[view][0];
elements.pageSubtitle.textContent = titles[view][1];
if (view === "documents") loadDocuments();
if (view === "audit") loadAuditAndStats();
}
function renderLoginState() {
if (state.currentUser) {
elements.loginForm.hidden = true;
elements.logoutBtn.hidden = false;
elements.loginState.innerHTML = `
${escapeHtml(state.currentUser.name)}
${escapeHtml(state.currentUser.phone)}
${escapeHtml(state.currentUser.roles.join(", ") || "employee")}
`;
} else if (state.token) {
elements.loginForm.hidden = false;
elements.logoutBtn.hidden = true;
elements.loginState.textContent = "已保存 Token,待验证";
} else {
elements.loginForm.hidden = false;
elements.logoutBtn.hidden = true;
elements.loginState.textContent = "未登录";
}
}
async function sendCode() {
setApiBase(elements.apiBaseInput.value);
const phone = elements.phoneInput.value.trim();
const result = await request("/api/auth/sms-code", {
method: "POST",
body: JSON.stringify({ phone }),
});
toast(`验证码已发送:${result.mock_code || "请查看短信"}`);
}
async function login() {
setApiBase(elements.apiBaseInput.value);
const phone = elements.phoneInput.value.trim();
const result = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ phone, code: "123456" }),
});
state.token = result.access_token;
localStorage.setItem("financeAiToken", state.token);
await loadCurrentUser();
await Promise.all([loadSessions(), loadStats()]);
toast("登录成功");
}
async function logout() {
if (state.token) {
try {
await request("/api/auth/logout", { method: "POST" });
} catch (error) {
console.warn(error);
}
}
state.token = "";
state.currentUser = null;
state.currentSessionId = null;
localStorage.removeItem("financeAiToken");
renderLoginState();
renderMessages([]);
toast("已退出当前账户");
}
async function loadCurrentUser() {
if (!state.token) {
renderLoginState();
return;
}
try {
state.currentUser = await request("/api/auth/me");
} catch (error) {
state.currentUser = null;
state.token = "";
localStorage.removeItem("financeAiToken");
}
renderLoginState();
}
async function loadStats() {
if (!state.token) return;
try {
state.stats = await request("/api/stats/overview");
const setText = (sel, value) => {
const el = document.querySelector(sel);
if (el) el.textContent = value;
};
setText("#auditQuestions", state.stats.question_count);
setText("#auditUsers", state.stats.active_user_count);
setText("#auditDocs", state.stats.document_count);
setText("#auditCount", state.stats.audit_count);
} catch (error) {
console.warn(error);
}
}
async function loadSessions() {
if (!state.token) return;
const sessions = await request("/api/chat/sessions");
elements.sessionList.innerHTML = "";
if (!sessions.length) {
elements.sessionList.innerHTML = '
暂无会话
';
renderMessages([]);
return;
}
if (!state.currentSessionId) {
state.currentSessionId = sessions[0].id;
}
sessions.forEach((session) => {
const item = document.createElement("div");
item.className = `session-item ${session.id === state.currentSessionId ? "active" : ""}`;
item.innerHTML = `
`;
item.querySelector(".session-body").addEventListener("click", async () => {
state.currentSessionId = session.id;
await loadSessions();
await loadMessages(session.id);
});
item.querySelector(".session-delete").addEventListener("click", (event) => {
event.stopPropagation();
deleteSession(session).catch((error) => toast(error.message));
});
elements.sessionList.appendChild(item);
});
await loadMessages(state.currentSessionId);
}
async function deleteSession(session) {
if (!window.confirm(`确认删除会话「${session.title}」?该会话下所有问答与引用记录会一并删除,不可恢复。`)) return;
await request(`/api/chat/sessions/${session.id}`, { method: "DELETE" });
if (state.currentSessionId === session.id) {
state.currentSessionId = null;
renderMessages([]);
}
await Promise.all([loadSessions(), loadStats()]);
toast("会话已删除");
}
async function createSession() {
const session = await request("/api/chat/sessions", {
method: "POST",
body: JSON.stringify({ title: "新会话" }),
});
state.currentSessionId = session.id;
await loadSessions();
}
async function loadMessages(sessionId) {
if (!sessionId) {
renderMessages([]);
return;
}
const messages = await request(`/api/chat/sessions/${sessionId}/messages`);
renderMessages(messages);
}
function renderMessages(messages) {
elements.messages.innerHTML = "";
if (!messages.length) {
elements.messages.innerHTML = '输入一个财务制度或流程问题开始问答
';
return;
}
messages.forEach((message) => {
appendMessage("user", message.question);
const { bubble } = appendMessage("assistant", message.answer || "回答生成中...");
appendReferences(bubble, message.references || []);
});
}
function appendMessage(kind, text) {
const wrapper = document.createElement("div");
wrapper.className = `message ${kind === "user" ? "user" : ""}`;
const bubble = document.createElement("div");
bubble.className = "bubble";
const body = document.createElement("div");
body.className = "bubble-body";
body.textContent = text;
bubble.appendChild(body);
wrapper.appendChild(bubble);
elements.messages.appendChild(wrapper);
elements.messages.scrollTop = elements.messages.scrollHeight;
return { wrapper, bubble, body };
}
function appendReferences(bubble, refs) {
if (!refs || !refs.length) return;
const box = document.createElement("div");
box.className = "bubble-references";
const itemsHtml = refs.map((ref, idx) => {
const title = escapeHtml(ref.source_title || "未知来源");
const pageHint = ref.page_no ? `第 ${escapeHtml(ref.page_no)} 页` : "";
const snippet = (ref.snippet || "").trim();
return `
📄${idx + 1}. ${title}${pageHint}
${snippet ? `${escapeHtml(snippet)}
` : ""}
`;
}).join("");
box.innerHTML = `
参考知识片段
${itemsHtml}
`;
bubble.appendChild(box);
elements.messages.scrollTop = elements.messages.scrollHeight;
}
async function sendQuestion(event) {
event.preventDefault();
if (!state.token) {
toast("请先登录");
return;
}
const question = elements.questionInput.value.trim();
if (!question) return;
elements.questionInput.value = "";
appendMessage("user", question);
const { bubble: answerBubble, body: answerBody } = appendMessage("assistant", "");
const currentRefs = [];
const response = await fetch(apiUrl("/api/chat/stream"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.token}`,
},
body: JSON.stringify({ question, conversation_id: state.currentSessionId }),
});
if (!response.ok || !response.body) {
answerBody.textContent = "请求失败,请稍后重试。";
return;
}
const conversationId = Number(response.headers.get("X-Conversation-Id"));
if (conversationId) state.currentSessionId = conversationId;
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() || "";
events.forEach((rawEvent) => handleSseEvent(rawEvent, answerBody, currentRefs));
}
if (buffer) handleSseEvent(buffer, answerBody, currentRefs);
appendReferences(answerBubble, currentRefs);
await Promise.all([loadSessions(), loadStats()]);
}
function handleSseEvent(rawEvent, answerBody, currentRefs) {
const eventLine = rawEvent.split("\n").find((line) => line.startsWith("event:"));
const dataLine = rawEvent.split("\n").find((line) => line.startsWith("data:"));
if (!eventLine || !dataLine) return;
const eventName = eventLine.replace("event:", "").trim();
const data = JSON.parse(dataLine.replace("data:", "").trim() || "{}");
if (eventName === "answer.delta") {
answerBody.textContent += data;
elements.messages.scrollTop = elements.messages.scrollHeight;
}
if (eventName === "answer.reference") {
currentRefs.push(data);
}
}
async function loadDocuments() {
if (!state.token) return;
elements.documentRows.innerHTML = '| 加载中… |
';
try {
const result = await request("/api/documents/adp?page=1&page_size=100");
const list = result.list || [];
if (!list.length) {
elements.documentRows.innerHTML = '| ADP 知识库为空 |
';
return;
}
elements.documentRows.innerHTML = list.map((doc) => `
| ${escapeHtml(doc.file_name)} |
${escapeHtml((doc.file_type || "-").toUpperCase())} |
${formatFileSize(doc.size_bytes || 0)} |
${escapeHtml(doc.status_desc || "-")}${doc.is_disabled ? '已停用' : ""} |
${escapeHtml(formatEnableScope(doc.enable_scope))} |
${escapeHtml(formatAdpTime(doc.update_time))} |
`).join("");
} catch (error) {
elements.documentRows.innerHTML = `| 加载失败:${escapeHtml(error.message)} |
`;
}
}
function formatEnableScope(scope) {
return ({ 1: "全部", 2: "答案推荐", 3: "搜索来源", 4: "答案推荐 + 搜索来源" })[scope] || "-";
}
function formatAdpTime(value) {
if (!value) return "-";
const num = Number(value);
if (!num) return String(value);
return formatTime(new Date(num * 1000));
}
async function createDocument(event) {
event.preventDefault();
if (!state.selectedDocumentFile) {
toast("请先选择或拖拽一个文件");
return;
}
const tags = $("#docTagsInput").value.split(",").map((tag) => tag.trim()).filter(Boolean);
const formData = new FormData();
formData.append("file", state.selectedDocumentFile);
formData.append("tags", tags.join(","));
formData.append("secrecy_level", $("#docLevelInput").value);
formData.append("owner_dept", "财务部");
const headers = {};
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const response = await fetch(apiUrl("/api/documents/upload"), {
method: "POST",
headers,
body: formData,
});
if (!response.ok) {
throw new Error(await response.text());
}
closeDocumentModal();
$("#docTagsInput").value = "";
await Promise.all([loadDocuments(), loadStats()]);
toast("文档已上传");
}
function openDocumentModal() {
state.selectedDocumentFile = null;
elements.docFileInput.value = "";
elements.dropTitle.textContent = "拖拽文件到这里,或点击选择文件";
elements.dropHint.textContent = "支持 PDF、DOCX、XLSX、TXT、PNG、JPG 等知识材料";
elements.dropZone.classList.remove("has-file");
elements.documentModal.hidden = false;
}
function closeDocumentModal() {
elements.documentModal.hidden = true;
state.selectedDocumentFile = null;
}
function selectDocumentFile(file) {
if (!file) return;
state.selectedDocumentFile = file;
elements.dropTitle.textContent = file.name;
elements.dropHint.textContent = `${formatFileSize(file.size)} · 已准备上传`;
elements.dropZone.classList.add("has-file");
}
function formatFileSize(size) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
async function loadAuditAndStats() {
if (!state.token) return;
await loadStats();
const logs = await request("/api/audit-logs");
elements.auditRows.innerHTML = logs.map((log) => `
| ${formatTime(log.created_at)} |
${escapeHtml(log.action)} |
${escapeHtml(log.target_type || "-")} ${escapeHtml(log.target_id || "")} |
${escapeHtml(JSON.stringify(log.detail_json || {}))} |
`).join("");
}
function formatTime(value) {
if (!value) return "-";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function bindEvents() {
elements.apiBaseInput.value = state.apiBase;
$$(".nav-item").forEach((item) => item.addEventListener("click", () => setView(item.dataset.view)));
$("#sendCodeBtn").addEventListener("click", () => sendCode().catch((error) => toast(error.message)));
$("#loginBtn").addEventListener("click", () => login().catch((error) => toast(error.message)));
$("#logoutBtn").addEventListener("click", () => logout().catch((error) => toast(error.message)));
$("#refreshBtn").addEventListener("click", () => bootstrap().catch((error) => toast(error.message)));
$("#newSessionBtn").addEventListener("click", () => createSession().catch((error) => toast(error.message)));
$("#chatForm").addEventListener("submit", (event) => sendQuestion(event).catch((error) => toast(error.message)));
const refreshDocsBtn = document.querySelector("#refreshDocsBtn");
if (refreshDocsBtn) {
refreshDocsBtn.addEventListener("click", () => loadDocuments().catch((error) => toast(error.message)));
}
const openDocumentModalBtn = document.querySelector("#openDocumentModalBtn");
if (openDocumentModalBtn) openDocumentModalBtn.addEventListener("click", openDocumentModal);
$("#closeDocumentModalBtn").addEventListener("click", closeDocumentModal);
$("#cancelDocumentModalBtn").addEventListener("click", closeDocumentModal);
elements.documentModal.addEventListener("click", (event) => {
if (event.target === elements.documentModal) closeDocumentModal();
});
elements.docFileInput.addEventListener("change", (event) => selectDocumentFile(event.target.files[0]));
elements.dropZone.addEventListener("dragover", (event) => {
event.preventDefault();
elements.dropZone.classList.add("is-dragging");
});
elements.dropZone.addEventListener("dragleave", () => elements.dropZone.classList.remove("is-dragging"));
elements.dropZone.addEventListener("drop", (event) => {
event.preventDefault();
elements.dropZone.classList.remove("is-dragging");
selectDocumentFile(event.dataTransfer.files[0]);
});
$("#documentForm").addEventListener("submit", (event) => createDocument(event).catch((error) => toast(error.message)));
$("#loadAuditBtn").addEventListener("click", () => loadAuditAndStats().catch((error) => toast(error.message)));
}
async function bootstrap() {
setApiBase(elements.apiBaseInput.value);
await checkHealth();
await loadCurrentUser();
if (state.currentUser) {
await Promise.all([loadSessions(), loadStats()]);
} else {
renderMessages([]);
}
}
bindEvents();
bootstrap().catch((error) => toast(error.message));