initialize project with Vue 3, Vite, and Element Plus; set up basic routing, components, configuration, and project structure

This commit is contained in:
2025-12-14 18:55:20 +08:00
commit 0959754d1e
24 changed files with 5575 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

34
AGENTS.md Normal file
View File

@@ -0,0 +1,34 @@
# Repository Guidelines
## 项目结构与模块
- `src/main.js`:应用入口,注册 Element Plus、全局样式。
- `src/App.vue`CRT 风格登录页,调用 `/login` 获取 JWT 与用户信息。
- `src/style.css`:全局字体与背景基调;保持单一来源的全局样式。
- `public/`:静态资源;`index.html` 仅包含挂载点与标题。
- `vite.config.js`Vite 基础配置,如需别名或代理在此扩展。
## 构建、测试与开发命令
- `npm install`:安装依赖。
- `npm run dev`:本地开发,默认端口 5173。
- `npm run build`:生产构建,输出到 `dist/`
- `npm run preview`:本地预览构建产物。
## 编码风格与命名
- 使用 Vue 3 SFC组件文件 PascalCase`LoginPanel.vue`),组合式函数 camelCase。
- 缩进 2 空格,优先使用 const必要时加少量行内注释说明意图。
- 样式保持 CRT 终端基调,变量命名语义化;若扩展组件样式,优先复用 Element Plus 变量或类。
- 资源/路径使用相对路径,避免硬编码绝对 URL。
## 环境与配置
- 后端基址通过环境变量 `VITE_API_BASE` 配置,默认 `http://localhost:8000/api`。开发时可在 `.env.local` 设置。
- 接口认证遵循 JWT Bearer`Authorization: Bearer <token>`
## 测试指引
- 一律以 TDD 推进:先写失败的测试,再实现功能,再回到绿色。若暂缺框架,请先搭建再编码。
- 单元测试推荐 Vitest端到端测试推荐 Playwright。新功能或改动必须附带测试覆盖正/反路径。
- 测试命名:`*.spec.ts|js`,按功能域分目录;保证可通过 `npm test` 或相应命令运行。
## 提交与 Pull Request
- 提交信息使用祈使句简述变更(例:`add login error display`),中文/英文均可,但保持简洁。
- PR 应包含:变更目的、主要修改点、测试结果;涉及 UI 需附关键截图或录屏。
- 保持小而可审的提交;引用关联 issue 时使用 `#ID`

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ARS CRT Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3474
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "ars-front",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"axios": "^1.13.2",
"element-plus": "^2.12.0",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"jsdom": "^27.3.0",
"vite": "^7.2.4",
"vitest": "^4.0.15"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

204
src/App.vue Normal file
View File

