feat: add admin login guard and build frontend

This commit is contained in:
JOJO 2026-01-11 19:12:49 +08:00
parent 2a3d3b85e1
commit fb296a0c5d
9 changed files with 417 additions and 717 deletions

View File

@ -1,11 +1,12 @@
from pathlib import Path from flask import Flask, send_from_directory, request, jsonify, session
from flask import Flask, send_from_directory
from .config import PROJECT_ROOT from .config import PROJECT_ROOT
from .routes.chat import bp as chat_bp from .routes.chat import bp as chat_bp
from .routes.faq import bp as faq_bp from .routes.faq import bp as faq_bp
from .routes.conversation import bp as convo_bp from .routes.conversation import bp as convo_bp
from .routes.auth import bp as auth_bp
from .config import SECRET_KEY
from .admin import ensure_admin_file
def create_app(): def create_app():
@ -16,11 +17,31 @@ def create_app():
template_folder=str(dist_dir), template_folder=str(dist_dir),
) )
app.secret_key = SECRET_KEY
# 确保管理员文件存在(仅存储哈希)
ensure_admin_file()
# 注册路由 # 注册路由
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp) app.register_blueprint(chat_bp)
app.register_blueprint(faq_bp) app.register_blueprint(faq_bp)
app.register_blueprint(convo_bp) app.register_blueprint(convo_bp)
@app.before_request
def require_login():
# 仅保护 API登录接口与静态资源除外
path = request.path or ""
if request.method == "OPTIONS":
return None
if path.startswith("/api/auth"):
return None
if not path.startswith("/api"):
return None
if session.get("user"):
return None
return jsonify({"error": "未登录"}), 401
# 前端静态资源 & SPA 回退 # 前端静态资源 & SPA 回退
@app.route("/", defaults={"path": ""}) @app.route("/", defaults={"path": ""})
@app.route("/<path:path>") @app.route("/<path:path>")

38
dialog/backend/admin.py Normal file
View File

@ -0,0 +1,38 @@
import json
from pathlib import Path
from typing import Optional
from werkzeug.security import generate_password_hash, check_password_hash
from .config import DATA_DIR
ADMIN_PATH = DATA_DIR / "admin.json"
DEFAULT_USERNAME = "cxf"
DEFAULT_PASSWORD = "NianJie1018"
def ensure_admin_file(username: str = DEFAULT_USERNAME, password: str = DEFAULT_PASSWORD) -> None:
"""Create admin file with hashed password if missing."""
if ADMIN_PATH.exists():
return
data = {
"username": username,
"password_hash": generate_password_hash(password),
}
ADMIN_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def get_admin() -> Optional[dict]:
if not ADMIN_PATH.exists():
return None
try:
return json.loads(ADMIN_PATH.read_text(encoding="utf-8"))
except Exception:
return None
def verify_credentials(username: str, password: str) -> bool:
admin = get_admin()
if not admin:
return False
return username == admin.get("username") and check_password_hash(admin.get("password_hash", ""), password)

View File

