Vue Router完整项目:任务列表、详情与编辑
本篇承接语言基础,围绕同一任务模型展开。示例未执行;命令与现象均为读者未来自行验证的步骤和预期,不能视为运行记录。
Vue任务工作台实现了哪些页面和操作
Vue任务工作台实现了哪些页面和操作?项目提供/tasks列表与创建、/tasks/:id详情、/tasks/:id/edit编辑及删除,以及未知路径兜底。列表最多显示API返回的前100条;详情支持404、加载、重试和版本冲突。创建/保存都有提交状态,前端验证标题非空和UTF-16长度,服务端仍独立校验。没有登录、SSR、离线缓存或假API。
目录examples/web/p2-vue-router可单独复制,openapi.json说明它依赖的同源API。可搭配Java教学项目,也可由读者实现其他满足契约的后端。前端trim()与Javastrip()的Unicode空白集合并不相同,界面预检不是契约校验的替代,最终以服务端处理为准。整个项目与验收规格均为示例未执行,本轮未安装依赖、执行Node示例、运行Vite、打开浏览器或调用接口。
Vue Router项目的依赖和契约如何固定
Vue Router项目的依赖和契约如何固定?教学基线精确固定Vue3.5.13、Vue Router4.5.0、Vite6.1.0、plugin-vue5.2.1,Node要求22.12+。这些是已发布标签而非当前最新推荐;没有安装,因此不提供lock文件或传递依赖锁定结论。手写契约先于Web实现落盘,与Java项目副本一致;任何字段修改都需同步两份契约和两个项目。
发布依据:Vue3.5.13、Router4.5.0、Vite6.1.0、plugin-vue5.2.1。版本存在不等于本机依赖解析或浏览器集成已通过;读者实际运行前应检查维护和安全状况。
Vue Router完整项目怎样复用同一份源码
Vue Router完整项目怎样复用同一份源码?下方包含完整源码、配置、契约、验收规格和README,同一源文件同时服务笔记与可复制项目。main装配Vue和Router,router定义页面,api封装请求,TaskList负责列表与创建,TaskPage负责详情/编辑/删除,NotFound负责未匹配URL。Vite代理只影响开发服务器,不会随打包自动进入生产。
文档包含不执行组件脚本;SFC解析和模板静态编译也不能证明浏览器行为。README写出未来可自行运行的命令和边界,test-spec.md记录预期,不把预期写成运行日志。样式简单服务阅读与状态反馈,没有额外UI库或构建生成代码。
index.html
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>任务路由练习</title></head><body><div id="app"></div><script type="module" src="/src/main.mjs"></script></body></html>openapi.json
完整文件:openapi.json 完整源码。保留原始内容,使用前请核对本文的版本与环境说明。
package.json
{
"name": "p2-vue-router-tasks",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"vue": "3.5.13",
"vue-router": "4.5.0"
},
"devDependencies": {
"vite": "6.1.0",
"@vitejs/plugin-vue": "5.2.1"
}
}README.md
# Vue Router任务工作台(示例未执行)
独立Vue3单页项目:Node22.12+,Vue3.5.13、Router4.5.0、Vite6.1.0、plugin-vue5.2.1均精确固定在package.json,作为已发布教学基线,非最新版本或生产依赖审计结论。无lock文件,传递依赖尚未锁定。未安装、构建、启动、浏览或请求API。openapi.json先于实现手写,与Java项目契约字节一致;可以配任何满足该契约的后端。无假登录、无SSR。
文件:index.html入口;vite.config.mjs开发代理;src/main.mjs装配;router.mjs定义命名路由;api.mjs统一错误/ID检查;App.vue外壳;TaskList.vue读取和创建;TaskPage.vue详情/编辑/删除;NotFound.vue页面404;style.css样式;test-spec.md预期验收。
读者以后在本目录自行执行 `npm install`、`npm run dev`;这会安装依赖并启动开发服务器,本轮未做。后端可使用examples/java/p2-spring(需自行启动);/api开发代理到127.0.0.1:8080。先创建任务,再进入详情和编辑,地址为/tasks、/tasks/字符串id、/tasks/字符串id/edit。列表上限100条,超过部分需后端扩展分页。
列表loading/error/empty彼此有明确显示,创建禁重复提交;详情侦听id和route.name,清旧数据,使用AbortController取消旧读取并用generation丢弃过时结果。写请求也用generation约束完成后的导航,但离开页面不撤销服务端写入。保存用读取到的字符串version,409要求重新加载;重新加载明确丢弃草稿。ID/version禁止Number转换。标题长度按UTF-16检查,与Java @Size对齐,但前后端空白定义可能不同,后端最终裁决。
读者以后可 `npm run build`、`npm run preview` 检查静态产物。preview不提供server.proxy;开发代理只对dev有效,生产必须同源反向代理/api。HTML history服务器把已存在的静态文件正常返回、/api交给后端,其余页面路由回index.html;避免将API404变成HTML200。发布子目录还需同步Vite base与createWebHistory(base),本例仅根路径部署。人工验证见test-spec.md,全部未执行。
网络写入超时可能已成功;没有幂等键,不自动重试POST。生产权限必须由后端验证;Router守卫不能保护API。异常模板采用文本插值,不使用v-html。读者需要自己完成真实集成、可访问性和浏览器兼容检查。src/api.mjs
export class ApiError extends Error {
constructor(status, message) { super(message); this.status = status }
}
export function validId(value) {
// 字符串长度和字典序检查Long上限;不经Number转换。
return typeof value === 'string' && /^[1-9][0-9]{0,18}$/.test(value)
&& (value.length < 19 || value <= '9223372036854775807')
}
export async function request(path, options = {}) {
const response = await fetch('/api/tasks' + path, {
...options, headers: { 'Content-Type': 'application/json', ...options.headers }
})
if (!response.ok) {
let message = '请求失败,请重试'
try { message = (await response.json()).message || message } catch { /* 非JSON错误 */ }
throw new ApiError(response.status, message)
}
return response.status === 204 ? null : response.json()
}src/App.vue
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<main><h1>任务路由练习</h1><RouterLink :to="{ name: 'tasks' }">任务列表</RouterLink>
<RouterView />
</main>
</template>src/main.mjs
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.mjs'
import './style.css'
createApp(App).use(router).mount('#app')src/NotFound.vue
<template><section><h2>页面不存在</h2><p>请通过上方链接返回任务列表。</p></section></template>src/router.mjs
import { createRouter, createWebHistory } from 'vue-router'
import TaskList from './TaskList.vue'
import TaskPage from './TaskPage.vue'
import NotFound from './NotFound.vue'
export default createRouter({ history: createWebHistory(), routes: [
{ path: '/', redirect: { name: 'tasks' } },
{ path: '/tasks', name: 'tasks', component: TaskList },
{ path: '/tasks/:id', name: 'task', component: TaskPage },
{ path: '/tasks/:id/edit', name: 'edit', component: TaskPage },
{ path: '/:pathMatch(.*)*', name: 'missing', component: NotFound }
] })src/style.css
:root { font-family: system-ui, sans-serif; color: #172536; background: #f3f6fa; }
main { max-width: 52rem; margin: 2rem auto; padding: 1.5rem; background: white; }
button, input { margin: .5rem; padding: .5rem; font: inherit; }
button:disabled { opacity: .6; }
[role="alert"] { color: #9f1724; }
a { color: #135f99; }src/TaskList.vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import { request } from './api.mjs'
const tasks = ref([]), title = ref(''), error = ref(''), loading = ref(false), saving = ref(false)
let controller, alive = true
async function load() {
controller?.abort()
const current = new AbortController(); controller = current
loading.value = true; error.value = ''; tasks.value = []
try { const result = await request('', { signal: current.signal }); if (alive && controller === current) tasks.value = result }
catch (e) { if (alive && controller === current && e.name !== 'AbortError') error.value = e.message }
finally { if (alive && controller === current) loading.value = false }
}
async function create() {
if (saving.value) return
const text = title.value.trim()
if (!text || title.value.length > 120) { error.value = '标题需为1至120个UTF-16单元'; return }
saving.value = true; error.value = ''
try {
await request('', { method: 'POST', body: JSON.stringify({ title: text, done: false }) })
if (alive) { title.value = ''; await load() }
} catch (e) { if (alive) error.value = e.message }
finally { if (alive) saving.value = false }
}
onMounted(load)
onBeforeUnmount(() => { alive = false; controller?.abort() })
</script>
<template>
<section><h2>前100条任务</h2>
<form @submit.prevent="create"><label>标题 <input v-model="title" maxlength="120" required :disabled="saving"></label>
<button :disabled="saving">{{ saving ? '提交中' : '创建' }}</button></form>
<p v-if="loading" role="status">加载中</p><p v-if="error" role="alert">{{ error }}</p>
<button :disabled="loading || saving" @click="load">重新加载</button>
<ul><li v-for="task in tasks" :key="task.id"><RouterLink :to="{ name: 'task', params: { id: task.id } }">{{ task.title }}</RouterLink> {{ task.done ? '已完成' : '未完成' }}</li></ul>
<p v-if="!loading && !error && !tasks.length">暂无任务</p>
</section>
</template>src/TaskPage.vue
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { request, validId } from './api.mjs'
const route = useRoute(), router = useRouter()
const task = ref(null), title = ref(''), done = ref(false), error = ref(''), loading = ref(false), saving = ref(false)
const retry = ref(0), editing = computed(() => route.name === 'edit')
let generation = 0
watch(() => [route.params.id, route.name, retry.value], async (_, __, onCleanup) => {
const ticket = ++generation, controller = new AbortController()
onCleanup(() => { ++generation; controller.abort() })
task.value = null; title.value = ''; done.value = false; error.value = ''; saving.value = false
const id = route.params.id
if (!validId(id)) { loading.value = false; error.value = '编号无效'; return }
loading.value = true
try {
const result = await request('/' + encodeURIComponent(id), { signal: controller.signal })
if (ticket !== generation) return
task.value = result; title.value = result.title; done.value = result.done
} catch (e) {
if (ticket === generation && e.name !== 'AbortError') error.value = e.status === 404 ? '任务不存在或已删除' : e.message
} finally { if (ticket === generation) loading.value = false }
}, { immediate: true })
async function mutate(remove = false) {
if (saving.value || !task.value) return
const text = title.value.trim()
if (!remove && (!text || title.value.length > 120)) { error.value = '标题需为1至120个UTF-16单元'; return }
const ticket = generation, snapshot = task.value
saving.value = true; error.value = ''
try {
const path = '/' + encodeURIComponent(snapshot.id)
await request(remove ? path + '?version=' + encodeURIComponent(snapshot.version) : path, {
method: remove ? 'DELETE' : 'PUT',
...(remove ? {} : { body: JSON.stringify({ title: text, done: done.value, version: snapshot.version }) })
})
if (ticket === generation) await router.push(remove ? { name: 'tasks' } : { name: 'task', params: { id: snapshot.id } })
} catch (e) {
if (ticket === generation) error.value = e.status === 409 ? '版本冲突:请重新加载,草稿将被覆盖' : e.message
} finally { if (ticket === generation) saving.value = false }
}
</script>
<template>
<section><h2>{{ editing ? '编辑任务' : '任务详情' }}</h2>
<p v-if="loading" role="status">加载中</p><p v-if="error" role="alert">{{ error }}</p>
<button :disabled="saving || loading" @click="retry++">重新加载(丢弃草稿)</button>
<template v-if="task">
<p>编号 {{ task.id }},版本 {{ task.version }}</p>
<form v-if="editing" @submit.prevent="mutate(false)">
<label>标题 <input v-model="title" required maxlength="120" :disabled="saving"></label>
<label><input v-model="done" type="checkbox" :disabled="saving">已完成</label>
<button :disabled="saving">{{ saving ? '提交中' : '保存' }}</button>
</form>
<template v-else><p>{{ task.title }}:{{ task.done ? '已完成' : '未完成' }}</p>
<RouterLink :to="{ name: 'edit', params: { id: task.id } }">编辑</RouterLink>
</template>
<button :disabled="saving" @click="mutate(true)">删除任务</button>
</template>
</section>
</template>test-spec.md
# 人工验收规格(示例未执行)
前置:自行启动匹配契约的任务API和Vite开发服务器。以下是未来验证步骤,不是本次测试记录,不提供假API或模拟服务。
1. 空列表显示“暂无任务”;创建合法标题后列表出现,详情URL含字符串ID。
2. 空白标题不能提交;121个UTF-16单元不能提交;后端独立校验仍必要。
3. 从任务A详情直接导航任务B详情,组件复用仍重新加载;使A读取慢于B,A结果与错误都不得覆盖B。卸载后不应更新视图。
4. GET返回404显示“任务不存在或已删除”;断网显示错误和重新加载按钮;未知前端路径显示页面不存在。
5. 两个窗口编辑同任务:先保存改变标题,再保存旧版本,应显示版本冲突;点击重新加载后草稿被覆盖,再次修改保存成功。
6. 连续点击保存只提交一次当前操作;保存中切换其他任务,旧结果不得导航回旧任务或覆盖新页。取消读取不保证服务器停止;写请求未取消,可能已经提交。
7. 删除携带当前版本;成功返回列表,旧详情404;旧版本删除409。
8. 直接访问/tasks/abc、/tasks/0或Long溢出地址显示编号无效,不发任务读取。用9007199254740993这类超Number安全范围但合法Long的契约样本,ID应始终保持字符串原值。
9. 开发环境刷新详情;部署后直接刷新详情:HTML返回SPA入口,/api仍代理后端,未知静态资源应404。preview不带开发代理,需单独同源API反向代理。
10. 断网后创建的结果不确定,禁止自动重试POST以免重复;先重新加载列表确认。PUT/DELETE也先重读版本。无认证、SSR、离线持久化或路由缓存验收。vite.config.mjs
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], server: {
proxy: { '/api': { target: 'http://127.0.0.1:8080' } }
} })Vue任务路由应该怎样自行验证
Vue任务路由应该怎样自行验证?读者未来可自行安装和启动项目后按test-spec.md验收:创建→列表→详情→编辑→删除,随后检查400/404/409和断网。重点让A读取慢于B,确认旧响应、旧错误和旧finally不影响B;保存中切换任务,旧写完成不得导航回旧页面。组件卸载后的行为也必须观察。
刷新详情需要验证真实静态服务器回退规则,不能只在开发服务器点击成功就认为生产可用。超安全整数但合法Long的ID要保持字符串原值;前端无效ID不发读取。当前静态检查只涵盖文件、契约、脚本语法和模板,不是这些交互验收通过;所有人工步骤与真实API集成均未执行。
练习:为复制项目准备交付检查
把Web项目单独复制给同事,同事只有静态服务器,没有API,也没有Node依赖。写出它为什么不能直接展示可用任务、还需要哪些准备,以及至少三个可观察验收结果。不要把验收预期写成已运行。
参考答案(推演,未执行)
源码需要按README安装匹配依赖并构建;任务操作还需满足openapi.json的后端。静态部署配置/api反向代理与history回退,开发server.proxy不会自动进入产物;preview也不是API代理。预期:创建后出现列表;合法但不存在的任务显示资源404;旧版本保存显示409;刷新详情仍加载SPA。上述需要真实集成才能证实,本轮没有执行,也不应临时伪造假API把界面成功当成接口通过。
继续阅读
- 响应式与组件
- 排错
- 响应式与组件