Compare commits

...
11 changed files with 173 additions and 11 deletions
+23 -1
View File
@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"lucide-vue-next": "^0.468.0",
"vue": "^3.5.13"
"vue": "^3.5.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^22.10.2",
@@ -1043,6 +1044,12 @@
"he": "^1.2.0"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/language-core": {
"version": "2.2.12",
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz",
@@ -1565,6 +1572,21 @@
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-tsc": {
"version": "2.2.12",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
+21 -4
View File
@@ -1,6 +1,23 @@
{
"name": "nalati-rag-frontend", "private": true, "version": "0.1.0", "type": "module",
"scripts": {"dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview"},
"dependencies": {"lucide-vue-next": "^0.468.0", "vue": "^3.5.13"},
"devDependencies": {"@types/node": "^22.10.2", "@vitejs/plugin-vue": "^5.2.1", "typescript": "~5.7.2", "vite": "^6.0.5", "vue-tsc": "^2.2.0"}
"name": "nalati-rag-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-vue-next": "^0.468.0",
"vue": "^3.5.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
+8 -4
View File
@@ -3,24 +3,28 @@ import { computed, onMounted, ref, watch } from 'vue'
import { Activity, BookOpen, Bot, CheckCircle2, ChevronRight, Database, FileText, Globe2, Link2, MessageSquareText, Plus, Search, Send, ShieldCheck, Sparkles, AlertCircle } from 'lucide-vue-next'
import { createKnowledge, createSource, getHealth, getKnowledge, getSources, type SourceItem } from './api'
import { retrieveHybrid } from './services/hybrid'
import { getCurrentUser } from './services/auth'
import { useRouter } from 'vue-router'
import type { SessionUser } from './types/auth'
type Page='chat'|'knowledge'|'sources'|'evaluation'
const page=ref<Page>('chat'), apiStatus=ref('检测中'), question=ref(''), busy=ref(false), search=ref(''), showForm=ref(false), error=ref(''), notice=ref('')
const router=useRouter(),currentUser=ref<SessionUser>(),page=ref<Page>('chat'), apiStatus=ref('检测中'), question=ref(''), busy=ref(false), search=ref(''), showForm=ref(false), error=ref(''), notice=ref('')
const messages=ref([{role:'assistant',content:'你好!我是边疆红驿文旅问答原型。当前后端仅为基础接口,尚未接入知识检索和大语言模型。'}] as {role:string;content:string;sources?:{title:string;source_url?:string|null}[]}[])
const items=ref<any[]>([]), form=ref({title:'',category:'旅游资源',content:'',source_url:''})
const sources=ref<SourceItem[]>([]), sourceSearch=ref(''), showSourceForm=ref(false), sourceForm=ref({name:'',source_url:'',notes:''})
const nav=[{id:'chat',label:'智能问答',icon:MessageSquareText},{id:'knowledge',label:'知识库管理',icon:Database},{id:'sources',label:'来源与更新',icon:Globe2},{id:'evaluation',label:'实验评估',icon:Activity}] as const
const nav=[{id:'chat',label:'智能问答',icon:MessageSquareText},{id:'knowledge',label:'知识库管理',icon:Database,adminOnly:true},{id:'sources',label:'来源与更新',icon:Globe2,adminOnly:true},{id:'evaluation',label:'实验评估',icon:Activity,adminOnly:true}] as const
const visibleNav=computed(()=>nav.filter(item=>!('adminOnly' in item)||!item.adminOnly||currentUser.value?.role==='admin'))
const filtered=computed(()=>items.value.filter(x=>`${x.title} ${x.category} ${x.content}`.toLowerCase().includes(search.value.toLowerCase())))
async function refresh(){try{items.value=await getKnowledge();error.value=''}catch{error.value='无法读取后端知识库,请确认 API 和 PostgreSQL 已启动。'}}
async function refreshSources(){try{sources.value=(await getSources(sourceSearch.value)).items;error.value=''}catch{error.value='无法读取来源列表,请确认 API 和 PostgreSQL 已启动。'}}
async function saveSource(){error.value='';notice.value='';if(!sourceForm.value.name.trim()){error.value='请填写来源名称。';return}if(!validUrl(sourceForm.value.source_url)){error.value='来源网址必须是有效的 http(s) 地址。';return}try{await createSource({...sourceForm.value,name:sourceForm.value.name.trim(),...(sourceForm.value.source_url?{source_url:sourceForm.value.source_url}:{})});sourceForm.value={name:'',source_url:'',notes:''};showSourceForm.value=false;notice.value='来源已登记,可继续创建关联文档和知识条目。';await refreshSources()}catch(e){error.value=e instanceof Error?e.message:'保存失败'}}
let sourceSearchTimer:number|undefined
watch(sourceSearch,()=>{window.clearTimeout(sourceSearchTimer);sourceSearchTimer=window.setTimeout(refreshSources,260)})
onMounted(async()=>{try{await getHealth();apiStatus.value='已连接'}catch{apiStatus.value='未连接'}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}}
function validUrl(value:string){try{if(value)new URL(value);return true}catch{return false}}
async function save(){error.value='';notice.value='';if(!form.value.title.trim()||!form.value.content.trim()){error.value='请填写知识标题和正文。';return}if(!validUrl(form.value.source_url)){error.value='来源网址必须是有效的 http(s) 地址。';return}try{await createKnowledge({...form.value,title:form.value.title.trim(),content:form.value.content.trim(),...(form.value.source_url?{source_url:form.value.source_url}:{})});form.value={title:'',category:'旅游资源',content:'',source_url:''};showForm.value=false;notice.value='已保存为待审核条目,尚未进入检索索引。';await refresh()}catch(e){error.value=e instanceof Error?e.message:'保存失败'}}
</script>
<template><div class="shell"><aside class="sidebar"><div class="brand"><div class="mark"><Bot :size="23"/></div><div><b>边疆红驿</b><small>NALATI KNOWLEDGE HUB</small></div></div><p class="muted label">功能列表</p><nav><button v-for="n in nav" :key="n.id" :class="{active:page===n.id}" @click="page=n.id"><component :is="n.icon" :size="18"/>{{n.label}}<ChevronRight v-if="page===n.id" class="chev" :size="15"/></button></nav><div class="quick-actions"><p class="muted label">快捷操作</p><button v-if="page==='knowledge'" class="side-action" @click="showForm=true"><Plus :size="16"/>新增知识</button><button v-if="page==='sources'" class="side-action" @click="showSourceForm=true"><Plus :size="16"/>新增来源</button><p v-if="page==='chat'" class="side-hint">选择示例问题,或直接开始提问。</p><p v-if="page==='evaluation'" class="side-hint">实验结果只展示真实运行数据。</p></div><div class="side-bottom"><div class="tip"><ShieldCheck :size="17"/><div><b>可信知识服务</b><p>优先展示可核验来源,信息不足时明确提示。</p></div></div><p class="api">● API {{apiStatus}} <span>v0.1.0</span></p></div></aside><main><header><span>边疆红驿 / {{nav.find(n=>n.id===page)?.label}}</span><div><span class="pill">● 原型阶段</span><i>研</i></div></header><section class="content">
<template><div class="shell"><aside class="sidebar"><div class="brand"><div class="mark"><Bot :size="23"/></div><div><b>边疆红驿</b><small>NALATI KNOWLEDGE HUB</small></div></div><p class="muted label">功能列表</p><nav><button v-for="n in visibleNav" :key="n.id" :class="{active:page===n.id}" @click="page=n.id"><component :is="n.icon" :size="18"/>{{n.label}}<ChevronRight v-if="page===n.id" class="chev" :size="15"/></button><button @click="router.push('/profile')"><ShieldCheck :size="18"/>个人中心</button><button v-if="currentUser?.role==='admin'" @click="router.push('/admin')"><Activity :size="18"/>用户管理</button></nav><div class="quick-actions"><p class="muted label">快捷操作</p><button v-if="page==='knowledge'" class="side-action" @click="showForm=true"><Plus :size="16"/>新增知识</button><button v-if="page==='sources'" class="side-action" @click="showSourceForm=true"><Plus :size="16"/>新增来源</button><p v-if="page==='chat'" class="side-hint">选择示例问题,或直接开始提问。</p><p v-if="page==='evaluation'" class="side-hint">实验结果只展示真实运行数据。</p></div><div class="side-bottom"><div class="tip"><ShieldCheck :size="17"/><div><b>{{currentUser?.role==='admin'?'管理员':'已登录用户'}}</b><p>{{currentUser?.nickname||currentUser?.username||'正在载入账户'}}</p></div></div><p class="api">● API {{apiStatus}} <span>v0.1.0</span></p></div></aside><main><header><span>边疆红驿 / {{nav.find(n=>n.id===page)?.label}}</span><div><span class="pill">● {{currentUser?.role==='admin'?'管理员':'用户'}}</span><i>研</i></div></header><section class="content">
<div v-if="page==='chat'"><div class="heading"><div><small class="eyebrow"><Sparkles :size="14"/> LOCAL KNOWLEDGE ASSISTANT</small><h1>文旅知识,问得明白</h1><p>面向那拉提文旅与民宿服务的知识问答原型</p></div><span class="chip"><ShieldCheck :size="15"/> 来源可追溯 · 设计目标</span></div><div class="chat-layout"><div class="card chat"><div class="chat-head"><div class="mark"><Bot :size="20"/></div><div><b>边疆红驿助手</b><small>问答服务原型</small></div></div><div class="messages"><div v-for="(m,i) in messages" :key="i" class="message" :class="m.role"><small>{{m.role==='assistant'?'智能助手':'你'}}</small><p>{{m.content}}</p><div v-if="m.sources?.length" class="sources"><b><Link2 :size="14"/> 参考来源</b><a v-for="s in m.sources" :key="s.title" :href="s.source_url ?? undefined" target="_blank">{{s.title}}</a></div></div><p v-if="busy">正在请求后端…</p></div><form class="composer" @submit.prevent="send"><textarea v-model="question" placeholder="输入你想了解的文旅问题…" rows="2" @keydown.enter.exact.prevent="send"/><div><small>当前为基础接口,尚未接入真实检索</small><button class="primary" :disabled="busy||!question.trim()"><Send :size="15"/> 发送</button></div></form></div><aside class="suggest"><b><Sparkles :size="16"/> 你可以这样问</b><button @click="question='那拉提有哪些值得了解的旅游资源?'"><Globe2/>旅游资源 <ChevronRight/></button><button @click="question='有哪些经过核验的红色文化资料?'"><BookOpen/>红色文化 <ChevronRight/></button><button @click="question='民宿服务信息应如何核验?'"><ShieldCheck/>民宿服务 <ChevronRight/></button><div class="notice"><b><AlertCircle :size="15"/> 信息说明</b><p>本项目仍处于原型阶段。演示内容不代表已核验的地方文旅事实。</p></div></aside></div></div>
<div v-else-if="page==='knowledge'"><div class="heading"><div><small class="eyebrow"><Database :size="14"/> KNOWLEDGE MANAGEMENT</small><h1>知识库管理</h1><p>集中管理领域知识、来源和审核状态。</p></div><button class="primary" @click="showForm=!showForm"><Plus :size="16"/> 新增知识</button></div><div v-if="showForm" class="card form-card"><b>新增知识条目</b><form class="knowledge-form" @submit.prevent="save"><label>标题<input v-model="form.title" required maxlength="250"/></label><label>分类<select v-model="form.category"><option>红色文化</option><option>旅游资源</option><option>民宿服务</option><option>交通出行</option><option>其他</option></select></label><label class="wide">正文<textarea v-model="form.content" required rows="3"/></label><label class="wide">来源网址(可选)<input v-model="form.source_url" type="url" placeholder="https://..."/></label><div class="wide actions"><button type="button" class="secondary" @click="showForm=false">取消</button><button class="primary">保存条目</button></div></form></div><div class="stats"><div class="card stat"><small>知识条目</small><strong>{{items.length}}</strong><span>来自当前数据库</span></div><div class="card stat"><small>已核验</small><strong>{{items.filter(x=>x.verification_status==='verified').length}}</strong><span>需经过真实审核</span></div><div class="card stat"><small>待审核</small><strong>{{items.filter(x=>x.verification_status==='pending').length}}</strong><span>尚未进入检索索引</span></div></div><p v-if="notice" class="success">{{notice}}</p><p v-if="error" class="error">{{error}}</p><div class="card table-card"><div class="table-head"><div><b>知识条目</b><p>列表来自后端接口,不含预设真实资料。</p></div><label class="search"><Search :size="15"/><input v-model="search" placeholder="搜索知识"/></label></div><div class="scroll"><table><thead><tr><th>标题</th><th>分类</th><th>来源</th><th>状态</th><th>创建时间</th></tr></thead><tbody><tr v-for="x in filtered" :key="x.id"><td><b>{{x.title}}</b><small>{{x.content.slice(0,45)}}</small></td><td>{{x.category}}</td><td><a v-if="x.source_url" :href="x.source_url" target="_blank">打开来源</a><span v-else>未填写</span></td><td><em>{{x.verification_status==='pending'?'待审核':x.verification_status}}</em></td><td>{{new Date(x.created_at).toLocaleDateString()}}</td></tr><tr v-if="!filtered.length"><td colspan="5" class="empty">暂无条目,点击“新增知识”添加。</td></tr></tbody></table></div></div></div>
<div v-else-if="page==='sources'"><div class="heading"><div><small class="eyebrow"><Globe2 :size="14"/> SOURCE OPERATIONS</small><h1>来源与更新</h1><p>登记可追溯来源,为文档采集和知识审核建立基础。</p></div><button class="primary" @click="showSourceForm=!showSourceForm"><Plus :size="16"/> 新增来源</button></div><div v-if="showSourceForm" class="card form-card"><b>登记来源</b><form class="knowledge-form" @submit.prevent="saveSource"><label>来源名称<input v-model="sourceForm.name" required maxlength="250" placeholder="例如:那拉提景区官网"/></label><label>来源网址(可选)<input v-model="sourceForm.source_url" type="url" placeholder="https://..."/></label><label class="wide">备注<textarea v-model="sourceForm.notes" rows="3" placeholder="记录来源用途、可靠性判断或待核验事项"/></label><div class="wide actions"><button type="button" class="secondary" @click="showSourceForm=false">取消</button><button class="primary">保存来源</button></div></form></div><p v-if="notice" class="success">{{notice}}</p><p v-if="error" class="error">{{error}}</p><div class="stats"><div class="card stat"><small>来源总数</small><strong>{{sources.length}}</strong><span>来自当前数据库</span></div><div class="card stat"><small>关联文档</small><strong>—</strong><span>文档工作台待接入</span></div><div class="card stat"><small>自动更新</small><strong class="pending">未启用</strong><span>当前不会自动抓取网页</span></div></div><div class="card table-card"><div class="table-head"><div><b>已登记来源</b><p>来源不是审核通过的证明,关键事实仍需交叉核验。</p></div><label class="search"><Search :size="15"/><input v-model="sourceSearch" placeholder="搜索名称或备注" @keyup.enter="refreshSources"/></label></div><div class="scroll"><table><thead><tr><th>名称</th><th>网址</th><th>备注</th><th>登记时间</th></tr></thead><tbody><tr v-for="source in sources" :key="source.id"><td><b>{{source.name}}</b></td><td><a v-if="source.source_url" :href="source.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td>{{source.notes||'—'}}</td><td>{{new Date(source.created_at).toLocaleDateString()}}</td></tr><tr v-if="!sources.length"><td colspan="4" class="empty">暂无来源,点击“新增来源”登记。</td></tr></tbody></table></div></div><div class="card roadmap"><b>来源处理边界</b><div><span>01 登记来源</span><ChevronRight/><span>02 人工核验</span><ChevronRight/><span>03 关联文档</span><ChevronRight/><span>04 后续检索</span></div></div></div>
+2
View File
@@ -0,0 +1,2 @@
<script setup lang="ts">import { RouterView } from 'vue-router';</script>
<template><RouterView/></template>
+3 -2
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import './enhancements.css'
import App from './App.vue'
createApp(App).mount('#app')
import Root from './Root.vue'
import router from './router'
createApp(Root).use(router).mount('#app')
+16
View File
@@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import { getCurrentUser } from './services/auth'
import type { UserRole } from './types/auth'
let role:UserRole|undefined
const router=createRouter({history:createWebHistory(),routes:[
{path:'/',component:App},
{path:'/login',component:()=>import('./views/LoginView.vue'),meta:{public:true}},
{path:'/register',component:()=>import('./views/RegisterView.vue'),meta:{public:true}},
{path:'/profile',component:()=>import('./views/ProfileView.vue')},
{path:'/settings',component:()=>import('./views/AccountSettingsView.vue')},
{path:'/admin',component:()=>import('./views/AdminView.vue'),meta:{role:'admin'}},
]})
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})
export default router
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { changePassword, getCurrentUser, updateMyProfile } from '../services/auth'
import type { SessionUser } from '../types/auth'
const router = useRouter()
const user = ref<SessionUser>()
const profile = ref({ nickname: '', email: '' })
const password = ref({ current_password: '', new_password: '', confirm_password: '' })
const loading = ref(true)
const savingProfile = ref(false)
const savingPassword = ref(false)
const error = ref('')
const success = ref('')
onMounted(async () => {
try {
user.value = await getCurrentUser()
profile.value = { nickname: user.value.nickname ?? '', email: user.value.email ?? '' }
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '无法读取账户资料'
} finally {
loading.value = false
}
})
async function saveProfile() {
error.value = ''
success.value = ''
if (profile.value.email && !/^\S+@\S+\.\S+$/.test(profile.value.email)) {
error.value = '请输入有效邮箱地址。'
return
}
savingProfile.value = true
try {
const updated = await updateMyProfile({ nickname: profile.value.nickname || null, email: profile.value.email || null })
if (user.value) user.value = { ...user.value, ...updated }
success.value = '基本资料已保存。'
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '保存资料失败'
} finally {
savingProfile.value = false
}
}
async function savePassword() {
error.value = ''
success.value = ''
if (password.value.new_password.length < 8) {
error.value = '新密码至少需要 8 个字符。'
return
}
if (password.value.new_password !== password.value.confirm_password) {
error.value = '两次输入的新密码不一致。'
return
}
savingPassword.value = true
try {
await changePassword({ current_password: password.value.current_password, new_password: password.value.new_password })
password.value = { current_password: '', new_password: '', confirm_password: '' }
router.replace('/login?changed=1')
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '修改密码失败'
} finally {
savingPassword.value = false
}
}
</script>
<template>
<main>
<RouterLink to="/profile">返回个人中心</RouterLink>
<h1>账户设置</h1>
<p v-if="loading">正在读取账户资料…</p>
<template v-else>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="success" role="status">{{ success }}</p>
<form @submit.prevent="saveProfile">
<h2>基本资料</h2>
<label>用户名<input :value="user?.username" disabled /></label>
<label>昵称<input v-model.trim="profile.nickname" maxlength="80" autocomplete="nickname" /></label>
<label>邮箱<input v-model.trim="profile.email" type="email" maxlength="320" autocomplete="email" /></label>
<button :disabled="savingProfile">{{ savingProfile ? '保存中…' : '保存资料' }}</button>
</form>
<form @submit.prevent="savePassword">
<h2>修改密码</h2>
<label>当前密码<input v-model="password.current_password" type="password" required autocomplete="current-password" /></label>
<label>新密码<input v-model="password.new_password" type="password" required minlength="8" autocomplete="new-password" /></label>
<label>确认新密码<input v-model="password.confirm_password" type="password" required minlength="8" autocomplete="new-password" /></label>
<p>修改密码后会退出当前会话,请使用新密码重新登录。</p>
<button :disabled="savingPassword">{{ savingPassword ? '提交中…' : '修改密码' }}</button>
</form>
</template>
</main>
</template>
+1
View File
@@ -0,0 +1 @@
<script setup lang="ts">import { onMounted,ref } from 'vue';import { getUsers } from '../services/auth';const users=ref<any[]>([]),error=ref('');onMounted(async()=>{try{users.value=await getUsers()}catch(e){error.value=e instanceof Error?e.message:'无管理员权限'}});</script><template><main><h1>管理员用户管理</h1><p v-if="error">{{error}}</p><table v-else><tr><th>用户名</th><th>邮箱</th><th>角色</th></tr><tr v-for="u in users" :key="u.id"><td>{{u.username}}</td><td>{{u.email}}</td><td>{{u.role}}</td></tr></table></main></template>
+1
View File
@@ -0,0 +1 @@
<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>
+1
View File
@@ -0,0 +1 @@
<script setup lang="ts">import { onMounted,ref } from 'vue';import { getCurrentUser,logout } from '../services/auth';import { useRouter } from 'vue-router';import type { SessionUser } from '../types/auth';const user=ref<SessionUser>(),error=ref(''),router=useRouter();onMounted(async()=>{try{user.value=await getCurrentUser()}catch(e){error.value=e instanceof Error?e.message:'无法读取资料'}});async function signOut(){try{await logout()}finally{router.replace('/login')}}</script><template><main><h1>个人中心</h1><p v-if="error">{{error}}</p><dl v-else-if="user"><dt>用户名</dt><dd>{{user.username}}</dd><dt>昵称</dt><dd>{{user.nickname||'未设置'}}</dd><dt>邮箱</dt><dd>{{user.email||'未设置'}}</dd><dt>角色</dt><dd>{{user.role}}</dd><dt>注册时间</dt><dd>{{new Date(user.created_at).toLocaleString()}}</dd></dl><div><RouterLink to="/settings">账户设置</RouterLink><button @click="signOut">退出登录</button></div></main></template>
+1
View File
@@ -0,0 +1 @@
<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>