9 changed files with 484 additions and 22 deletions
@ -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> |
<template> |
||||
<div> |
<div class="p-8 flex flex-col gap-4 max-w-md"> |
||||
dsa |
<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> |
</div> |
||||
</template> |
</template> |
||||
@ -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 }, |
||||
|
}; |
||||
|
}); |
||||
@ -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; |
||||
|
} |
||||
|
|
||||
|
|
||||
@ -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** - 本会话内顺序执行 |
||||
|
|
||||
|
选择哪种方式? |
||||
Binary file not shown.
Loading…
Reference in new issue