在 Laravel 5.7 使用 Vue 2.5 實作「簽到簿」應用程式

環境

  • Homestead

建立專案

1
laravel new guestbook

設置 .env 檔

1
2
3
4
DB_CONNECTION=sqlite
DB_HOST=127.0.0.1
DB_PORT=3306
# DB_DATABASE=homestead

建立資料庫

1
touch database/database.sqlite

建立 API 路由

routes/api.php

1
2
3
Route::resource('signatures', 'SignatureController')->except([
'create', 'edit'
]);

routes/web.php

1
2
3
4
5
Route::namespace('Front')->group(function () {
Route::resource('signatures', 'SignatureController')->only([
'index', 'create'
]);
});

設置路由服務提供者

1
2
3
4
5
6
7
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->namespace . '\Api')
->group(base_path('routes/api.php'));
}

新增模型

1
2
3
protected $fillable = [
'name', 'email', 'content',
];

新增遷移

1
2
3
4
5
6
7
8
Schema::create('signatures', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('email');
$table->text('content');
$table->softDeletes();
$table->timestamps();
});

新增填充

1
factory(App\Signature::class, 100)->create();

新增工廠

1
2
3
4
5
6
7
$factory->define(App\Signature::class, function (Faker $faker) {
return [
'name' => $faker->name,
'email' => $faker->safeEmail,
'content' => $faker->sentence
];
});

執行遷移

1
php artisan migrate --seed

新增控制器

app/Http/Controllers/Api/SignatureController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
namespace App\Http\Controllers\Api;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Resources\SignatureResource;
use App\Http\Requests\SignatureRequest;
use App\Signature;

class SignatureController extends Controller
{
/**
* Display a listing of the resource.
*
* @param \App\Signature $signature
* @return \Illuminate\Http\Response
*/
public function index(Request $request, Signature $signature)
{
return SignatureResource::collection($signature->paginate($request->per_page));
}

/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\SignatureRequest $request
* @param \App\Signature $signature
* @return \Illuminate\Http\Response
*/
public function store(SignatureRequest $request, Signature $signature)
{
if ($request->validator) {
return response($request->validator->errors(), 400);
}

$signature = $signature->create($request->all());

return new SignatureResource($signature);
}

/**
* Display the specified resource.
*
* @param \App\Signature $signature
* @return \Illuminate\Http\Response
*/
public function show(Signature $signature)
{
return new SignatureResource($signature);
}

/**
* Update the specified resource in storage.
*
* @param \App\Http\Requests\SignatureRequest $request
* @param \App\Signature $signature
* @return \Illuminate\Http\Response
*/
public function update(SignatureRequest $request, Signature $signature)
{
if ($request->validator) {
return response($request->validator->errors(), 400);
}

$signature->update($request->all());

return new SignatureResource($signature);
}

/**
* Remove the specified resource from storage.
*
* @param \App\Signature $signature
* @return \Illuminate\Http\Response
*/
public function destroy(Signature $signature)
{
$signature->delete();
}
}

app/Http/Controllers/Front/SignatureController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
namespace App\Http\Controllers\Front;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class SignatureController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
return view('signature.index');
}

/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
return view('signature.create');
}
}

監聽資源

1
2
npm install
npm run watch

設置 app.js 檔

1
2
Vue.component('signature-index', require('./components/signature/IndexComponent.vue'));
Vue.component('signature-create', require('./components/signature/CreateComponent.vue'));

建立視圖

resources/views/layouts/app.blade.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<link href="{{ asset('css/app.css') }}" rel="stylesheet" type="text/css">
</head>
<body>
<div id="app">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="{{ route('signatures.index') }}">GuestBook</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>

<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="{{ route('signatures.index') }}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('signatures.create') }}">Sign</a>
</li>
</ul>
</div>
</nav>

@yield('content')
</div>
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>

resources/views/signature/index.blade.php

1
2
3
4
5
6
7
8
9
10
11
@extends('layouts.app')

@section('content')
<div class="container">
<div class="row">
<div class="col-md-12">
<signature-index></signature-index>
</div>
</div>
</div>
@endsection

resources/views/signature/create.blade.php

1
2
3
4
5
6
7
8
9
10
11
@extends('layouts.app')

@section('content')
<div class="container">
<div class="row">
<div class="col-md-12">
<signature-create></signature-create>
</div>
</div>
</div>
@endsection

新增 Vue 元件

resources/js/components/signature/IndexComponent.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
<template>
<div>
<div class="row mb-3">
<div class="col-3 offset-9">
<select class="form-control" v-model="per_page">
<option>5</option>
<option>10</option>
<option>15</option>
</select>
</div>
</div>

<table class="table table-striped">
<thead>
<tr>
<th>編號</th>
<th>名字</th>
<th>內容</th>
<th>訊息</th>
</tr>
</thead>
<tbody>
<tr v-for="signature in data" :key="signature.index">
<td>{{ signature.id }}</td>
<td>{{ signature.name }}</td>
<td>{{ signature.content }}</td>
<td><a href="" @click.prevent="destroy(signature.id)">刪除</a></td>
</tr>
</tbody>
</table>