@ -5,6 +5,9 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BASE_DIR.parent PROJECT_ROOT = BASE_DIR.parent
# Flask session
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
# 数据文件 # 数据文件
DATA_DIR = PROJECT_ROOT / "data" DATA_DIR = PROJECT_ROOT / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,35 @@
from flask import Blueprint, jsonify, request, session
from ..admin import verify_credentials, get_admin
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@bp.post("/login")
def login():
data = request.get_json(force=True, silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username or not password:
return jsonify({"error": "用户名和密码必填"}), 400
if not verify_credentials(username, password):
return jsonify({"error": "用户名或密码错误"}), 401
session["user"] = username
return jsonify({"username": username})
@bp.post("/logout")
def logout():
session.clear()
return jsonify({"ok": True})
@bp.get("/me")
def me():
user = session.get("user")
if not user:
return jsonify({"error": "未登录"}), 401
admin = get_admin() or {}
return jsonify({"username": user, "role": "admin", "has_admin": bool(admin)})

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@
"vue": "^3.5.24" "vue": "^3.5.24"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^4.5.2",
"vite": "^7.2.4" "vite": "^4.5.5"
} }
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="app-shell"> <div v-if="user" class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo">念界客服</div> <div class="logo">念界客服</div>
@ -21,7 +21,13 @@
<main class="chat-pane"> <main class="chat-pane">
<header class="topbar"> <header class="topbar">
<div class="title">{{ mode === 'faq' ? '快速问答(不计入记录)' : 'AI 客服' }}</div> <div class="title">{{ mode === 'faq' ? '快速问答(不计入记录)' : 'AI 客服' }}</div>
<div class="status">{{ statusText }}</div> <div class="top-actions">
<span class="status">{{ statusText }}</span>
<div class="account">
<span class="user-name">👤 {{ user.username }}</span>
<button class="ghost" @click="handleLogout">退出</button>
</div>
</div>
</header> </header>
<section ref="logRef" class="chat-log"> <section ref="logRef" class="chat-log">
@ -82,11 +88,34 @@
</footer> </footer>
</main> </main>
</div> </div>
<div v-else class="login-shell">
<div class="login-card">
<div class="logo">念界后台登录</div>
<p class="login-sub">请输入管理员账户</p>
<form @submit.prevent="handleLogin" class="login-form">
<label>用户名</label>
<input v-model="loginForm.username" placeholder="用户名" autocomplete="username" />
<label>密码</label>
<input v-model="loginForm.password" type="password" placeholder="密码" autocomplete="current-password" />
<div v-if="loginError" class="error">{{ loginError }}</div>
<button class="primary" type="submit" :disabled="loginLoading">{{ loginLoading ? '登录中...' : '登录' }}</button>
</form>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, nextTick, onBeforeUnmount } from 'vue'; import { ref, reactive, onMounted, nextTick, onBeforeUnmount } from 'vue';
import { fetchTopQuestions, fetchQuestionById, searchQuestions, sendAiMessage } from './api'; import {
fetchTopQuestions,
fetchQuestionById,
searchQuestions,
sendAiMessage,
login as loginApi,
logout as logoutApi,
fetchMe,
} from './api';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
const messages = ref([]); const messages = ref([]);
@ -100,11 +129,15 @@ const lastQuery = ref('');
const suggestions = ref([]); const suggestions = ref([]);
const suggestionPage = ref(1); const suggestionPage = ref(1);
const logRef = ref(null); const logRef = ref(null);
const user = ref(null);
const loginForm = reactive({ username: '', password: '' });
const loginError = ref('');
const loginLoading = ref(false);
const popStateHandler = () => { const popStateHandler = () => {
const id = parseConversationIdFromPath(window.location.pathname); const id = parseConversationIdFromPath(window.location.pathname);
if (id) { if (id && user.value) {
loadConversation(id); loadConversation(id);
} else { } else if (user.value) {
resetFaq({ syncUrl: false }); resetFaq({ syncUrl: false });
} }
}; };
@ -129,7 +162,6 @@ function ensureUrlForConversation(id, { replace = false } = {}) {
function format(text) { function format(text) {
if (!text) return ''; if (!text) return '';
//
return text return text
.replace(/\r?\n/g, '<br>') .replace(/\r?\n/g, '<br>')
.replace(/\\n/g, '<br>'); .replace(/\\n/g, '<br>');
@ -172,14 +204,18 @@ function pushUser(text) {
} }
async function handleSelectOption(opt) { async function handleSelectOption(opt) {
pushUser(opt.question); try {
const item = await fetchQuestionById(opt.id); pushUser(opt.question);
messages.value.push({ const item = await fetchQuestionById(opt.id);
id: uuid(), messages.value.push({
role: 'assistant', id: uuid(),
content: item.answer || '', role: 'assistant',
}); content: item.answer || '',
scrollToBottom(); });
scrollToBottom();
} catch (err) {
if (err.code === 401) await onUnauthorized();
}
} }
async function handleSend() { async function handleSend() {
@ -190,10 +226,14 @@ async function handleSend() {
lastQuery.value = text; lastQuery.value = text;
pushUser(text); pushUser(text);
input.value = ''; input.value = '';
const res = await searchQuestions(text); try {
suggestions.value = res.items || []; const res = await searchQuestions(text);
suggestionPage.value = 1; suggestions.value = res.items || [];
showSuggestionPage(1); suggestionPage.value = 1;
showSuggestionPage(1);
} catch (err) {
if (err.code === 401) await onUnauthorized();
}
} else { } else {
await sendAi(text); await sendAi(text);
} }
@ -238,7 +278,6 @@ async function sendAi(text) {
statusText.value = 'AI 生成中'; statusText.value = 'AI 生成中';
isStreaming.value = true; isStreaming.value = true;
// reactive
const aiMsg = reactive({ id: uuid(), role: 'assistant', type: 'ai', before: '', after: '', searching: false, searchDone: false }); const aiMsg = reactive({ id: uuid(), role: 'assistant', type: 'ai', before: '', after: '', searching: false, searchDone: false });
messages.value.push(aiMsg); messages.value.push(aiMsg);
scrollToBottom(); scrollToBottom();
@ -294,29 +333,38 @@ async function sendAi(text) {
scrollToBottom(); scrollToBottom();
} }
//
const tail = buffer.trim(); const tail = buffer.trim();
if (tail) { if (tail) {
try { handleEvent(JSON.parse(tail)); } catch (e) { console.warn('parse tail err', e, tail); } try { handleEvent(JSON.parse(tail)); } catch (e) { console.warn('parse tail err', e, tail); }
} }
} catch (err) { } catch (err) {
aiMsg.before = '出错了,请重试'; if (err && err.code === 401) {
console.error(err); await onUnauthorized();
aiMsg.before = '未登录,请重新登录。';
} else {
aiMsg.before = '出错了,请重试';
console.error(err);
}
} finally { } finally {
isStreaming.value = false; isStreaming.value = false;
statusText.value = '空闲'; statusText.value = '空闲';
loadConversations(); if (user.value) {
loadConversations();
}
} }
} }
async function loadConversations() { async function loadConversations() {
const res = await fetch('/api/conversations'); if (!user.value) return;
const res = await fetch('/api/conversations', { credentials: 'include' });
if (res.status === 401) { await onUnauthorized(); return; }
if (!res.ok) return; if (!res.ok) return;
conversations.value = await res.json(); conversations.value = await res.json();
} }
async function loadConversation(id) { async function loadConversation(id) {
const res = await fetch(`/api/conversations/${id}`); const res = await fetch(`/api/conversations/${id}`, { credentials: 'include' });
if (res.status === 401) { await onUnauthorized(); return; }
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
conversationId.value = data.id; conversationId.value = data.id;
@ -328,17 +376,14 @@ async function loadConversation(id) {
const m = list[i]; const m = list[i];
if (m.role === 'tool') continue; if (m.role === 'tool') continue;
// + +
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) { if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
let after = ''; let after = '';
let j = i + 1; let j = i + 1;
// tool
while (j < list.length) { while (j < list.length) {
const next = list[j]; const next = list[j];
if (next.role === 'tool') { j++; continue; } if (next.role === 'tool') { j++; continue; }
if (next.role === 'assistant' && (!next.tool_calls || next.tool_calls.length === 0)) { if (next.role === 'assistant' && (!next.tool_calls || next.tool_calls.length === 0)) {
after = next.content || ''; after = next.content || '';
//
i = j; i = j;
} }
break; break;
@ -370,15 +415,56 @@ function toggleSidebar() {
if (el) el.classList.toggle('open'); if (el) el.classList.toggle('open');
} }
onMounted(() => { async function onUnauthorized() {
loadConversations(); user.value = null;
loginError.value = '';
isStreaming.value = false;
statusText.value = '空闲';
messages.value = [];
conversations.value = [];
conversationId.value = null;
}
async function handleLogin() {
loginError.value = '';
loginLoading.value = true;
try {
const res = await loginApi(loginForm.username.trim(), loginForm.password);
user.value = res;
await postLoginInit();
} catch (err) {
loginError.value = err.code === 401 ? '用户名或密码错误' : '登录失败,请稍后重试';
} finally {
loginLoading.value = false;
}
}
async function handleLogout() {
try { await logoutApi(); } catch (e) { /* ignore */ }
await onUnauthorized();
}
async function postLoginInit() {
await loadConversations();
const idFromUrl = parseConversationIdFromPath(window.location.pathname); const idFromUrl = parseConversationIdFromPath(window.location.pathname);
if (idFromUrl) { if (idFromUrl) {
ensureUrlForConversation(idFromUrl, { replace: true }); ensureUrlForConversation(idFromUrl, { replace: true });
loadConversation(idFromUrl); await loadConversation(idFromUrl);
} else { } else {
resetFaq({ syncUrl: false }); resetFaq({ syncUrl: false });
} }
}
onMounted(async () => {
try {
const me = await fetchMe();
user.value = me;
} catch (err) {
user.value = null;
}
if (user.value) {
await postLoginInit();
}
window.addEventListener('popstate', popStateHandler); window.addEventListener('popstate', popStateHandler);
}); });

View File

@ -1,30 +1,69 @@
async function request(url, { method = 'GET', body, headers = {} } = {}) {
const res = await fetch(url, {
method,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...headers },
body,
});
if (res.status === 401) {
const err = new Error('unauthorized');
err.code = 401;
throw err;
}
if (!res.ok) {
const err = new Error(`http ${res.status}`);
err.code = res.status;
throw err;
}
return res;
}
export async function fetchTopQuestions() { export async function fetchTopQuestions() {
const res = await fetch('/api/faq/top'); const res = await request('/api/faq/top');
return res.json(); return res.json();
} }
export async function fetchQuestionById(id) { export async function fetchQuestionById(id) {
const res = await fetch(`/api/faq/item/${id}`); const res = await request(`/api/faq/item/${id}`);
if (!res.ok) throw new Error('not found');
return res.json(); return res.json();
} }
export async function searchQuestions(query) { export async function searchQuestions(query) {
const res = await fetch('/api/faq/search', { const res = await request('/api/faq/search', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }), body: JSON.stringify({ query }),
}); });
return res.json(); return res.json();
} }
export async function sendAiMessage(text, conversationId = null) { export async function sendAiMessage(text, conversationId = null) {
const res = await fetch('/api/chat', { const res = await request('/api/chat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, conversation_id: conversationId }), body: JSON.stringify({ message: text, conversation_id: conversationId }),
}); });
if (!res.ok || !res.body) throw new Error('请求失败'); if (!res.body) {
const err = new Error('请求失败');
err.code = 'no-body';
throw err;
}
const newId = res.headers.get('X-Conversation-Id'); const newId = res.headers.get('X-Conversation-Id');
return { stream: res.body, conversationId: newId }; return { stream: res.body, conversationId: newId };
} }
export async function login(username, password) {
const res = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
return res.json();
}
export async function logout() {
const res = await request('/api/auth/logout', { method: 'POST' });
return res.json();
}
export async function fetchMe() {
const res = await request('/api/auth/me');
return res.json();
}