@@ -0,0 +1,204 @@
<script setup>
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import SessionSidebar from './components/SessionSidebar.vue'
const route = useRoute()
const menus = [
{ name: 'welcome', label: '仪表盘', to: '/welcome' },
{ name: 'users', label: '用户管理', to: '/users' },
]
const isAuthRoute = computed(() => route.name === 'login')
const activeMenuLabel = computed(
() => menus.find((m) => m.name === route.name)?.label || '控制台'
)
const miniMenuOpen = ref(false)
</script>
<template>
<div class="app-shell">
<div v-if="isAuthRoute" class="auth-shell">
<div class="auth-bg"></div>
<div class="auth-card">
<router-view />
</div>
</div>
<div v-else class="admin-shell">
<SessionSidebar />
<section class="main">
<header class="topbar">
<div class="top-title">{{ activeMenuLabel }}</div>
<div class="top-meta">会话列表常驻左侧Apple 风格控制台</div>
</header>
<div class="page">
<router-view />
</div>
</section>
<el-popover
v-model:visible="miniMenuOpen"
placement="top-start"
trigger="click"
popper-class="mini-popper"
>
<div class="mini-menu">
<p class="mini-title">快捷导航</p>
<router-link
v-for="item in menus"
:key="item.name"
:to="item.to"
class="mini-link"
@click="miniMenuOpen = false"
>
{{ item.label }}
</router-link>
</div>
<template #reference>
<button class="mini-btn" aria-label="打开快捷导航">
<span class="mini-icon"></span>
<span class="mini-label">快捷</span>
</button>
</template>
</el-popover>
</div>
</div>
</template>
<style scoped>
.app-shell {
min-height: 100vh;
background: radial-gradient(circle at 20% 20%, #f3f6ff, #e7ebf5 40%, #dae0ec),
linear-gradient(135deg, rgba(255, 255, 255, 0.6), rgba(220, 226, 238, 0.9));
}
.auth-shell {
position: relative;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 16px;
}
.auth-bg {
position: absolute;
inset: 0;
background: linear-gradient(180deg, #e9eef7, #f6f8fc);
filter: blur(0);
z-index: 0;
}
.auth-card {
position: relative;
z-index: 1;
width: min(960px, 92vw);
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 18px;
box-shadow: 0 30px 80px rgba(36, 49, 89, 0.16);
backdrop-filter: blur(12px);
padding: 32px;
}
.admin-shell {
display: grid;
grid-template-columns: 300px 1fr;
min-height: 100vh;
position: relative;
}
.main {
display: flex;
flex-direction: column;
padding: 26px 28px;
gap: 18px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 6px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.top-meta {
color: #6b7280;
font-size: 14px;
}
.page {
min-height: calc(100vh - 140px);
}
.mini-btn {
position: fixed;
left: 18px;
bottom: 18px;
height: 46px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 8px 22px rgba(17, 24, 39, 0.14);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
color: #111827;
}
.mini-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 26px rgba(17, 24, 39, 0.18);
}
.mini-icon {
font-size: 15px;
color: #1d4ed8;
}
.mini-label {
font-weight: 700;
letter-spacing: 0.02em;
color: #111827;
}
.mini-menu {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 160px;
}
.mini-title {
margin: 0;
font-weight: 700;
color: #0f172a;
font-size: 14px;
}
.mini-link {
padding: 8px 10px;
border-radius: 10px;
color: #1f2937;
transition: all 0.16s ease;
}
.mini-link:hover {
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
}
.mini-popper {
border-radius: 12px;
box-shadow: 0 20px 60px rgba(17, 24, 39, 0.2) !important;
}
@media (max-width: 900px) {
.admin-shell {
grid-template-columns: 1fr;
}
}
</style>

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const props = defineProps({
inline: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['created'])
const router = useRouter()
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
const creating = ref(false)
const sessionName = ref('')
const lastSessionId = ref(localStorage.getItem('ars-last-session') || '')
const popoverVisible = ref(false)
const togglePopover = () => {
popoverVisible.value = !popoverVisible.value
}
const createSession = async () => {
const token = localStorage.getItem('ars-token') || ''
if (!token) {
router.replace({ name: 'login' })
return
}
creating.value = true
const payload = {
session_name: sessionName.value.trim() || '新会话',
}
try {
const { data } = await axios.post(`${apiBase}/sessions`, payload, {
headers: { Authorization: `Bearer ${token}` },
})
lastSessionId.value = data.session_id
localStorage.setItem('ars-last-session', data.session_id)
ElMessage.success('新会话已创建')
sessionName.value = ''
popoverVisible.value = false
emit('created', data)
} catch (err) {
const message = err.response?.data?.message || '创建会话失败'
ElMessage.error(message)
if (err.response?.status === 401) {
router.replace({ name: 'login' })
}
} finally {
creating.value = false
}
}
</script>
<template>
<el-popover
v-model:visible="popoverVisible"
trigger="manual"
placement="bottom-start"
popper-class="mini-popper"
width="260"
:teleported="false"
>
<template #reference>
<button
class="new-chat-btn"
:class="{ inline: props.inline }"
aria-label="新建会话"
data-testid="new-chat-trigger"
@click.stop="togglePopover"
>
<span class="plus"></span>
<span class="label">新建聊天</span>
</button>
</template>
<div class="new-chat-card">
<p class="title">新建 Chat Session</p>
<el-input
v-model="sessionName"
size="large"
placeholder="可选:会话名称"
data-testid="session-name"
/>
<el-button
type="primary"
class="create-btn"
color="#1d4ed8"
:loading="creating"
data-testid="create-session"
@click="createSession"
>
创建
</el-button>
<p v-if="lastSessionId" class="last">
最近会话<code>{{ lastSessionId }}</code>
</p>
</div>
</el-popover>
</template>
<style scoped>
.new-chat-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 8px 20px rgba(17, 24, 39, 0.12);
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
color: #111827;
}
.new-chat-btn.inline {
width: 100%;
justify-content: center;
box-shadow: none;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.new-chat-btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(17, 24, 39, 0.15);
}
.plus {
font-size: 18px;
font-weight: 700;
}
.label {
font-weight: 700;
letter-spacing: 0.02em;
}
.new-chat-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
margin: 0;
font-weight: 700;
color: #0f172a;
}
.create-btn {
width: 100%;
font-weight: 700;
}
.last {
margin: 0;
font-size: 12px;
color: #4b5563;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,314 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import NewChatButton from './NewChatButton.vue'
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
const router = useRouter()
const sessions = ref([])
const loading = ref(false)
const errorMessage = ref('')
const canLoadMore = computed(
() => pagination.page * pagination.perPage < pagination.total
)
const filters = reactive({
q: '',
status: 'OPEN',
})
const pagination = reactive({
page: 1,
perPage: 20,
total: 0,
})
const currentSessionId = ref(localStorage.getItem('ars-current-session') || '')
const goLogin = () => {
router.replace({ name: 'login' })
}
const headers = () => ({
Authorization: `Bearer ${localStorage.getItem('ars-token') || ''}`,
})
const fetchSessions = async ({ append = false } = {}) => {
const token = localStorage.getItem('ars-token') || ''
if (!token) {
goLogin()
return
}
loading.value = true
errorMessage.value = ''
try {
const { data } = await axios.get(`${apiBase}/sessions`, {
params: {
page: pagination.page,
per_page: pagination.perPage,
status: filters.status || undefined,
q: filters.q || undefined,
},
headers: headers(),
})
const list = data.data || []
sessions.value = append ? [...sessions.value, ...list] : list
pagination.total = data.total || 0
pagination.page = data.current_page || pagination.page
pagination.perPage = data.per_page || pagination.perPage
} catch (err) {
const status = err.response?.status
const message = err.response?.data?.message || '获取会话列表失败'
errorMessage.value = message
ElMessage.error(message)
if (status === 401) goLogin()
} finally {
loading.value = false
}
}
const selectSession = (session) => {
currentSessionId.value = session.session_id
localStorage.setItem('ars-current-session', session.session_id)
ElMessage.success(`已切换到会话:${session.session_name || '未命名'}`)
}
const handleCreated = (session) => {
if (session?.session_id) {
const existing = sessions.value.find(
(s) => s.session_id === session.session_id
)
if (!existing) {
sessions.value = [session, ...sessions.value]
}
currentSessionId.value = session.session_id
localStorage.setItem('ars-current-session', session.session_id)
}
fetchSessions()
}
const handleSearch = () => {
pagination.page = 1
fetchSessions()
}
const loadMore = () => {
if (!canLoadMore.value || loading.value) return
pagination.page += 1
fetchSessions({ append: true })
}
onMounted(() => {
fetchSessions()
})
</script>
<template>
<aside class="sidebar">
<div class="sidebar-head">
<div class="brand">
<span class="dot"></span>
<span>ARS</span>
</div>
<NewChatButton inline @created="handleCreated" />
</div>
<div class="filters">
<el-input
v-model="filters.q"
placeholder="搜索会话"
size="small"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
<el-select v-model="filters.status" size="small" @change="handleSearch">
<el-option label="打开" value="OPEN" />
<el-option label="锁定" value="LOCKED" />
<el-option label="关闭" value="CLOSED" />
<el-option label="全部" value="" />
</el-select>
<el-button
size="small"
color="#1d4ed8"
:loading="loading"
@click="fetchSessions"
>
刷新
</el-button>
</div>
<el-scrollbar class="session-scroll">
<el-empty v-if="!sessions.length && !loading" description="暂无会话" />
<div
v-for="item in sessions"
:key="item.session_id"
class="session-item"
:class="{ active: item.session_id === currentSessionId }"
@click="selectSession(item)"
>
<div class="item-title">
<span class="name">{{ item.session_name || '未命名会话' }}</span>
<el-tag
size="small"
:type="item.status === 'OPEN' ? 'success' : item.status === 'LOCKED' ? 'warning' : 'info'"
>
{{ item.status }}
</el-tag>
</div>
<p class="preview">
{{ item.last_message_preview || '尚无消息' }}
</p>
<p class="meta">
更新{{ item.updated_at?.slice(0, 16) || '--' }} · seq
{{ item.last_seq ?? 0 }}
</p>
</div>
</el-scrollbar>
<div class="pagination">
<span>{{ pagination.total }} 个会话</span>
<el-button
size="small"
text
:disabled="!canLoadMore || loading"
@click="loadMore"
>
加载更多
</el-button>
</div>
<el-alert
v-if="errorMessage"
type="error"
:closable="false"
show-icon
class="error"
:title="errorMessage"
/>
</aside>
</template>
<style scoped>
.sidebar {
width: 300px;
min-height: 100vh;
padding: 18px;
background: rgba(255, 255, 255, 0.8);
border-right: 1px solid rgba(0, 0, 0, 0.06);
backdrop-filter: blur(10px);
box-shadow: 12px 0 30px rgba(17, 24, 39, 0.08);
display: flex;
flex-direction: column;
gap: 14px;
}
.sidebar-head {
display: flex;
flex-direction: column;
gap: 10px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 700;
color: #111827;
letter-spacing: 0.02em;
font-size: 18px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: linear-gradient(135deg, #4ade80, #22c55e);
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.12);
}
.filters {
display: grid;
grid-template-columns: 1fr 120px 80px;
gap: 8px;
align-items: center;
}
.session-scroll {
flex: 1;
}
.session-item {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 6px 14px rgba(17, 24, 39, 0.06);
margin-bottom: 10px;
cursor: pointer;
transition: all 0.16s ease;
}
.session-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.08);
}
.session-item.active {
border-color: rgba(37, 99, 235, 0.4);
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
}
.item-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.name {
font-weight: 700;
color: #111827;
}
.preview {
margin: 6px 0 4px;
color: #4b5563;
font-size: 13px;
line-height: 1.4;
max-height: 38px;
overflow: hidden;
}
.meta {
margin: 0;
color: #9ca3af;
font-size: 12px;
}
.pagination {
display: flex;
justify-content: flex-start;
align-items: center;
font-size: 12px;
color: #6b7280;
}
.error {
margin-top: 6px;
}
@media (max-width: 960px) {
.sidebar {
width: 100%;
min-height: auto;
border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.08);
}
.session-item {
margin-bottom: 8px;
}
}
</style>