<div class="d-flex justify-content-center">
<nav aria-label="Page navigation">
<ul class="pagination">
<li class="page-item" :class="[meta.current_page == 1 ? 'disabled' : '']">
<a class="page-link" href="" @click.prevent="fetch(links.first)">第一頁</a>
</li>
<li class="page-item" :class="[meta.current_page == 1 ? 'disabled' : '']">
<a class="page-link" href="" @click.prevent="fetch(links.prev)">上一頁</a>
</li>
<li class="page-item" v-for="page in pages" :key="page.index" :class="[page == meta.current_page ? 'active' : '']">
<a class="page-link" href="" @click.prevent="fetch(url + '?page=' + page)">{{ page }}</a>
</li>
<li class="page-item" :class="[meta.current_page == meta.last_page ? 'disabled' : '']">
<a class="page-link" href="" @click.prevent="fetch(links.next)">下一頁</a>
</li>
<li class="page-item" :class="[meta.current_page == meta.last_page ? 'disabled' : '']">
<a class="page-link" href="" @click.prevent="fetch(links.last)">最後頁</a>
</li>
</ul>
</nav>
</div>
</div>
</template>

<style>
.page-link:active {
z-index: 1;
color: #fff;
background-color: #3490dc;
border-color: #3490dc;
}
.page-link:focus {
-webkit-box-shadow: 0 0 0 0rem;
box-shadow: 0 0 0 0rem;
}
</style>

<script>
export default {
data() {
return {
url: '/api/signatures',
per_page: 10,
pagination_size: 5,
data: [],
links: [],
meta: [],
pages: []
};
},
created() {
this.fetch();
},
watch: {
per_page() {
this.fetch();
}
},
methods: {
fetch(url = this.url + '?page=') {
axios.get(url + '&per_page=' + this.per_page)
.then(({data}) => {
this.data = data.data;
this.links = data.links;
this.meta = data.meta;
this.paginate();
});
},
paginate(meta = this.meta) {
let arr = [];
let begin;
let end;

switch (true) {
case (meta.current_page <= (this.pagination_size - 1) / 2):
begin = 1;
end = this.pagination_size;
break;

case (meta.current_page >= meta.last_page - (this.pagination_size - 1) / 2):
begin = meta.last_page - (this.pagination_size - 1);
end = meta.last_page;
break;

default:
begin = meta.current_page - (this.pagination_size - 1) / 2;
end = meta.current_page + (this.pagination_size - 1) / 2;
}

if (meta.last_page < this.pagination_size) {
begin = 1;
end = meta.last_page;
}

for (let i = begin; i <= end; i++) {
arr.push(i);
}

this.pages = arr;
},
destroy(id) {
if (confirm('確定刪除?')) {
axios.delete(this.url + '/' + id)
.then(response => {
this.data = _.remove(this.data, function (data) {
return data.id !== id;
});
this.fetch(this.url + '?page=' + this.meta.current_page);
});
}
}
}
}
</script>

resources/js/components/signature/CreateComponent.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
<template>
<div>
<form @submit.prevent="onSubmit">
<fieldset>
<legend class="text-center">GuestBook</legend>

<div class="form-group">
<label for="name">名字</label>
<div>
<input type="text"
minlength="3"
maxlength="30"
id="name"
:class="[
'form-control', {
'is-valid': nameIsValid,
'is-invalid': nameIsInvalid
}
]"
v-model="signature.name"
required>
<span class="invalid-feedback" v-if="nameIsInvalid">{{ errors.name[0] }}</span>
</div>
</div>

<div class="form-group">
<label for="email">信箱</label>
<div>
<input type="email"
minlength="3"
maxlength="30"
id="email"
:class="[
'form-control', {
'is-valid': emailIsValid,
'is-invalid': emailIsInvalid
}
]"
v-model="signature.email"
required>
<span class="invalid-feedback" v-if="emailIsInvalid">{{ errors.email[0] }}</span>
</div>
</div>

<div class="form-group">
<label for="content">訊息</label>
<div>
<textarea id="content"
:class="[
'form-control', {
'is-valid': contentIsValid,
'is-invalid': contentIsInvalid
}
]"
v-model="signature.content"
required></textarea>
<span class="invalid-feedback" v-if="contentIsInvalid">{{ errors.content[0] }}</span>
</div>
</div>

<div class="form-group">
<div class="text-center">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div>
</fieldset>
</form>

<div class="alert alert-success alert-dismissible fade show" role="alert" v-if="saved">
<strong>成功!表單已送出!</strong>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
</template>

<script>
export default {
data() {
return {
url: '/api/signatures',
signature: {
name: '',
email: '',
content: ''
},
validation: {
name: /^[a-zA-Z0-9\u4e00-\u9fa5]{3,30}$/,
email: /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/,
content: /^.{3,30}$/
},
saved: false,
errors: []
};
},
computed: {
nameIsValid: function() {
return this.validation.name.test(this.signature.name.trim());
},
nameIsInvalid: function() {
return (!this.validation.name.test(this.signature.name.trim()) && this.errors.name);
},
emailIsValid: function() {
return this.validation.email.test(this.signature.email.trim());
},
emailIsInvalid: function() {
return (!this.validation.email.test(this.signature.email.trim()) && this.errors.email);
},
contentIsValid: function() {
return this.validation.content.test(this.signature.content.trim());
},
contentIsInvalid: function() {
return (!this.validation.content.test(this.signature.content.trim()) && this.errors.content);
}
},
methods: {
onSubmit() {
this.saved = false;
axios.post(this.url, this.signature)
.then(({data}) => {
this.success()
})
.catch(({response}) => {
this.error(response.data)
});
},
success() {
this.saved = true;
this.reset();
},
error(data) {
this.errors = data;
},
reset() {
this.errors = [];
this.signature = {
name: '',
email: '',
content: ''
};
}
}
}
</script>

程式碼