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, 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
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
|
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)
|
||||||
|
|||||||
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"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,6 +204,7 @@ function pushUser(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSelectOption(opt) {
|
async function handleSelectOption(opt) {
|
||||||
|
try {
|
||||||
pushUser(opt.question);
|
pushUser(opt.question);
|
||||||
const item = await fetchQuestionById(opt.id);
|
const item = await fetchQuestionById(opt.id);
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
@ -180,6 +213,9 @@ async function handleSelectOption(opt) {
|
|||||||
content: item.answer || '',
|
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 = '';
|
||||||
|
try {
|
||||||
const res = await searchQuestions(text);
|
const res = await searchQuestions(text);
|
||||||
suggestions.value = res.items || [];
|
suggestions.value = res.items || [];
|
||||||
suggestionPage.value = 1;
|
suggestionPage.value = 1;
|
||||||
showSuggestionPage(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) {
|
||||||
|
if (err && err.code === 401) {
|
||||||
|
await onUnauthorized();
|
||||||
|
aiMsg.before = '未登录,请重新登录。';
|
||||||
|
} else {
|
||||||
aiMsg.before = '出错了,请重试';
|
aiMsg.before = '出错了,请重试';
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isStreaming.value = false;
|
isStreaming.value = false;
|
||||||
statusText.value = '空闲';
|
statusText.value = '空闲';
|
||||||
|
if (user.value) {
|
||||||
loadConversations();
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user