feat: add admin login guard and build frontend
This commit is contained in:
parent
2a3d3b85e1
commit
fb296a0c5d
@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, send_from_directory
|
||||
from flask import Flask, send_from_directory, request, jsonify, session
|
||||
|
||||
from .config import PROJECT_ROOT
|
||||
from .routes.chat import bp as chat_bp
|
||||
from .routes.faq import bp as faq_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():
|
||||
@ -16,11 +17,31 @@ def create_app():
|
||||
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(faq_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 回退
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>")
|
||||
|
||||
38
dialog/backend/admin.py
Normal file
38
dialog/backend/admin.py
Normal 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)
|
||||
@ -5,6 +5,9 @@ from pathlib import Path
|
||||
BASE_DIR = Path(__file__).resolve().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.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
35
dialog/backend/routes/auth.py
Normal file
35
dialog/backend/routes/auth.py
Normal 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)})
|
||||
812
dialog/frontend/package-lock.json
generated
812
dialog/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.2.4"
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"vite": "^4.5.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<div v-if="user" class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">念界客服</div>
|
||||
@ -21,7 +21,13 @@
|
||||
<main class="chat-pane">
|
||||
<header class="topbar">
|
||||
<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>
|
||||
|
||||
<section ref="logRef" class="chat-log">
|
||||
@ -82,11 +88,34 @@
|
||||
</footer>
|
||||
</main>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
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';
|
||||
|
||||
const messages = ref([]);
|
||||
@ -100,11 +129,15 @@ const lastQuery = ref('');
|
||||
const suggestions = ref([]);
|
||||
const suggestionPage = ref(1);
|
||||
const logRef = ref(null);
|
||||
const user = ref(null);
|
||||
const loginForm = reactive({ username: '', password: '' });
|
||||
const loginError = ref('');
|
||||
const loginLoading = ref(false);
|
||||
const popStateHandler = () => {
|
||||
const id = parseConversationIdFromPath(window.location.pathname);
|
||||
if (id) {
|
||||
if (id && user.value) {
|
||||
loadConversation(id);
|
||||
} else {
|
||||
} else if (user.value) {
|
||||
resetFaq({ syncUrl: false });
|
||||
}
|
||||
};
|
||||
@ -129,7 +162,6 @@ function ensureUrlForConversation(id, { replace = false } = {}) {
|
||||
|
||||
function format(text) {
|
||||
if (!text) return '';
|
||||
// 支持既有真实换行,也支持字符串中的转义换行
|
||||
return text
|
||||
.replace(/\r?\n/g, '<br>')
|
||||
.replace(/\\n/g, '<br>');
|
||||
@ -172,6 +204,7 @@ function pushUser(text) {
|
||||
}
|
||||
|
||||
async function handleSelectOption(opt) {
|
||||
try {
|
||||
pushUser(opt.question);
|
||||
const item = await fetchQuestionById(opt.id);
|
||||
messages.value.push({
|
||||
@ -180,6 +213,9 @@ async function handleSelectOption(opt) {
|
||||
content: item.answer || '',
|
||||
});
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
if (err.code === 401) await onUnauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
@ -190,10 +226,14 @@ async function handleSend() {
|
||||
lastQuery.value = text;
|
||||
pushUser(text);
|
||||
input.value = '';
|
||||
try {
|
||||
const res = await searchQuestions(text);
|
||||
suggestions.value = res.items || [];
|
||||
suggestionPage.value = 1;
|
||||
showSuggestionPage(1);
|
||||
} catch (err) {
|
||||
if (err.code === 401) await onUnauthorized();
|
||||
}
|
||||
} else {
|
||||
await sendAi(text);
|
||||
}
|
||||
@ -238,7 +278,6 @@ async function sendAi(text) {
|
||||
statusText.value = 'AI 生成中';
|
||||
isStreaming.value = true;
|
||||
|
||||
// reactive 以确保流式增量更新到视图
|
||||
const aiMsg = reactive({ id: uuid(), role: 'assistant', type: 'ai', before: '', after: '', searching: false, searchDone: false });
|
||||
messages.value.push(aiMsg);
|
||||
scrollToBottom();
|
||||
@ -294,29 +333,38 @@ async function sendAi(text) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// 尾部剩余(可能是不完整帧),仅在能解析时处理
|
||||
const tail = buffer.trim();
|
||||
if (tail) {
|
||||
try { handleEvent(JSON.parse(tail)); } catch (e) { console.warn('parse tail err', e, tail); }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && err.code === 401) {
|
||||
await onUnauthorized();
|
||||
aiMsg.before = '未登录,请重新登录。';
|
||||
} else {
|
||||
aiMsg.before = '出错了,请重试';
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
isStreaming.value = false;
|
||||
statusText.value = '空闲';
|
||||
if (user.value) {
|
||||
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;
|
||||
conversations.value = await res.json();
|
||||
}
|
||||
|
||||
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;
|
||||
const data = await res.json();
|
||||
conversationId.value = data.id;
|
||||
@ -328,17 +376,14 @@ async function loadConversation(id) {
|
||||
const m = list[i];
|
||||
if (m.role === 'tool') continue;
|
||||
|
||||
// 将“搜索前 + 搜索完成 + 搜索后”合成一个气泡
|
||||
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
||||
let after = '';
|
||||
let j = i + 1;
|
||||
// 寻找紧随其后的最终回答(跳过 tool)
|
||||
while (j < list.length) {
|
||||
const next = list[j];
|
||||
if (next.role === 'tool') { j++; continue; }
|
||||
if (next.role === 'assistant' && (!next.tool_calls || next.tool_calls.length === 0)) {
|
||||
after = next.content || '';
|
||||
// 已消费这个最终回答
|
||||
i = j;
|
||||
}
|
||||
break;
|
||||
@ -370,15 +415,56 @@ function toggleSidebar() {
|
||||
if (el) el.classList.toggle('open');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConversations();
|
||||
async function onUnauthorized() {
|
||||
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);
|
||||
if (idFromUrl) {
|
||||
ensureUrlForConversation(idFromUrl, { replace: true });
|
||||
loadConversation(idFromUrl);
|
||||
await loadConversation(idFromUrl);
|
||||
} else {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@ -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() {
|
||||
const res = await fetch('/api/faq/top');
|
||||
const res = await request('/api/faq/top');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchQuestionById(id) {
|
||||
const res = await fetch(`/api/faq/item/${id}`);
|
||||
if (!res.ok) throw new Error('not found');
|
||||
const res = await request(`/api/faq/item/${id}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function searchQuestions(query) {
|
||||
const res = await fetch('/api/faq/search', {
|
||||
const res = await request('/api/faq/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sendAiMessage(text, conversationId = null) {
|
||||
const res = await fetch('/api/chat', {
|
||||
const res = await request('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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');
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 18px 24px 12px; }
|
||||
.topbar .title { font-weight: 600; font-size: 16px; }
|
||||
.top-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.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; }
|
||||
.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%; }
|
||||
.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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user