Pārlūkot izejas kodu

增加用户登录,注册,管理,将所有记录录入数据库

sequoia00 1 nedēļu atpakaļ
vecāks
revīzija
6b80057a8f
5 mainītis faili ar 1819 papildinājumiem un 188 dzēšanām
  1. 47 1
      README
  2. 731 151
      fastchat.py
  3. 724 35
      static/app.js
  4. 74 1
      static/index.html
  5. 243 0
      static/styles.css

+ 47 - 1
README

@@ -1 +1,47 @@
-和云雾大模型进行聊天的工具
+和云雾大模型进行聊天的工具,现已支持多用户登录、管理员后台以及导出内容追踪。
+
+## 主要特性
+
+- **账号系统**:支持注册普通用户与账号密码登录,采用 MySQL 存储并自动维护默认管理员(`admin / Admin@123`)。
+- **角色权限**:区分管理员与普通用户。管理员可以在后台界面中新增、重置、删除普通用户,并查看所有用户的导出记录。
+- **聊天与历史**:每位用户拥有独立的聊天会话、历史记录以及上传文件。删除/归档操作仅影响当前用户。
+- **导出追踪**:导出回答不但会生成本地文件,还会写入数据库。管理员可按用户检索、下载导出内容,普通用户可在「我的导出」中查看并下载自己的历史导出。
+- **现代化 UI**:新增登录/注册界面、用户信息展示、导出面板与后台浮层,跟随登录状态自动切换。
+
+## 环境配置
+
+1. **安装依赖**
+
+   ```bash
+   pip install fastapi uvicorn sqlalchemy pymysql python-multipart openai
+   ```
+
+2. **准备数据库**
+
+   - 默认连接:`mysql+pymysql://root:792199Zhao*@127.0.0.1:3306/chat_fast`
+   - 启动服务后会自动创建数据库与数据表,并初始化默认管理员账号。
+   - 如需修改连接参数,可通过环境变量:
+     - `CHATFAST_DB_HOST`
+     - `CHATFAST_DB_PORT`
+     - `CHATFAST_DB_USER`
+     - `CHATFAST_DB_PASSWORD`
+     - `CHATFAST_DB_NAME`
+
+3. **启动服务**
+
+   ```bash
+   uvicorn fastchat:app --host 0.0.0.0 --port 18018
+   ```
+
+   前端静态资源位于 `static/`,运行后直接访问服务地址即可。
+
+## 使用流程
+
+1. 访问页面后先登录/注册。默认管理员:`admin / Admin@123`。
+2. 普通用户登录后可直接聊天、查看个人历史与「我的导出」列表。
+3. 管理员在页面右上角点击「用户管理」可进入后台:
+   - 新增普通用户
+   - 重置普通用户密码
+   - 删除普通用户
+   - 查看所有用户的导出记录并可下载
+4. 聊天过程中点击任意回答的「导出」按钮,内容会保存到博客目录与数据库,随后可在导出列表中下载。

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 731 - 151
fastchat.py


+ 724 - 35
static/app.js

@@ -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 || '请求失败');

+ 74 - 1
static/index.html

@@ -7,7 +7,35 @@
     <link rel="stylesheet" href="/static/styles.css">
 </head>
 <body>
-    <div class="app-shell">
+    <div id="auth-view" class="auth-view">
+        <div class="auth-card">
+            <h1>欢迎使用聊天系统</h1>
+            <p class="auth-hint">系统分为管理员与普通用户,默认管理员:admin / Admin@123</p>
+            <form id="login-form" class="auth-form">
+                <label>用户名
+                    <input type="text" name="username" required autocomplete="username">
+                </label>
+                <label>密码
+                    <input type="password" name="password" required autocomplete="current-password">
+                </label>
+                <button type="submit" class="primary-button">登录</button>
+            </form>
+            <form id="register-form" class="auth-form hidden">
+                <label>用户名
+                    <input type="text" name="username" required autocomplete="username">
+                </label>
+                <label>密码
+                    <input type="password" name="password" required autocomplete="new-password">
+                </label>
+                <button type="submit" class="primary-button">注册并登录</button>
+            </form>
+            <div class="auth-switch">
+                <button type="button" class="secondary-button" data-auth-mode="login">已有账号?去登录</button>
+                <button type="button" class="secondary-button" data-auth-mode="register">没有账号?去注册</button>
+            </div>
+        </div>
+    </div>
+    <div class="app-shell hidden" id="app-shell">
         <aside class="sidebar">
             <div class="sidebar-scroll">
                 <section class="sidebar-section">
@@ -47,6 +75,12 @@
         <main class="main-panel">
             <header class="app-header">
                 <h1>ChatGPT-like Clone</h1>
+                <div class="header-actions">
+                    <span id="user-badge" class="user-badge"></span>
+                    <button id="export-btn" class="secondary-button" disabled>我的导出</button>
+                    <button id="admin-btn" class="secondary-button hidden">用户管理</button>
+                    <button id="logout-btn" class="secondary-button" disabled>退出登录</button>
+                </div>
             </header>
             <section id="chat-messages" class="chat-messages"></section>
             <form id="chat-form" class="chat-form">
@@ -60,6 +94,45 @@
             </form>
         </main>
     </div>
