Vue 3 纯小白入门指南
一、Vue 3 是什么?
Vue 3 是一个用于构建用户界面的 JavaScript 框架。简单来说,它让你能用更简单的方式创建网页应用。
类比理解:
- HTML:就像房子的结构(墙、门、窗)
- CSS:就像房子的装修(颜色、样式)
- JavaScript:就像房子的功能(开关灯、开门)
- Vue 3:就像智能家居系统,让所有功能协调工作
二、学习前准备
2.1 需要的基础知识
- HTML:了解基本标签(div、p、button、input等)
- CSS:了解基本样式(颜色、大小、布局)
- JavaScript:了解变量、函数、数组、对象等基础概念
2.2 开发工具准备
- 浏览器:Chrome 或 Edge(最新版)
- 代码编辑器:推荐 VS Code(免费)
- Node.js:用于运行 JavaScript(下载地址:nodejs.org)
三、最快速的上手方式
3.1 在线体验(无需安装)
直接在浏览器中体验 Vue 3:
- 打开 Vue SFC Playground
- 在左侧写代码,右侧看效果
- 这是最快了解 Vue 的方式
3.2 本地快速开始
<!-- 1. 创建一个 HTML 文件,比如 index.html -->
<!DOCTYPE html>
<html>
<head>
<title>我的第一个 Vue 应用</title>
<!-- 引入 Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<!-- Vue 模板 -->
<h1>{{ message }}</h1>
<button @click="count++">点击了 {{ count }} 次</button>
</div>
<script>
// 创建 Vue 应用
const { createApp } = Vue
createApp({
// 数据
data() {
return {
message: 'Hello Vue 3!',
count: 0
}
}
}).mount('#app') // 挂载到 id="app" 的元素
</script>
</body>
</html>
运行步骤:
- 复制上面代码到
index.html - 用浏览器打开这个文件
- 看到效果:显示 "Hello Vue 3!" 和一个按钮
四、Vue 3 核心概念(小白版)
4.1 声明式渲染
<!-- 传统 JavaScript -->
<div id="old-way"></div>
<script>
const element = document.getElementById('old-way')
element.textContent = 'Hello World'
</script>
<!-- Vue 3 方式 -->
<div id="app">{{ message }}</div>
<script>
// 数据变化,界面自动更新
data() {
return {
message: 'Hello Vue 3'
}
}
</script>
4.2 响应式数据
// 普通变量
let count = 0
count = 1 // 界面不会自动更新
// Vue 响应式数据
data() {
return {
count: 0 // 当 count 改变时,界面自动更新
}
}
4.3 指令(Directives)
指令是带有 v-前缀的特殊属性:
<!-- v-if:条件显示 -->
<p v-if="isShow">我会根据条件显示/隐藏</p>
<!-- v-for:循环列表 -->
<ul>
<li v-for="item in items">{{ item.name }}</li>
</ul>
<!-- v-on:事件监听(简写 @) -->
<button @click="handleClick">点击我</button>
<!-- v-bind:属性绑定(简写 :) -->
<img :src="imageUrl" :alt="imageAlt">
五、第一个完整的 Vue 3 应用
5.1 待办事项应用(ToDo List)
<!DOCTYPE html>
<html>
<head>
<title>我的待办事项</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
body { font-family: Arial; max-width: 500px; margin: 50px auto; }
.todo-item { padding: 10px; border-bottom: 1px solid #eee; }
.completed { text-decoration: line-through; color: #888; }
input, button { padding: 8px; margin: 5px; }
</style>
</head>
<body>
<div id="app">
<h1>📝 我的待办事项</h1>
<!-- 添加新事项 -->
<div>
<input
v-model="newTodo"
@keyup.enter="addTodo"
placeholder="输入待办事项,按回车添加"
>
<button @click="addTodo">添加</button>
</div>
<!-- 待办事项列表 -->
<div v-if="todos.length === 0" style="color: #888; margin-top: 20px;">
还没有待办事项,添加一个吧!
</div>
<div v-else>
<div v-for="(todo, index) in todos" :key="index" class="todo-item">
<input type="checkbox" v-model="todo.completed">
<span :class="{ completed: todo.completed }">
{{ todo.text }}
</span>
<button @click="removeTodo(index)" style="float: right;">删除</button>
</div>
<!-- 统计 -->
<div style="margin-top: 20px;">
总计:{{ todos.length }} 项 |
已完成:{{ completedCount }} 项 |
<button @click="clearCompleted">清除已完成</button>
</div>
</div>
</div>
<script>
const { createApp } = Vue
createApp({
data() {
return {
newTodo: '', // 输入框的内容
todos: [ // 待办事项列表
{ text: '学习 Vue 3', completed: false },
{ text: '写一个待办应用', completed: true },
{ text: '分享给朋友', completed: false }
]
}
},
// 计算属性(自动计算的值)
computed: {
completedCount() {
return this.todos.filter(todo => todo.completed).length
}
},
// 方法(函数)
methods: {
addTodo() {
if (this.newTodo.trim() === '') return
this.todos.push({
text: this.newTodo,
completed: false
})
this.newTodo = '' // 清空输入框
},
removeTodo(index) {
this.todos.splice(index, 1)
},
clearCompleted() {
this.todos = this.todos.filter(todo => !todo.completed)
}
}
}).mount('#app')
</script>
</body>
</html>
功能说明:
- 添加待办事项
- 标记完成/未完成
- 删除事项
- 统计数量
- 清除已完成事项
六、Vue 3 的两种写法
6.1 选项式 API(适合初学者)
// 像填表格一样,把代码写在对应的位置
export default {
// 数据
data() {
return {
count: 0
}
},
// 方法
methods: {
increment() {
this.count++
}
},
// 计算属性
computed: {
doubleCount() {
return this.count * 2
}
},
// 生命周期
mounted() {
console.log('组件加载完成')
}
}
6.2 组合式 API(Vue 3 新特性)
// 像写普通 JavaScript 函数
import { ref, computed, onMounted } from 'vue'
export default {
setup() {
// 数据
const count = ref(0)
// 方法
function increment() {
count.value++
}
// 计算属性
const doubleCount = computed(() => count.value * 2)
// 生命周期
onMounted(() => {
console.log('组件加载完成')
})
// 返回给模板使用
return {
count,
increment,
doubleCount
}
}
}
Vue 3 组合式 API 核心语法详解
我将用最简单的示例讲解 Vue 3 组合式 API 中的核心语法。每个概念都有代码示例和实际应用场景。
一、基础环境设置
首先,创建一个 Vue 3 项目(使用 Vite):
# 创建项目
npm create vue@latest my-vue-app
# 选择:TypeScript、Pinia、Router、ESLint
# 进入项目
cd my-vue-app
npm install
npm run dev
二、组合式 API 基础结构
<!-- App.vue -->
<template>
<div>
<!-- 所有模板语法都在这里 -->
</div>
</template>
<script setup lang="ts">
// 组合式 API 代码写在这里
// 不需要 export default,直接写逻辑
</script>
<style scoped>
/* 组件样式 */
</style>
三、模板语法基础
3.1 插值表达式 {{ }}
<template>
<div>
<!-- 显示文本 -->
<h1>{{ title }}</h1>
<p>{{ count }}</p>
<!-- 表达式 -->
<p>{{ count + 1 }}</p>
<p>{{ title.toUpperCase() }}</p>
<!-- 三元表达式 -->
<p>{{ isActive ? '活跃' : '未活跃' }}</p>
<!-- 调用函数 -->
<p>{{ formatPrice(price) }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// 响应式数据
const title = ref('Vue 3 教程')
const count = ref(0)
const isActive = ref(true)
const price = ref(99.99)
// 函数
const formatPrice = (value: number) => {
return `¥${value.toFixed(2)}`
}
</script>
四、v-if、v-else-if、v-else条件渲染
<template>
<div>
<!-- 基本条件 -->
<p v-if="count > 0">计数大于0</p>
<p v-else-if="count === 0">计数等于0</p>
<p v-else>计数小于0</p>
<!-- 多个元素的条件渲染 -->
<template v-if="isLoggedIn">
<h2>欢迎回来!</h2>
<p>您已登录</p>
</template>
<template v-else>
<h2>请登录</h2>
<button @click="login">登录</button>
</template>
<!-- 复杂条件示例 -->
<div class="user-info">
<div v-if="user.role === 'admin'">
<h3>管理员面板</h3>
<button>删除用户</button>
<button>修改设置</button>
</div>
<div v-else-if="user.role === 'editor'">
<h3>编辑者面板</h3>
<button>发布文章</button>
<button>编辑内容</button>
</div>
<div v-else>
<h3>访客面板</h3>
<p>只能查看内容</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(5)
const isLoggedIn = ref(false)
const user = ref({
name: '张三',
role: 'admin', // 可以改为 'editor' 或 'guest' 测试
age: 25
})
const login = () => {
isLoggedIn.value = true
console.log('用户已登录')
}
</script>
<style scoped>
.user-info {
margin-top: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
</style>
v-if 是条件渲染,切换时销毁/创建元素;v-show 是条件显示,切换时只改 CSS display 属性。v-if 初始渲染开销小但切换成本高,v-show 初始渲染开销大但切换成本低。
五、v-for列表渲染
<template>
<div>
<!-- 基本数组循环 -->
<h3>用户列表</h3>
<ul>
<li v-for="(user, index) in users" :key="user.id">
{{ index + 1 }}. {{ user.name }} - {{ user.age }}岁
</li>
</ul>
<!-- 循环对象 -->
<h3>用户信息</h3>
<ul>
<li v-for="(value, key, index) in userInfo" :key="key">
{{ index + 1 }}. {{ key }}: {{ value }}
</li>
</ul>
<!-- 循环数字范围 -->
<h3>数字循环</h3>
<span v-for="n in 5" :key="n" style="margin: 0 5px;">{{ n }}</span>
<!-- 带条件的循环 -->
<h3>成年用户</h3>
<ul>
<li v-for="user in adultUsers" :key="user.id">
{{ user.name }} ({{ user.age }}岁)
</li>
</ul>
<!-- 循环嵌套 -->
<h3>分组用户</h3>
<div v-for="group in groupedUsers" :key="group.groupName">
<h4>{{ group.groupName }}</h4>
<ul>
<li v-for="user in group.members" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// 数组数据
const users = ref([
{ id: 1, name: '张三', age: 25 },
{ id: 2, name: '李四', age: 30 },
{ id: 3, name: '王五', age: 17 },
{ id: 4, name: '赵六', age: 22 }
])
// 对象数据
const userInfo = ref({
name: '张三',
age: 25,
email: 'zhangsan@example.com',
city: '北京'
})
// 计算成年用户
const adultUsers = computed(() => {
return users.value.filter(user => user.age >= 18)
})
// 分组数据
const groupedUsers = ref([
{
groupName: '开发组',
members: [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' }
]
},
{
groupName: '设计组',
members: [
{ id: 3, name: '王五' },
{ id: 4, name: '赵六' }
]
}
])
</script>
<style scoped>
</style>
六、:class动态类绑定
<template>
<div>
<!-- 1. 对象语法(最常用) -->
<div :class="{ active: isActive, 'text-danger': hasError }">
对象语法:根据条件添加类
</div>
<!-- 2. 数组语法 -->
<div :class="[activeClass, errorClass]">
数组语法:绑定多个类
</div>
<!-- 3. 数组和对象混合 -->
<div :class="[{ active: isActive }, 'base-class', errorClass]">
混合语法
</div>
<!-- 4. 计算属性返回类 -->
<div :class="computedClasses">
计算属性返回的类
</div>
<!-- 5. 实际应用:导航菜单 -->
<div class="nav-menu">
<button
v-for="item in menuItems"
:key="item.id"
:class="{ 'nav-button': true, 'active': activeMenu === item.id }"
@click="activeMenu = item.id"
>
{{ item.name }}
</button>
</div>
<!-- 6. 实际应用:消息提示 -->
<div class="messages">
<div
v-for="msg in messages"
:key="msg.id"
class="message"
:class="msg.type"
>
{{ msg.text }}
</div>
</div>
<!-- 7. 实际应用:表单验证样式 -->
<div class="form-group">
<input
v-model="email"
type="email"
placeholder="请输入邮箱"
:class="{
'form-input': true,
'valid': isValid,
'invalid': !isValid && email.length > 0
}"
/>
<p v-if="!isValid && email.length > 0" class="error-text">
请输入有效的邮箱地址
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// 1. 对象语法
const isActive = ref(true)
const hasError = ref(false)
// 2. 数组语法
const activeClass = ref('active')
const errorClass = ref('text-danger')
// 3. 计算属性返回类
const computedClasses = computed(() => {
return {
'highlight': isActive.value,
'warning': hasError.value,
'rounded': true // 总是添加的类
}
})
// 4. 导航菜单
const activeMenu = ref(1)
const menuItems = ref([
{ id: 1, name: '首页' },
{ id: 2, name: '产品' },
{ id: 3, name: '关于我们' },
{ id: 4, name: '联系' }
])
// 5. 消息提示
const messages = ref([
{ id: 1, text: '操作成功', type: 'success' },
{ id: 2, text: '警告信息', type: 'warning' },
{ id: 3, text: '错误信息', type: 'error' }
])
// 6. 表单验证
const email = ref('')
const isValid = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+.[^\s@]+$/
return emailRegex.test(email.value)
})
</script>
<style scoped>
/* 基础样式 */
.active {
background-color: #007bff;
color: white;
padding: 10px;
}
.text-danger {
color: #dc3545;
}
.base-class {
padding: 10px;
margin: 10px 0;
}
.highlight {
background-color: #ffeb3b;
padding: 10px;
}
.warning {
border: 2px solid #ff9800;
}
.rounded {
border-radius: 5px;
}
/* 导航菜单样式 */
.nav-menu {
display: flex;
gap: 10px;
margin: 20px 0;
}
.nav-button {
padding: 10px 20px;
border: none;
background-color: #f0f0f0;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
}
.nav-button:hover {
background-color: #e0e0e0;
}
.nav-button.active {
background-color: #007bff;
color: white;
}
/* 消息提示样式 */
.messages {
margin: 20px 0;
}
.message {
padding: 10px;
margin: 5px 0;
border-radius: 4px;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* 表单样式 */
.form-group {
margin: 20px 0;
}
.form-input {
padding: 10px;
width: 300px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-input:focus {
outline: none;
border-color: #007bff;
}
.form-input.valid {
border-color: #28a745;
}
.form-input.invalid {
border-color: #dc3545;
}
.error-text {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
}
</style>
七、计算属性 computed
<template>
<div>
<h1>购物车示例</h1>
<!-- 基本计算属性 -->
<div class="cart-summary">
<h3>购物车总览</h3>
<p>商品数量: {{ totalQuantity }}</p>
<p>商品总价: ¥{{ totalPrice.toFixed(2) }}</p>
<p>折扣: {{ discount * 100 }}%</p>
<p>折后价格: ¥{{ discountedPrice.toFixed(2) }}</p>
<p>运费: ¥{{ shippingFee.toFixed(2) }}</p>
<p class="total">实付款: ¥{{ finalPrice.toFixed(2) }}</p>
</div>
<!-- 购物车列表 -->
<div class="cart-items">
<h3>购物车商品</h3>
<div v-for="item in cartItems" :key="item.id" class="cart-item">
<div class="item-info">
<span class="item-name">{{ item.name }}</span>
<span class="item-price">¥{{ item.price.toFixed(2) }}</span>
</div>
<div class="item-controls">
<button @click="decreaseQuantity(item.id)">-</button>
<span class="quantity">{{ item.quantity }}</span>
<button @click="increaseQuantity(item.id)">+</button>
<button @click="removeItem(item.id)" class="remove-btn">删除</button>
</div>
<div class="item-total">
小计: ¥{{ (item.price * item.quantity).toFixed(2) }}
</div>
</div>
</div>
<!-- 优惠券应用 -->
<div class="coupon-section">
<h3>优惠券</h3>
<div v-for="coupon in availableCoupons" :key="coupon.id">
<input
type="radio"
:id="'coupon-' + coupon.id"
:value="coupon.id"
v-model="selectedCouponId"
>
<label :for="'coupon-' + coupon.id">
{{ coupon.name }} - 减¥{{ coupon.discount }}
</label>
</div>
<p v-if="selectedCoupon">已选择: {{ selectedCoupon.name }}</p>
</div>
<!-- 商品过滤 -->
<div class="filter-section">
<h3>商品筛选</h3>
<input v-model="searchText" placeholder="搜索商品名称">
<p>找到 {{ filteredItems.length }} 个商品</p>
<div v-for="item in filteredItems" :key="item.id" class="product">
{{ item.name }} - ¥{{ item.price }}
<button @click="addToCart(item)">加入购物车</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// 购物车数据
const cartItems = ref([
{ id: 1, name: 'iPhone 13', price: 5999, quantity: 1 },
{ id: 2, name: 'MacBook Pro', price: 12999, quantity: 1 },
{ id: 3, name: 'AirPods Pro', price: 1999, quantity: 2 },
{ id: 4, name: 'iPad Air', price: 4799, quantity: 1 }
])
// 1. 基本计算属性:商品总数量
const totalQuantity = computed(() => {
return cartItems.value.reduce((total, item) => total + item.quantity, 0)
})
// 2. 基本计算属性:商品总价
const totalPrice = computed(() => {
return cartItems.value.reduce((total, item) => {
return total + (item.price * item.quantity)
}, 0)
})
// 3. 折扣计算:根据总价自动计算折扣
const discount = computed(() => {
if (totalPrice.value > 10000) {
return 0.1 // 10% 折扣
} else if (totalPrice.value > 5000) {
return 0.05 // 5% 折扣
}
return 0 // 无折扣
})
// 4. 折后价格
const discountedPrice = computed(() => {
return totalPrice.value * (1 - discount.value)
})
// 5. 运费计算:满200免运费
const shippingFee = computed(() => {
if (discountedPrice.value > 200) {
return 0
}
return 15
})
// 6. 最终价格
const finalPrice = computed(() => {
return discountedPrice.value + shippingFee.value - couponDiscount.value
})
// 7. 优惠券相关
const availableCoupons = ref([
{ id: 1, name: '新人券', discount: 50 },
{ id: 2, name: '满减券', discount: 100 },
{ id: 3, name: '会员券', discount: 200 }
])
const selectedCouponId = ref<number | null>(null)
// 8. 计算选中的优惠券
const selectedCoupon = computed(() => {
return availableCoupons.value.find(coupon => coupon.id === selectedCouponId.value)
})
// 9. 计算优惠券折扣
const couponDiscount = computed(() => {
if (!selectedCoupon.value) return 0
return selectedCoupon.value.discount
})
// 10. 商品搜索
const products = ref([
{ id: 1, name: 'iPhone 13', price: 5999 },
{ id: 2, name: 'MacBook Pro', price: 12999 },
{ id: 3, name: 'AirPods Pro', price: 1999 },
{ id: 4, name: 'iPad Air', price: 4799 },
{ id: 5, name: 'Apple Watch', price: 2999 },
{ id: 6, name: 'iMac', price: 10999 }
])
const searchText = ref('')
// 11. 过滤商品
const filteredItems = computed(() => {
if (!searchText.value) return products.value
return products.value.filter(product =>
product.name.toLowerCase().includes(searchText.value.toLowerCase())
)
})
// 操作方法
const increaseQuantity = (itemId: number) => {
const item = cartItems.value.find(item => item.id === itemId)
if (item) item.quantity++
}
const decreaseQuantity = (itemId: number) => {
const item = cartItems.value.find(item => item.id === itemId)
if (item && item.quantity > 1) {
item.quantity--
}
}
const removeItem = (itemId: number) => {
cartItems.value = cartItems.value.filter(item => item.id !== itemId)
}
const addToCart = (product: any) => {
const existingItem = cartItems.value.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity++
} else {
cartItems.value.push({ ...product, quantity: 1 })
}
}
// 监听购物车变化
watch(cartItems, (newItems) => {
console.log('购物车更新:', newItems)
// 这里可以保存到本地存储
localStorage.setItem('cart', JSON.stringify(newItems))
}, { deep: true })
</script>
<style scoped>
.cart-summary, .cart-items, .coupon-section, .filter-section {
margin: 20px 0;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.total {
font-size: 24px;
color: #e53935;
font-weight: bold;
}
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.item-info {
flex: 1;
}
.item-name {
font-weight: bold;
margin-right: 20px;
}
.item-controls button {
margin: 0 5px;
padding: 5px 10px;
cursor: pointer;
}
.quantity {
margin: 0 10px;
font-weight: bold;
}
.remove-btn {
background-color: #ff5252;
color: white;
border: none;
border-radius: 4px;
}
.product {
padding: 10px;
margin: 5px 0;
background-color: #f5f5f5;
border-radius: 4px;
}
.product button {
margin-left: 10px;
background-color: #4caf50;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
input[type="text"] {
padding: 8px;
width: 300px;
margin-right: 10px;
}
</style>
工作流程示例:
假设有两个优惠券:
- 优惠券 A:
{ id: 1, name: ‘新人券‘, discount: 10 } - 优惠券 B:
{ id: 2, name: ‘满减券‘, discount: 20 }
-
渲染时,Vue 会创建两个单选框:
- 第一个:
<input value=“1” ...>(对应优惠券A) - 第二个:
<input value=“2” ...>(对应优惠券B)
- 第一个:
-
当用户点击第一个单选框(优惠券A)时,浏览器事件会告诉 Vue:“
value为”1”的选项被选中了”。 -
v-model指令监听到这个变化,于是将selectedCouponId这个变量的值更新为”1”。 -
现在,
selectedCouponId === “1”。Vue 会检查所有v-model绑定的单选框,发现第一个单选框的:value(即”1”) 与selectedCouponId的值相等,于是自动为这个单选框添加checked属性,使其显示为选中状态。
总结:
v-model:管理哪个值被选中了(与selectedCouponId变量同步)。:value:定义每一个选项具体是哪个值(将coupon.id绑定到每个选项上)。
八、ref响应式变量
<template>
<div>
<h1>ref 响应式变量</h1>
<!-- 1. 基本使用 -->
<div class="section">
<h3>1. 基本类型响应式</h3>
<p>计数器: {{ count }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">重置</button>
</div>
<!-- 2. 引用类型 -->
<div class="section">
<h3>2. 对象响应式</h3>
<p>用户名: {{ user.name }}</p>
<p>年龄: {{ user.age }}</p>
<input v-model="user.name" placeholder="修改用户名">
<button @click="user.age++">增加年龄</button>
</div>
<!-- 3. 数组操作 -->
<div class="section">
<h3>3. 数组响应式</h3>
<ul>
<li v-for="(item, index) in items" :key="index">
{{ item }}
<button @click="removeItem(index)">删除</button>
</li>
</ul>
<input v-model="newItem" placeholder="输入新项目">
<button @click="addItem">添加</button>
</div>
<!-- 4. DOM 引用 -->
<div class="section">
<h3>4. 模板引用 (ref 获取 DOM)</h3>
<input ref="inputRef" type="text" placeholder="点击按钮聚焦">
<button @click="focusInput">聚焦输入框</button>
<p>输入框值: {{ inputValue }}</p>
</div>
<!-- 5. 组件引用 -->
<div class="section">
<h3>5. 组件引用</h3>
<ChildComponent ref="childRef" />
<button @click="callChildMethod">调用子组件方法</button>
</div>
<!-- 6. 计算属性 vs ref -->
<div class="section">
<h3>6. ref 与计算属性的区别</h3>
<p>原始价格: ¥{{ price }}</p>
<p>折扣: {{ discount * 100 }}%</p>
<p>折后价格 (ref): ¥{{ discountedPriceRef }}</p>
<p>折后价格 (computed): ¥{{ discountedPriceComputed }}</p>
<button @click="updateDiscount">修改折扣</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import ChildComponent from './ChildComponent.vue'
// 1. 基本类型 ref
const count = ref(0)
const increment = () => {
count.value++ // 注意:需要访问 .value
}
const decrement = () => {
count.value--
}
const reset = () => {
count.value = 0
}
// 2. 对象类型 ref(自动深度响应)
const user = ref({
name: '张三',
age: 25,
email: 'zhangsan@example.com'
})
// 3. 数组类型 ref
const items = ref(['苹果', '香蕉', '橙子'])
const newItem = ref('')
const addItem = () => {
if (newItem.value.trim()) {
items.value.push(newItem.value)
newItem.value = ''
}
}
const removeItem = (index: number) => {
items.value.splice(index, 1)
}
// 4. 模板引用(DOM 元素)
const inputRef = ref<HTMLInputElement | null>(null)
const inputValue = ref('')
const focusInput = () => {
if (inputRef.value) {
// inputRef.value 就是真实的 <input> DOM 元素
inputRef.value.focus()
inputRef.value.value = '已聚焦!'
inputValue.value = inputRef.value.value
}
}
// 5. 组件引用
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
const callChildMethod = () => {
if (childRef.value) {
childRef.value.sayHello()
}
}
// 6. ref 与计算属性的区别
const price = ref(100)
const discount = ref(0.1)
// 使用 ref 存储计算结果(需要手动更新)
const discountedPriceRef = ref(price.value * (1 - discount.value))
// 使用 computed 自动计算(推荐)
const discountedPriceComputed = computed(() => {
return price.value * (1 - discount.value)
})
const updateDiscount = () => {
discount.value += 0.1
if (discount.value > 0.5) discount.value = 0
// 手动更新 ref 的值
discountedPriceRef.value = price.value * (1 - discount.value)
}
// 监听 ref 的变化
watch(count, (newValue, oldValue) => {
console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})
watch(user, (newUser) => {
console.log('用户信息更新:', newUser)
}, { deep: true }) // 深度监听对象变化
// 生命周期钩子
onMounted(() => {
console.log('组件已挂载')
console.log('inputRef:', inputRef.value)
console.log('childRef:', childRef.value)
})
</script>
<style scoped>
.section {
margin: 20px 0;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
h3 {
margin-top: 0;
color: #333;
}
button {
margin: 5px;
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
input {
padding: 8px;
margin: 5px;
border: 1px solid #ddd;
border-radius: 4px;
}
ul {
list-style: none;
padding: 0;
}
li {
padding: 8px;
margin: 5px 0;
background-color: #f5f5f5;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
<!-- ChildComponent.vue -->
<template>
<div class="child">
<h4>子组件</h4>
<p>子组件计数: {{ childCount }}</p>
<button @click="childCount++">子组件 +1</button>
</div>
</template>
<script setup lang="ts">
import { ref, defineExpose } from 'vue'
const childCount = ref(0)
const sayHello = () => {
alert(`Hello from Child! Count is ${childCount.value}`)
childCount.value++
}
// 暴露给父组件使用的方法
defineExpose({
sayHello,
childCount
})
</script>
<style scoped>
.child {
padding: 15px;
background-color: #f0f0f0;
border-radius: 5px;
margin-top: 10px;
}
</style>
一、ref的核心特性总结
- 响应式包装器:
ref将普通数据包装成响应式对象,使其能够在数据变化时自动更新视图 .value访问:在<script>中通过.value访问/修改值,在<template>中自动解包无需.value- 类型安全:支持 TypeScript 类型定义,提供更好的开发体验
- 广泛适用:支持基本类型、对象、数组、DOM 元素、组件实例等多种数据类型
二、ref的四大主要用途
- 响应式状态管理
- 基本类型:数字、字符串、布尔值等
- 引用类型:对象、数组(自动深度响应)
- 优势:替代 Vue 2 的
data,更灵活的组合式管理
- 模板引用 (Template Refs)
<!-- 引用 DOM 元素 -->
<input ref="inputRef">
<!-- 引用子组件 -->
<ChildComponent ref="childRef" />
- 应用场景:直接操作 DOM、调用子组件方法、集成第三方库
三、reactive() - 用于创建响应式的对象
const user = reactive({
name: '张三',
age: 25,
email: 'zhangsan@example.com'
})
const updateUserName = () => {
if (newName.value.trim()) {
user.name = newName.value
newName.value = ''
}
}
四、toRef() - 用于从响应式对象中提取单个属性并保持其响应性
<script setup>
import { reactive, toRef, ref } from 'vue'
const state = reactive({
name: '张三',
age: 25
})
// 方式1: 使用 toRef(保持连接)
const nameRef1 = toRef(state, 'name')
// 方式2: 使用 ref(失去连接)
const nameRef2 = ref(state.name)
const updateData = () => {
// 修改原对象
state.name = '李四'
console.log('nameRef1:', nameRef1.value) // '李四' ✓ 同步更新
console.log('nameRef2:', nameRef2.value) // '张三' ✗ 不同步
// 修改 ref
nameRef1.value = '王五'
console.log('state.name:', state.name) // '王五' ✓ 同步
nameRef2.value = '赵六'
console.log('state.name:', state.name) // '王五' ✗ 不同步
}
</script>
九、v-model双向数据绑定
<template>
<div>
<h1>v-model 双向绑定</h1>
<!-- 1. 基础输入框 -->
<div class="section">
<h3>1. 文本输入框</h3>
<input v-model="message" placeholder="输入文本">
<p>输入的内容: {{ message }}</p>
<p>字符数: {{ message.length }}</p>
</div>
<!-- 2. 多行文本框 -->
<div class="section">
<h3>2. 多行文本框</h3>
<textarea v-model="bio" placeholder="个人简介"></textarea>
<p>预览:</p>
<pre>{{ bio }}</pre>
<p>行数: {{ bio.split('\n').length }}</p>
</div>
<!-- 3. 复选框 -->
<div class="section">
<h3>3. 复选框</h3>
<div>
<label>
<input type="checkbox" v-model="checked">
我同意条款
</label>
<p>状态: {{ checked ? '已同意' : '未同意' }}</p>
</div>
<h4>多个复选框</h4>
<div v-for="option in options" :key="option.id">
<label>
<input type="checkbox" :value="option.value" v-model="selectedOptions">
{{ option.label }}
</label>
</div>
<p>选择: {{ selectedOptions.join(', ') }}</p>
</div>
<!-- 4. 单选框 -->
<div class="section">
<h3>4. 单选框</h3>
<div v-for="gender in genders" :key="gender.value">
<label>
<input type="radio" :value="gender.value" v-model="selectedGender">
{{ gender.label }}
</label>
</div>
<p>选择: {{ selectedGender }}</p>
</div>
<!-- 5. 选择框 -->
<div class="section">
<h3>5. 下拉选择框</h3>
<select v-model="selectedCity">
<option disabled value="">请选择城市</option>
<option v-for="city in cities" :key="city.value" :value="city.value">
{{ city.label }}
</option>
</select>
<p>选择: {{ selectedCity }}</p>
<h4>多选下拉</h4>
<select v-model="selectedCities" multiple style="height: 100px;">
<option v-for="city in cities" :key="city.value" :value="city.value">
{{ city.label }}
</option>
</select>
<p>选择: {{ selectedCities.join(', ') }}</p>
</div>
<!-- 6. 表单绑定对象 -->
<div class="section">
<h3>6. 表单绑定到对象</h3>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label>用户名:</label>
<input v-model="formData.username" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>邮箱:</label>
<input v-model="formData.email" type="email" placeholder="请输入邮箱">
</div>
<div class="form-group">
<label>年龄:</label>
<input v-model.number="formData.age" type="number" min="0" max="150">
</div>
<div class="form-group">
<label>性别:</label>
<select v-model="formData.gender">
<option value="male">男</option>
<option value="female">女</option>
<option value="other">其他</option>
</select>
</div>
<div class="form-group">
<label>爱好:</label>
<div v-for="hobby in hobbies" :key="hobby">
<label>
<input type="checkbox" :value="hobby" v-model="formData.hobbies">
{{ hobby }}
</label>
</div>
</div>
<div class="form-group">
<label>个人简介:</label>
<textarea v-model="formData.bio" placeholder="个人简介"></textarea>
</div>
<button type="submit">提交</button>
<button type="button" @click="resetForm">重置</button>
</form>
<h4>表单数据预览:</h4>
<pre>{{ formData }}</pre>
</div>
<!-- 7. 自定义组件 v-model -->
<div class="section">
<h3>7. 自定义组件的 v-model</h3>
<p>父组件值: {{ customValue }}</p>
<CustomInput v-model="customValue" />
<CustomInput v-model.number="customNumber" />
</div>
<!-- 8. v-model 修饰符 -->
<div class="section">
<h3>8. v-model 修饰符</h3>
<h4>.lazy - 输入时不会立即更新,失去焦点时更新</h4>
<input v-model.lazy="lazyMessage" placeholder="输入内容">
<p>值: {{ lazyMessage }}</p>
<h4>.number - 自动转换为数字</h4>
<input v-model.number="numberValue" type="number" placeholder="输入数字">
<p>值: {{ numberValue }},类型: {{ typeof numberValue }}</p>
<h4>.trim - 自动去除首尾空格</h4>
<input v-model.trim="trimmedValue" placeholder="输入带空格的内容">
<p>值: "{{ trimmedValue }}"</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import CustomInput from './CustomInput.vue'
// 1. 文本输入
const message = ref('')
// 2. 多行文本
const bio = ref('')
// 3. 复选框
const checked = ref(false)
// 多个复选框
const options = ref([
{ id: 1, label: '选项 A', value: 'A' },
{ id: 2, label: '选项 B', value: 'B' },
{ id: 3, label: '选项 C', value: 'C' }
])
const selectedOptions = ref<string[]>([])
// 4. 单选框
const genders = ref([
{ label: '男', value: 'male' },
{ label: '女', value: 'female' },
{ label: '其他', value: 'other' }
])
const selectedGender = ref('male')
// 5. 选择框
const cities = ref([
{ label: '北京', value: 'beijing' },
{ label: '上海', value: 'shanghai' },
{ label: '广州', value: 'guangzhou' },
{ label: '深圳', value: 'shenzhen' }
])
const selectedCity = ref('')
const selectedCities = ref<string[]>([])
// 6. 表单对象
const formData = reactive({
username: '',
email: '',
age: 25,
gender: 'male',
hobbies: [] as string[],
bio: ''
})
const hobbies = ref(['篮球', '足球', '游泳', '阅读', '音乐'])
const handleSubmit = () => {
console.log('表单提交:', formData)
alert('表单提交成功!查看控制台输出')
}
const resetForm = () => {
Object.assign(formData, {
username: '',
email: '',
age: 25,
gender: 'male',
hobbies: [] as string[],
bio: ''
})
}
// 7. 自定义组件 v-model
const customValue = ref('')
const customNumber = ref(0)
// 8. v-model 修饰符
const lazyMessage = ref('')
const numberValue = ref(0)
const trimmedValue = ref('')
</script>
<style scoped>
.section {
margin: 20px 0;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
h3 {
margin-top: 0;
color: #333;
}
h4 {
margin: 15px 0 10px 0;
color: #666;
}
input[type="text"],
input[type="email"],
input[type="number"],
textarea,
select {
width: 300px;
padding: 8px;
margin: 5px 0;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
textarea {
height: 100px;
resize: vertical;
}
.form-group {
margin: 10px 0;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
button {
margin: 10px 5px 0 0;
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
pre {
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
label {
display: inline-flex;
align-items: center;
margin-right: 15px;
cursor: pointer;
}
input[type="checkbox"],
input[type="radio"] {
margin-right: 5px;
}
</style>
<!-- CustomInput.vue - 自定义组件 -->
<template>
<div class="custom-input">
<input
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
:placeholder="placeholder"
/>
<span class="char-count">{{ charCount }} 字符</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
// 定义 props
defineProps({
modelValue: {
type: [String, Number],
default: ''
},
type: {
type: String,
default: 'text'
},
placeholder: {
type: String,
default: '请输入'
}
})
// 定义 emits
defineEmits(['update:modelValue'])
// 计算属性:字符数
const charCount = computed(() => {
return String(props.modelValue).length
})
</script>
<style scoped>
.custom-input {
display: inline-flex;
align-items: center;
gap: 10px;
}
.custom-input input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.char-count {
font-size: 12px;
color: #666;
}
</style>
十、综合示例:用户管理系统
<template>
<div class="user-management">
<h1>用户管理系统</h1>
<!-- 搜索和过滤 -->
<div class="controls">
<div class="search-box">
<input
v-model.trim="searchQuery"
placeholder="搜索用户..."
@input="debouncedSearch"
>
<span v-if="searchQuery" class="clear-btn" @click="clearSearch">✕</span>
</div>
<div class="filters">
<select v-model="roleFilter">
<option value="">所有角色</option>
<option v-for="role in roles" :key="role" :value="role">{{ role }}</option>
</select>
<label class="toggle">
<input type="checkbox" v-model="showActiveOnly">
仅显示活跃用户
</label>
</div>
</div>
<!-- 用户列表 -->
<div class="user-list">
<div v-if="filteredUsers.length === 0" class="empty-state">
没有找到用户
</div>
<div
v-for="user in filteredUsers"
:key="user.id"
class="user-card"
:class="{
'active': user.isActive,
'inactive': !user.isActive,
'selected': selectedUserId === user.id
}"
@click="selectUser(user.id)"
>
<div class="user-avatar">
{{ user.name.charAt(0) }}
</div>
<div class="user-info">
<h3>{{ user.name }}</h3>
<p class="user-email">{{ user.email }}</p>
<div class="user-meta">
<span class="role-badge" :class="user.role">{{ user.role }}</span>
<span class="age">{{ user.age }}岁</span>
<span class="status" :class="user.isActive ? 'active' : 'inactive'">
{{ user.isActive ? '活跃' : '非活跃' }}
</span>
</div>
</div>
<div class="user-actions">
<button
@click.stop="toggleActive(user.id)"
:class="user.isActive ? 'btn-deactivate' : 'btn-activate'"
>
{{ user.isActive ? '停用' : '激活' }}
</button>
<button
@click.stop="editUser(user.id)"
class="btn-edit"
>
编辑
</button>
<button
@click.stop="deleteUser(user.id)"
class="btn-delete"
>
删除
</button>
</div>
</div>
</div>
<!-- 统计信息 -->
<div class="stats">
<div class="stat-card">
<h4>总用户数</h4>
<p class="stat-value">{{ totalUsers }}</p>
</div>
<div class="stat-card">
<h4>活跃用户</h4>
<p class="stat-value">{{ activeUsersCount }}</p>
</div>
<div class="stat-card">
<h4>平均年龄</h4>
<p class="stat-value">{{ averageAge }}</p>
</div>
<div class="stat-card">
<h4>角色分布</h4>
<div class="role-distribution">
<span v-for="role in roleDistribution" :key="role.name" class="role-item">
{{ role.name }}: {{ role.count }}
</span>
</div>
</div>
</div>
<!-- 用户表单 -->
<div class="user-form">
<h3>{{ isEditing ? '编辑用户' : '添加新用户' }}</h3>
<form @submit.prevent="submitForm">
<div class="form-row">
<div class="form-group">
<label>姓名 *</label>
<input
v-model="form.name"
required
placeholder="请输入姓名"
:class="{ 'error': !form.name && submitted }"
>
<p v-if="!form.name && submitted" class="error-message">姓名不能为空</p>
</div>
<div class="form-group">
<label>邮箱 *</label>
<input
v-model="form.email"
type="email"
required
placeholder="请输入邮箱"
:class="{ 'error': !isEmailValid && submitted }"
>
<p v-if="!isEmailValid && submitted" class="error-message">请输入有效的邮箱</p>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>年龄</label>
<input
v-model.number="form.age"
type="number"
min="1"
max="150"
placeholder="年龄"
>
</div>
<div class="form-group">
<label>角色</label>
<select v-model="form.role">
<option v-for="role in roles" :key="role" :value="role">{{ role }}</option>
</select>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.isActive">
活跃状态
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-submit">
{{ isEditing ? '更新用户' : '添加用户' }}
</button>
<button type="button" @click="resetForm" class="btn-reset">
重置
</button>
<button v-if="isEditing" type="button" @click="cancelEdit" class="btn-cancel">
取消编辑
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
// 用户接口
interface User {
id: number
name: string
email: string
age: number
role: string
isActive: boolean
}
// 初始用户数据
const initialUsers: User[] = [
{ id: 1, name: '张三', email: 'zhangsan@example.com', age: 25, role: 'admin', isActive: true },
{ id: 2, name: '李四', email: 'lisi@example.com', age: 30, role: 'editor', isActive: true },
{ id: 3, name: '王五', email: 'wangwu@example.com', age: 22, role: 'user', isActive: false },
{ id: 4, name: '赵六', email: 'zhaoliu@example.com', age: 28, role: 'editor', isActive: true },
{ id: 5, name: '钱七', email: 'qianqi@example.com', age: 35, role: 'user', isActive: true }
]
// 响应式数据
const users = ref<User[]>(initialUsers)
const searchQuery = ref('')
const roleFilter = ref('')
const showActiveOnly = ref(false)
const selectedUserId = ref<number | null>(null)
// 表单数据
const form = reactive({
id: 0,
name: '',
email: '',
age: 25,
role: 'user',
isActive: true
})
const isEditing = ref(false)
const submitted = ref(false)
// 角色列表
const roles = ['admin', 'editor', 'user']
// 计算属性
const filteredUsers = computed(() => {
let filtered = users.value
// 搜索过滤
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
)
}
// 角色过滤
if (roleFilter.value) {
filtered = filtered.filter(user => user.role === roleFilter.value)
}
// 活跃状态过滤
if (showActiveOnly.value) {
filtered = filtered.filter(user => user.isActive)
}
return filtered
})
const totalUsers = computed(() => users.value.length)
const activeUsersCount = computed(() => users.value.filter(user => user.isActive).length)
const averageAge = computed(() => {
if (users.value.length === 0) return 0
const total = users.value.reduce((sum, user) => sum + user.age, 0)
return Math.round(total / users.value.length)
})
const roleDistribution = computed(() => {
const distribution: Record<string, number> = {}
users.value.forEach(user => {
distribution[user.role] = (distribution[user.role] || 0) + 1
})
return Object.entries(distribution).map(([name, count]) => ({ name, count }))
})
const isEmailValid = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+.[^\s@]+$/
return emailRegex.test(form.email)
})
// 方法
const selectUser = (userId: number) => {
selectedUserId.value = userId
}
const toggleActive = (userId: number) => {
const user = users.value.find(u => u.id === userId)
if (user) {
user.isActive = !user.isActive
}
}
const editUser = (userId: number) => {
const user = users.value.find(u => u.id === userId)
if (user) {
Object.assign(form, { ...user })
isEditing.value = true
selectedUserId.value = userId
}
}
const deleteUser = (userId: number) => {
if (confirm('确定要删除这个用户吗?')) {
users.value = users.value.filter(user => user.id !== userId)
if (selectedUserId.value === userId) {
selectedUserId.value = null
}
}
}
const submitForm = () => {
submitted.value = true
if (!form.name || !isEmailValid.value) {
return
}
if (isEditing.value) {
// 更新用户
const index = users.value.findIndex(user => user.id === form.id)
if (index !== -1) {
users.value[index] = { ...form }
}
} else {
// 添加新用户
const newUser: User = {
id: users.value.length > 0 ? Math.max(...users.value.map(u => u.id)) + 1 : 1,
...form
}
users.value.push(newUser)
}
resetForm()
}
const resetForm = () => {
Object.assign(form, {
id: 0,
name: '',
email: '',
age: 25,
role: 'user',
isActive: true
})
isEditing.value = false
submitted.value = false
selectedUserId.value = null
}
const cancelEdit = () => {
resetForm()
}
const clearSearch = () => {
searchQuery.value = ''
}
const debouncedSearch = (() => {
let timeoutId: number
return () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
// 搜索逻辑已由计算属性处理
}, 300)
}
})()
// 模拟 API 加载
onMounted(() => {
console.log('用户管理系统已加载')
// 这里可以添加从 API 加载数据的逻辑
})
</script>
<style scoped>
.user-management {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
gap: 20px;
flex-wrap: wrap;
}
.search-box {
position: relative;
flex: 1;
max-width: 400px;
}
.search-box input {
width: 100%;
padding: 10px 40px 10px 15px;
border: 2px solid #ddd;
border-radius: 25px;
font-size: 16px;
transition: border-color 0.3s;
}
.search-box input:focus {
outline: none;
border-color: #007bff;
}
.clear-btn {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #999;
font-size: 18px;
}
.clear-btn:hover {
color: #333;
}
.filters {
display: flex;
gap: 20px;
align-items: center;
}
.filters select {
padding: 10px 15px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 14px;
background-color: white;
}
.toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.user-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: #999;
font-size: 18px;
}
.user-card {
background: white;
border: 2px solid #eee;
border-radius: 10px;
padding: 20px;
cursor: pointer;
transition: all 0.3s;
display: flex;
gap: 15px;
align-items: center;
}
.user-card:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
border-color: #007bff;
}
.user-card.selected {
border-color: #007bff;
background-color: #f0f8ff;
}
.user-card.active {
border-left: 4px solid #4caf50;
}
.user-card.inactive {
border-left: 4px solid #ff5252;
opacity: 0.8;
}
.user-avatar {
width: 50px;
height: 50px;
background-color: #007bff;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
flex-shrink: 0;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-info h3 {
margin: 0 0 5px 0;
color: #333;
font-size: 18px;
}
.user-email {
margin: 0 0 10px 0;
color: #666;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
}
.user-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.role-badge {
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.role-badge.admin {
background-color: #ff9800;
color: white;
}
.role-badge.editor {
background-color: #2196f3;
color: white;
}
.role-badge.user {
background-color: #4caf50;
color: white;
}
.age, .status {
font-size: 12px;
color: #666;
padding: 3px 8px;
background-color: #f5f5f5;
border-radius: 12px;
}
.status.active {
background-color: #e8f5e9;
color: #4caf50;
}
.status.inactive {
background-color: #ffebee;
color: #ff5252;
}
.user-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-actions button {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: opacity 0.3s;
}
.user-actions button:hover {
opacity: 0.9;
}
.btn-activate {
background-color: #4caf50;
color: white;
}
.btn-deactivate {
background-color: #ff9800;
color: white;
}
.btn-edit {
background-color: #2196f3;
color: white;
}
.btn-delete {
background-color: #ff5252;
color: white;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border: 1px solid #eee;
border-radius: 10px;
padding: 20px;
text-align: center;
}
.stat-card h4 {
margin: 0 0 10px 0;
color: #666;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-value {
margin: 0;
font-size: 32px;
font-weight: bold;
color: #333;
}
.role-distribution {
display: flex;
flex-direction: column;
gap: 5px;
}
.role-item {
font-size: 14px;
color: #666;
}
.user-form {
background: white;
border: 1px solid #eee;
border-radius: 10px;
padding: 30px;
margin-top: 30px;
}
.user-form h3 {
margin: 0 0 20px 0;
color: #333;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #007bff;
}
.form-group input.error {
border-color: #ff5252;
}
.error-message {
margin: 5px 0 0 0;
color: #ff5252;
font-size: 12px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.form-actions button {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: opacity 0.3s;
}
.form-actions button:hover {
opacity: 0.9;
}
.btn-submit {
background-color: #007bff;
color: white;
}
.btn-reset {
background-color: #6c757d;
color: white;
}
.btn-cancel {
background-color: #dc3545;
color: white;
}
</style>