从零开始搭建一个使用GHCR构建的Devops全栈项目

准备

本次搭建的项目技术选型:

  • 前端-vue3
  • 后端-fastify
  • 数据库-mysql
  • https证书-certbot
  • 开发环境-WSL2(非必须)
  • 生产环境-Ubuntu

创建项目

首先新建一个文件夹,用来储存我们的前后端项目,例如ghcr-application-demo,推荐建立在wsl文件系统目录下

新建前端项目

进入到ghcr-application-demo文件夹,执行pnpm create vue,将项目命名为ghcr-application-vue3

新建后端项目

ghcr-application-demo文件夹继续执行:npx fastify-cli generate ghcr-application-fastify --lang=ts,创建好后端项目

创建开发环境

将开发环境也运行在docker上是为了完全统一开发环境和生产环境,避免代码出现“在我电脑可以用,在你电脑上用不了/在服务器上用不了”这种情况

新建前端开发环境

进入到前端源码文件夹ghcr-application-vue3,新建一个文件Dockerfile.dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 使用官方 Node.js 镜像
FROM node:22-alpine

# 设置工作目录
WORKDIR /app

# 复制 package.json 和 package-lock.json (或 yarn.lock)
COPY package*.json ./

# 安装依赖
RUN npm install

# 复制所有剩余的源代码
COPY . .

# 暴露 Vite/Vue-CLI 的开发服务器端口
EXPOSE 5173

# 运行开发服务器
# 假设你的 package.json 中有一个 "dev" 脚本: "dev": "vite" 或 "vue-cli-service serve"
CMD ["npm", "run", "dev"]

修改vite.config.ts,新增配置

1
2
3
4
server: {
host: '0.0.0.0', // 监听所有地址
port: 5173
}

创建docker compose脚本

回到ghcr-application-demo目录,新建文件docker-compose.yml,写入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
services:
# 前端 Vue3 服务
ghcr-application-vue3:
build:
context: ./ghcr-application-vue3
dockerfile: Dockerfile.dev
container_name: demo-frontend-dev
ports:
- "5173:5173" # 将容器的5173端口映射到主机的5173端口
volumes:
- ./ghcr-application-vue3:/app # 将整个前端项目挂载进去
- /app/node_modules # 匿名卷,防止主机的node_modules覆盖容器内的
environment:
- NODE_ENV=development

运行前端开发环境

如果项目没在wsl文件系统下,需要设置{ usePolling: true },否则修改前端代码vite不会进行热重载,缺点是cpu占用会提高,详情可以了解:

https://cn.vitejs.dev/config/server-options.html#server-watch

在根目录执行docker compose up -d,此时会成功启动容器ghcr-application-vue3-dev,打开http://localhost:5173/即可访问到前端项目

这里开发的时候因为node_module都在镜像里,所以vscode会找不到依赖导致代码标红,有两种解决方案

  1. vscode安装Dev Containers插件,然后远程连接开发容器到ghcr-application-vue3-dev,打开/app目录进行开发(推荐)
  2. wsl终端手动运行一遍npm i,缺点是每次更新依赖都要手动运行一次,而且会占用双份磁盘空间

新建后端开发环境

进入到后端源码文件夹ghcr-application-vue3,新建一个文件Dockerfile.dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 使用官方 Node.js 镜像
FROM node:22-alpine

# 设置工作目录
WORKDIR /app

# 复制 package.json 和 package-lock.json (或 yarn.lock)
# 这样可以利用Docker的缓存机制,只有在依赖变化时才重新安装
COPY package*.json ./

# 安装所有依赖,包括开发依赖 (比如 nodemon)
RUN npm install

# 复制所有剩余的源代码
COPY . .

# 暴露 Fastify 默认监听的端口
EXPOSE 3000

# 使用 nodemon 启动开发服务器
CMD ["npm", "run", "dev"]

修改docker compose脚本

