上一章你把项目跑起来了,但页面还是脚手架自带的欢迎页。这一章教你把自己的数据「接」到页面上:用 {{ }} 显示内容、用 v-bind 绑属性、用 v-on 接住点击、用 v-model 做双向绑定。学完这四件工具,你就能独立写出「作品搜索表单」这种真实页面里的核心交互。
<script setup>
import { ref } from 'vue'
const title = ref('静夜思')
const author = ref('李白')
const views = ref(328)
const isHot = ref(true)
</script>
<template>
<!-- ① 变量 -->
<p>{{ title }} · {{ author }}</p>
<!-- ② 表达式:算术、字符串拼接 -->
<p>共 {{ views }} 次浏览,点赞率约 {{ (42 / views * 100).toFixed(1) }}%</p>
<!-- ③ 三元表达式:条件不同显示不同 -->
<p>热度:{{ views > 300 ? '热门' : '普通' }}</p>
<!-- ④ 逻辑运算:有值才显示 -->
<p>{{ isHot && '本周推荐' }}</p>
</template>
<script setup>
import { ref } from 'vue'
const title = ref('静夜思')
const link = ref('/api/article/detail/1')
const notLoggedIn = ref(true)
const tip = ref('点击查看「静夜思」详情')
</script>
<template>
<!-- 完整写法 -->
<a v-bind:href="link" v-bind:title="tip">{{ title }}</a>
<button v-bind:disabled="notLoggedIn">收藏</button>
<!-- 简写:v-bind: 一律可以缩成一个冒号,效果完全一样 -->
<a :href="link" :title="tip">{{ title }}</a>
<button :disabled="notLoggedIn">收藏</button>
<!-- 一次绑一个对象的所有字段:把 article 的每个属性都摊到标签上 -->
<img v-bind="cover" />
</template>
<script setup>
import { ref } from 'vue'
const isRecommend = ref(true)
const size = ref('large')
const activeTab = ref('唐诗')
const tabs = ref(['唐诗', '宋词', '元曲'])
</script>
<template>
<!-- ① 对象写法:键是类名,值是「要不要加」的布尔值 -->
<span :class="{ hot: isRecommend, large: size === 'large' }">念奴娇·赤壁怀古</span>
<!-- ② 数组写法:变量本身就是一个类名 -->
<span :class="['tag', size]">宋词</span>
<!-- ③ 静态 + 动态混用:Vue 会合并,不会互相覆盖 -->
<span class="tag" :class="{ hot: isRecommend }">推荐</span>
<!-- ④ 数组里也能放对象 -->
<a v-for="t in tabs" :key="t" :class="['tab', { on: t === activeTab }]">{{ t }}</a>
<!-- style 同理:对象写法与 CSS 属性一一对应 -->
<p :style="{ color: isRecommend ? '#c0392b' : '#333', fontSize: '14px' }">热度 412</p>
</template>
<script setup>
import { ref } from 'vue'
const likes = ref(42)
const log = ref([])
function handleLike() {
likes.value++
log.value.push('点了赞,现在 ' + likes.value + ' 个')
}
function handleJump(url) {
log.value.push('准备跳到 ' + url)
}
</script>
<template>
<!-- 完整写法 v-on:click,简写 @click -->
<button v-on:click="handleLike">点赞 {{ likes }}</button>
<button @click="handleLike">点赞</button>
<!-- 直接写一行表达式也行 -->
<button @click="likes++">+1</button>
<!-- .prevent 修饰符:拦住链接的默认跳转 -->
<a href="/api/article/detail/3" @click.prevent="handleJump('/api/article/detail/3')">看详情</a>
<!-- 表单提交也常用:不加 .prevent 会刷新整页 -->
<form @submit.prevent="handleJump('/api/article/list')">
<input placeholder="搜作品" />
<button type="submit">搜索</button>
</form>
<!-- 其它常见修饰符:禁止冒泡、只触发一次、键盘按键 -->
<div class="card" @click.stop="log.push('点到了里层')">里层</div>
<button @click.once="log.push('只会记这一次')">只算一次</button>
<input @keyup.enter="handleJump('/api/article/list')" />
<p v-for="(line, i) in log" :key="i">{{ line }}</p>
</template>
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const nickname = ref('青莲居士')
</script>
<template>
<input v-model="keyword" placeholder="搜作品名 / 作者" />
<p>你正在搜:{{ keyword }}</p>
<p>关键词长度:{{ keyword.length }}</p>
<input v-model="nickname" />
<button @click="nickname = ''">清空昵称</button>
</template>
<!-- 写法 A:一行搞定 -->
<input v-model="keyword" />
<!-- 写法 B:v-model 等价于下面这两行,这就是它叫「语法糖」的原因 -->
<input
:value="keyword"
@input="keyword = $event.target.value"
/>
<script setup>
import { ref } from 'vue'
const keyword = ref('') // 单行文本 → 字符串
const onlyRecommend = ref(true) // 单个复选框 → 布尔
const tags = ref(['唐诗']) // 多个复选框 → 数组
const dynasty = ref('唐') // 单选 → 字符串
const categoryId = ref('') // 下拉 → 与 option 的 value 同类型
const content = ref('') // 多行文本 → 字符串
const dynasties = ['唐', '宋', '元']
const categories = [
{ id: 1, name: '唐诗' },
{ id: 2, name: '宋词' },
{ id: 4, name: '散文' }
]
</script>
<template>
<form>
<!-- ① 单行文本:.trim 自动去掉首尾空格 -->
<input v-model.trim="keyword" placeholder="关键词" />
<!-- ② 复选框:绑布尔 = 勾没勾;绑数组 = 勾了哪几个 -->
<label><input type="checkbox" v-model="onlyRecommend" />只看推荐</label>
<label v-for="t in categories" :key="t.id">
<input type="checkbox" :value="t.name" v-model="tags" />{{ t.name }}
</label>
<!-- ③ 单选:同一组共用一个变量,:value 就是选中后拿到的值 -->
<label v-for="d in dynasties" :key="d">
<input type="radio" :value="d" v-model="dynasty" />{{ d }}
</label>
<!-- ④ 下拉:option 的 value 决定拿到什么 -->
<select v-model="categoryId">
<option value="">全部分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<!-- ⑤ 多行文本:不要在里面写 {{ }},用 v-model;.lazy 离开焦点才更新 -->
<textarea v-model.lazy="content"></textarea>
</form>
<p>当前条件:{{ keyword }} / {{ dynasty }} / {{ categoryId || '全部分类' }}</p>
</template>
<script setup>
import { ref } from 'vue'
// 子组件把搜索结果交给父组件(组件通信第 6 章细讲,这里先照写法用)
const emit = defineEmits(['search'])
const keyword = ref('')
const categoryId = ref('')
const page = ref(1)
const loading = ref(false)
const categories = [
{ id: 1, name: '唐诗' },
{ id: 2, name: '宋词' },
{ id: 3, name: '元曲' },
{ id: 4, name: '散文' },
{ id: 5, name: '小说' }
]
async function onSearch() {
loading.value = true
page.value = 1 // 换了搜索条件必须回到第 1 页,否则可能查到空列表
const params = new URLSearchParams({
page: page.value,
page_size: 10
})
if (keyword.value) params.append('keyword', keyword.value)
if (categoryId.value) params.append('category_id', categoryId.value)
const res = await fetch('/api/article/list?' + params)
const json = await res.json()
if (json.code === 200) {
emit('search', json.data) // data 里有 list、total、page、page_size
} else {
alert(json.msg) // 失败时 msg 可以直接给用户看
}
loading.value = false
}
</script>
<template>
<form class="search-bar" @submit.prevent="onSearch">
<input v-model.trim="keyword" placeholder="搜作品名 / 作者" />
<select v-model.number="categoryId">
<option value="">全部分类</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<button type="submit" :disabled="loading">
{{ loading ? '搜索中…' : '搜索' }}
</button>
<button type="button" @click="keyword = ''; categoryId = ''">重置</button>
</form>
</template>
模板语法本身不难,难在「看起来差不多、结果差很远」。下面四组对照,建议抄下来贴在手边。
<!-- ① 属性上不能用插值 -->
<img src="{{ cover }}" /> 错误:src 会变成字面量 "{{ cover }}"
<img :src="cover" /> 正确
<!-- ② 插值里不能写语句 -->
<p>{{ if (isHot) '热门' }}</p> 错误:编译直接报错
<p>{{ isHot ? '热门' : '普通' }}</p> 正确
<!-- ③ v-model 是「赋值目标」,不能写表达式 -->
<input v-model="keyword + '!'" /> 错误:赋值给表达式没有意义
<input v-model="keyword" /> 正确
<!-- ④ 表单提交忘了 .prevent,整页刷新 -->
<form @submit="onSearch"> 错误:浏览器默认提交并刷新页面
<form @submit.prevent="onSearch"> 正确
<!-- 第 1 题:作品条目 -->
<script setup>
import { ref } from 'vue'
const title = ref('念奴娇·赤壁怀古')
const author = ref('苏轼')
const views = ref(412)
const likes = ref(58)
</script>
<template>
<a :href="'/api/article/detail/3'" :class="{ hot: likes > 50 }">{{ title }}</a>
<span>{{ author }} 浏览 {{ views }} 点赞 {{ likes }}</span>
<span>{{ views > 300 ? '热门' : '普通' }}</span>
</template>
<!-- 第 2 题:发书评 -->
<script setup>
import { ref } from 'vue'
const content = ref('')
const favorite = ref(true)
function onSubmit() {
console.log({ article_id: 1, content: content.value, favorite: favorite.value })
}
</script>
<template>
<form @submit.prevent="onSubmit">
<textarea v-model.trim="content"></textarea>
<label><input type="checkbox" v-model="favorite" />同时收藏</label>
<button type="submit" :disabled="!content">发布</button>
</form>
</template>
<!-- 第 3 题:红影赏析的电影搜索(表结构不同,思路完全一致) -->
<script setup>
import { ref } from 'vue'
const keyword = ref('')
const genre = ref('')
const minRating = ref('')
const genres = ['科幻', '剧情', '动画', '悬疑']
function onSearch() {
const query = { page: 1, page_size: 10 }
if (keyword.value) query.keyword = keyword.value
if (genre.value) query.genre = genre.value
if (minRating.value !== '') query.min_rating = minRating.value
console.log('查询参数:', JSON.stringify(query))
}
</script>
<template>
<form @submit.prevent="onSearch">
<input v-model.trim="keyword" placeholder="搜片名" />
<select v-model="genre">
<option value="">全部类型</option>
<option v-for="g in genres" :key="g" :value="g">{{ g }}</option>
</select>
<input v-model.number="minRating" type="number" placeholder="最低评分" />
<button type="submit">搜索</button>
</form>
</template>
第 3 题的关键:min_rating 必须用 .number。它要参与数值比较(评分大于等于 8),字符串 "8" 送到后端,轻则被当成非法参数返回 400,重则被拼进 SQL 变成类型比较的隐式转换。凡是「参与计算或数值比较」的输入,一律加 .number。另外注意判断空值要用 minRating.value !== '' 而不是 if (minRating.value)——评分 0 也是合法值。