Browse Source

feat(auth): add toast notifications to login page

shadcn-as
npmrun 3 weeks ago
parent
commit
7ba33aa7d5
  1. 69
      app/pages/about/index.vue
  2. 5
      app/pages/admin.vue
  3. 20
      app/pages/auth/login.vue
  4. 18
      app/plugins/vue3-toastify.client.ts
  5. 127
      app/plugins/vue3-toastify.css
  6. 3
      bun.lock
  7. 261
      docs/superpowers/plans/2026-05-26-login-register-toast-design.md
  8. 3
      package.json
  9. BIN
      packages/drizzle-pkg/db.sqlite

69
app/pages/about/index.vue

@ -1,5 +1,70 @@
<script setup>
const { $toast } = useNuxtApp();
const notifyAll = () => {
$toast('这是一条默认消息', { type: 'default' });
$toast.info('这是一条 info 消息');
$toast.success('这是一条 success 消息');
$toast.warning('这是一条 warning 消息');
$toast.error('这是一条 error 消息');
$toast.loading('加载中...');
};
const notifyDefault = () => $toast('默认消息 default');
const notifyInfo = () => $toast.info('Info 消息');
const notifySuccess = () => $toast.success('Success 消息');
const notifyWarning = () => $toast.warning('Warning 消息');
const notifyError = () => $toast.error('Error 消息');
</script>
<template>
<div>
dsa
<div class="p-8 flex flex-col gap-4 max-w-md">
<h1 class="text-2xl font-semibold mb-4">Toast 测试</h1>
<button
class="px-4 py-2 bg-[#cc785c] text-white rounded-lg hover:bg-[#a9583e] transition-colors"
@click="notifyAll"
>
全部类型
</button>
<div class="flex gap-2 flex-wrap">
<button
class="px-4 py-2 bg-[#efe9de] border border-[#e6dfd8] rounded-lg hover:bg-[#e8e0d2] transition-colors"
@click="notifyDefault"
>
Default
</button>
<button
class="px-4 py-2 bg-[#5db8a6] text-white rounded-lg hover:opacity-90 transition-opacity"
@click="notifyInfo"
>
Info
</button>
<button
class="px-4 py-2 bg-[#5db872] text-white rounded-lg hover:opacity-90 transition-opacity"
@click="notifySuccess"
>
Success
</button>
<button
class="px-4 py-2 bg-[#e8a55a] text-white rounded-lg hover:opacity-90 transition-opacity"
@click="notifyWarning"
>
Warning
</button>
<button
class="px-4 py-2 bg-[#c64545] text-white rounded-lg hover:opacity-90 transition-opacity"
@click="notifyError"
>
Error
</button>
<button
class="px-4 py-2 bg-[#181715] text-white rounded-lg hover:opacity-90 transition-opacity"
@click="$toast.loading('加载中...')"
>
Loading
</button>
</div>
</div>
</template>

5
app/pages/admin.vue