View File

@ -41,7 +41,10 @@ button.ghost { background: transparent; border: none; font-size: 18px; padding:
.chat-pane { display: grid; grid-template-rows: auto 1fr auto; height: 100vh; } .chat-pane { display: grid; grid-template-rows: auto 1fr auto; height: 100vh; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 18px 24px 12px; } .topbar { display: flex; align-items: center; justify-content: space-between; padding: 18px 24px 12px; }
.topbar .title { font-weight: 600; font-size: 16px; } .topbar .title { font-weight: 600; font-size: 16px; }
.top-actions { display: flex; align-items: center; gap: 12px; }
.status { color: var(--muted); font-size: 13px; } .status { color: var(--muted); font-size: 13px; }
.account { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; background: #f1f5ff; border: 1px solid var(--border); border-radius: 10px; }
.user-name { font-size: 13px; color: #0f2a5f; }
.chat-log { padding: 12px 24px 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; } .chat-log { padding: 12px 24px 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
.message { display: inline-block; max-width: 70%; padding: 12px 14px; border-radius: var(--radius); background: #fff; border: 1px solid var(--border); box-shadow: var(--shadow); line-height: 1.5; white-space: normal; word-break: break-word; } .message { display: inline-block; max-width: 70%; padding: 12px 14px; border-radius: var(--radius); background: #fff; border: 1px solid var(--border); box-shadow: var(--shadow); line-height: 1.5; white-space: normal; word-break: break-word; }
@ -86,3 +89,12 @@ button.ghost { background: transparent; border: none; font-size: 18px; padding:
.chat-log .message { max-width: 100%; } .chat-log .message { max-width: 100%; }
.topbar { padding-right: 12px; } .topbar { padding-right: 12px; }
} }
.login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); padding: 20px; }
.login-card { width: 360px; background: #fff; border: 1px solid var(--border); border-radius: 16px; box-shadow: var(--shadow); padding: 24px; display: flex; flex-direction: column; gap: 12px; }
.login-card .logo { font-weight: 700; font-size: 20px; letter-spacing: 0.4px; }
.login-sub { color: var(--muted); margin: 0 0 8px; }
.login-form { display: flex; flex-direction: column; gap: 10px; }
.login-form input { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); font-size: 14px; }
.login-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(31,111,235,0.12); }
.login-form .error { color: #c2410c; background: #fff3e8; border: 1px solid #f1c9a8; padding: 8px 10px; border-radius: 8px; font-size: 13px; }