主要是加入了后端配置

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
services:
# 后端 Fastify 服务
ghcr-application-fastify:
build:
context: ./ghcr-application-fastify
dockerfile: Dockerfile.dev
container_name: ghcr-application-fastify-dev
ports:
- "5174:3000" # 将容器的3000端口映射到主机的5174端口
volumes:
- ./ghcr-application-fastify:/app # 将整个后端项目挂载到容器中,实现代码热更新
- /app/node_modules # 匿名卷,防止主机的node_modules覆盖容器内的
# 如果需要,也可以挂载其他目录
environment:
- NODE_ENV=development
# 前端 Vue3 服务
ghcr-application-vue3:
build:
context: ./ghcr-application-vue3
dockerfile: Dockerfile.dev
container_name: ghcr-application-vue3-dev
ports:
- "5173:5173" # 将容器的5173端口映射到主机的5173端口
volumes:
- ./ghcr-application-vue3:/app # 将整个前端项目挂载进去
- /app/node_modules # 匿名卷,防止主机的node_modules覆盖容器内的
environment:
- NODE_ENV=development
depends_on:
- ghcr-application-fastify # 确保后端服务先于前端启动

重新执行docker compose up -d,我们会发现启动了两个容器,分别是前端项目ghcr-application-vue3-dev和后端项目ghcr-application-fastify-dev,可以访问http://localhost:5173/http://localhost:5174/验证服务是否正常

引入mysql和env文件

创建一个mysql容器很简单,只需要在docker-compose.ylm文件里加上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  ghcr-application-mysql:
image: mysql:8.0.42
container_name: ghcr-application-mysql-dev
restart: always
env_file:
- .env.dev
volumes:
- mysql-dev-data:/var/lib/mysql # 持久化数据
ports:
- '5175:3306'
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD"]
interval: 15s
timeout: 10s
retries: 60
volumes:
mysql-dev-data:

这里我们使用了env_file来注入环境变量,所以我们需要在同级目录下再创建一个.env.dev文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# .env.dev

# 标识当前环境为开发环境

VITE_NODE_ENV=development # 前端只能读取到 VITE_ 开头的环境变量
# # 后端可以读取到 NODE_ENV 变量 之所以不复用 VITE_NODE_ENV 是因为这样更加解耦
# 而且 VITE_ 开头的变量会被打包到前端代码中,请不要乱用
NODE_ENV=development

VITE_APP_VERSION=1.0.1 # 前端应用版本号

NODE_APP_VERSION=1.0.2 # 后端应用版本号

# MySQL 数据库凭证
MYSQL_ROOT_PASSWORD=your_strong_root_password
MYSQL_DATABASE=myapp_db
MYSQL_USER=myapp_user
MYSQL_PASSWORD=your_strong_user_password

这时候我们便可以把刚才前端和后端手动配置的environment都交给.env.dev文件管理,然后使用depends_on调整下容器启动顺序,保证先启动mysql容器,然后启动后端再启动前端,一个完整的docker-compose.ylm文件如下

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
services:
# 后端 Fastify 服务
ghcr-application-fastify:
build:
context: ./ghcr-application-fastify # 构建上下文目录
dockerfile: Dockerfile.dev # 使用开发环境的 Dockerfile
container_name: ghcr-application-fastify-dev # 容器名
ports:
- '5174:3000' # 将容器的3000端口映射到主机的5174端口
volumes:
- ./ghcr-application-fastify/src:/app/src # 将主机的src目录挂载到容器中,实现代码热更新
# 如果需要,也可以挂载其他目录
env_file:
- .env.dev
environment: # 我们保留这个 environment 块,用于定义那些不是来自 .env 文件的变量
- DB_HOST=ghcr-application-mysql # 这个变量是跟Docker网络结构相关的,放在这里很合适
depends_on:
ghcr-application-mysql:
condition: service_healthy
# 前端 Vue3 服务
ghcr-application-vue3:
build:
context: ./ghcr-application-vue3 # 构建上下文目录
dockerfile: Dockerfile.dev # 使用开发环境的 Dockerfile
container_name: ghcr-application-vue3-dev # 容器名
ports:
- '5173:5173' # 将容器的5173端口映射到主机的5173端口
volumes:
- ./ghcr-application-vue3:/app # 将整个前端项目挂载进去
- /app/node_modules # 匿名卷,防止主机的node_modules覆盖容器内的
env_file:
- .env.dev
environment:
- NODE_ENV=development
depends_on:
- ghcr-application-fastify # 确保后端服务先于前端启动
# MySQL 数据库服务
ghcr-application-mysql:
image: mysql:8.0.41
container_name: ghcr-application-mysql-dev
restart: always
env_file:
- .env.dev
volumes:
- mysql-dev-data:/var/lib/mysql # 持久化数据
ports:
- '5175:3306'
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD"]
interval: 15s
timeout: 10s
retries: 60
volumes:
mysql-dev-data:

重新执行下docker compose up -d启动这三个容器,观察下是否按顺序启动了,再测试下运行都是否正常。

第一次启动会很慢,需要耐心等待

模拟开发

接下来我们简单做个增删改查功能,模拟我们日常开发

编写后端代码

首先远程连接到后端容器安装mysql依赖:npm i mysql2
修改后端代码ghcr-application-fastify\src\routes\example\index.ts

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
import { FastifyPluginAsync } from 'fastify'
import mysql from 'mysql2/promise'

interface User {
id?: number
name: string
email: string
age?: number
}

const createConnection = async () => {
return await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE
})
}

const initDatabase = async () => {
const connection = await createConnection()
try {
await connection.execute(`
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
age INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`)
console.log('Users table created or already exists')
} catch (error) {
console.error('Error creating users table:', error)
} finally {
await connection.end()
}
}

const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {

await initDatabase()

fastify.post('/users', async function (request, reply) {
const { name, email, age } = request.body as User

const connection = await createConnection()
try {
const [result] = await connection.execute(
'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
[name, email, age]
)
return { success: true, id: (result as any).insertId }
} catch (error) {
reply.code(500)
return { error: 'Failed to create user' }
} finally {
await connection.end()
}
})

fastify.get('/users', async function (request, reply) {
const connection = await createConnection()
try {
const [rows] = await connection.execute('SELECT * FROM users')
return { users: rows }
} catch (error) {
console.log('Error fetching users:', error);

reply.code(500)
return { error: 'Failed to fetch users' }
} finally {
await connection.end()
}
})

fastify.get('/users/:id', async function (request, reply) {
const { id } = request.params as { id: string }

const connection = await createConnection()
try {
const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [id])
const users = rows as User[]
if (users.length === 0) {
reply.code(404)
return { error: 'User not found' }
}
return { user: users[0] }
} catch (error) {
reply.code(500)
return { error: 'Failed to fetch user' }
} finally {
await connection.end()
}
})

fastify.put('/users/:id', async function (request, reply) {
const { id } = request.params as { id: string }
const { name, email, age } = request.body as User

const connection = await createConnection()
try {
const [result] = await connection.execute(
'UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?',
[name, email, age, id]
)
if ((result as any).affectedRows === 0) {
reply.code(404)
return { error: 'User not found' }
}
return { success: true }
} catch (error) {
reply.code(500)
return { error: 'Failed to update user' }
} finally {
await connection.end()
}
})

fastify.delete('/users/:id', async function (request, reply) {
const { id } = request.params as { id: string }

const connection = await createConnection()
try {
const [result] = await connection.execute('DELETE FROM users WHERE id = ?', [id])
if ((result as any).affectedRows === 0) {
reply.code(404)
return { error: 'User not found' }
}
return { success: true }
} catch (error) {
reply.code(500)
return { error: 'Failed to delete user' }
} finally {
await connection.end()
}
})
}

export default example

编写前端代码

前端需要修改两处,第一处是ghcr-application-vue3\vite.config.ts,要加上代理,防止跨域

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
import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'

// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
host: '0.0.0.0', // 监听所有地址
port: 5173,
proxy: {
'/api': {
// 目标不再是 localhost,而是后端的服务名
// 端口是后端容器内部正在监听的端口 (3000)
target: 'http://ghcr-application-fastify:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})

然后修改ghcr-application-vue3\src\App.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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface User {
id?: number
name: string
email: string
age?: number
}

const users = ref<User[]>([])
const currentUser = ref<User>({ name: '', email: '', age: undefined })
const editingId = ref<number | null>(null)
const loading = ref(false)



const fetchUsers = async () => {
try {
loading.value = true
const response = await fetch(`/api/example/users`)
const data = await response.json()
users.value = data.users || []
} catch (error) {
console.error('获取用户失败:', error)
alert('获取用户失败')
} finally {
loading.value = false
}
}

const saveUser = async () => {
if (!currentUser.value.name || !currentUser.value.email) {
alert('请填写姓名和邮箱')
return
}

try {
loading.value = true
const url = editingId.value
? `/api/example/users/${editingId.value}`
: `/api/example/users`

const method = editingId.value ? 'PUT' : 'POST'

const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(currentUser.value),
})

if (response.ok) {
resetForm()
await fetchUsers()
alert('操作成功')
} else {
alert('操作失败')
}
} catch (error) {
console.error('保存用户失败:', error)
alert('操作失败')
} finally {
loading.value = false
}
}

const editUser = (user: User) => {
currentUser.value = { ...user }
editingId.value = user.id!
}

const deleteUser = async (id: number) => {
if (!confirm('确定要删除这个用户吗?')) return

try {
loading.value = true
const response = await fetch(`/api/example/users/${id}`, {
method: 'DELETE',
})

if (response.ok) {
await fetchUsers()
alert('删除成功')
} else {
alert('删除失败')
}
} catch (error) {
console.error('删除用户失败:', error)
alert('删除失败')
} finally {
loading.value = false
}
}

const resetForm = () => {
currentUser.value = { name: '', email: '', age: undefined }
editingId.value = null
}

onMounted(() => {
fetchUsers()
})
</script>

<template>
<div class="app">
<!-- 表单区域 -->
<div class="form-container">
<div class="card-header">
<h2 class="form-title">
<span class="icon">{{ editingId ? '✏️' : '➕' }}</span>
{{ editingId ? '编辑用户' : '添加用户' }}
</h2>
</div>

<form @submit.prevent="saveUser" class="user-form">
<div class="form-row">
<div class="form-group">
<label class="form-label">
<span class="label-icon">👤</span>
姓名
</label>
<input
v-model="currentUser.name"
type="text"
class="form-input"
placeholder="请输入姓名"
required
/>
</div>

<div class="form-group">
<label class="form-label">
<span class="label-icon">📧</span>
邮箱
</label>
<input
v-model="currentUser.email"
type="email"
class="form-input"
placeholder="请输入邮箱"
required
/>
</div>

<div class="form-group">
<label class="form-label">
<span class="label-icon">🎂</span>
年龄
</label>
<input
v-model.number="currentUser.age"
type="number"
class="form-input"
placeholder="请输入年龄"
/>
</div>
</div>

<div class="form-actions">
<button type="submit" :disabled="loading" class="btn btn-primary">
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '处理中...' : (editingId ? '更新用户' : '创建用户') }}
</button>
<button type="button" @click="resetForm" v-if="editingId" class="btn btn-secondary">
取消编辑
</button>
</div>
</form>
</div>

<!-- 用户列表区域 -->
<div class="users-container">
<div class="list-header">
<h2 class="list-title">
<span class="icon">📋</span>
用户列表
<span class="user-count">({{ users.length }})</span>
</h2>
<button @click="fetchUsers" :disabled="loading" class="btn btn-refresh">
<span class="refresh-icon">🔄</span>
{{ loading ? '加载中...' : '刷新数据' }}
</button>
</div>

<div v-if="loading && users.length === 0" class="loading-state">
<div class="loading-spinner-large"></div>
<p>正在加载用户数据...</p>
</div>

<div v-else-if="users.length === 0" class="empty-state">
<div class="empty-icon">📭</div>
<h3>暂无用户数据</h3>
<p>点击上方"创建用户"按钮添加第一个用户</p>
</div>

<div v-else class="users-grid">
<div v-for="user in users" :key="user.id" class="user-card">
<div class="user-avatar">
<span class="avatar-text">{{ user.name.charAt(0).toUpperCase() }}</span>
</div>

<div class="user-info">
<h3 class="user-name">{{ user.name }}</h3>
<div class="user-details">
<div class="detail-item">
<span class="detail-icon">📧</span>
<span class="detail-text">{{ user.email }}</span>
</div>
<div v-if="user.age" class="detail-item">
<span class="detail-icon">🎂</span>
<span class="detail-text">{{ user.age }} 岁</span>
</div>
</div>
</div>

<div class="user-actions">
<button @click="editUser(user)" class="btn btn-edit">
<span class="btn-icon">✏️</span>
编辑
</button>
<button @click="deleteUser(user.id!)" class="btn btn-delete">
<span class="btn-icon">🗑️</span>
删除
</button>
</div>
</div>
</div>
</div>
</div>
</template>

<style scoped>
/* 全局样式 */
* {
box-sizing: border-box;
}

.app {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}

/* 主标题区域 */
.header {
text-align: center;
margin-bottom: 40px;
padding: 40px 20px;
}

.main-title {
font-size: 3rem;
font-weight: 700;
background: linear-gradient(135deg, #fff 0%, #f8f9ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0 0 10px 0;
text-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.subtitle {
color: rgba(255, 255, 255, 0.9);
font-size: 1.2rem;
font-weight: 300;
margin: 0;
}

/* 卡片通用样式 */
.form-container, .users-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
margin-bottom: 30px;
overflow: hidden;
}

/* 表单容器样式 */
.card-header {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
padding: 25px 30px;
border-bottom: none;
}

.form-title {
color: white;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}

.form-title .icon {
font-size: 1.3rem;
}

.user-form {
padding: 30px;
}

.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 25px;
margin-bottom: 30px;
}

.form-group {
position: relative;
}

.form-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
font-size: 0.95rem;
}

.label-icon {
font-size: 1rem;
}

.form-input {
width: 100%;
padding: 15px 18px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 16px;
transition: all 0.3s ease;
background: #fafafa;
}

.form-input:focus {
outline: none;
border-color: #4f46e5;
background: white;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
transform: translateY(-1px);
}

.form-actions {
display: flex;
gap: 15px;
justify-content: flex-start;
}

/* 按钮样式 */
.btn {
padding: 12px 24px;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
position: relative;
overflow: hidden;
}

.btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}

.btn:active {
transform: translateY(0);
}

.btn-primary {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: white;
}

.btn-secondary {
background: #6b7280;
color: white;
}

.btn-refresh {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}

.btn-edit {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
padding: 8px 16px;
font-size: 13px;
}

.btn-delete {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
padding: 8px 16px;
font-size: 13px;
}

.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}

/* 加载动画 */
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}

.loading-spinner-large {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-radius: 50%;
border-top-color: #4f46e5;
animation: spin 1s ease-in-out infinite;
margin: 0 auto 20px;
}

@keyframes spin {
to { transform: rotate(360deg); }
}

/* 列表头部 */
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 25px 30px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}

.list-title {
color: #111827;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}

.user-count {
font-size: 1rem;
color: #6b7280;
font-weight: 400;
}

.refresh-icon {
display: inline-block;
transition: transform 0.3s ease;
}

.btn-refresh:hover .refresh-icon {
transform: rotate(180deg);
}

/* 空状态和加载状态 */
.empty-state, .loading-state {
text-align: center;
padding: 60px 30px;
color: #6b7280;
}

.empty-icon {
font-size: 4rem;
margin-bottom: 20px;
}

.empty-state h3 {
color: #374151;
margin: 0 0 10px 0;
font-size: 1.5rem;
}

.empty-state p {
margin: 0;
font-size: 1rem;
}

/* 用户网格 */
.users-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 25px;
padding: 30px;
}

/* 用户卡片 */
.user-card {
background: white;
border-radius: 16px;
padding: 25px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
border: 1px solid #f3f4f6;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}

.user-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
}

.user-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
}

.user-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow: 0 4px 15px rgba(79, 70, 229, 0.3);
}

.avatar-text {
color: white;
font-size: 1.5rem;
font-weight: 700;
}

.user-info {
margin-bottom: 20px;
}

.user-name {
color: #111827;
margin: 0 0 15px 0;
font-size: 1.3rem;
font-weight: 600;
}

.user-details {
display: flex;
flex-direction: column;
gap: 8px;
}

.detail-item {
display: flex;
align-items: center;
gap: 10px;
color: #6b7280;
font-size: 0.9rem;
}

.detail-icon {
font-size: 1rem;
width: 20px;
}

.detail-text {
flex: 1;
}

.user-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}

.btn-icon {
font-size: 0.9rem;
}

/* 响应式设计 */
@media (max-width: 768px) {
.app {
padding: 15px;
}

.main-title {
font-size: 2.5rem;
}

.form-row {
grid-template-columns: 1fr;
gap: 20px;
}

.list-header {
flex-direction: column;
gap: 15px;
align-items: stretch;
}

.users-grid {
grid-template-columns: 1fr;
padding: 20px;
}

.user-card {
padding: 20px;
}

.form-actions {
flex-direction: column;
}

.btn {
justify-content: center;
}
}

@media (max-width: 480px) {
.main-title {
font-size: 2rem;
}

.subtitle {
font-size: 1rem;
}

.user-actions {
flex-direction: column;
}
}
</style>

接下来访问http://localhost:5173/

即可预览我们创建的项目了

后续文档待完善中