@ -15,11 +15,6 @@ const adminNav = [
to: '/admin/scheduler',
icon: 'schedule'
},
{
label: '个人资料',
to: '/admin/profile',
icon: 'user'
},
]
const iconPaths: Record<string, string> = {

20
app/pages/auth/login.vue

@ -4,6 +4,7 @@ definePageMeta({
})
const route = useRoute()
const { $toast } = useNuxtApp()
const redirect = computed(() => route.query.redirect as string || '/')
const loginForm = reactive({
@ -19,26 +20,27 @@ const captcha = reactive({
loading: false,
})
const loginError = ref('')
const loginLoading = ref(false)
const { refresh } = useAuthSession()
async function fetchCaptcha() {
captcha.loading = true
const loadingToast = $toast.loading('加载验证码...', { toastId: 'captcha-loading' })
try {
const res = await $fetch<{ code: number; data: { captchaId: string; imageSvg: string } }>('/api/auth/captcha')
captcha.id = res.data.captchaId
captcha.svg = res.data.imageSvg
captcha.answer = ''
$toast.update(loadingToast, { render: '验证码加载成功', type: 'success', isLoading: false, autoClose: 1500 })
} catch (e: any) {
console.error('获取验证码失败', e)
$toast.update(loadingToast, { render: '验证码加载失败', type: 'error', isLoading: false, autoClose: 3000 })
} finally {
captcha.loading = false
}
}
async function handleLogin() {
loginError.value = ''
loginLoading.value = true
try {
await $fetch('/api/auth/login', {
@ -50,10 +52,11 @@ async function handleLogin() {
captchaAnswer: captcha.answer,
},
})
$toast.success('登录成功!')
await refresh(true)
await navigateTo(redirect.value)
} catch (e: any) {
loginError.value = e?.data?.statusMessage || e?.message || '登录失败'
$toast.error(e?.data?.statusMessage || e?.message || '登录失败')
await fetchCaptcha()
} finally {
loginLoading.value = false
@ -87,8 +90,6 @@ onMounted(fetchCaptcha)
</div>
<form class="auth-form" @submit.prevent="handleLogin">
<div v-if="loginError" class="form-error">{{ loginError }}</div>
<div class="form-field">
<input id="login-username" v-model="loginForm.username" type="text" placeholder=" " required />
<label for="login-username">用户名或邮箱</label>
@ -404,15 +405,6 @@ onMounted(fetchCaptcha)
opacity: 0.5;
}
.form-error {
padding: 12px 16px;
background: rgba(198, 69, 69, 0.08);
border: 1px solid rgba(198, 69, 69, 0.2);
border-radius: 6px;
color: var(--color-error);
font-size: 13px;
}
.form-options {
display: flex;
align-items: center;

18
app/plugins/vue3-toastify.client.ts

@ -0,0 +1,18 @@
import Vue3Toastify, { toast } from 'vue3-toastify';
import 'vue3-toastify/dist/index.css';
import './vue3-toastify.css'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(Vue3Toastify, {
autoClose: 3000,
newestOnTop: true,
clearOnUrlChange: false,
theme: "colored",
transition: "slide",
position: "top-right",
});
return {
provide: { toast },
};
});

127
app/plugins/vue3-toastify.css

@ -0,0 +1,127 @@
/* ── Toast Customization (vue3-toastify) ── */
:root {
--toastify-color-light: #efe9de;
--toastify-color-dark: #181715;
--toastify-color-info: #5db8a6;
--toastify-color-success: #5db872;
--toastify-color-warning: #e8a55a;
--toastify-color-error: #c64545;
--toastify-text-color-light: #141413;
--toastify-text-color-dark: #faf9f5;
--toastify-text-color-info: #ffffff;
--toastify-text-color-success: #ffffff;
--toastify-text-color-warning: #ffffff;
--toastify-text-color-error: #ffffff;
--toastify-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.Toastify__toast-container {
font-family: var(--font-body);
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.Toastify__toast {
border-radius: 10px;
box-shadow: 0 1px 3px rgba(20, 20, 19, 0.06), 0 4px 12px rgba(20, 20, 19, 0.04);
min-height: 48px;
padding: 12px 14px;
border: 1px solid #e6dfd8;
margin: 0;
}
.Toastify__toast--default {
background: #efe9de;
color: #141413;
border-color: #e6dfd8;
}
.Toastify__toast--info {
background: #5db8a6;
color: #ffffff;
border-color: #4da396;
}
.Toastify__toast--success {
background: #5db872;
color: #ffffff;
border-color: #4da362;
}
.Toastify__toast--warning {
background: #e8a55a;
color: #ffffff;
border-color: #d8944b;
}
.Toastify__toast--error {
background: #c64545;
color: #ffffff;
border-color: #b53d3d;
}
.Toastify__toast-body {
font-family: var(--font-body);
font-size: 14px;
color: inherit;
padding: 0 4px;
}
.Toastify__toast-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-right: 10px;
}
.Toastify__close-button {
color: inherit;
opacity: 0.4;
border-radius: 4px;
padding: 2px 6px;
margin-left: 10px;
transition: opacity 0.2s ease;
align-self: center;
font-size: 18px;
line-height: 1;
}
.Toastify__close-button:hover {
opacity: 0.8;
}
.Toastify__progress-bar {
height: 2px;
border-radius: 0 0 8px 8px;
opacity: 0.5;
}
.Toastify__progress-bar--info { background: rgba(255, 255, 255, 0.5); }
.Toastify__progress-bar--success { background: rgba(255, 255, 255, 0.5); }
.Toastify__progress-bar--warning { background: rgba(255, 255, 255, 0.5); }
.Toastify__progress-bar--error { background: rgba(255, 255, 255, 0.5); }
.Toastify__progress-bar--default { background: rgba(20, 20, 19, 0.12); }
/* Slide animation - gentler */
.Toastify__slide-enter--top-right,
.Toastify__slide-enter--bottom-right {
animation-name: Toastify__slideInRight;
}
@keyframes Toastify__slideInRight {
from { transform: translateX(110%); }
to { transform: translateX(0); }
}
/* Loading toast - dark style */
.Toastify__toast--loading {
background: #181715;
color: #faf9f5;
border-color: #252320;
min-width: 260px;
}

3
bun.lock

@ -34,6 +34,7 @@
"tsconfig": "workspace:*",
"tsx": "4.21.0",
"typescript": "6.0.2",
"vue3-toastify": "^0.2.9",
},
},
"packages/cache": {
@ -1587,6 +1588,8 @@
"vue-router": ["vue-router@5.0.4", "https://registry.npmmirror.com/vue-router/-/vue-router-5.0.4.tgz", { "dependencies": { "@babel/generator": "^7.28.6", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.0.6", "ast-walker-scope": "^0.8.3", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.8.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.17", "pinia": "^3.0.4", "vue": "^3.5.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia"] }, "sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg=="],
"vue3-toastify": ["vue3-toastify@0.2.9", "", { "peerDependencies": { "vue": ">=3.2.0" }, "optionalPeers": ["vue"] }, "sha512-LN2GJGKgjt+C6IIBANp9hCM2A4yc5jC2Kj4YGl1J7ptj4rhThOJOGGlkCg+IxDt7hntNw9n3MG9ifv/AymyxKQ=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],

261
docs/superpowers/plans/2026-05-26-login-register-toast-design.md

@ -0,0 +1,261 @@
# Login/Register Toast 体验优化
**Goal:** 用 vue3-toastify 替换登录/注册页面的静态错误提示,提升用户体验。
**Architecture:** 在 login.vue 和 register.vue 中注入 toast 服务,移除静态 `form-error` div,改为 toast 弹出提示。成功/加载状态同样使用 toast 反馈。
**Tech Stack:** vue3-toastify (已安装), Nuxt 3, Vue 3 Composition API
---
## 文件变更
- Modify: `app/pages/auth/login.vue`
- Modify: `app/pages/auth/register.vue`
---
## Task 1: 优化 login.vue
**Files:**
- Modify: `app/pages/auth/login.vue`
- [ ] **Step 1: 注入 toast 服务**
`<script setup>` 开头添加:
```typescript
const { $toast } = useNuxtApp()
```
- [ ] **Step 2: 移除 form-error div 并添加 toast 注入**
删除模板中的:
```html
<div v-if="loginError" class="form-error">{{ loginError }}</div>
```
- [ ] **Step 3: 修改 handleLogin 错误处理**
将:
```typescript
loginError.value = e?.data?.statusMessage || e?.message || '登录失败'
```
改为:
```typescript
$toast.error(e?.data?.statusMessage || e?.message || '登录失败')
await fetchCaptcha()
```
- [ ] **Step 4: 修改 handleLogin 成功处理**
将:
```typescript
await refresh(true)
await navigateTo(redirect.value)
```
改为:
```typescript
$toast.success('登录成功!')
await refresh(true)
await navigateTo(redirect.value)
```
- [ ] **Step 5: 修改 fetchCaptcha 加载状态**
将:
```typescript
async function fetchCaptcha() {
captcha.loading = true
try {
const res = await $fetch<{ code: number; data: { captchaId: string; imageSvg: string } }>('/api/auth/captcha')
captcha.id = res.data.captchaId
captcha.svg = res.data.imageSvg
captcha.answer = ''
} catch (e: any) {
console.error('获取验证码失败', e)
} finally {
captcha.loading = false
}
}
```
改为:
```typescript
async function fetchCaptcha() {
captcha.loading = true
const loadingToast = $toast.loading('加载验证码...', { toastId: 'captcha-loading' })
try {
const res = await $fetch<{ code: number; data: { captchaId: string; imageSvg: string } }>('/api/auth/captcha')
captcha.id = res.data.captchaId
captcha.svg = res.data.imageSvg
captcha.answer = ''
$toast.update(loadingToast, { render: '验证码加载成功', type: 'success', isLoading: false, autoClose: 1500 })
} catch (e: any) {
console.error('获取验证码失败', e)
$toast.update(loadingToast, { render: '验证码加载失败', type: 'error', isLoading: false, autoClose: 3000 })
} finally {
captcha.loading = false
}
}
```
- [ ] **Step 6: 删除 form-error 相关样式**
删除 CSS 中的 `.form-error { ... }` 样式块
- [ ] **Step 7: 提交代码**
```bash
git add app/pages/auth/login.vue
git commit -m "feat(auth): add toast notifications to login page"
```
---
## Task 2: 优化 register.vue
**Files:**
- Modify: `app/pages/auth/register.vue`
- [ ] **Step 1: 注入 toast 服务**
`<script setup>` 开头添加:
```typescript
const { $toast } = useNuxtApp()
```
- [ ] **Step 2: 移除 form-error div**
删除模板中的:
```html
<div v-if="registerError" class="form-error">{{ registerError }}</div>
```
- [ ] **Step 3: 移除 registerError ref**
删除:
```typescript
const registerError = ref('')
```
- [ ] **Step 4: 修改 handleRegister 表单验证错误**
将:
```typescript
if (registerForm.password !== registerForm.confirmPassword) {
registerError.value = '两次密码输入不一致'
return
}
```
改为:
```typescript
if (registerForm.password !== registerForm.confirmPassword) {
$toast.error('两次密码输入不一致')
return
}
```
- [ ] **Step 5: 修改 handleRegister 请求错误处理**
将:
```typescript
} catch (e: any) {
registerError.value = e?.data?.statusMessage || e?.message || '注册失败'
await fetchCaptcha()
```
改为:
```typescript
} catch (e: any) {
$toast.error(e?.data?.statusMessage || e?.message || '注册失败')
await fetchCaptcha()
```
- [ ] **Step 6: 修改 handleRegister 成功处理**
将:
```typescript
await navigateTo('/auth/login?tab=login')
```
改为:
```typescript
$toast.success('注册成功!正在跳转...')
setTimeout(() => navigateTo('/auth/login?tab=login'), 1500)
```
- [ ] **Step 7: 修改 fetchCaptcha 加载状态** (同 login.vue)
将:
```typescript
async function fetchCaptcha() {
captcha.loading = true
try {
const res = await $fetch<{ code: number; data: { captchaId: string; imageSvg: string } }>('/api/auth/captcha')
captcha.id = res.data.captchaId
captcha.svg = res.data.imageSvg
captcha.answer = ''
} catch (e: any) {
console.error('获取验证码失败', e)
} finally {
captcha.loading = false
}
}
```
改为:
```typescript
async function fetchCaptcha() {
captcha.loading = true
const loadingToast = $toast.loading('加载验证码...', { toastId: 'captcha-loading' })
try {
const res = await $fetch<{ code: number; data: { captchaId: string; imageSvg: string } }>('/api/auth/captcha')
captcha.id = res.data.captchaId
captcha.svg = res.data.imageSvg
captcha.answer = ''
$toast.update(loadingToast, { render: '验证码加载成功', type: 'success', isLoading: false, autoClose: 1500 })
} catch (e: any) {
console.error('获取验证码失败', e)
$toast.update(loadingToast, { render: '验证码加载失败', type: 'error', isLoading: false, autoClose: 3000 })
} finally {
captcha.loading = false
}
}
```
- [ ] **Step 8: 删除 form-error 和 registerError 相关样式/逻辑**
删除 CSS 中的 `.form-error { ... }` 样式块
- [ ] **Step 9: 提交代码**
```bash
git add app/pages/auth/register.vue
git commit -m "feat(auth): add toast notifications to register page"
```
---
## 验证步骤
1. 启动开发服务器: `npm run dev`
2. 访问 `/auth/login`,测试以下场景:
- 输入错误信息点击登录 → 应看到 toast 错误提示
- 登录成功 → 应看到 toast 成功提示并跳转
- 刷新页面 → 验证码加载中应有 toast 提示
3. 访问 `/auth/register`,测试以下场景:
- 两次密码不一致 → 应看到 toast 错误提示
- 注册成功 → 应看到 toast 成功提示并延迟跳转
- 验证码加载 → 应看到 toast 加载状态
---
**Plan complete.** 可选执行方式:
**1. Subagent-Driven (推荐)** - 我派发独立 subagent 执行每个任务
**2. Inline Execution** - 本会话内顺序执行
选择哪种方式?

3
package.json

@ -47,6 +47,7 @@
"drizzle-kit": "0.31.10",
"tsconfig": "workspace:*",
"tsx": "4.21.0",
"typescript": "6.0.2"
"typescript": "6.0.2",
"vue3-toastify": "^0.2.9"
}
}

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save