8
src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
import router from './router'
createApp(App).use(ElementPlus).use(router).mount('#app')

16
src/router/index.js Normal file
View File

@@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
import LoginView from '../views/LoginView.vue'
import WelcomeView from '../views/WelcomeView.vue'
import UsersView from '../views/UsersView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', name: 'login', component: LoginView },
{ path: '/welcome', name: 'welcome', component: WelcomeView },
{ path: '/users', name: 'users', component: UsersView },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
export default router

37
src/style.css Normal file
View File

@@ -0,0 +1,37 @@
:root {
font-family: -apple-system, "SF Pro Text", "SF Pro Display", "Helvetica Neue",
Arial, sans-serif;
line-height: 1.6;
font-weight: 400;
color: #111827;
background-color: #e9edf3;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background-color: #e9edf3;
color: #111827;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
#app {
min-height: 100vh;
}

202
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,202 @@
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const router = useRouter()
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
const form = reactive({
email: '',
password: '',
})
const loading = ref(false)
const errorMessage = ref('')
const handleLogin = async () => {
errorMessage.value = ''
loading.value = true
try {
const { data } = await axios.post(`${apiBase}/login`, {
email: form.email,
password: form.password,
})
localStorage.setItem('ars-token', data.token)
localStorage.setItem('ars-user', JSON.stringify(data.user))
ElMessage.success('登录成功,正在进入后台')
router.push({ name: 'welcome' })
} catch (err) {
const message =
err.response?.data?.message || '登录失败,请检查邮箱与密码'
errorMessage.value = message
ElMessage.error(message)
} finally {
loading.value = false
}
}
</script>
<template>
<main class="login">
<div class="copy">
<p class="eyebrow">ARS Access</p>
<h1>登录后台</h1>
<p class="lede">
使用账户连接到 ARS 控制台登录后可携带 JWT 调用后台接口
</p>
<p class="hint">接口基址{{ apiBase }}</p>
</div>
<el-card class="login-card" shadow="never">
<template #header>
<div class="card-header">
<span>账户登录</span>
<span class="dot"></span>
</div>
</template>
<el-form label-position="top" :model="form" @submit.prevent>
<el-form-item label="邮箱">
<el-input
v-model="form.email"
size="large"
placeholder="user@example.com"
autocomplete="email"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
size="large"
show-password
placeholder="Password123"
autocomplete="current-password"
/>
</el-form-item>
<el-button
type="primary"
class="login-btn"
:loading="loading"
color="#1d4ed8"
@click="handleLogin"
>
进入系统
</el-button>
</el-form>
<el-alert
v-if="errorMessage"
type="error"
:closable="false"
class="mt"
show-icon
:title="errorMessage"
/>
<div class="tips">
<p>使用你的账户登录成功后自动跳转欢迎页</p>
<p>登录后可携带 JWT 访问 /me/users 等接口</p>
</div>
</el-card>
</main>
</template>
<style scoped>
.login {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 32px;
align-items: center;
}
.copy {
padding: 12px;
}
.eyebrow {
margin: 0 0 6px;
font-size: 13px;
color: #6b7280;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 0 0 8px;
font-size: 32px;
color: #0f172a;
}
.lede {
margin: 0 0 12px;
color: #4b5563;
}
.hint {
margin: 0;
font-size: 13px;
color: #6b7280;
}
.login-card {
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 700;
color: #0f172a;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.16);
}
.login-btn {
width: 100%;
margin-top: 6px;
font-weight: 700;
letter-spacing: 0.02em;
}
.tips {
margin-top: 16px;
font-size: 13px;
color: #6b7280;
}
.mt {
margin-top: 12px;
}
:deep(.el-card__body) {
background: transparent;
}
:deep(.el-form-item__label) {
color: #334155;
letter-spacing: 0.02em;
}
:deep(.el-input__wrapper) {
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
:deep(.el-input__inner) {
color: #0f172a;
}
</style>

416
src/views/UsersView.vue Normal file
View File

@@ -0,0 +1,416 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const router = useRouter()
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
const token = ref(localStorage.getItem('ars-token') || '')
const users = ref([])
const loading = ref(false)
const errorMessage = ref('')
const pagination = reactive({
page: 1,
perPage: 10,
total: 0,
})
const dialogVisible = ref(false)
const dialogMode = ref('create')
const form = reactive({
id: null,
name: '',
email: '',
password: '',
})
const goLogin = () => {
localStorage.removeItem('ars-token')
localStorage.removeItem('ars-user')
router.replace({ name: 'login' })
}
const headers = () => ({
Authorization: `Bearer ${token.value}`,
})
const fetchUsers = async () => {
if (!token.value) {
goLogin()
return
}
loading.value = true
errorMessage.value = ''
try {
const { data } = await axios.get(`${apiBase}/users`, {
params: {
page: pagination.page,
per_page: pagination.perPage,
},
headers: headers(),
})
users.value = data.data || []
pagination.total = data.total || 0
pagination.page = data.current_page || pagination.page
pagination.perPage = data.per_page || pagination.perPage
} catch (err) {
const status = err.response?.status
const message = err.response?.data?.message || '获取用户列表失败'
errorMessage.value = message
ElMessage.error(message)
if (status === 401) {
goLogin()
}
} finally {
loading.value = false
}
}
const handleSizeChange = (size) => {
pagination.perPage = size
pagination.page = 1
fetchUsers()
}
const handlePageChange = (page) => {
pagination.page = page
fetchUsers()
}
const openCreate = () => {
dialogMode.value = 'create'
Object.assign(form, { id: null, name: '', email: '', password: '' })
dialogVisible.value = true
}
const openEdit = (user) => {
dialogMode.value = 'edit'
Object.assign(form, { id: user.id, name: user.name, email: user.email, password: '' })
dialogVisible.value = true
}
const saveUser = async () => {
if (!token.value) {
goLogin()
return
}
try {
loading.value = true
const payload = {
name: form.name,
email: form.email,
}
if (form.password) {
payload.password = form.password
}
if (dialogMode.value === 'edit' && form.id) {
const { data } = await axios.put(
`${apiBase}/users/${form.id}`,
payload,
{ headers: headers() }
)
const idx = users.value.findIndex((u) => u.id === form.id)
if (idx !== -1) {
users.value[idx] = { ...users.value[idx], ...data }
}
ElMessage.success('用户信息已更新')
} else {
const { data } = await axios.post(`${apiBase}/users`, payload, {
headers: headers(),
})
users.value.unshift(data)
pagination.total += 1
ElMessage.success('用户已创建')
}
dialogVisible.value = false
} catch (err) {
const message = err.response?.data?.message || '操作失败'
ElMessage.error(message)
} finally {
loading.value = false
}
}
const toggleActive = async (user) => {
if (!token.value) {
goLogin()
return
}
const action = user.is_active ? 'deactivate' : 'activate'
try {
loading.value = true
await axios.post(
`${apiBase}/users/${user.id}/${action}`,
{},
{ headers: headers() }
)
user.is_active = !user.is_active
ElMessage.success(`用户已${user.is_active ? '启用' : '停用'}`)
} catch (err) {
const message = err.response?.data?.message || '状态更新失败'
ElMessage.error(message)
if (err.response?.status === 401) goLogin()
} finally {
loading.value = false
}
}
onMounted(() => {
if (!token.value) {
goLogin()
return
}
fetchUsers()
})
</script>
<template>
<main class="users">
<el-card class="hero" shadow="never">
<div class="hero-head">
<div>
<p class="eyebrow">Users</p>
<h1>用户列表</h1>
<p class="lede">
携带 JWT 调用 <code>/users</code>支持分页浏览与联调
</p>
<p class="hint">当前接口{{ apiBase }}</p>
</div>
<div class="actions">
<el-button color="#1d4ed8" :loading="loading" @click="fetchUsers">
刷新列表
</el-button>
<el-button type="primary" plain @click="openCreate">新建用户</el-button>
</div>
</div>
</el-card>
<el-card class="panel" shadow="never">
<template #header>
<div class="card-header">
<span>用户管理</span>
</div>
</template>
<el-alert
v-if="errorMessage"
type="error"
:closable="false"
show-icon
class="mb"
:title="errorMessage"
/>
<el-table
:data="users"
border
stripe
v-loading="loading"
empty-text="暂无用户"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户">
<template #default="{ row }">
<div class="user-cell" data-testid="user-row">
<div class="name">{{ row.name }}</div>
<div class="email">{{ row.email }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'">
{{ row.is_active ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<el-space>
<el-button
size="small"
@click="openEdit(row)"
:data-testid="`edit-btn-${row.id}`"
>
编辑
</el-button>
<el-button
size="small"
type="warning"
plain
:data-testid="`toggle-btn-${row.id}`"
@click="toggleActive(row)"
>
{{ row.is_active ? '停用' : '启用' }}
</el-button>
</el-space>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" />
</el-table>
<div class="pagination">
<el-pagination
background
layout="prev, pager, next, sizes, total"
:page-sizes="[5, 10, 15, 20]"
:page-size="pagination.perPage"
:total="pagination.total"
:current-page="pagination.page"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'edit' ? '编辑用户' : '新建用户'"
width="520px"
>
<el-form label-position="top">
<el-form-item label="姓名">
<el-input
v-model="form.name"
placeholder="请输入姓名"
data-testid="edit-name"
/>
</el-form-item>
<el-form-item label="邮箱">
<el-input
v-model="form.email"
placeholder="example@ars.com"
data-testid="edit-email"
/>
</el-form-item>
<el-form-item
:label="dialogMode === 'edit' ? '密码(可选)' : '密码'"
>
<el-input
v-model="form.password"
type="password"
show-password
placeholder="不修改则留空"
data-testid="edit-password"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="loading"
@click="saveUser"
data-testid="save-user"
>
保存
</el-button>
</template>
</el-dialog>
</main>
</template>
<style scoped>
.users {
position: relative;
max-width: 1280px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
z-index: 1;
}
.hero {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
}
.hero-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.eyebrow {
margin: 0 0 6px;
font-size: 12px;
color: #6b7280;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 0 0 8px;
font-size: 30px;
color: #0f172a;
}
.lede {
margin: 0 0 8px;
color: #4b5563;
line-height: 1.6;
}
.hint {
margin: 0;
color: #6b7280;
font-size: 13px;
}
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.panel {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.06);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 700;
color: #0f172a;
}
.user-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.name {
font-weight: 600;
color: #0f172a;
}
.email {
font-size: 13px;
color: #6b7280;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.mb {
margin-bottom: 10px;
}
:deep(.el-card__body) {
background: transparent;
}
</style>

245
src/views/WelcomeView.vue Normal file
View File

@@ -0,0 +1,245 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const router = useRouter()
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
const token = ref(localStorage.getItem('ars-token') || '')
const user = ref(
localStorage.getItem('ars-user')
? JSON.parse(localStorage.getItem('ars-user'))
: null
)
const loadingUser = ref(false)
const greeting = computed(
() => user.value?.name || user.value?.email || '访客'
)
const logout = () => {
localStorage.removeItem('ars-token')
localStorage.removeItem('ars-user')
router.replace({ name: 'login' })
}
const fetchMe = async () => {
if (!token.value) return
try {
loadingUser.value = true
const { data } = await axios.get(`${apiBase}/me`, {
headers: { Authorization: `Bearer ${token.value}` },
})
user.value = data
localStorage.setItem('ars-user', JSON.stringify(data))
} catch (err) {
const message =
err.response?.data?.message || '会话失效,请重新登录'
ElMessage.error(message)
logout()
} finally {
loadingUser.value = false
}
}
onMounted(() => {
if (!token.value) {
logout()
return
}
fetchMe()
})
</script>
<template>
<main class="welcome">
<el-card class="hero" shadow="never">
<div class="hero-header">
<div>
<p class="eyebrow">WELCOME</p>
<h1>你好{{ greeting }}</h1>
<p class="lede">
控制台已连接 {{ apiBase }}可以携带 JWT 调用后台接口
</p>
</div>
<div class="actions">
<el-button
color="#1d4ed8"
:loading="loadingUser"
@click="fetchMe"
>
刷新当前用户
</el-button>
<el-button plain type="danger" @click="logout">退出登录</el-button>
</div>
</div>
<div class="stat-row">
<div class="stat">
<p class="stat-label">当前用户</p>
<p class="stat-value">
{{
user ? `${user.name} (${user.email})` : '等待刷新 / 登录'
}}
</p>
</div>
<div class="stat">
<p class="stat-label">JWT</p>
<p class="stat-value mono">
{{ token ? '已获取' : '未获取' }}
</p>
</div>
<div class="stat">
<p class="stat-label">快速入口</p>
<router-link to="/users" class="link-accent">用户列表</router-link>
</div>
</div>
</el-card>
<el-card class="panel" shadow="never">
<template #header>
<div class="card-header">
<span>快速导航</span>
</div>
</template>
<ul class="links">
<li>
<span>接口文档</span>
<code>backend/docs/user/user-api.md</code>
</li>
<li>
<span>下一步</span>
<code>使用 JWT 调用 /me /users 进行联调</code>
</li>
</ul>
</el-card>
</main>
</template>
<style scoped>
.welcome {
position: relative;
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 18px;
z-index: 1;
}
.hero {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
}
.hero-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.eyebrow {
margin: 0 0 6px;
font-size: 12px;
color: #6b7280;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1 {
margin: 0 0 8px;
font-size: 30px;
color: #0f172a;
}
.lede {
margin: 0 0 12px;
color: #4b5563;
line-height: 1.6;
}
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.panel {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.06);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 700;
color: #0f172a;
}
.links {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 12px;
}
.links li {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 12px;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 10px;
color: #111827;
}
.links code {
color: #475569;
font-size: 13px;
word-break: break-all;
text-align: right;
}
.stat-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-top: 16px;
}
.stat {
padding: 12px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.stat-label {
margin: 0 0 4px;
color: #6b7280;
font-size: 13px;
}
.stat-value {
margin: 0;
font-weight: 700;
color: #0f172a;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
}
.link-accent {
color: #2563eb;
font-weight: 600;
}
</style>

83
tests/new-chat.spec.js Normal file
View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ElementPlus from 'element-plus'
import NewChatButton from '../src/components/NewChatButton.vue'
import LoginView from '../src/views/LoginView.vue'
import axios from 'axios'
vi.mock('axios', () => ({
default: {
post: vi.fn(),
},
}))
const mockedAxios = axios
const makeRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'login', component: LoginView },
{ path: '/welcome', name: 'welcome', component: LoginView },
],
})
describe('NewChatButton', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('redirects to login when no token', async () => {
const router = makeRouter()
router.push('/welcome')
await router.isReady()
const wrapper = mount(NewChatButton, {
global: {
plugins: [router, ElementPlus],
},
})
await wrapper.find('[data-testid="new-chat-trigger"]').trigger('click')
await flushPromises()
await wrapper.find('[data-testid="create-session"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('login')
})
it('creates session with Authorization header', async () => {
localStorage.setItem('ars-token', 'jwt-token')
mockedAxios.post.mockResolvedValue({
data: { session_id: 'session-123', session_name: 'Demo' },
})
const router = makeRouter()
router.push('/welcome')
await router.isReady()
const wrapper = mount(NewChatButton, {
global: {
plugins: [router, ElementPlus],
},
})
// 直接调用方法以绕过弹层结构细节
wrapper.vm.sessionName = 'Demo Session'
await wrapper.vm.createSession()
await flushPromises()
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://localhost:8000/api/sessions',
expect.objectContaining({ session_name: 'Demo Session' }),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer jwt-token',
}),
})
)
expect(localStorage.getItem('ars-last-session')).toBe('session-123')
})
})

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ElementPlus from 'element-plus'
import SessionSidebar from '../src/components/SessionSidebar.vue'
import LoginView from '../src/views/LoginView.vue'
import axios from 'axios'
vi.mock('axios', () => ({
default: {
get: vi.fn(),
},
}))
const mockedAxios = axios
const makeRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'login', component: LoginView },
{ path: '/welcome', name: 'welcome', component: LoginView },
],
})
describe('SessionSidebar', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('redirects to login without token', async () => {
const router = makeRouter()
router.push('/welcome')
await router.isReady()
mount(SessionSidebar, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
expect(router.currentRoute.value.name).toBe('login')
})
it('renders sessions and selects one', async () => {
localStorage.setItem('ars-token', 'jwt-token')
mockedAxios.get.mockResolvedValue({
data: {
data: [
{
session_id: 's1',
session_name: 'Demo',
status: 'OPEN',
last_message_preview: 'Hi there',
last_seq: 3,
updated_at: '2025-02-14T10:00:00Z',
},
],
total: 1,
current_page: 1,
per_page: 20,
},
})
const router = makeRouter()
router.push('/welcome')
await router.isReady()
const wrapper = mount(SessionSidebar, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
expect(mockedAxios.get).toHaveBeenCalledWith(
'http://localhost:8000/api/sessions',
expect.any(Object)
)
expect(wrapper.text()).toContain('Demo')
await wrapper.find('.session-item').trigger('click')
expect(localStorage.getItem('ars-current-session')).toBe('s1')
})
})

163
tests/users-view.spec.js Normal file
View File

@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ElementPlus from 'element-plus'
import UsersView from '../src/views/UsersView.vue'
import LoginView from '../src/views/LoginView.vue'
import WelcomeView from '../src/views/WelcomeView.vue'
import axios from 'axios'
vi.mock('axios', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
}))
const mockedAxios = axios
const makeRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'login', component: LoginView },
{ path: '/welcome', name: 'welcome', component: WelcomeView },
{ path: '/users', name: 'users', component: UsersView },
],
})
describe('UsersView', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('redirects to login when token is missing', async () => {
const router = makeRouter()
router.push('/users')
await router.isReady()
mount(UsersView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
expect(router.currentRoute.value.name).toBe('login')
})
it('renders user rows when token exists and API succeeds', async () => {
localStorage.setItem('ars-token', 'jwt-token')
mockedAxios.get.mockResolvedValue({
data: {
data: [
{ id: 1, name: 'Alice', email: 'alice@example.com', is_active: true },
{ id: 2, name: 'Bob', email: 'bob@example.com', is_active: false },
],
current_page: 1,
per_page: 10,
total: 2,
},
})
const router = makeRouter()
router.push('/users')
await router.isReady()
const wrapper = mount(UsersView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
const rows = wrapper.findAll('[data-testid="user-row"]')
expect(rows).toHaveLength(2)
expect(wrapper.text()).toContain('Alice')
expect(wrapper.text()).toContain('bob@example.com')
})
it('deactivates a user via API and updates UI', async () => {
localStorage.setItem('ars-token', 'jwt-token')
mockedAxios.get.mockResolvedValue({
data: {
data: [{ id: 1, name: 'Alice', email: 'alice@example.com', is_active: true }],
current_page: 1,
per_page: 10,
total: 1,
},
})
mockedAxios.post.mockResolvedValue({ data: { id: 1, is_active: false } })
const router = makeRouter()
router.push('/users')
await router.isReady()
const wrapper = mount(UsersView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await wrapper.find('[data-testid="toggle-btn-1"]').trigger('click')
await flushPromises()
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://localhost:8000/api/users/1/deactivate',
{},
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer jwt-token' }),
})
)
expect(wrapper.text()).toContain('停用')
})
it('edits a user and sends PUT payload', async () => {
localStorage.setItem('ars-token', 'jwt-token')
mockedAxios.get.mockResolvedValue({
data: {
data: [{ id: 1, name: 'Alice', email: 'alice@example.com', is_active: true }],
current_page: 1,
per_page: 10,
total: 1,
},
})
mockedAxios.put.mockResolvedValue({
data: { id: 1, name: 'Alice Updated', email: 'alice@new.com', is_active: true },
})
const router = makeRouter()
router.push('/users')
await router.isReady()
const wrapper = mount(UsersView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await wrapper.find('[data-testid="edit-btn-1"]').trigger('click')
await wrapper.find('[data-testid="edit-name"]').setValue('Alice Updated')
await wrapper.find('[data-testid="edit-email"]').setValue('alice@new.com')
await wrapper.find('[data-testid="save-user"]').trigger('click')
await flushPromises()
expect(mockedAxios.put).toHaveBeenCalledWith(
'http://localhost:8000/api/users/1',
expect.objectContaining({
name: 'Alice Updated',
email: 'alice@new.com',
}),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer jwt-token' }),
})
)
expect(wrapper.text()).toContain('Alice Updated')
expect(wrapper.text()).toContain('alice@new.com')
})
})

12
vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: 'vitest.setup.js',
},
})

1
vitest.setup.js Normal file
View File

@@ -0,0 +1 @@
// Vitest setup placeholder. Add global mocks/helpers here if needed.