|
|
@@ -1,6 +1,8 @@
|
|
|
(function () {
|
|
|
'use strict';
|
|
|
|
|
|
+ const TOKEN_KEY = 'chatfast_token';
|
|
|
+
|
|
|
const state = {
|
|
|
config: null,
|
|
|
sessionId: null,
|
|
|
@@ -15,6 +17,12 @@
|
|
|
historyCount: 0,
|
|
|
searchQuery: '',
|
|
|
streaming: false,
|
|
|
+ token: null,
|
|
|
+ user: null,
|
|
|
+ authMode: 'login',
|
|
|
+ myExports: [],
|
|
|
+ adminUsers: [],
|
|
|
+ adminExports: [],
|
|
|
};
|
|
|
|
|
|
const dom = {};
|
|
|
@@ -24,37 +32,43 @@
|
|
|
async function init() {
|
|
|
cacheDom();
|
|
|
bindEvents();
|
|
|
+ resetChatState();
|
|
|
|
|
|
- try {
|
|
|
- await loadConfig();
|
|
|
- const querySessionId = getSessionIdFromUrl();
|
|
|
- let loadedFromQuery = false;
|
|
|
-
|
|
|
- if (querySessionId !== null) {
|
|
|
- try {
|
|
|
- await loadSession(querySessionId, { silent: true, updateUrl: true, replaceUrl: true });
|
|
|
- loadedFromQuery = true;
|
|
|
- } catch (err) {
|
|
|
- console.warn('Failed to load session from URL parameter:', err);
|
|
|
- showToast('指定的会话不存在,已加载最新会话。', 'error');
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (!loadedFromQuery) {
|
|
|
- await loadLatestSession({ updateUrl: true, replaceUrl: true });
|
|
|
- }
|
|
|
+ state.token = window.localStorage.getItem(TOKEN_KEY);
|
|
|
+ if (!state.token) {
|
|
|
+ showAuthView('login');
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- await loadHistory();
|
|
|
+ try {
|
|
|
+ await fetchProfile();
|
|
|
+ await bootstrapAfterAuth();
|
|
|
} catch (err) {
|
|
|
- showToast(err.message || '初始化失败', 'error');
|
|
|
+ console.error('Failed to bootstrap with existing session', err);
|
|
|
+ handleUnauthorized(false);
|
|
|
}
|
|
|
-
|
|
|
- renderSidebar();
|
|
|
- renderMessages();
|
|
|
- renderHistory();
|
|
|
}
|
|
|
|
|
|
function cacheDom() {
|
|
|
+ dom.appShell = document.getElementById('app-shell');
|
|
|
+ dom.authView = document.getElementById('auth-view');
|
|
|
+ dom.loginForm = document.getElementById('login-form');
|
|
|
+ dom.registerForm = document.getElementById('register-form');
|
|
|
+ dom.authSwitchers = document.querySelectorAll('[data-auth-mode]');
|
|
|
+ dom.logoutButton = document.getElementById('logout-btn');
|
|
|
+ dom.userBadge = document.getElementById('user-badge');
|
|
|
+ dom.adminButton = document.getElementById('admin-btn');
|
|
|
+ dom.exportButton = document.getElementById('export-btn');
|
|
|
+ dom.adminPanel = document.getElementById('admin-panel');
|
|
|
+ dom.adminClose = document.getElementById('admin-close');
|
|
|
+ dom.adminCreateForm = document.getElementById('admin-create-form');
|
|
|
+ dom.adminUserList = document.getElementById('admin-user-list');
|
|
|
+ dom.adminExportSearch = document.getElementById('admin-export-search');
|
|
|
+ dom.adminExportRefresh = document.getElementById('admin-export-refresh');
|
|
|
+ dom.adminExportList = document.getElementById('admin-export-list');
|
|
|
+ dom.exportPanel = document.getElementById('export-panel');
|
|
|
+ dom.exportClose = document.getElementById('export-close');
|
|
|
+ dom.exportList = document.getElementById('export-list');
|
|
|
dom.modelSelect = document.getElementById('model-select');
|
|
|
dom.outputMode = document.getElementById('output-mode');
|
|
|
dom.searchInput = document.getElementById('search-input');
|
|
|
@@ -81,6 +95,56 @@
|
|
|
}
|
|
|
|
|
|
function bindEvents() {
|
|
|
+ if (dom.loginForm) {
|
|
|
+ dom.loginForm.addEventListener('submit', handleLogin);
|
|
|
+ }
|
|
|
+ if (dom.registerForm) {
|
|
|
+ dom.registerForm.addEventListener('submit', handleRegister);
|
|
|
+ }
|
|
|
+ if (dom.authSwitchers && dom.authSwitchers.length) {
|
|
|
+ dom.authSwitchers.forEach((btn) => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ const mode = btn.getAttribute('data-auth-mode') || 'login';
|
|
|
+ setAuthMode(mode);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (dom.logoutButton) {
|
|
|
+ dom.logoutButton.addEventListener('click', handleLogout);
|
|
|
+ }
|
|
|
+ if (dom.adminButton) {
|
|
|
+ dom.adminButton.addEventListener('click', () => {
|
|
|
+ void openAdminPanel();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (dom.adminClose) {
|
|
|
+ dom.adminClose.addEventListener('click', hideAdminPanel);
|
|
|
+ }
|
|
|
+ if (dom.adminCreateForm) {
|
|
|
+ dom.adminCreateForm.addEventListener('submit', handleAdminCreate);
|
|
|
+ }
|
|
|
+ if (dom.adminExportRefresh) {
|
|
|
+ dom.adminExportRefresh.addEventListener('click', async (event) => {
|
|
|
+ event.preventDefault();
|
|
|
+ await loadAdminExports(dom.adminExportSearch.value || '');
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (dom.adminExportSearch) {
|
|
|
+ dom.adminExportSearch.addEventListener('keydown', async (event) => {
|
|
|
+ if (event.key === 'Enter') {
|
|
|
+ event.preventDefault();
|
|
|
+ await loadAdminExports(dom.adminExportSearch.value || '');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (dom.exportButton) {
|
|
|
+ dom.exportButton.addEventListener('click', () => {
|
|
|
+ void openExportPanel();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (dom.exportClose) {
|
|
|
+ dom.exportClose.addEventListener('click', hideExportPanel);
|
|
|
+ }
|
|
|
dom.modelSelect.addEventListener('change', () => {
|
|
|
state.model = dom.modelSelect.value;
|
|
|
});
|
|
|
@@ -116,6 +180,10 @@
|
|
|
});
|
|
|
|
|
|
dom.newChatButton.addEventListener('click', async () => {
|
|
|
+ if (!state.token) {
|
|
|
+ showToast('请先登录', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
if (state.streaming) {
|
|
|
showToast('请等待当前回复完成后再新建会话', 'error');
|
|
|
return;
|
|
|
@@ -156,6 +224,553 @@
|
|
|
window.addEventListener('popstate', handlePopState);
|
|
|
}
|
|
|
|
|
|
+ function resetChatState() {
|
|
|
+ state.sessionId = null;
|
|
|
+ state.messages = [];
|
|
|
+ state.expandedMessages = new Set();
|
|
|
+ state.historyItems = [];
|
|
|
+ state.historyTotal = 0;
|
|
|
+ state.historyPage = 0;
|
|
|
+ state.historyCount = 0;
|
|
|
+ state.myExports = [];
|
|
|
+ state.adminUsers = [];
|
|
|
+ state.adminExports = [];
|
|
|
+ renderSidebar();
|
|
|
+ renderMessages();
|
|
|
+ renderHistory();
|
|
|
+ renderMyExports();
|
|
|
+ renderAdminUsers();
|
|
|
+ renderAdminExports();
|
|
|
+ }
|
|
|
+
|
|
|
+ function showAuthView(mode = 'login') {
|
|
|
+ state.authMode = mode;
|
|
|
+ if (dom.appShell) {
|
|
|
+ dom.appShell.classList.add('hidden');
|
|
|
+ }
|
|
|
+ if (dom.authView) {
|
|
|
+ dom.authView.classList.remove('hidden');
|
|
|
+ }
|
|
|
+ if (dom.loginForm) {
|
|
|
+ dom.loginForm.reset();
|
|
|
+ }
|
|
|
+ if (dom.registerForm) {
|
|
|
+ dom.registerForm.reset();
|
|
|
+ }
|
|
|
+ setAuthMode(mode);
|
|
|
+ }
|
|
|
+
|
|
|
+ function hideAuthView() {
|
|
|
+ if (dom.authView) {
|
|
|
+ dom.authView.classList.add('hidden');
|
|
|
+ }
|
|
|
+ if (dom.appShell) {
|
|
|
+ dom.appShell.classList.remove('hidden');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function setAuthMode(mode) {
|
|
|
+ state.authMode = mode;
|
|
|
+ if (!dom.loginForm || !dom.registerForm) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (mode === 'register') {
|
|
|
+ dom.loginForm.classList.add('hidden');
|
|
|
+ dom.registerForm.classList.remove('hidden');
|
|
|
+ } else {
|
|
|
+ dom.registerForm.classList.add('hidden');
|
|
|
+ dom.loginForm.classList.remove('hidden');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveToken(token) {
|
|
|
+ state.token = token;
|
|
|
+ if (token) {
|
|
|
+ window.localStorage.setItem(TOKEN_KEY, token);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function clearToken() {
|
|
|
+ state.token = null;
|
|
|
+ window.localStorage.removeItem(TOKEN_KEY);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function fetchProfile() {
|
|
|
+ const data = await fetchJSON('/api/auth/me');
|
|
|
+ state.user = data;
|
|
|
+ updateUserUi();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function bootstrapAfterAuth() {
|
|
|
+ hideAuthView();
|
|
|
+ try {
|
|
|
+ if (!state.config) {
|
|
|
+ await loadConfig();
|
|
|
+ }
|
|
|
+ const querySessionId = getSessionIdFromUrl();
|
|
|
+ let loadedFromQuery = false;
|
|
|
+
|
|
|
+ if (querySessionId !== null) {
|
|
|
+ try {
|
|
|
+ await loadSession(querySessionId, { silent: true, updateUrl: true, replaceUrl: true });
|
|
|
+ loadedFromQuery = true;
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('Failed to load session from URL parameter:', err);
|
|
|
+ showToast('指定的会话不存在,已加载最新会话。', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!loadedFromQuery) {
|
|
|
+ await loadLatestSession({ updateUrl: true, replaceUrl: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ await loadHistory();
|
|
|
+ await loadMyExports();
|
|
|
+ if (isAdmin()) {
|
|
|
+ await loadAdminUsers();
|
|
|
+ await loadAdminExports(dom.adminExportSearch ? dom.adminExportSearch.value || '' : '');
|
|
|
+ } else {
|
|
|
+ renderAdminUsers();
|
|
|
+ renderAdminExports();
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '初始化失败', 'error');
|
|
|
+ }
|
|
|
+
|
|
|
+ renderSidebar();
|
|
|
+ renderMessages();
|
|
|
+ renderHistory();
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateUserUi() {
|
|
|
+ if (dom.userBadge) {
|
|
|
+ if (state.user) {
|
|
|
+ const roleText = state.user.role === 'admin' ? '管理员' : '普通用户';
|
|
|
+ dom.userBadge.textContent = `${state.user.username} · ${roleText}`;
|
|
|
+ } else {
|
|
|
+ dom.userBadge.textContent = '';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (dom.adminButton) {
|
|
|
+ dom.adminButton.classList.toggle('hidden', !isAdmin());
|
|
|
+ }
|
|
|
+ if (dom.exportButton) {
|
|
|
+ dom.exportButton.disabled = !state.token;
|
|
|
+ }
|
|
|
+ if (dom.logoutButton) {
|
|
|
+ dom.logoutButton.disabled = !state.token;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function isAdmin() {
|
|
|
+ return Boolean(state.user && state.user.role === 'admin');
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleUnauthorized(showMessage = true) {
|
|
|
+ if (!state.token) {
|
|
|
+ showAuthView('login');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ clearToken();
|
|
|
+ state.user = null;
|
|
|
+ resetChatState();
|
|
|
+ showAuthView('login');
|
|
|
+ updateUserUi();
|
|
|
+ if (showMessage) {
|
|
|
+ showToast('登录状态已过期,请重新登录。', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleLogin(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ const formData = new FormData(dom.loginForm);
|
|
|
+ const username = String(formData.get('username') || '').trim();
|
|
|
+ const password = String(formData.get('password') || '').trim();
|
|
|
+ if (!username || !password) {
|
|
|
+ showToast('请输入用户名和密码', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const data = await fetchJSON('/api/auth/login', {
|
|
|
+ method: 'POST',
|
|
|
+ body: { username, password },
|
|
|
+ });
|
|
|
+ saveToken(data.token);
|
|
|
+ state.user = data.user;
|
|
|
+ updateUserUi();
|
|
|
+ showToast('登录成功', 'success');
|
|
|
+ await bootstrapAfterAuth();
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '登录失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleRegister(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ const formData = new FormData(dom.registerForm);
|
|
|
+ const username = String(formData.get('username') || '').trim();
|
|
|
+ const password = String(formData.get('password') || '').trim();
|
|
|
+ if (!username || !password) {
|
|
|
+ showToast('请输入用户名和密码', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const data = await fetchJSON('/api/auth/register', {
|
|
|
+ method: 'POST',
|
|
|
+ body: { username, password },
|
|
|
+ });
|
|
|
+ saveToken(data.token);
|
|
|
+ state.user = data.user;
|
|
|
+ updateUserUi();
|
|
|
+ showToast('注册并登录成功', 'success');
|
|
|
+ await bootstrapAfterAuth();
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '注册失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleLogout(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ if (!state.token) {
|
|
|
+ showAuthView('login');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await fetchJSON('/api/auth/logout', { method: 'POST' });
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('Logout failed', err);
|
|
|
+ } finally {
|
|
|
+ clearToken();
|
|
|
+ state.user = null;
|
|
|
+ resetChatState();
|
|
|
+ showAuthView('login');
|
|
|
+ updateUserUi();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadMyExports() {
|
|
|
+ if (!state.token) {
|
|
|
+ state.myExports = [];
|
|
|
+ renderMyExports();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const data = await fetchJSON('/api/exports/me');
|
|
|
+ state.myExports = Array.isArray(data.items) ? data.items : [];
|
|
|
+ renderMyExports();
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('Failed to load user exports', err);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMyExports() {
|
|
|
+ if (!dom.exportList) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ dom.exportList.innerHTML = '';
|
|
|
+ if (!state.token) {
|
|
|
+ const empty = document.createElement('p');
|
|
|
+ empty.className = 'empty-note';
|
|
|
+ empty.textContent = '登录后可查看导出历史。';
|
|
|
+ dom.exportList.appendChild(empty);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!state.myExports.length) {
|
|
|
+ const empty = document.createElement('p');
|
|
|
+ empty.className = 'empty-note';
|
|
|
+ empty.textContent = '暂无导出记录。';
|
|
|
+ dom.exportList.appendChild(empty);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ state.myExports.forEach((item) => {
|
|
|
+ const row = document.createElement('div');
|
|
|
+ row.className = 'admin-row';
|
|
|
+ const info = document.createElement('div');
|
|
|
+ info.className = 'admin-row-info';
|
|
|
+ const title = document.createElement('strong');
|
|
|
+ title.textContent = item.filename || `导出 #${item.id}`;
|
|
|
+ info.appendChild(title);
|
|
|
+ const meta = document.createElement('span');
|
|
|
+ meta.className = 'admin-row-meta';
|
|
|
+ meta.textContent = new Date(item.created_at || Date.now()).toLocaleString();
|
|
|
+ info.appendChild(meta);
|
|
|
+ const preview = document.createElement('div');
|
|
|
+ preview.className = 'admin-row-preview';
|
|
|
+ preview.textContent = item.content_preview || '';
|
|
|
+ info.appendChild(preview);
|
|
|
+ row.appendChild(info);
|
|
|
+
|
|
|
+ const actionBox = document.createElement('div');
|
|
|
+ actionBox.className = 'admin-row-actions';
|
|
|
+ const downloadButton = document.createElement('button');
|
|
|
+ downloadButton.type = 'button';
|
|
|
+ downloadButton.className = 'secondary-button small';
|
|
|
+ downloadButton.textContent = '下载';
|
|
|
+ downloadButton.addEventListener('click', () => {
|
|
|
+ void downloadExport(item.id, item.filename);
|
|
|
+ });
|
|
|
+ actionBox.appendChild(downloadButton);
|
|
|
+ row.appendChild(actionBox);
|
|
|
+ dom.exportList.appendChild(row);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openExportPanel() {
|
|
|
+ if (!state.token) {
|
|
|
+ showToast('请先登录', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await loadMyExports();
|
|
|
+ if (dom.exportPanel) {
|
|
|
+ dom.exportPanel.classList.remove('hidden');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function hideExportPanel() {
|
|
|
+ if (dom.exportPanel) {
|
|
|
+ dom.exportPanel.classList.add('hidden');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openAdminPanel() {
|
|
|
+ if (!isAdmin()) {
|
|
|
+ showToast('需要管理员权限', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await Promise.all([
|
|
|
+ loadAdminUsers(),
|
|
|
+ loadAdminExports(dom.adminExportSearch ? dom.adminExportSearch.value || '' : ''),
|
|
|
+ ]);
|
|
|
+ if (dom.adminPanel) {
|
|
|
+ dom.adminPanel.classList.remove('hidden');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function hideAdminPanel() {
|
|
|
+ if (dom.adminPanel) {
|
|
|
+ dom.adminPanel.classList.add('hidden');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadAdminUsers() {
|
|
|
+ if (!isAdmin()) {
|
|
|
+ state.adminUsers = [];
|
|
|
+ renderAdminUsers();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const data = await fetchJSON('/api/admin/users?page=0&page_size=200');
|
|
|
+ state.adminUsers = Array.isArray(data.items) ? data.items : [];
|
|
|
+ renderAdminUsers();
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '加载用户列表失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAdminUsers() {
|
|
|
+ if (!dom.adminUserList) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ dom.adminUserList.innerHTML = '';
|
|
|
+ if (!state.adminUsers.length) {
|
|
|
+ const empty = document.createElement('p');
|
|
|
+ empty.className = 'empty-note';
|
|
|
+ empty.textContent = isAdmin() ? '暂无普通用户。' : '无权限查看用户。';
|
|
|
+ dom.adminUserList.appendChild(empty);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ state.adminUsers.forEach((user) => {
|
|
|
+ const row = document.createElement('div');
|
|
|
+ row.className = 'admin-row';
|
|
|
+ const info = document.createElement('div');
|
|
|
+ info.className = 'admin-row-info';
|
|
|
+ const name = document.createElement('strong');
|
|
|
+ name.textContent = user.username;
|
|
|
+ info.appendChild(name);
|
|
|
+ const meta = document.createElement('span');
|
|
|
+ meta.className = 'admin-row-meta';
|
|
|
+ const roleLabel = user.role === 'admin' ? '管理员' : '普通用户';
|
|
|
+ meta.textContent = `${roleLabel} · ${new Date(user.created_at || Date.now()).toLocaleString()}`;
|
|
|
+ info.appendChild(meta);
|
|
|
+ row.appendChild(info);
|
|
|
+
|
|
|
+ const actions = document.createElement('div');
|
|
|
+ actions.className = 'admin-row-actions';
|
|
|
+ const resetButton = document.createElement('button');
|
|
|
+ resetButton.type = 'button';
|
|
|
+ resetButton.className = 'secondary-button small';
|
|
|
+ resetButton.textContent = '重置密码';
|
|
|
+ resetButton.disabled = user.role === 'admin';
|
|
|
+ resetButton.addEventListener('click', () => {
|
|
|
+ void handleAdminReset(user);
|
|
|
+ });
|
|
|
+ actions.appendChild(resetButton);
|
|
|
+
|
|
|
+ const deleteButton = document.createElement('button');
|
|
|
+ deleteButton.type = 'button';
|
|
|
+ deleteButton.className = 'secondary-button danger small';
|
|
|
+ deleteButton.textContent = '删除';
|
|
|
+ deleteButton.disabled = user.role === 'admin';
|
|
|
+ deleteButton.addEventListener('click', () => {
|
|
|
+ void handleAdminDelete(user);
|
|
|
+ });
|
|
|
+ actions.appendChild(deleteButton);
|
|
|
+ row.appendChild(actions);
|
|
|
+ dom.adminUserList.appendChild(row);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleAdminCreate(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ if (!isAdmin()) {
|
|
|
+ showToast('需要管理员权限', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const formData = new FormData(dom.adminCreateForm);
|
|
|
+ const username = String(formData.get('username') || '').trim();
|
|
|
+ const password = String(formData.get('password') || '').trim();
|
|
|
+ if (!username || !password) {
|
|
|
+ showToast('请输入用户名和密码', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await fetchJSON('/api/admin/users', {
|
|
|
+ method: 'POST',
|
|
|
+ body: { username, password },
|
|
|
+ });
|
|
|
+ dom.adminCreateForm.reset();
|
|
|
+ showToast('已创建用户', 'success');
|
|
|
+ await loadAdminUsers();
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '创建用户失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleAdminReset(user) {
|
|
|
+ if (!isAdmin() || !user || user.role === 'admin') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const password = window.prompt(`请输入 ${user.username} 的新密码:`);
|
|
|
+ if (!password) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await fetchJSON(`/api/admin/users/${user.id}`, {
|
|
|
+ method: 'PUT',
|
|
|
+ body: { password },
|
|
|
+ });
|
|
|
+ showToast('密码已更新', 'success');
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '重置密码失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleAdminDelete(user) {
|
|
|
+ if (!isAdmin() || !user || user.role === 'admin') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const confirmed = window.confirm(`确定要删除 ${user.username} 吗?`);
|
|
|
+ if (!confirmed) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await fetchJSON(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
|
|
+ showToast('已删除用户', 'success');
|
|
|
+ await loadAdminUsers();
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '删除用户失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadAdminExports(keyword = '') {
|
|
|
+ if (!isAdmin()) {
|
|
|
+ state.adminExports = [];
|
|
|
+ renderAdminExports();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const query = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
|
|
|
+ try {
|
|
|
+ const data = await fetchJSON(`/api/admin/exports${query}`);
|
|
|
+ state.adminExports = Array.isArray(data.items) ? data.items : [];
|
|
|
+ renderAdminExports();
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '获取导出列表失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAdminExports() {
|
|
|
+ if (!dom.adminExportList) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ dom.adminExportList.innerHTML = '';
|
|
|
+ if (!state.adminExports.length) {
|
|
|
+ const empty = document.createElement('p');
|
|
|
+ empty.className = 'empty-note';
|
|
|
+ empty.textContent = isAdmin() ? '暂无导出记录。' : '无权限查看。';
|
|
|
+ dom.adminExportList.appendChild(empty);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ state.adminExports.forEach((item) => {
|
|
|
+ const row = document.createElement('div');
|
|
|
+ row.className = 'admin-row';
|
|
|
+ const info = document.createElement('div');
|
|
|
+ info.className = 'admin-row-info';
|
|
|
+ const title = document.createElement('strong');
|
|
|
+ title.textContent = item.filename || `导出 #${item.id}`;
|
|
|
+ info.appendChild(title);
|
|
|
+ const meta = document.createElement('span');
|
|
|
+ meta.className = 'admin-row-meta';
|
|
|
+ meta.textContent = `${item.username || '未知用户'} · ${new Date(item.created_at || Date.now()).toLocaleString()}`;
|
|
|
+ info.appendChild(meta);
|
|
|
+ const preview = document.createElement('div');
|
|
|
+ preview.className = 'admin-row-preview';
|
|
|
+ preview.textContent = item.content_preview || '';
|
|
|
+ info.appendChild(preview);
|
|
|
+ row.appendChild(info);
|
|
|
+
|
|
|
+ const actions = document.createElement('div');
|
|
|
+ actions.className = 'admin-row-actions';
|
|
|
+ const downloadButton = document.createElement('button');
|
|
|
+ downloadButton.type = 'button';
|
|
|
+ downloadButton.className = 'secondary-button small';
|
|
|
+ downloadButton.textContent = '下载';
|
|
|
+ downloadButton.addEventListener('click', () => {
|
|
|
+ void downloadExport(item.id, item.filename);
|
|
|
+ });
|
|
|
+ actions.appendChild(downloadButton);
|
|
|
+ row.appendChild(actions);
|
|
|
+ dom.adminExportList.appendChild(row);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function downloadExport(exportId, filename) {
|
|
|
+ try {
|
|
|
+ const response = await fetch(`/api/exports/${exportId}/download`, {
|
|
|
+ headers: buildAuthHeaders(),
|
|
|
+ });
|
|
|
+ if (response.status === 401) {
|
|
|
+ handleUnauthorized();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!response.ok) {
|
|
|
+ const message = await readErrorMessage(response);
|
|
|
+ throw new Error(message || '下载失败');
|
|
|
+ }
|
|
|
+ const blob = await response.blob();
|
|
|
+ const url = window.URL.createObjectURL(blob);
|
|
|
+ const link = document.createElement('a');
|
|
|
+ link.href = url;
|
|
|
+ link.download = filename || `export-${exportId}.txt`;
|
|
|
+ document.body.appendChild(link);
|
|
|
+ link.click();
|
|
|
+ link.remove();
|
|
|
+ window.URL.revokeObjectURL(url);
|
|
|
+ showToast('下载完成', 'success');
|
|
|
+ } catch (err) {
|
|
|
+ showToast(err.message || '下载失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
async function loadConfig() {
|
|
|
const config = await fetchJSON('/api/config');
|
|
|
state.config = config;
|
|
|
@@ -185,6 +800,9 @@
|
|
|
}
|
|
|
|
|
|
async function loadLatestSession(options = {}) {
|
|
|
+ if (!state.token) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
const { updateUrl = true, replaceUrl = false } = options;
|
|
|
const data = await fetchJSON('/api/session/latest');
|
|
|
state.sessionId = typeof data.session_id === 'number' ? data.session_id : 0;
|
|
|
@@ -204,6 +822,9 @@
|
|
|
}
|
|
|
|
|
|
async function loadSession(sessionId, options = {}) {
|
|
|
+ if (!state.token) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
const { silent = false, updateUrl = true, replaceUrl = false } = options;
|
|
|
|
|
|
if (state.streaming) {
|
|
|
@@ -239,6 +860,10 @@
|
|
|
}
|
|
|
|
|
|
async function loadHistory() {
|
|
|
+ if (!state.token) {
|
|
|
+ renderHistory();
|
|
|
+ return;
|
|
|
+ }
|
|
|
try {
|
|
|
const data = await fetchJSON(`/api/history?page=${state.historyPage}&page_size=${state.historyPageSize}`);
|
|
|
const total = data.total || 0;
|
|
|
@@ -266,13 +891,17 @@
|
|
|
}
|
|
|
updateHistorySlider();
|
|
|
updateSearchFeedback();
|
|
|
+ updateUserUi();
|
|
|
}
|
|
|
|
|
|
function updateHistorySlider() {
|
|
|
const total = state.messages.length;
|
|
|
- dom.historyRange.max = String(total);
|
|
|
- state.historyCount = Math.min(state.historyCount, total);
|
|
|
- dom.historyRange.value = String(state.historyCount);
|
|
|
+ if (dom.historyRange) {
|
|
|
+ dom.historyRange.max = String(total);
|
|
|
+ dom.historyRange.disabled = !state.token;
|
|
|
+ state.historyCount = Math.min(state.historyCount, total);
|
|
|
+ dom.historyRange.value = String(state.historyCount);
|
|
|
+ }
|
|
|
dom.historyRangeLabel.textContent = `选择使用的历史消息数量(共${total}条)`;
|
|
|
dom.historyRangeValue.textContent = `您选择的历史消息数量是: ${state.historyCount}`;
|
|
|
}
|
|
|
@@ -318,10 +947,24 @@
|
|
|
function renderHistory() {
|
|
|
if (dom.historyCount) {
|
|
|
const total = Number.isFinite(state.historyTotal) ? state.historyTotal : 0;
|
|
|
- dom.historyCount.textContent = `共 ${total} 条`;
|
|
|
+ dom.historyCount.textContent = state.token ? `共 ${total} 条` : '';
|
|
|
}
|
|
|
|
|
|
dom.historyList.innerHTML = '';
|
|
|
+ if (!state.token) {
|
|
|
+ const notice = document.createElement('div');
|
|
|
+ notice.className = 'sidebar-help';
|
|
|
+ notice.textContent = '请先登录以查看历史记录。';
|
|
|
+ dom.historyList.appendChild(notice);
|
|
|
+ if (dom.historyPrev) {
|
|
|
+ dom.historyPrev.disabled = true;
|
|
|
+ }
|
|
|
+ if (dom.historyNext) {
|
|
|
+ dom.historyNext.disabled = true;
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
if (!state.historyItems.length) {
|
|
|
const empty = document.createElement('div');
|
|
|
empty.className = 'sidebar-help';
|
|
|
@@ -411,12 +1054,26 @@
|
|
|
}
|
|
|
|
|
|
const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
|
|
|
- dom.historyPrev.disabled = state.historyPage <= 0;
|
|
|
- dom.historyNext.disabled = state.historyPage >= totalPages - 1;
|
|
|
+ if (dom.historyPrev) {
|
|
|
+ dom.historyPrev.disabled = state.historyPage <= 0;
|
|
|
+ }
|
|
|
+ if (dom.historyNext) {
|
|
|
+ dom.historyNext.disabled = state.historyPage >= totalPages - 1;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
function renderMessages() {
|
|
|
+ if (!dom.chatMessages) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
dom.chatMessages.innerHTML = '';
|
|
|
+ if (!state.token) {
|
|
|
+ const notice = document.createElement('div');
|
|
|
+ notice.className = 'message notice';
|
|
|
+ notice.textContent = '请先登录以开始聊天。';
|
|
|
+ dom.chatMessages.appendChild(notice);
|
|
|
+ return;
|
|
|
+ }
|
|
|
const total = state.messages.length;
|
|
|
const searching = Boolean(state.searchQuery);
|
|
|
|
|
|
@@ -468,11 +1125,15 @@
|
|
|
exportButton.textContent = '导出';
|
|
|
exportButton.addEventListener('click', async () => {
|
|
|
try {
|
|
|
- await fetchJSON('/api/export', {
|
|
|
+ const data = await fetchJSON('/api/export', {
|
|
|
method: 'POST',
|
|
|
- body: { content: message.content },
|
|
|
+ body: { content: message.content, session_id: state.sessionId },
|
|
|
});
|
|
|
- showToast('已导出到 blog 文件夹。', 'success');
|
|
|
+ if (data && data.export) {
|
|
|
+ state.myExports = [data.export, ...state.myExports];
|
|
|
+ renderMyExports();
|
|
|
+ }
|
|
|
+ showToast('已导出并保存到数据库。', 'success');
|
|
|
} catch (err) {
|
|
|
showToast(err.message || '导出失败', 'error');
|
|
|
}
|
|
|
@@ -818,6 +1479,10 @@
|
|
|
|
|
|
async function handleSubmitMessage(event) {
|
|
|
event.preventDefault();
|
|
|
+ if (!state.token) {
|
|
|
+ showToast('请先登录后再聊天', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
if (state.streaming) {
|
|
|
showToast('请等待当前回复完成', 'error');
|
|
|
return;
|
|
|
@@ -926,6 +1591,7 @@
|
|
|
Array.from(fileList).forEach((file) => formData.append('files', file));
|
|
|
const response = await fetch('/api/upload', {
|
|
|
method: 'POST',
|
|
|
+ headers: buildAuthHeaders(),
|
|
|
body: formData,
|
|
|
});
|
|
|
if (!response.ok) {
|
|
|
@@ -971,9 +1637,13 @@
|
|
|
async function streamAssistantReply(payload, assistantMessage, assistantIndex) {
|
|
|
const response = await fetch('/api/chat', {
|
|
|
method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
+ headers: buildAuthHeaders({ 'Content-Type': 'application/json' }),
|
|
|
body: JSON.stringify(payload),
|
|
|
});
|
|
|
+ if (response.status === 401) {
|
|
|
+ handleUnauthorized();
|
|
|
+ throw new Error('未授权');
|
|
|
+ }
|
|
|
if (!response.ok || !response.body) {
|
|
|
const errorText = await safeReadText(response);
|
|
|
throw new Error(errorText || '生成失败');
|
|
|
@@ -1044,6 +1714,9 @@
|
|
|
}
|
|
|
|
|
|
function scrollToBottom() {
|
|
|
+ if (!dom.chatMessages) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
|
|
|
}
|
|
|
|
|
|
@@ -1084,6 +1757,9 @@
|
|
|
}
|
|
|
|
|
|
async function handlePopState(event) {
|
|
|
+ if (!state.token) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
if (state.streaming) {
|
|
|
return;
|
|
|
}
|
|
|
@@ -1104,14 +1780,27 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ function buildAuthHeaders(baseHeaders = {}) {
|
|
|
+ const headers = { ...(baseHeaders || {}) };
|
|
|
+ if (state.token) {
|
|
|
+ headers.Authorization = `Bearer ${state.token}`;
|
|
|
+ }
|
|
|
+ return headers;
|
|
|
+ }
|
|
|
+
|
|
|
async function fetchJSON(url, options = {}) {
|
|
|
const opts = { ...options };
|
|
|
- opts.headers = { ...(opts.headers || {}) };
|
|
|
+ opts.headers = buildAuthHeaders(opts.headers || {});
|
|
|
if (opts.body && !(opts.body instanceof FormData) && typeof opts.body !== 'string') {
|
|
|
opts.headers['Content-Type'] = 'application/json';
|
|
|
opts.body = JSON.stringify(opts.body);
|
|
|
}
|
|
|
const response = await fetch(url, opts);
|
|
|
+ if (response.status === 401) {
|
|
|
+ handleUnauthorized();
|
|
|
+ const message = await readErrorMessage(response);
|
|
|
+ throw new Error(message || '未授权');
|
|
|
+ }
|
|
|
if (!response.ok) {
|
|
|
const message = await readErrorMessage(response);
|
|
|
throw new Error(message || '请求失败');
|