.NET WebAPI With Vue.js and Docker - Todolist
2023-09-11
實作使用 .NET WebAPI 作為後端,Vue.js 作為前端,並且使用 Docker 進行容器化的部署方式,設計一個簡單的 Todolist 應用程式。
說明
建立 ASP.NET Core WebAPI 專案
項目 | 名稱 |
---|---|
解決方案 | Todolist |
WebAPI 專案 | TodolistAPI |
Vue 專案 | TodolistVue |
先進行 git 初始化:
git init
WebAPI
Models
使用 cli 安裝 Entity Framework Core:
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
接著加入 TodoItem Model:
TodoItem.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public class TodoItem
{
[Key]
public int TodoItemID { get; set; }
[Required]
[MaxLength(255)]
public string? Title { get; set; }
[MaxLength]
public string? Description { get; set; }
public DateTime? DueDate { get; set; }
[ForeignKey("Category")]
public int? CategoryID { get; set; }
public Category? Category { get; set; }
}
Category.cs
using System.ComponentModel.DataAnnotations;
public class Category
{
[Key]
public int CategoryID { get; set; }
[Required]
[MaxLength(255)]
public string? CategoryName { get; set; }
}
最後加上 DBContext,來連結 Models 與 Database。
TodoContext.cs
using Microsoft.EntityFrameworkCore;
namespace TodolistService.Models;
public class TodoContext : DbContext
{
public TodoContext(DbContextOptions<TodoContext> options) : base(options) { }
public DbSet<Category> Categories { get; set; }
public DbSet<TodoItem> TodoItems { get; set; }
}
連線字串設定在 appsettings.json,簡化使用 LocalDB 以及 Integrated Security 的連線方式來示範,實際專案可以用明確的 User ID 與 Password 以及搭配正式的 SQL Server 來使用。
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=TodoListDB;Integrated Security=true;TrustServerCertificate=true"
},
"AllowedHosts": "*"
}
接著進行 Migration (如果尚未安裝 dotnet-ef 必須先進行安裝):
dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate
dotnet ef database update
完成 Datbase Update 後,會發現 Entity Framework Core 已經自動在 LocalDB 中建立了 TodoListDB Database,並且建立了 Categories 與 TodoItems Table。
Controllers
接著建立 TodoItemsController 來處理 TodoItem 的 CRUD。
透過 Scaffold-DbContext 來建立 Controller,只進行以下的修改,加入 Categories 以及回應 TodiItems 使用 Eager Loading:
// GET: api/Categories
[HttpGet("/api/Categories")]
public async Task<ActionResult<IEnumerable<Category>>> GetCategories()
{
return await _context.Categories.ToListAsync();
}
// GET: api/TodoItems
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
{
if (_context.TodoItems == null)
{
return NotFound();
}
return await _context.TodoItems.Include(i => i.Category).ToListAsync();
}
完成後啟用網站,有看到 Swagger 表示已經可以進行 CRUD 了,
使用 POST 輸入一筆資料,同時建立 TodoItem 以及 Category (能夠這麼方便全是因為 Model Binding 的幫忙 😎):
{
"title": "Buy Milk",
"description": "🥛",
"dueDate": "2023-09-11T12:00:00.939Z",
"category": {
"categoryName": "Life"
}
}
啟用 CORS
在 Startup.cs 中加入 CORS Policy:
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
builder.Services.AddCors(options =>
{
options.AddPolicy(name: MyAllowSpecificOrigins,
policy =>
{
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowAnyOrigin();
});
});
app.UseCors(MyAllowSpecificOrigins);
Vue
首先加入專案,類型選擇「獨立 JavaScript Vue 專案」。
首先調整 index.html 加入對 Bootstrap 及 Google Icon 的引用:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
</body>
</html>
src/App.vue
Template 的部分:
<template>
<div class="container">
<div class="row text-center my-4">
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
<h1>Vue.js Todolist</h1>
</div>
<div class="row">
<div class="col-md-4 p-3">
<Form :postDataObj="postDataObj"
:editMode="editMode"
:categories="categories"
@init-post="initPostData"
@load-todo="loadTodos" />
</div>
<div class="col-md-8">
<Table :todos="todos" @edit-todo="editTodoItem" @add-todo="addTodoItem" @delete-todo="deleteTodoItem" />
</div>
</div>
</div>
</template>
Script 與 Style 的部分:
<script>
import Form from './components/Form.vue';
import Table from './components/Table.vue';
export default {
components: {
Form,
Table,
},
data() {
return {
postDataObj: {
title: '',
description: '',
dueDate: this.getDatetimeNow(),
},
editMode: false,
todos: [],
categories: [],
};
},
mounted() {
this.loadTodos();
this.loadCategories();
this.initPostData();
},
methods: {
getDatetimeNow() {
return new Date().toISOString().slice(0, 16);
},
initPostData() {
this.postDataObj = {
title: '',
description: '',
dueDate: this.getDatetimeNow(),
};
document.getElementById('title').focus();
},
loadTodos() {
fetch(`${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`, { method: 'GET' })
.then(response => response.json())
.then(data => {
this.todos = data;
})
.catch(error => {
console.error('Error fetching data:', error);
});
},
loadCategories() {
fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Categories/`)
.then(response => response.json())
.then(data => {
this.categories = data.map(item => item);
})
.catch(error => {
console.error('Error fetching data:', error);
});
},
editTodoItem(todo) {
this.postDataObj.title = todo.title;
this.postDataObj.description = todo.description;
this.postDataObj.dueDate = todo.dueDate;
this.postDataObj.categoryId = todo.category ? todo.category.categoryID : null;
this.postDataObj.TodoItemID = todo.todoItemID;
// Change the button label and action
this.editMode = true;
},
addTodoItem() {
this.editMode = false;
this.initPostData();
},
deleteTodoItem(todo) {
const confirmDelete = confirm("Are you sure you want to delete this item?");
if (confirmDelete) {
fetch(`${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${todo.todoItemID}`, {
method: 'DELETE'
})
.then(response => {
if (response.ok) {
this.loadTodos();
this.initPostData();
console.log('Successfully deleted todo item.');
} else {
console.error('Failed to delete todo item.');
}
})
.catch(error => {
console.error('Error deleting todo item:', error);
});
}
},
postData() {
const requestOptions = {
method: this.editMode ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.postDataObj)
};
const url = this.editMode
? `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${this.postDataObj.TodoItemID}`
: `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`;
if (requestOptions.method == 'PUT') {
fetch(url, requestOptions)
.then(response => {
this.loadTodos();
console.log('Success fetching data:', response);
})
.catch(error => {
console.error('Error fetching data:', error);
});
}
else {
fetch(url, requestOptions)
.then(response => response.json())
.then(data => {
this.initPostData();
this.loadTodos();
console.log('Success fetching data:', data);
})
.catch(error => {
console.error('Error fetching data:', error);
});
}
}
}
};
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Gochi+Hand&display=swap');
body {
font-family: 'Gochi Hand', cursive;
font-size: 1.2em;
}
</style>
src/components/form.vue
Template 的部分:
<template>
<form @submit.prevent="postData">
<div class="mb-3 row">
<label for="title" class="col-md-4 col-form-label">Title</label>
<div class="col-md-8">
<input type="text" id="title" class="form-control" v-model="postDataObj.title" required>
</div>
</div>
<div class="mb-3 row">
<label for="description" class="col-md-4 col-form-label">Description</label>
<div class="col-md-8">
<input type="text" id="description" class="form-control" v-model="postDataObj.description" required>
</div>
</div>
<div class="mb-3 row">
<label for="dueDate" class="col-md-4 col-form-label">Due Date</label>
<div class="col-md-8">
<input type="datetime-local" id="dueDate" class="form-control" v-model="postDataObj.dueDate" required>
</div>
</div>
<div class="mb-3 row">
<label for="category" class="col-md-4 col-form-label">Category</label>
<div class="col-md-8">
<select id="category" class="form-select" v-model="postDataObj.categoryId" required>
<option v-for="category in categories" :key="category.categoryID" :value="category.categoryID">{{ category.categoryName }}</option>
</select>
</div>
</div>
<div class="mb-3">
<div v-if="editMode == true">
<button type="submit" class="btn btn-success">Edit</button>
</div>
<div v-else>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div>
</form>
</template>
Script 的部分:
<script>
export default {
props: {
postDataObj: Object,
editMode: Boolean,
categories: Array,
},
methods: {
postData() {
const requestOptions = {
method: this.editMode ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.postDataObj)
};
const url = this.editMode
? `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${this.postDataObj.TodoItemID}`
: `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`;
if (requestOptions.method == 'PUT') {
fetch(url, requestOptions)
.then(response => {
this.$emit('load-todo');
console.log('Success fetching data:', response);
})
.catch(error => {
console.error('Error fetching data:', error);
});
} else {
fetch(url, requestOptions)
.then(response => response.json())
.then(data => {
this.$emit('init-post');
this.$emit('load-todo');
console.log('Success fetching data:', data);
})
.catch(error => {
console.error('Error fetching data:', error);
});
}
},
},
};
</script>
src/components/table.vue
template 的部分:
<template>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Description</th>
<th>Due Date</th>
<th>Category</th>
<th class="text-success">
<span class="material-icons fs-3" style="cursor: pointer;" @click="addTodoItem()">add</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="todo in todos" :key="todo.todoItemID" @click="editTodoItem(todo)">
<td>{{ todo.todoItemID }}</td>
<td>{{ todo.title }}</td>
<td>{{ todo.description }}</td>
<td>{{ todo.dueDate }}</td>
<td>{{ todo.category ? todo.category.categoryName : 'N/A' }}</td>
<td class="text-danger">
<button @click="deleteTodoItem(todo)" class="btn btn-danger">Delete</button>
</td>
</tr>
</tbody>
</table>
</template>
Script 的部分:
<script>
export default {
props: {
todos: Array,
},
methods: {
editTodoItem(todo) {
this.$emit('edit-todo', todo);
},
addTodoItem() {
this.$emit('add-todo');
},
deleteTodoItem(todo) {
this.$emit('delete-todo', todo);
},
},
};
</script>
並且在 vue.js 專案加入 .env
.env
VITE_API_BASE_URL="https://localhost:44396"