Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9db687ee00 | ||
|
|
af52ee5f58 | ||
|
|
644dcd20d9 | ||
|
|
b12f315cb3 | ||
|
|
4b9b4f4371 |
@@ -1,15 +1,34 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
from app.schemas.chat import ChatRequest, ChatResponse
|
from app.schemas.chat import ChatRequest, ChatResponse
|
||||||
|
from app.schemas.retrieval import RetrievalRequest
|
||||||
|
from app.services.retrieval import retrieve
|
||||||
|
|
||||||
router = APIRouter(tags=["chat"])
|
router = APIRouter(tags=["chat"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat", response_model=ChatResponse)
|
@router.post("/chat", response_model=ChatResponse)
|
||||||
def chat(payload: ChatRequest) -> ChatResponse:
|
def chat(payload: ChatRequest, db: Session = Depends(get_db)) -> ChatResponse:
|
||||||
# TODO: retrieval -> context assembly -> LLM -> evidence/source checks
|
"""运行可复核的关键词检索;模型未配置时绝不伪造生成式回答。"""
|
||||||
|
result = retrieve(db, RetrievalRequest(query=payload.question, top_k=5))
|
||||||
|
if result.status == "no_evidence":
|
||||||
|
return ChatResponse(
|
||||||
|
answer="未检索到可用的已核验知识依据。请补充更具体的关键词,或由管理员补充并审核资料。",
|
||||||
|
status="no_evidence",
|
||||||
|
sources=[],
|
||||||
|
retrieval_run_id=result.run_id,
|
||||||
|
)
|
||||||
return ChatResponse(
|
return ChatResponse(
|
||||||
answer=f"已收到问题:{payload.question}。当前为基础壳子,尚未接入知识检索或大语言模型。",
|
answer=(
|
||||||
status="stub",
|
f"已检索到 {result.total_hits} 条已核验知识依据。当前尚未配置大语言模型,"
|
||||||
sources=[],
|
"因此系统仅返回可追溯证据,不生成或改写答案。"
|
||||||
|
),
|
||||||
|
status="retrieval_only",
|
||||||
|
sources=[
|
||||||
|
{"title": hit.title, "source_url": hit.source_url}
|
||||||
|
for hit in result.results
|
||||||
|
],
|
||||||
|
retrieval_run_id=result.run_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -12,5 +15,6 @@ class SourcePreview(BaseModel):
|
|||||||
|
|
||||||
class ChatResponse(BaseModel):
|
class ChatResponse(BaseModel):
|
||||||
answer: str
|
answer: str
|
||||||
status: str
|
status: Literal["retrieval_only", "no_evidence"]
|
||||||
sources: list[SourcePreview] = []
|
sources: list[SourcePreview] = []
|
||||||
|
retrieval_run_id: UUID
|
||||||
|
|||||||
@@ -271,7 +271,9 @@ def test_all_management_routes_require_admin_and_history_is_private(anonymous_cl
|
|||||||
for method, path in paths:
|
for method, path in paths:
|
||||||
assert c.request(method, path, json={}).status_code == 403, path
|
assert c.request(method, path, json={}).status_code == 403, path
|
||||||
assert c.post("/api/retrieval", json={"query": "占位"}).json()["status"] == "no_evidence"
|
assert c.post("/api/retrieval", json={"query": "占位"}).json()["status"] == "no_evidence"
|
||||||
assert c.post("/api/chat", json={"question": "占位"}).json()["status"] == "stub"
|
response = c.post("/api/chat", json={"question": "占位"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "no_evidence"
|
||||||
|
|
||||||
|
|
||||||
def test_admin_access_live_role_and_no_csrf_bypass(client, migrated_database):
|
def test_admin_access_live_role_and_no_csrf_bypass(client, migrated_database):
|
||||||
|
|||||||
@@ -59,12 +59,28 @@ def review(client, item, status="verified"):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_health_docs_and_stub(client):
|
def test_health_docs_and_chat_without_evidence(client):
|
||||||
assert client.get("/api/health").json()["status"] == "ok"
|
assert client.get("/api/health").json()["status"] == "ok"
|
||||||
assert client.get("/api/ready").json()["database"] == "connected"
|
assert client.get("/api/ready").json()["database"] == "connected"
|
||||||
assert client.get("/docs").status_code == 200
|
assert client.get("/docs").status_code == 200
|
||||||
assert "/api/knowledge/{item_id}/review" in client.get("/openapi.json").json()["paths"]
|
assert "/api/knowledge/{item_id}/review" in client.get("/openapi.json").json()["paths"]
|
||||||
assert client.post("/api/chat", json={"question": "测试"}).json()["status"] == "stub"
|
response = client.post("/api/chat", json={"question": "测试"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "no_evidence"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_returns_real_retrieval_evidence_without_generation(client):
|
||||||
|
item = knowledge(client, title="那拉提测试资料", content="那拉提文旅服务测试依据")
|
||||||
|
assert review(client, item).status_code == 200
|
||||||
|
|
||||||
|
response = client.post("/api/chat", json={"question": "那拉提文旅服务"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["status"] == "retrieval_only"
|
||||||
|
assert "不生成" in body["answer"]
|
||||||
|
assert body["retrieval_run_id"]
|
||||||
|
assert body["sources"] == [{"title": "那拉提测试资料", "source_url": "https://example.com/"}]
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_create_and_missing_evidence(client):
|
def test_legacy_create_and_missing_evidence(client):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Activity, BookOpen, Bot, CheckCircle2, ChevronRight, Database, FileText
|
|||||||
import { createKnowledge, createSource, getHealth, getKnowledge, getSources, type SourceItem } from './api'
|
import { createKnowledge, createSource, getHealth, getKnowledge, getSources, type SourceItem } from './api'
|
||||||
import { retrieveHybrid } from './services/hybrid'
|
import { retrieveHybrid } from './services/hybrid'
|
||||||
import { getCurrentUser } from './services/auth'
|
import { getCurrentUser } from './services/auth'
|
||||||
|
import { saveLocalHistory } from './services/localHistory'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import type { SessionUser } from './types/auth'
|
import type { SessionUser } from './types/auth'
|
||||||
type Page='chat'|'knowledge'|'sources'|'evaluation'
|
type Page='chat'|'knowledge'|'sources'|'evaluation'
|
||||||
@@ -20,7 +21,7 @@ async function saveSource(){error.value='';notice.value='';if(!sourceForm.value.
|
|||||||
let sourceSearchTimer:number|undefined
|
let sourceSearchTimer:number|undefined
|
||||||
watch(sourceSearch,()=>{window.clearTimeout(sourceSearchTimer);sourceSearchTimer=window.setTimeout(refreshSources,260)})
|
watch(sourceSearch,()=>{window.clearTimeout(sourceSearchTimer);sourceSearchTimer=window.setTimeout(refreshSources,260)})
|
||||||
onMounted(async()=>{try{currentUser.value=await getCurrentUser()}catch{router.replace('/login');return}try{await getHealth();apiStatus.value='已连接'}catch{apiStatus.value='未连接'}if(currentUser.value.role==='admin')await Promise.all([refresh(),refreshSources()])})
|
onMounted(async()=>{try{currentUser.value=await getCurrentUser()}catch{router.replace('/login');return}try{await getHealth();apiStatus.value='已连接'}catch{apiStatus.value='未连接'}if(currentUser.value.role==='admin')await Promise.all([refresh(),refreshSources()])})
|
||||||
async function send(){const q=question.value.trim();if(!q||busy.value)return;messages.value.push({role:'user',content:q});question.value='';busy.value=true;try{const r=await retrieveHybrid({query:q,top_k:5,candidate_k:50});const content=r.status==='no_evidence'?'未检索到可用的已核验知识依据。请调整问题或补充审核后的资料。':`已通过 RRF 混合检索命中 ${r.total_hits} 条依据(关键词 ${r.lexical_total_hits},向量 ${r.vector_total_hits},${r.elapsed_ms.toFixed(1)} ms)。当前仅展示检索证据,尚未接入大语言模型生成回答。`;messages.value.push({role:'assistant',content,sources:r.results.map(hit=>({title:`${hit.title}:${hit.snippet}`,source_url:hit.source_url}))})}catch(e){messages.value.push({role:'assistant',content:e instanceof Error?e.message:'暂时无法连接混合检索服务。'})}finally{busy.value=false}}
|
async function send(){const q=question.value.trim();if(!q||busy.value)return;messages.value.push({role:'user',content:q});question.value='';busy.value=true;try{const r=await retrieveHybrid({query:q,top_k:5,candidate_k:50});const content=r.status==='no_evidence'?'未检索到可用的已核验知识依据。请调整问题或补充审核后的资料。':`已通过 RRF 混合检索命中 ${r.total_hits} 条依据(关键词 ${r.lexical_total_hits},向量 ${r.vector_total_hits},${r.elapsed_ms.toFixed(1)} ms)。当前仅展示检索证据,尚未接入大语言模型生成回答。`;messages.value.push({role:'assistant',content,sources:r.results.map(hit=>({title:`${hit.title}:${hit.snippet}`,source_url:hit.source_url}))});saveLocalHistory(q,content)}catch(e){const content=e instanceof Error?e.message:'暂时无法连接混合检索服务。';messages.value.push({role:'assistant',content});saveLocalHistory(q,content)}finally{busy.value=false}}
|
||||||
function validUrl(value:string){try{if(value)new URL(value);return true}catch{return false}}
|
function validUrl(value:string){try{if(value)new URL(value);return true}catch{return false}}
|
||||||
async function save(){
|
async function save(){
|
||||||
error.value='';notice.value=''
|
error.value='';notice.value=''
|
||||||
|
|||||||
+6
-1
@@ -3,11 +3,16 @@ import type { VectorRetrievalResult } from './services/vectors'
|
|||||||
import type { ChunkKeywordResult, HybridResult } from './services/hybrid'
|
import type { ChunkKeywordResult, HybridResult } from './services/hybrid'
|
||||||
|
|
||||||
export const getHealth = () => apiRequest<{status:string}>('/api/health')
|
export const getHealth = () => apiRequest<{status:string}>('/api/health')
|
||||||
export const askQuestion = (question:string) => apiRequest<{answer:string;status:string;sources:{title:string;source_url?:string|null}[]}>('/api/chat',{method:'POST',body:JSON.stringify({question})})
|
export const askQuestion = (question:string) => apiRequest<{answer:string;status:'retrieval_only'|'no_evidence';sources:{title:string;source_url?:string|null}[];retrieval_run_id:string}>('/api/chat',{method:'POST',body:JSON.stringify({question})})
|
||||||
export const getKnowledge = () => apiRequest<{id:number;title:string;category:string;content:string;source_url:string|null;verification_status:string;version:number}[]>('/api/knowledge')
|
export const getKnowledge = () => apiRequest<{id:number;title:string;category:string;content:string;source_url:string|null;verification_status:string;version:number}[]>('/api/knowledge')
|
||||||
export const createKnowledge = (payload:{title:string;category:string;content:string;source_url?:string;collected_at?:string}) => apiRequest('/api/knowledge',{method:'POST',body:JSON.stringify(payload)},true)
|
export const createKnowledge = (payload:{title:string;category:string;content:string;source_url?:string;collected_at?:string}) => apiRequest('/api/knowledge',{method:'POST',body:JSON.stringify(payload)},true)
|
||||||
|
|
||||||
export type VerificationStatus='pending'|'verified'|'rejected'
|
export type VerificationStatus='pending'|'verified'|'rejected'
|
||||||
|
export type KnowledgeItem={id:number;version:number;title:string;category:string;content:string;source_url:string|null;source_id:number|null;document_id:number|null;collected_at:string|null;verification_status:VerificationStatus;reviewer:string|null;reviewed_at:string|null;verification_notes:string|null;created_at:string;updated_at:string;is_deleted:boolean}
|
||||||
|
export function getKnowledgePage(filters:{q?:string;category?:string;verification_status?:VerificationStatus;page?:number;page_size?:number}={}){const params=new URLSearchParams();for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!=='')params.set(key,String(value))}return apiRequest<{items:KnowledgeItem[];total:number;page:number;page_size:number}>(`/api/knowledge/page?${params}`)}
|
||||||
|
export function updateKnowledge(id:number,payload:{expected_version:number;title?:string;category?:string;content?:string;source_url?:string|null;collected_at?:string|null}){return apiRequest<KnowledgeItem>(`/api/knowledge/${id}`,{method:'PATCH',body:JSON.stringify(payload)},true)}
|
||||||
|
export function deleteKnowledge(id:number,expectedVersion:number){return apiRequest<void>(`/api/knowledge/${id}?expected_version=${expectedVersion}`,{method:'DELETE'},true)}
|
||||||
|
export function getKnowledgeHistory(id:number,page=1,pageSize=20){return apiRequest<{items:AuditItem[];total:number;page:number;page_size:number}>(`/api/knowledge/${id}/history?page=${page}&page_size=${pageSize}`)}
|
||||||
export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string}
|
export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string}
|
||||||
export function getSources(q='',page=1,pageSize=20){return apiRequest<{items:SourceItem[];total:number;page:number;page_size:number}>(`/api/sources?q=${encodeURIComponent(q)}&page=${page}&page_size=${pageSize}`)}
|
export function getSources(q='',page=1,pageSize=20){return apiRequest<{items:SourceItem[];total:number;page:number;page_size:number}>(`/api/sources?q=${encodeURIComponent(q)}&page=${page}&page_size=${pageSize}`)}
|
||||||
export function createSource(payload:{name:string;source_url?:string;notes:string}){return apiRequest<SourceItem>('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)}
|
export function createSource(payload:{name:string;source_url?:string;notes:string}){return apiRequest<SourceItem>('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)}
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ const router=createRouter({history:createWebHistory(),routes:[
|
|||||||
{path:'/',component:App},
|
{path:'/',component:App},
|
||||||
{path:'/login',component:()=>import('./views/LoginView.vue'),meta:{public:true}},
|
{path:'/login',component:()=>import('./views/LoginView.vue'),meta:{public:true}},
|
||||||
{path:'/register',component:()=>import('./views/RegisterView.vue'),meta:{public:true}},
|
{path:'/register',component:()=>import('./views/RegisterView.vue'),meta:{public:true}},
|
||||||
|
{path:'/403',component:()=>import('./views/ForbiddenView.vue'),meta:{public:true}},
|
||||||
|
{path:'/history',component:()=>import('./views/HistoryView.vue')},
|
||||||
{path:'/profile',component:()=>import('./views/ProfileView.vue')},
|
{path:'/profile',component:()=>import('./views/ProfileView.vue')},
|
||||||
{path:'/settings',component:()=>import('./views/AccountSettingsView.vue')},
|
{path:'/settings',component:()=>import('./views/AccountSettingsView.vue')},
|
||||||
{path:'/indexes',component:()=>import('./views/IndexManagementView.vue'),meta:{role:'admin'}},
|
{path:'/indexes',component:()=>import('./views/IndexManagementView.vue'),meta:{role:'admin'}},
|
||||||
{path:'/reviews',component:()=>import('./views/KnowledgeReviewView.vue'),meta:{role:'admin'}},
|
{path:'/reviews',component:()=>import('./views/KnowledgeReviewView.vue'),meta:{role:'admin'}},
|
||||||
|
{path:'/knowledge',component:()=>import('./views/KnowledgeManagementView.vue'),meta:{role:'admin'}},
|
||||||
{path:'/documents',component:()=>import('./views/DocumentManagementView.vue'),meta:{role:'admin'}},
|
{path:'/documents',component:()=>import('./views/DocumentManagementView.vue'),meta:{role:'admin'}},
|
||||||
{path:'/sources',component:()=>import('./views/SourceManagementView.vue'),meta:{role:'admin'}},
|
{path:'/sources',component:()=>import('./views/SourceManagementView.vue'),meta:{role:'admin'}},
|
||||||
{path:'/retrieval-exports',component:()=>import('./views/RetrievalExportView.vue'),meta:{role:'admin'}},
|
{path:'/retrieval-exports',component:()=>import('./views/RetrievalExportView.vue'),meta:{role:'admin'}},
|
||||||
{path:'/admin',component:()=>import('./views/AdminView.vue'),meta:{role:'admin'}},
|
{path:'/admin',component:()=>import('./views/AdminView.vue'),meta:{role:'admin'}},
|
||||||
|
{path:'/:pathMatch(.*)*',component:()=>import('./views/NotFoundView.vue'),meta:{public:true}},
|
||||||
]})
|
]})
|
||||||
router.beforeEach(async to=>{if(to.meta.public)return true;try{role=(await getCurrentUser()).role}catch{return {path:'/login',query:{redirect:to.fullPath}}}if(to.meta.role&&role!==to.meta.role)return {path:'/profile',query:{forbidden:'1'}};return true})
|
router.beforeEach(async to=>{if(to.meta.public)return true;try{role=(await getCurrentUser()).role}catch{return {path:'/login',query:{redirect:to.fullPath}}}if(to.meta.role&&role!==to.meta.role)return {path:'/403'};return true})
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type LocalHistoryItem={id:string;question:string;summary:string;created_at:string}
|
||||||
|
const key='nalati-local-history-v1'
|
||||||
|
export function readLocalHistory():LocalHistoryItem[]{try{const value=JSON.parse(sessionStorage.getItem(key)||'[]');return Array.isArray(value)?value:[]}catch{return []}}
|
||||||
|
export function saveLocalHistory(question:string,summary:string){const item:LocalHistoryItem={id:crypto.randomUUID(),question,summary,created_at:new Date().toISOString()};sessionStorage.setItem(key,JSON.stringify([item,...readLocalHistory()].slice(0,100)))}
|
||||||
|
export function removeLocalHistory(id:string){sessionStorage.setItem(key,JSON.stringify(readLocalHistory().filter(item=>item.id!==id)))}
|
||||||
|
export function clearLocalHistory(){sessionStorage.removeItem(key)}
|
||||||
@@ -29,7 +29,7 @@ onMounted(() => load(0))
|
|||||||
<main>
|
<main>
|
||||||
<h1>管理员用户管理</h1>
|
<h1>管理员用户管理</h1>
|
||||||
<p>用户数据由受服务端管理员权限保护的接口实时提供。</p>
|
<p>用户数据由受服务端管理员权限保护的接口实时提供。</p>
|
||||||
<p><RouterLink to="/reviews">进入知识审核工作台</RouterLink> <RouterLink to="/sources">进入来源管理</RouterLink> <RouterLink to="/documents">进入文档管理</RouterLink> <RouterLink to="/indexes">进入索引管理</RouterLink> <RouterLink to="/retrieval-exports">导出检索历史</RouterLink></p>
|
<p><RouterLink to="/knowledge">进入知识管理</RouterLink> <RouterLink to="/reviews">进入知识审核工作台</RouterLink> <RouterLink to="/sources">进入来源管理</RouterLink> <RouterLink to="/documents">进入文档管理</RouterLink> <RouterLink to="/indexes">进入索引管理</RouterLink> <RouterLink to="/retrieval-exports">导出检索历史</RouterLink></p>
|
||||||
<p v-if="error" role="alert">{{ error }}</p>
|
<p v-if="error" role="alert">{{ error }}</p>
|
||||||
<p v-else-if="loading">正在读取用户列表…</p>
|
<p v-else-if="loading">正在读取用户列表…</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<template><main><h1>403 无权限</h1><p>当前账户没有访问此页面的权限。管理员能力仍必须由后端授权。</p><RouterLink to="/">返回首页</RouterLink></main></template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { clearLocalHistory, readLocalHistory, removeLocalHistory, type LocalHistoryItem } from '../services/localHistory'
|
||||||
|
const items=ref<LocalHistoryItem[]>(readLocalHistory()),selected=ref<LocalHistoryItem>()
|
||||||
|
const empty=computed(()=>!items.value.length)
|
||||||
|
function refresh(){items.value=readLocalHistory()}
|
||||||
|
function remove(id:string){removeLocalHistory(id);if(selected.value?.id===id)selected.value=undefined;refresh()}
|
||||||
|
function clear(){if(window.confirm('确定清除本浏览器的全部本地对话记录吗?')){clearLocalHistory();selected.value=undefined;refresh()}}
|
||||||
|
</script>
|
||||||
|
<template><main><RouterLink to="/">返回智能问答</RouterLink><h1>历史对话</h1><p>当前记录仅保存在本浏览器,尚未接入服务器持久化;清除浏览器数据后可能丢失。</p><button :disabled="empty" @click="clear">清除全部本地记录</button><p v-if="empty">暂无本地记录。请先在智能问答页发起提问。</p><table v-else><thead><tr><th>问题</th><th>时间</th><th>操作</th></tr></thead><tbody><tr v-for="item in items" :key="item.id"><td>{{item.question}}</td><td>{{new Date(item.created_at).toLocaleString()}}</td><td><button @click="selected=item">查看</button><button @click="remove(item.id)">删除</button></td></tr></tbody></table><section v-if="selected"><h2>{{selected.question}}</h2><p>{{selected.summary}}</p></section></main></template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { deleteKnowledge, getKnowledgeHistory, getKnowledgePage, updateKnowledge, type AuditItem, type KnowledgeItem, type VerificationStatus } from '../api'
|
||||||
|
type Form={title:string;category:string;content:string;source_url:string;collected_at:string}
|
||||||
|
const items=ref<KnowledgeItem[]>([]), selected=ref<KnowledgeItem>(), historyFor=ref<KnowledgeItem>(), historyItems=ref<AuditItem[]>([])
|
||||||
|
const query=ref(''), category=ref(''), status=ref<VerificationStatus|''>(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), historyLoading=ref(false), error=ref(''), notice=ref('')
|
||||||
|
const form=ref<Form>({title:'',category:'',content:'',source_url:'',collected_at:''})
|
||||||
|
function failure(cause:unknown,fallback:string){return cause instanceof Error?cause.message:fallback}
|
||||||
|
function validUrl(value:string){if(!value)return true;try{const url=new URL(value);return url.protocol==='http:'||url.protocol==='https:'}catch{return false}}
|
||||||
|
async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getKnowledgePage({q:query.value,category:category.value,verification_status:status.value||undefined,page:next});items.value=result.items;page.value=result.page;total.value=result.total}catch(cause){error.value=failure(cause,'无法读取知识条目')}finally{loading.value=false}}
|
||||||
|
function edit(item:KnowledgeItem){selected.value=item;form.value={title:item.title,category:item.category,content:item.content,source_url:item.source_url??'',collected_at:item.collected_at?new Date(item.collected_at).toISOString().slice(0,16):''};error.value='';notice.value=''}
|
||||||
|
function cancel(){selected.value=undefined}
|
||||||
|
async function save(){if(!selected.value)return;error.value='';notice.value='';if(!form.value.title.trim()||!form.value.category.trim()||!form.value.content.trim()){error.value='标题、分类和正文不能为空。';return}if(!validUrl(form.value.source_url.trim())){error.value='来源网址必须是有效的 http(s) 地址。';return}saving.value=true;try{const payload={expected_version:selected.value.version,title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),source_url:form.value.source_url.trim()||null,...(form.value.collected_at?{collected_at:new Date(form.value.collected_at).toISOString()}: {})};await updateKnowledge(selected.value.id,payload);notice.value='知识条目已更新,后端已将其重新置为待审核。';cancel();await refresh()}catch(cause){error.value=failure(cause,'更新知识条目失败')}finally{saving.value=false}}
|
||||||
|
async function remove(item:KnowledgeItem){if(!window.confirm(`确定删除“${item.title}”吗?该操作为软删除,历史记录会保留。`))return;error.value='';notice.value='';try{await deleteKnowledge(item.id,item.version);notice.value='知识条目已软删除。';if(selected.value?.id===item.id)cancel();await refresh(items.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=failure(cause,'删除知识条目失败')}}
|
||||||
|
async function showHistory(item:KnowledgeItem){historyFor.value=item;historyLoading.value=true;error.value='';try{historyItems.value=(await getKnowledgeHistory(item.id)).items}catch(cause){error.value=failure(cause,'无法读取知识历史');historyFor.value=undefined}finally{historyLoading.value=false}}
|
||||||
|
onMounted(()=>refresh(1))
|
||||||
|
</script>
|
||||||
|
<template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>知识管理</h1><p>列表、编辑、删除和历史均直接调用管理员受保护接口;编辑后条目会由后端重新置为待审核。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="refresh(1)"><label>关键词<input v-model="query"/></label><label>分类<input v-model="category"/></label><label>状态<select v-model="status"><option value="">全部</option><option value="pending">待审核</option><option value="verified">已核验</option><option value="rejected">已驳回</option></select></label><button>筛选</button></form><p v-if="loading">正在读取知识条目…</p><template v-else><table><thead><tr><th>标题</th><th>分类</th><th>状态</th><th>来源</th><th>操作</th></tr></thead><tbody><tr v-for="item in items" :key="item.id"><td><b>{{item.title}}</b><p>{{item.content.slice(0,120)}}</p></td><td>{{item.category}}</td><td>{{item.verification_status}}</td><td><a v-if="item.source_url" :href="item.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td><button @click="edit(item)">编辑</button><button @click="showHistory(item)">历史</button><button @click="remove(item)">删除</button></td></tr><tr v-if="!items.length"><td colspan="5">暂无符合条件的知识条目。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><form v-if="selected" @submit.prevent="save"><h2>编辑:{{selected.title}}</h2><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>来源网址<input v-model.trim="form.source_url" type="url" maxlength="1000" placeholder="https://..."/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local"/></label><button type="button" :disabled="saving" @click="cancel">取消</button><button :disabled="saving">{{saving?'保存中…':'保存修改'}}</button></form><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{new Date(item.created_at).toLocaleString()}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template>
|
||||||
@@ -1 +1,10 @@
|
|||||||
<script setup lang="ts">import { ref } from 'vue';import { useRoute,useRouter } from 'vue-router';import { login } from '../services/auth';const username=ref(''),password=ref(''),error=ref(''),router=useRouter(),route=useRoute();async function submit(){try{await login({username:username.value,password:password.value});await router.replace(typeof route.query.redirect==='string'?route.query.redirect:'/')}catch(e){error.value=e instanceof Error?e.message:'登录失败'}}</script><template><main><h1>登录</h1><form @submit.prevent="submit"><input v-model="username" autocomplete="username" required placeholder="用户名"><input v-model="password" type="password" autocomplete="current-password" required placeholder="密码"><p v-if="error">{{error}}</p><button>登录</button></form><RouterLink to="/register">注册账户</RouterLink></main></template>
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { login } from '../services/auth'
|
||||||
|
const username=ref(''),password=ref(''),error=ref(''),submitting=ref(false),router=useRouter(),route=useRoute()
|
||||||
|
const redirect=computed(()=>typeof route.query.redirect==='string'?route.query.redirect:'/')
|
||||||
|
async function submit(){if(submitting.value)return;error.value='';submitting.value=true;try{await login({username:username.value.trim(),password:password.value});await router.replace(redirect.value)}catch(cause){error.value=cause instanceof Error?cause.message:'登录失败,请稍后重试。'}finally{submitting.value=false}}
|
||||||
|
</script>
|
||||||
|
<template><main class="auth-page"><section class="auth-intro"><p>边疆红驿 · NALATI KNOWLEDGE HUB</p><h1>每一次提问,<br>都有据可查。</h1><span>聚焦那拉提红色文化、旅游资源与民宿服务的可信知识问答。</span></section><section class="auth-panel"><RouterLink to="/">边疆红驿</RouterLink><form class="auth-card" @submit.prevent="submit"><p>WELCOME BACK</p><h2>登录账户</h2><label>用户名<input v-model.trim="username" autocomplete="username" required maxlength="100" placeholder="请输入用户名"/></label><label>密码<input v-model="password" type="password" autocomplete="current-password" required placeholder="请输入密码"/></label><p v-if="error" class="auth-error" role="alert">{{error}}</p><button :disabled="submitting">{{submitting?'登录中…':'登录'}}</button><small>还没有账户? <RouterLink to="/register">注册账户</RouterLink></small></form></section></main></template>
|
||||||
|
<style scoped>.auth-page{min-height:100vh;display:grid;grid-template-columns:1.1fr .9fr;background:#f5f3eb}.auth-intro{padding:12vw;background:#214d3b;color:#fff;display:flex;flex-direction:column;justify-content:center}.auth-intro p,.auth-card>p{letter-spacing:.14em;font-size:11px;font-weight:700;color:#b8d1be}.auth-intro h1{font-size:clamp(42px,5vw,68px);line-height:1.08;letter-spacing:-.06em;margin:20px 0}.auth-intro span{max-width:410px;line-height:1.9;color:#d7e7da}.auth-panel{padding:34px;display:flex;flex-direction:column;align-items:center;color:#214d3b;font-weight:700}.auth-panel>a{align-self:stretch}.auth-card{width:min(100%,410px);margin:auto 0;padding:34px;border:1px solid #e3e1d8;border-radius:16px;background:#fffefa;box-shadow:0 18px 45px #27332b14;display:grid;gap:16px}.auth-card h2{margin:0;font-size:30px;letter-spacing:-.04em;color:#27332b}.auth-card label{display:grid;gap:7px;font-size:12px;color:#526052}.auth-card input{padding:12px;border:1px solid #dfe2d8;border-radius:9px;font:inherit;outline-color:#6f967c}.auth-card button{border:0;border-radius:9px;padding:13px;background:#214d3b;color:#fff;font-weight:700}.auth-card small{color:#707a70;font-weight:400}.auth-error{margin:0;padding:10px;border-radius:8px;background:#f8e9e4;color:#a5423a;font-size:12px}@media(max-width:780px){.auth-page{grid-template-columns:1fr}.auth-intro{padding:48px 28px}.auth-panel{padding:22px}.auth-card{margin:44px 0}}</style>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<template><main><h1>404 页面不存在</h1><p>请检查地址,或返回智能问答首页。</p><RouterLink to="/">返回首页</RouterLink></main></template>
|
||||||
@@ -1 +1,9 @@
|
|||||||
<script setup lang="ts">import { ref } from 'vue';import { useRouter } from 'vue-router';import { register } from '../services/auth';const username=ref(''),password=ref(''),error=ref(''),router=useRouter();async function submit(){try{await register({username:username.value,password:password.value});await router.push('/login')}catch(e){error.value=e instanceof Error?e.message:'注册失败'}}</script><template><main><h1>注册</h1><form @submit.prevent="submit"><input v-model="username" required minlength="3" placeholder="用户名"><input v-model="password" required minlength="15" type="password" placeholder="至少 15 位密码"><p v-if="error">{{error}}</p><button>注册</button></form></main></template>
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { register } from '../services/auth'
|
||||||
|
const username=ref(''),password=ref(''),confirmPassword=ref(''),error=ref(''),notice=ref(''),submitting=ref(false),router=useRouter()
|
||||||
|
async function submit(){if(submitting.value)return;error.value='';notice.value='';if(password.value!==confirmPassword.value){error.value='两次输入的密码不一致。';return}submitting.value=true;try{await register({username:username.value.trim(),password:password.value});notice.value='注册成功,正在跳转登录页…';window.setTimeout(()=>router.push('/login'),500)}catch(cause){error.value=cause instanceof Error?cause.message:'注册失败,请稍后重试。'}finally{submitting.value=false}}
|
||||||
|
</script>
|
||||||
|
<template><main class="auth-page"><section class="auth-panel"><RouterLink to="/">边疆红驿</RouterLink><form class="auth-card" @submit.prevent="submit"><p>CREATE ACCOUNT</p><h1>注册账户</h1><span>新注册账户默认为普通用户;管理员权限仅能由后端授权。</span><label>用户名<input v-model.trim="username" autocomplete="username" required minlength="3" maxlength="100" placeholder="至少 3 个字符"/></label><label>密码<input v-model="password" autocomplete="new-password" required minlength="15" type="password" placeholder="至少 15 位密码"/></label><label>确认密码<input v-model="confirmPassword" autocomplete="new-password" required type="password" placeholder="再次输入密码"/></label><p v-if="error" class="auth-error" role="alert">{{error}}</p><p v-if="notice" class="auth-notice" role="status">{{notice}}</p><button :disabled="submitting">{{submitting?'注册中…':'创建账户'}}</button><small>已有账户? <RouterLink to="/login">前往登录</RouterLink></small></form></section><section class="auth-intro"><p>TRUSTWORTHY KNOWLEDGE</p><h2>从可靠资料出发,<br>再开始提问。</h2><span>未审核知识不会进入正式检索;没有可靠依据时,系统会明确说明知识不足。</span></section></main></template>
|
||||||
|
<style scoped>.auth-page{min-height:100vh;display:grid;grid-template-columns:.9fr 1.1fr;background:#f5f3eb}.auth-intro{padding:12vw;background:#a5423a;color:#fff;display:flex;flex-direction:column;justify-content:center}.auth-intro p,.auth-card>p{letter-spacing:.14em;font-size:11px;font-weight:700;color:#efc9be}.auth-intro h2{font-size:clamp(40px,4.7vw,64px);line-height:1.08;letter-spacing:-.06em;margin:20px 0}.auth-intro span{max-width:410px;line-height:1.9;color:#f6ded7}.auth-panel{padding:34px;display:flex;flex-direction:column;align-items:center;color:#214d3b;font-weight:700}.auth-panel>a{align-self:stretch}.auth-card{width:min(100%,410px);margin:auto 0;padding:34px;border:1px solid #e3e1d8;border-radius:16px;background:#fffefa;box-shadow:0 18px 45px #27332b14;display:grid;gap:14px}.auth-card h1{margin:0;font-size:30px;letter-spacing:-.04em;color:#27332b}.auth-card>span{color:#707a70;font-size:12px;line-height:1.7;font-weight:400}.auth-card label{display:grid;gap:7px;font-size:12px;color:#526052}.auth-card input{padding:12px;border:1px solid #dfe2d8;border-radius:9px;font:inherit;outline-color:#6f967c}.auth-card button{border:0;border-radius:9px;padding:13px;background:#214d3b;color:#fff;font-weight:700}.auth-card small{color:#707a70;font-weight:400}.auth-error,.auth-notice{margin:0;padding:10px;border-radius:8px;font-size:12px}.auth-error{background:#f8e9e4;color:#a5423a}.auth-notice{background:#e8f1e8;color:#356443}@media(max-width:780px){.auth-page{grid-template-columns:1fr}.auth-panel{padding:22px;order:2}.auth-intro{padding:48px 28px;order:1}.auth-card{margin:44px 0}}</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user