+    <div id="admin-panel" class="overlay hidden">
+        <div class="overlay-content">
+            <div class="overlay-header">
+                <h2>用户管理</h2>
+                <button id="admin-close" class="icon-button" type="button">×</button>
+            </div>
+            <form id="admin-create-form" class="admin-form">
+                <h3>新增普通用户</h3>
+                <div class="admin-form-grid">
+                    <input type="text" name="username" placeholder="用户名" required>
+                    <input type="password" name="password" placeholder="密码" required>
+                    <button type="submit" class="primary-button">创建</button>
+                </div>
+            </form>
+            <div class="admin-section">
+                <h3>用户列表</h3>
+                <div id="admin-user-list" class="admin-list"></div>
+            </div>
+            <div class="admin-section">
+                <div class="admin-section-header">
+                    <h3>导出记录</h3>
+                    <form class="admin-search">
+                        <input id="admin-export-search" type="text" placeholder="按用户名搜索">
+                        <button id="admin-export-refresh" class="secondary-button" type="button">查询</button>
+                    </form>
+                </div>
+                <div id="admin-export-list" class="admin-list"></div>
+            </div>
+        </div>
+    </div>
+    <div id="export-panel" class="overlay hidden">
+        <div class="overlay-content">
+            <div class="overlay-header">
+                <h2>我的导出</h2>
+                <button id="export-close" class="icon-button" type="button">×</button>
+            </div>
+            <div id="export-list" class="admin-list"></div>
+        </div>
+    </div>
     <div id="toast" class="toast hidden"></div>
     <script defer src="/static/app.js"></script>
 </body>

+ 243 - 0
static/styles.css

@@ -12,6 +12,226 @@
     --user-message: #e9f2ff;
     --assistant-message: #f1f3f5;
     --shadow: 0 2px 12px rgba(15, 23, 42, 0.08);
+.hidden {
+    display: none !important;
+}
+
+.auth-view {
+    position: fixed;
+    inset: 0;
+    background: linear-gradient(120deg, #4f46e5, #9333ea);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 20px;
+    z-index: 20;
+}
+
+.auth-card {
+    width: min(420px, 100%);
+    background: #fff;
+    border-radius: 16px;
+    padding: 32px;
+    box-shadow: var(--shadow);
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+}
+
+.auth-card h1 {
+    margin: 0;
+    font-size: 24px;
+    text-align: center;
+    color: #111;
+}
+
+.auth-hint {
+    margin: 0;
+    font-size: 14px;
+    color: var(--secondary-text);
+    text-align: center;
+}
+
+.auth-form {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+}
+
+.auth-form label {
+    font-size: 14px;
+    color: var(--secondary-text);
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+
+.auth-form input {
+    border-radius: 8px;
+    border: 1px solid var(--border-color);
+    padding: 10px;
+    font-size: 14px;
+}
+
+.auth-switch {
+    display: flex;
+    justify-content: space-between;
+    gap: 12px;
+}
+
+.header-actions {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.user-badge {
+    font-size: 14px;
+    color: var(--secondary-text);
+}
+
+.overlay {
+    position: fixed;
+    inset: 0;
+    background: rgba(15, 23, 42, 0.45);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 20px;
+    z-index: 30;
+}
+
+.overlay-content {
+    width: min(720px, 100%);
+    max-height: 90vh;
+    overflow-y: auto;
+    background: #fff;
+    border-radius: 16px;
+    padding: 24px;
+    box-shadow: var(--shadow);
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+}
+
+.overlay-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+}
+
+.icon-button {
+    border: none;
+    background: transparent;
+    font-size: 20px;
+    cursor: pointer;
+    padding: 4px 8px;
+}
+
+.admin-form {
+    border: 1px solid var(--border-color);
+    border-radius: 12px;
+    padding: 16px;
+    background: #f8fafc;
+}
+
+.admin-form h3 {
+    margin: 0 0 12px;
+    font-size: 16px;
+}
+
+.admin-form-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+    gap: 12px;
+}
+
+.admin-form input {
+    width: 100%;
+    border-radius: 8px;
+    border: 1px solid var(--border-color);
+    padding: 8px 10px;
+}
+
+.admin-section {
+    border: 1px solid var(--border-color);
+    border-radius: 12px;
+    padding: 16px;
+    background: #fff;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+}
+
+.admin-section-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+}
+
+.admin-search {
+    display: flex;
+    gap: 8px;
+}
+
+.admin-search input {
+    border: 1px solid var(--border-color);
+    border-radius: 8px;
+    padding: 8px 10px;
+}
+
+.admin-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+
+.admin-row {
+    border: 1px solid var(--border-color);
+    border-radius: 10px;
+    padding: 12px;
+    display: flex;
+    justify-content: space-between;
+    gap: 12px;
+    background: #fdfdfd;
+}
+
+.admin-row-info {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+
+.admin-row-info strong {
+    font-size: 15px;
+    color: #111;
+}
+
+.admin-row-meta {
+    font-size: 13px;
+    color: var(--secondary-text);
+}
+
+.admin-row-preview {
+    font-size: 13px;
+    color: var(--secondary-text);
+    max-height: 60px;
+    overflow: hidden;
+}
+
+.admin-row-actions {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    align-items: flex-end;
+}
+
+.empty-note {
+    margin: 0;
+    font-size: 14px;
+    color: var(--secondary-text);
+    text-align: center;
 }
 
 * {
@@ -162,6 +382,21 @@ body {
     background: #e6e9ef;
 }
 
+.secondary-button.small {
+    font-size: 13px;
+    padding: 6px 10px;
+}
+
+.secondary-button.danger {
+    background: #dc3545;
+    border-color: #dc3545;
+    color: #fff;
+}
+
+.secondary-button.danger:hover:not(:disabled) {
+    background: #c82333;
+}
+
 .history-list {
     display: flex;
     flex-direction: column;
@@ -295,6 +530,13 @@ body {
     gap: 10px;
 }
 
+.message.notice {
+    background: #fff8e1;
+    border-style: dashed;
+    color: #8c6b00;
+    align-self: center;
+}
+
 .message.user {
     background: var(--user-message);
     align-self: flex-end;
@@ -578,3 +820,4 @@ mark.hl {
         justify-content: center;
     }
 }
+:root {

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels