Try   HackMD

2023-07-24 Java程式設計師養成班上課筆記 Part II

筆記網址

Part I: https://hackmd.io/@aaronlife/2023-java-1-1
Part II: https://hackmd.io/@aaronlife/2023-java-1

2023-07-24 ~ 2023-07-28 課綱

  1. 全端開發環境安裝
  2. Bootstrap使用
  3. Yarn + vite 開發方式
  4. Vue.js + vue-router + pinia
  5. Spring Boot + MVC架構
  6. Scrum專案開發
  7. GCE雲端部屬(nodejs+tomcat)

2023-07-28

後端如何查log

  1. 進入/opt/tomcat/apache-tomcat-10.1.11/logs目錄

    如果有權限問題,可以透過chmod指令來開放權限
    $ sudo chmod 777 logs
    請在/opt/tomcat/apache-tomcat-10.1.11目錄下該指令

  2. 瀏覽catalina.out檔案:
    $ cat catalina.out

後端資料庫斷線重連

repository/BaseRepository.java
package com.aaronlife.blog.repository; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.stereotype.Repository; import com.aaronlife.blog.model.MyConfig; @Repository public class BaseRepository { @Autowired private MyConfig myConfig; private Connection conn = null; public void init() { // 如果已經連線過,就不需要再連線 if(this.conn != null) return; try { // 註冊驅動程式 Class.forName(myConfig.getDriverClassName()); // 建立連線 conn = DriverManager.getConnection(myConfig.getUrl() + "?allowPublicKeyRetrieval=true&useSSL=false&user=" + myConfig.getUsername() + "&password=" + myConfig.getPassword()); } catch(SQLException e) { conn = null; System.out.println("BaseRespsition: " + e.getMessage()); System.out.println(e.getErrorCode()); System.out.println(e.getSQLState()); } catch(ClassNotFoundException e) { conn = null; System.out.println(e.getMessage()); } } public Connection getConnection() { return this.conn; } // 關閉練線並清除連線物件 public void closeConnection() { try { conn.close(); } catch (SQLException e) { System.out.println(e.getMessage()); } conn = null; } }
repository/ArticleRepository.java
package com.aaronlife.blog.repository; import java.sql.Array; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.stereotype.Repository; import com.aaronlife.blog.model.ArticleDetailModel; import com.aaronlife.blog.model.ArticleListModel; import com.aaronlife.blog.model.MyConfig; @Repository public class ArticleRepository extends BaseRepository { @Autowired private MyConfig myConfig; public ArrayList<ArticleListModel> findAll() { super.init(); if(getConnection() != null) // 確定有連線成功 { try { Statement stmt = getConnection().createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM articles"); ArrayList<ArticleListModel> data = new ArrayList<>(); // 取得每一筆文章資訊 while(rs.next()) { ArticleListModel a = new ArticleListModel(); a.setId(rs.getInt("id")); // id欄位名稱必須和資料庫蘭為名稱一致 a.setTitle(rs.getString("title")); a.setImgUrl(rs.getString("img_url")); a.setContent(rs.getString("content")); data.add(a); } // 模擬資料庫斷線 getConnection().close(); return data; } catch (SQLException e) { System.out.println("ArticleRepository: " + e.getMessage()); System.out.println(e.getErrorCode()); System.out.println(e.getSQLState()); if(e.getErrorCode() == 0 && e.getSQLState().equals("08003")) { closeConnection(); // 斷線並釋放資源 init(); // 重新連線 return findAll(); } return null; } } return null; } public ArticleDetailModel findArticleById(int id) { super.init(); if(getConnection() != null) // 確定有連線成功 { try { PreparedStatement stmt = getConnection() .prepareStatement("SELECT * FROM articles WHERE id=?"); stmt.setInt(1, id); ResultSet rs = stmt.executeQuery(); boolean isExists = rs.next(); // 判斷有沒有資料 if(isExists) { // true = 有資料 ArticleDetailModel a = new ArticleDetailModel(); a.setId(rs.getInt("id")); a.setTitle(rs.getString("title")); a.setSubtitle(rs.getString("subtitle")); a.setContent(rs.getString("content")); a.setDatetime(rs.getDate("update_time")); a.setAuthor(rs.getString("author")); return a; } else { return null; } } catch (SQLException e) { System.out.println("ArticleRepository: " + e.getMessage()); System.out.println(e.getErrorCode()); System.out.println(e.getSQLState()); if(e.getErrorCode() == 0 && e.getSQLState().equals("08003")) { closeConnection(); // 斷線並釋放資源 init(); // 重新連線 return findArticleById(id); } return null; } } return null; } }

注意:
修改完後須重新佈署

加上版本號碼來部屬時確認有無成功

BlogApplication.java
package com.aaronlife.blog; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class BlogApplication { public static void main(String[] args) { System.out.println("版本: 1.00.01"); SpringApplication.run(BlogApplication.class, args); } }

修改Spring Boot Banner

  1. main/resources/application.properites內心增設定:
spring.banner.charset=UTF-8
spring.banner.location=classpath:banner.txt
  1. 新增banner.txt檔案,跟application.properties放在同一個目錄下
                                       
 ,--,--. ,--,--.,--.--. ,---. ,--,--,  
' ,-.  |' ,-.  ||  .--'| .-. ||      \ 
\ '-'  |\ '-'  ||  |   ' '-' '|  ||  | 
 `--`--' `--`--'`--'    `---' `--''--' 
Version: 1.00.01

Banner產生器: https://devops.datenkollektiv.de/banner.txt/index.html

防火牆設定

211.23.203.0/24
220.130.40.0/24
60.0.0.0/8

2023-07-27

部署

GCE免費方案: https://cloud.google.com/free/docs/free-cloud-features?hl=zh-tw#compute

啟用Compute Engine API

Screen Shot 2023-12-07 at 7.13.34 AM

MacOS產生金鑰方式

$ ssh-keygen -t rsa -f ~/.ssh/user -C user

設定金鑰

Compute Engine -> 中繼資料 -> 安全殼層資料 -> 編輯 -> 新增項目

ssh連線到雲端

  1. 打開putty
  2. Host name: {username}@{public IP}
  3. 設定Private Key: Connection->SSH->Auth->Credintals->第一個框要選擇剛剛儲存的Private Key

MacOS ssh連線

$ chmod 600 ~/.ssh/{account}
$ ssh -i ~/.ssh/{account} {account}@{ip}

安裝環境

更新套件管理工具

$ sudo apt update

安裝JDK 17

$ sudo apt install openjdk-17-jdk -y

$ java --version確認有無安裝成功

安裝nginx

$ sudo apt install nginx

下載WinSCP

https://winscp.net/download/WinSCP-6.1.1-Setup.exe

MacOS SCP

scp -i ~/.ssh/my-ssh-name -r ~/Downloads/dist my-ssh-name@35.238.194.116:~

確認檔案室否在雲端

$ cd ~
$ ls
$ ls -l
$ cd dist
$ ls -l

確認nginx狀態

$ systemctl status nginx

啟動nginx

$ sudo systemctl start nginx

前端網頁複製到/var/www/html目錄下

$ sudo cp -r ~/dist/* /var/www/html

需先使用WinSCP複製到家目錄
$ cd /var/www/html 可以切到nginx的網站home目錄

調整nginx上的vue-router相關設定

切換到`/etc/nginx/sites-enabled目錄下

$ cd /etc/nginx/sites-enabled

編輯default檔案

sudo vim default

重要: 在vim內按i進入編輯模式

畫面下方會出現--INSERT--文字
找到location {} 區塊,然後加上#字號註解掉後加上vue-router官方的設定

location / {
  try_files $uri $uri/ /index.html;
}

參考:
https://router.vuejs.org/guide/essentials/history-mode.html

編輯完後,按ESC離開編輯模式(--INSERT--文字消失)

接著輸入:wq儲存並離開vim

重新啟動nginx後生效剛剛設定

$ sudo systemctl restart nginx

安裝tomcat

網址: https://tomcat.apache.org/download-10.cgi
下載: https://dlcdn.apache.org/tomcat/tomcat-10/v10.1.16/bin/apache-tomcat-10.1.16.tar.gz

$ cd ~
$ wget https://dlcdn.apache.org/tomcat/tomcat-10/v10.1.16/bin/apache-tomcat-10.1.16.tar.gz

安裝tomcat

$ cd /opt
$ sudo mkdir tomcat
$ cd tomcat
$ sudo tar -xvf ~/apache-tomcat-10.1.16.tar.gz -C .
$ cd apache-tomcat-10.1.16

啟動tomcat

$ sudo su
$ cd bin
$ ./startup.sh

設定防火牆給8080 port

進入設定防火牆規則->建立防火牆規則

名稱: tomcat
目的標記: tomcat
來源IPv4範圍: 0.0.0.0/0
指定的通訊部號: TCP 打勾, 通訊埠: 8080

重要!!!

回到VM清單後點VM名稱進入,再點編輯,在網路標記的地方加上tomcat標記,否則防火牆設定不會套用。

打包war檔

  1. Maven->Execute Command
  2. 先執行Clean清除快取檔案
  3. Maven->Execute Command
  4. Install進行打包

將war透過WinSCP上傳到雲端vm

佈署war檔

切換到webapps目錄下

$ cd /opt/tomcat/apache-tomcat-10.1.16/webapps

將原本的ROOT目錄改名為ROOT_bkp

$ mv ROOT ROOT_bkp

將上船的war檔複製到webapps目錄下並改名為ROOT.war

$ cp /home/{account}/myspring-0.0.1-SNAPSHOT.war ROOT.war

補充:

  1. 以上指令必須先使用sudo su切換到root帳號
  2. war檔複製到webapps內後,tomcat會自動解壓縮還原

試打API

http://{ip}:8080/v1/product

會出現

{"code":999,"message":"無法取得文章清單","data":null}

有看到JSON response代表佈署成功,因為資料庫未安裝,所以還沒有資料。

安裝mySQl

  1. 新增mysql設定到apt
# cd ~
$ wget https://dev.mysql.com/get/mysql-apt-config_0.8.24-1_all.deb
$ sudo apt install ./mysql-apt-config_0.8.24-1_all.deb
$ sudo apt update

須先exit離開su模式

  1. 安裝mysql
$ sudo apt install mysql-server

root密碼請設複雜一點,因為如果開放到網路會很容易被駭客入侵

  1. 確認mysql狀態
$ sudo service mysql status
  1. 檢查mySQL使用了那些port
$ sudo netstat -tulpn | grep LISTEN
  1. 新增防火牆規則3306, 33060兩個port
  2. 將root帳號的遠端連線權限打開
$ mysql -u root -p
mysql> CREATE USER 'root'@'%' IDENTIFIED BY '0000';
mysql> GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;

7.調整後端Spring Boot的BaseRepository.java建立連線時需要加上allowPublicKeyRetrieval=true&useSSL=false&

// 建立連線
            conn = DriverManager.getConnection(myConfig.getUrl() + "?allowPublicKeyRetrieval=true&useSSL=false&user=" + 
                myConfig.getUsername() + "&password=" + myConfig.getPassword());

2023-07-26

部落格後端開發

1. 設計資料庫(使用https://dbdiagram.io)

Table articles {
  id integer [primary key, increment]
  title varchar(128)
  subtitle varchar(128)
  img_url varchar
  content varchar
  author varchar(32)
  update_time datetime
}

2. 建立資料庫(使用dbeaver)

資料庫名稱: blog
也順便加入一些測試用資料到Table中

測試資料SQL dump:

-- MySQL dump 10.13  Distrib 8.0.19, for Win64 (x86_64)
--
-- Host: localhost    Database: blog
-- ------------------------------------------------------
-- Server version	8.0.17

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;

--
-- Table structure for table `articles`
--

DROP TABLE IF EXISTS `articles`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `articles` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(128) DEFAULT NULL,
  `subtitle` varchar(128) DEFAULT NULL,
  `img_url` varchar(255) DEFAULT NULL,
  `content` varchar(255) DEFAULT NULL,
  `author` varchar(32) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `articles`
--

LOCK TABLES `articles` WRITE;
/*!40000 ALTER TABLE `articles` DISABLE KEYS */;
INSERT INTO `articles` VALUES (1,'張育成穩了?紅襪交易內野工具人赫南德茲回道奇!換回2右投','三立新聞網','https://s.yimg.com/ny/api/res/1.2/L7zdh9J3E6ZHFY1B3KFkvA--/YXBwaWQ9aGlnaGxhbmRlcjt3PTk2MDtoPTU0MDtjZj13ZWJw/https://media.zenfs.com/ko/setn.com.tw/4ecc2b856d399e42bc580f45518da290','張育成穩了?紅襪把他受傷期間頂替守游擊的工具人赫南德茲(Enrique Hernandez)交易給前東家道奇,換回2位投手。','記者劉彥池','2023-07-26 00:00:00'),(2,'SpeXial前成員疑遭毆濺血 肇事女子被攔「當場跪地求饒」','中時新聞網','https://s.yimg.com/ny/api/res/1.2/0pKlgZcKz4LPVvK5qt5zJw--/YXBwaWQ9aGlnaGxhbmRlcjt3PTk2MDtoPTY0MDtjZj13ZWJw/https://media.zenfs.com/ko/mirrormedia.mg/4fdd62e508a9db728a06efa875c2b762','Teddy(陳向熙)和《加油喜事》演員臧芮軒等人開心聚餐飲酒。想不到卻變成喋血事件,因被隔壁桌客人的衝突波及,竟被打到鼻孔爆血,還報警、送急診室。藝人常常成團歡聚,卻也有不少嗨事演變成衝突的例子。','娛樂組','2023-07-25 00:00:00'),(3,'杜蘇芮「蛇行打轉中」速度變了!暴雨炸10縣市 大台北風狂吹','三立新聞網','https://s.yimg.com/ny/api/res/1.2/9zTJXy8vgZs_MuWmSlbCvg--/YXBwaWQ9aGlnaGxhbmRlcjt3PTk2MDtoPTk2MDtjZj13ZWJw/https://media.zenfs.com/ko/setn.com.tw/7014e688bae91ea7beb70175773d16a7','根據今(26)早8點資料,中颱「杜蘇芮」在菲律賓近海有打轉現象,北上速度稍微變慢,預計今日下午會逐漸觸碰到恆春半島陸地。中央氣象局目前針對台南、高雄、屏東等地發布陸警;屏東縣山區、宜蘭縣山區、花蓮縣、台東縣等10縣市大、豪雨特報,民眾務必多加留意。','記者楊晏琳','2023-07-26 00:00:00'),(4,'麥特戴蒙拒演《阿凡達》擦身78億片酬!自嘲「推掉最多錢的演員」','現年52歲的美國男星','https://s.yimg.com/ny/api/res/1.2/OlTxRh5FjU4i7p6Sulolcw--/YXBwaWQ9aGlnaGxhbmRlcjt3PTk2MDtoPTY3MjtjZj13ZWJw/https://media.zenfs.com/en/innews_com_tw_7/ca5e5e9b5f888826dfa04d9386cebe07','據外媒報導,近日麥特戴蒙接受《CNN》專訪時,談及2009年間,《阿凡達》導演詹姆斯卡麥隆(James Cameron)曾有意邀他擔綱男主角,還附上一定比例的票房分紅作為條件,待遇非常優渥。但當時他覺得仍有責任在身,需先完成手上的動作電影《神鬼認證》系列,不想因演出《阿凡達》而拋下劇組,因此婉拒了片約。','張疏影','2023-07-25 00:00:00');
/*!40000 ALTER TABLE `articles` ENABLE KEYS */;
UNLOCK TABLES;

--
-- Dumping routines for database 'blog'
--
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2023-07-26  9:48:28

3. 建立Spring Boot專案(使用Visual Studio Code)

  1. Ctrl+Shift+P打開VSCode功能選單
  2. 選擇:Create Java Project
  3. 選擇:Spring Boot
  4. 選擇:Maven Project
  5. 選擇:3.1.2
  6. 選擇:Java
  7. Group Id: [Enter]
  8. Artifact Id: [Enter]
  9. War
  10. Java Version: 17
  11. Dependencies:
    • Spring Web
    • Spring Boot Devtools(Hot Reload)
    • MySQL Driver
    • Lombok(org.projectlombok)

4. 建立專案目錄

地點: /src/main/java/{gorup id}/{artiface id}/

  • service: 實際API要使用到的服務
  • controller: API本體,接收request,回應response
  • repository: 負責資料庫的存取
  • model: 純資料Java POJO類別

5. 設定參數到application.properites

spring.datasource.url=jdbc:mysql://localhost/blog spring.datasource.username=root spring.datasource.password=0000 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # 設定Tomcat使用的port server.port=8080

6. 建立MyConfig.java用來

model/MyConfig.java
package com.aaronlife.blog.model; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import lombok.Data; @Component @Data public class MyConfig { @Value("${spring.datasource.url}") private String url; @Value("${spring.datasource.username}") private String username; @Value("${spring.datasource.password}") private String password; @Value("${spring.datasource.driver-class-name}") private String driverClassName; }

7. 建立Repository父類別

功能:

  1. 註冊JDBC
  2. 建立資料庫連線
repository/BaseRepository.java
package com.aaronlife.blog.repository; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.stereotype.Repository; import com.aaronlife.blog.model.MyConfig; @Repository public class BaseRepository { @Autowired private MyConfig myConfig; private Connection conn = null; public void init() { // 如果已經連線過,就不需要再連線 if(this.conn != null) return; try { // 註冊驅動程式 Class.forName(myConfig.getDriverClassName()); // 建立連線 conn = DriverManager.getConnection(myConfig.getUrl() + "?user=" + myConfig.getUsername() + "&password=" + myConfig.getPassword()); } catch(SQLException e) { conn = null; System.out.println(e.getMessage()); } catch(ClassNotFoundException e) { conn = null; System.out.println(e.getMessage()); } } public Connection getConnection() { return this.conn; } }

8. 定義存放文章清單用的model

model/ArticleListModel.java
package com.aaronlife.blog.model; import lombok.Data; @Data public class ArticleListModel { private int id; private String imgUrl; private String title; private String content; }

9. 建立存放文章詳細資訊的model

model/ArticleDetailModel.java
package com.aaronlife.blog.model; import java.sql.Date; import lombok.Data; @Data public class ArticleDetailModel { private int id; private String title; private String subtitle; private String content; private String author; private Date datetime; }

10. 建立存取文章Table repository

處理關於article table的CRUD

repository/ArticleRepository.java
package com.aaronlife.blog.repository; import java.sql.Array; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.stereotype.Repository; import com.aaronlife.blog.model.ArticleDetailModel; import com.aaronlife.blog.model.ArticleListModel; import com.aaronlife.blog.model.MyConfig; @Repository public class ArticleRepository extends BaseRepository { @Autowired private MyConfig myConfig; public ArrayList<ArticleListModel> findAll() { super.init(); if(getConnection() != null) // 確定有連線成功 { try { Statement stmt = getConnection().createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM articles"); ArrayList<ArticleListModel> data = new ArrayList<>(); // 取得每一筆文章資訊 while(rs.next()) { ArticleListModel a = new ArticleListModel(); a.setId(rs.getInt("id")); // id欄位名稱必須和資料庫蘭為名稱一致 a.setTitle(rs.getString("title")); a.setImgUrl(rs.getString("img_url")); a.setContent(rs.getString("content")); data.add(a); } return data; } catch (SQLException e) { System.out.println(e.getMessage()); return null; } } return null; } public ArticleDetailModel findArticleById(int id) { super.init(); if(getConnection() != null) // 確定有連線成功 { try { PreparedStatement stmt = getConnection() .prepareStatement("SELECT * FROM articles WHERE id=?"); stmt.setInt(1, id); ResultSet rs = stmt.executeQuery(); boolean isExists = rs.next(); // 判斷有沒有資料 if(isExists) { // true = 有資料 ArticleDetailModel a = new ArticleDetailModel(); a.setId(rs.getInt("id")); a.setTitle(rs.getString("title")); a.setSubtitle(rs.getString("subtitle")); a.setContent(rs.getString("content")); a.setDatetime(rs.getDate("update_time")); a.setAuthor(rs.getString("author")); return a; } else { return null; } } catch (SQLException e) { System.out.println(e.getMessage()); return null; } } return null; } }

11. 建立Service用來提供文章相關服務

service/ArticleService.java
package com.aaronlife.blog.service; import java.util.ArrayList; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.aaronlife.blog.model.ArticleDetailModel; import com.aaronlife.blog.model.ArticleListModel; import com.aaronlife.blog.repository.ArticleRepository; @Service public class ArticleService { @Autowired private ArticleRepository articleRepository; // 取得文章清單 public ArrayList<ArticleListModel> getAllArticle() { return articleRepository.findAll(); } // 以id取得該篇文章 public ArticleDetailModel getArticle(int id) { return articleRepository.findArticleById(id); } }

12. 建立文章清單API需要用到的Response model

model/BaseResponseModel.java

因為code和message欄位是所有response都會需要的,所以抽離出來成為父類別

package com.aaronlife.blog.model; import lombok.Data; @Data public class BaseResponseModel { private int code; private String message; public BaseResponseModel(int code, String message) { this.code = code; this.message = message; } }
model/ArticleListResponseModel.java
package com.aaronlife.blog.model; import java.util.ArrayList; import lombok.Data; @Data public class ArticleListResponseModel extends BaseResponseModel { private ArrayList<ArticleListModel> data; public ArticleListResponseModel(int code, String message , ArrayList<ArticleListModel> data) { super(code, message); this.data = data; } }
model/ArticleDetailResponseModel.java
package com.aaronlife.blog.model; import lombok.Data; @Data public class ArticleDetailResponseModel extends BaseResponseModel { private ArticleDetailModel data; public ArticleDetailResponseModel(int code, String message , ArticleDetailModel data) { super(code, message); this.data = data; } }

13. 實作文章API

controller/ArticleController.java
package com.aaronlife.blog.controller; import java.util.ArrayList; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import com.aaronlife.blog.model.ArticleDetailModel; import com.aaronlife.blog.model.ArticleDetailResponseModel; import com.aaronlife.blog.model.ArticleListModel; import com.aaronlife.blog.model.ArticleListResponseModel; import com.aaronlife.blog.service.ArticleService; @RestController @RequestMapping("/v1") @CrossOrigin(value = "*") // 允許任何網站打這裡的API public class ArticleController { @Autowired private ArticleService articleService; @RequestMapping(value = "/articles", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) public ArticleListResponseModel getArticleList() { // 取得全部文章 ArrayList<ArticleListModel> a = articleService.getAllArticle(); if(a == null) return new ArticleListResponseModel(999, "無法取得文章清單", null); else return new ArticleListResponseModel(0, "成功", a); } @RequestMapping(value = "/article", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) public ArticleDetailResponseModel getArticle(int id) { // 取得全部文章 ArticleDetailModel a = articleService.getArticle(id); if(a == null) return new ArticleDetailResponseModel(999, "無法取得文章", null); else return new ArticleDetailResponseModel(0, "成功", a); } }

14. 前端Vue.js安裝axios套件

官網: https://github.com/axios/axios#installing

$ yarn add axios

15. 前端專案HomePage.vue打/v1/articles API取得文章清單

/src/pages/HomePage.vue
<template> <Banner /> <h1 class="title text-center mt-5 mb-3 fw-bold">熱門文章</h1> <section class="articles"> <Card v-for="item in articles" :content="item" /> </section> </template> <script> import Banner from '/src/components/Banner.vue' import Card from '/src/components/Card.vue' import axios from 'axios'; export default { name: 'HomePage', components: { Banner, Card, }, data() { return { articles: [ { id: 0, imgUrl: 'https://picsum.photos/id/1011/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 1, imgUrl: 'https://picsum.photos/id/1005/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 2, imgUrl: 'https://picsum.photos/id/103/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 3, imgUrl: 'https://picsum.photos/id/103/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, ] } }, mounted() { axios.get("http://localhost:8080/v1/articles") .then((response) => { console.log(response.data); if(response.data.code == 0) { this.articles = response.data.data; // 將資料換成API給的 } else { console.log('取得文章清單失敗'); } }).catch((error) => { // 發生錯誤 console.log(error); }) } } </script> <style scoped> .articles { display: grid; max-width: 1200px; margin-inline: auto; padding-inline: 24px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; } .title { } </style>

示意圖:

16. 前端專案文章內容頁打API要資料

/src/pages/Article.vue
<template> <div class="w-100 mt-5"> <div class="bg-body-tertiary pt-3 px-3 pt-md-5 px-md-5 text-center overflow-hidden"> <div class="my-3 p-3"> <h2 class="display-5">{{ content.title }}</h2> <p class="lead">{{ content.subtitle }}</p> </div> <div class="bg-dark shadow-sm mx-auto p-5" style="width: 80%; height: fit-content; border-radius: 21px 21px 21px 21px;"> <h5 class="text-white-50 text-start">{{ content.datetime }}</h5> <h5 class="text-white-50 text-start">{{ content.author }}</h5> <p class="text-white text-start text-break">{{ content.content }}</p> </div> </div> </div> </template> <script> import axios from 'axios'; export default { name: 'Article', data() { return{ content: { id: this.$route.query.id, // 把id從網址列上取下來 title: '文章標題', subtitle: '文章副標題', content: '文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容', author: '作者', datetime: '2023-08-01' } } }, mounted() { axios.get("http://localhost:8080/v1/article?id=" + this.$route.query.id) .then((response) => { console.log(response.data); if(response.data.code == 0) { this.content = response.data.data; } else { console.log('無法取得文章內容'); } }) .catch((error) => { console.log(error); }); } } </script>

示意圖:

17. 將前端的API HOST抽離放到環境變數

.env
# 開頭一定是 VITE_ VITE_API_HOST=http://localhost:8080

注意:
該檔案比須放在專案的跟目錄vite才能讀取到

接著再呼叫API的地方將環境變數設定給axios,之後呼叫API時就不需要給在host了。

mounted() {
    // 設定axios的基本URL
    axios.defaults.baseURL = import.meta.env.VITE_API_HOST;

    axios.get("/v1/articles")
    .then((response) => {
          console.log(response.data);
          if(response.data.code == 0) {
            this.articles = response.data.data; // 將資料換成API給的
          } else {
            console.log('取得文章清單失敗');
          }
    }).catch((error) => {
        // 發生錯誤
        console.log(error);
    })
}

補充:
import.meta.env.VITE_API_HOST用來讀取上面設定在.env內的環境變數

2023-07-25

1. Create Project

$ yarn create vite simple-blog --template vue

2. 進入專案目錄

$ cd simple-blog

3. 初始化專案套件

$ yarn

4. 執行(以開發用伺服器啟動網站)

$ yarn dev

你會看到下面畫面:

VITE v4.4.7  ready in 480 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h to show help

5. 用Chrome瀏覽器打開 http://localhost:5173/ 網頁

6. 用Visual Studio打開剛剛建立的專案

7. 使用Visual Stuiod的終端機安裝套件

vue-router
$ yarn add vue-router@4
Bootstrap 5.3.0
$ yarn add bootstrap@5.3.0
Bootstrap需要用到的套件
$ yarn add @popperjs/core

8. 設定vue-router

a. 在src目錄下新增router.js
/src/router.js
// 引入vue-router import {createRouter, createWebHistory} from 'vue-router' import HelloWorld from './components/HelloWorld.vue' // 定義路由 const routes = [ { path: '/helloworld', component: HelloWorld }, ]; // 建立VueRouter const router = createRouter({ mode: 'history', history: createWebHistory(), routes, // short for `routes: routes` }); export default { router }
b. 在/src/App.vue下的<template>標籤內新增<router-view />並刪除<template>內的其他元素
/src/App.vue
<script setup>
</script>

<template>
  <router-view />
</template>

<style scoped>
</style>

補充:
也順便把import和<style>內容清除

c. 將建立好的router-view傳遞給vue app(在main.js)
/src/main.js
import { createApp } from 'vue' import './style.css' import App from './App.vue' import router from './router' const app = createApp(App); // 將vue-router傳遞給vue app app.use(router.router); app.mount('#app')
d. 到瀏覽器http://localhost:5173/helloworld確認可以看到HelloWorld.vue元件內的內容

9. 在src目錄下建立css子目錄,並將style.css移入

因為style.css路徑改變,所以必須同步修改main.js裡面的style.css的引入路徑

/src/main.js
import { createApp } from 'vue' import './css/style.css' import App from './App.vue' import router from './router' const app = createApp(App); // 將vue-router傳遞給vue app app.use(router.router); app.mount('#app')

10. 清除/src/style.css內容並引入Bootstrap樣式

/src/css/style.css
@import 'bootstrap/dist/css/bootstrap.css';

11. 使用Bootstrap Example內的Header

網址: https://getbootstrap.com/docs/5.3/examples/

12. 新增Header.vue, 並貼上打算使用的Bootstrap Header

/src/components/Header.vue
<template> <header class="p-3 text-bg-dark"> <div class="container"> <div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start"> <a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none"> <svg class="bi me-2" width="40" height="32" role="img" aria-label="Bootstrap"><use xlink:href="#bootstrap"/></svg> </a> <ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0"> <li><a href="#" class="nav-link px-2 text-secondary">Home</a></li> <li><a href="#" class="nav-link px-2 text-white">Features</a></li> <li><a href="#" class="nav-link px-2 text-white">Pricing</a></li> <li><a href="#" class="nav-link px-2 text-white">FAQs</a></li> <li><a href="#" class="nav-link px-2 text-white">About</a></li> </ul> <form class="col-12 col-lg-auto mb-3 mb-lg-0 me-lg-3" role="search"> <input type="search" class="form-control form-control-dark text-bg-dark" placeholder="Search..." aria-label="Search"> </form> <div class="text-end"> <button type="button" class="btn btn-outline-light me-2">Login</button> <button type="button" class="btn btn-warning">Sign-up</button> </div> </div> </div> </header> </template>

在/src/pages/建立HomePage.vue用來開發首頁用

/src/pages/HomePage.vue
<template> <Header /> </template> <script> import Header from '/src/components/Header.vue' export default { name: 'HomePage', components: { Header } } </script> <style scoped> </style>

13. 在router.js內加上HomePage的路由

/src/router.js
// 引入vue-router import {createRouter, createWebHistory} from 'vue-router' import HelloWorld from './components/HelloWorld.vue' import HomePage from '/src/pages/HomePage.vue' // 定義路由 const routes = [ { path: '/helloworld', component: HelloWorld }, // 首頁 { path: '/', component: HomePage }, ]; // 建立VueRouter const router = createRouter({ mode: 'history', history: createWebHistory(), routes, // short for `routes: routes` }); export default { router }

14. 將Header.vue的樣式補齊

  • 將svg icon加進來
  • 將headers.css檔案加入到/src/css目錄下
/src/css/headers.css
.form-control-dark { border-color: var(--bs-gray); } .form-control-dark:focus { border-color: #fff; box-shadow: 0 0 0 .25rem rgba(255, 255, 255, .25); } .text-small { font-size: 85%; } .dropdown-toggle:not(:focus) { outline: 0; }
  • 將svg icon改為白色
/src/components/Header.vue
<template> <svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> <symbol id="bootstrap" viewBox="0 0 118 94"> <title>Bootstrap</title> <path fill-rule="evenodd" clip-rule="evenodd" fill="#ffffff" d="M24.509 0c-6.733 0-11.715 5.893-11.492 12.284.214 6.14-.064 14.092-2.066 20.577C8.943 39.365 5.547 43.485 0 44.014v5.972c5.547.529 8.943 4.649 10.951 11.153 2.002 6.485 2.28 14.437 2.066 20.577C12.794 88.106 17.776 94 24.51 94H93.5c6.733 0 11.714-5.893 11.491-12.284-.214-6.14.064-14.092 2.066-20.577 2.009-6.504 5.396-10.624 10.943-11.153v-5.972c-5.547-.529-8.934-4.649-10.943-11.153-2.002-6.484-2.28-14.437-2.066-20.577C105.214 5.894 100.233 0 93.5 0H24.508zM80 57.863C80 66.663 73.436 72 62.543 72H44a2 2 0 01-2-2V24a2 2 0 012-2h18.437c9.083 0 15.044 4.92 15.044 12.474 0 5.302-4.01 10.049-9.119 10.88v.277C75.317 46.394 80 51.21 80 57.863zM60.521 28.34H49.948v14.934h8.905c6.884 0 10.68-2.772 10.68-7.727 0-4.643-3.264-7.207-9.012-7.207zM49.948 49.2v16.458H60.91c7.167 0 10.964-2.876 10.964-8.281 0-5.406-3.903-8.178-11.425-8.178H49.948z"> </path> </symbol> <symbol id="home" viewBox="0 0 16 16"> <path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146zM2.5 14V7.707l5.5-5.5 5.5 5.5V14H10v-4a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v4H2.5z" /> </symbol> <symbol id="speedometer2" viewBox="0 0 16 16"> <path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z" /> <path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z" /> </symbol> <symbol id="table" viewBox="0 0 16 16"> <path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z" /> </symbol> <symbol id="people-circle" viewBox="0 0 16 16"> <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" /> <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z" /> </symbol> <symbol id="grid" viewBox="0 0 16 16"> <path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3z" /> </symbol> </svg> <header class="p-3 text-bg-dark"> <div class="container"> <div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start"> <a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none"> <svg class="bi me-2" width="40" height="32" role="img" aria-label="Bootstrap"> <use xlink:href="#bootstrap" /> </svg> </a> <ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0"> <li><a href="#" class="nav-link px-2 text-secondary">Home</a></li> <li><a href="#" class="nav-link px-2 text-white">Features</a></li> <li><a href="#" class="nav-link px-2 text-white">Pricing</a></li> <li><a href="#" class="nav-link px-2 text-white">FAQs</a></li> <li><a href="#" class="nav-link px-2 text-white">About</a></li> </ul> <form class="col-12 col-lg-auto mb-3 mb-lg-0 me-lg-3" role="search"> <input type="search" class="form-control form-control-dark text-bg-dark" placeholder="Search..." aria-label="Search"> </form> <div class="text-end"> <button type="button" class="btn btn-outline-light me-2">Login</button> <button type="button" class="btn btn-warning">Sign-up</button> </div> </div> </div> </header> </template> <style scoped> @import '/src/css/headers.css' </style>

15. 新增Banner.vue元件

來源: https://getbootstrap.com/docs/5.3/examples/product/

/src/components/Banner.vue
<template> <svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> <symbol id="aperture" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" /> <path d="M14.31 8l5.74 9.94M9.69 8h11.48M7.38 12l5.74-9.94M9.69 16L3.95 6.06M14.31 16H2.83m13.79-4l-5.74 9.94" /> </symbol> <symbol id="cart" viewBox="0 0 16 16"> <path d="M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" /> </symbol> <symbol id="chevron-right" viewBox="0 0 16 16"> <path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" /> </symbol> </svg> <div style="background-size: cover; background-image: url(https://png.pngtree.com/background/20210709/original/pngtree-simple-technology-business-line-picture-image_938374.jpg);" class="position-relative overflow-hidden p-md-5 p-lg-0 text-center bg-body-tertiary"> <div class="col-md-6 p-lg-5 mx-auto my-5"> <h1 class="text-white display-3 fw-bold">Designed for engineers</h1> <h3 class="text-white fw-normal mb-3">Build anything you want with Aperture</h3> <div class="d-flex gap-3 justify-content-center lead fw-normal"> <a class="icon-link bg-body-secondary" href="#"> Learn more <svg class="bi"> <use xlink:href="#chevron-right" /> </svg> </a> <a class="icon-link bg-body-secondary" href="#"> Buy <svg class="bi"> <use xlink:href="#chevron-right" /> </svg> </a> </div> </div> <div class="product-device shadow-sm d-none"></div> <div class="product-device product-device-2 shadow-sm d-none"></div> </div> </template> <style scoped>@import '/src/css/product.css'</style>

這裡必須將product.css加入到/src/css中

/src/css/product.css
.container { max-width: 100%; } .icon-link > .bi { width: .75em; height: .75em; } /* * Custom translucent site header */ .site-header { background-color: rgba(0, 0, 0, .85); -webkit-backdrop-filter: saturate(180%) blur(20px); backdrop-filter: saturate(180%) blur(20px); } .site-header a { color: #8e8e8e; transition: color .15s ease-in-out; } .site-header a:hover { color: #fff; text-decoration: none; } /* * Dummy devices (replace them with your own or something else entirely!) */ .product-device { position: absolute; right: 10%; bottom: -30%; width: 300px; height: 540px; background-color: #333; border-radius: 21px; transform: rotate(30deg); } .product-device::before { position: absolute; top: 10%; right: 10px; bottom: 10%; left: 10px; content: ""; background-color: rgba(255, 255, 255, .1); border-radius: 5px; } .product-device-2 { top: -25%; right: auto; bottom: 0; left: 5%; background-color: #e5e5e5; } /* * Extra utilities */ .flex-equal > * { flex: 1; } @media (min-width: 768px) { .flex-md-equal > * { flex: 1; } }

16. 將Banner元件加到HomePage.vue

/src/pages/HomePage.vue
<template> <Header /> <Banner /> </template> <script> import Header from '/src/components/Header.vue' import Banner from '/src/components/Banner.vue' export default { name: 'HomePage', components: { Header, Banner } } </script> <style scoped> </style>

目前畫面:

17. 新增部落格卡片Card.vue

來源: https://codepen.io/utilitybend/pen/bGvjLba

/src/components/Card.vue
<template> <article> <div class="article-wrapper"> <figure> <img src="https://picsum.photos/id/1011/800/450" alt="" /> </figure> <div class="article-body"> <h2>This is some title</h2> <p> Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas. </p> <a href="#" class="read-more"> Read more <span class="sr-only">about this is some title</span> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" /> </svg> </a> </div> </div> </article> <!-- <article> <div class="article-wrapper"> <figure> <img src="https://picsum.photos/id/1005/800/450" alt="" /> </figure> <div class="article-body"> <template> <article> <div class="article-wrapper"> <figure> <img src="https://picsum.photos/id/1011/800/450" alt="" /> </figure> <div class="article-body"> <h2>This is some title</h2> <p> Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas. </p> <a href="#" class="read-more"> Read more <span class="sr-only">about this is some title</span> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" /> </svg> </a> </div> </div> </article> </template> <style scoped> article { --img-scale: 1.001; --title-color: black; --link-icon-translate: -20px; --link-icon-opacity: 0; position: relative; border-radius: 16px; box-shadow: none; background: #fff; transform-origin: center; transition: all 0.4s ease-in-out; overflow: hidden; } article a::after { position: absolute; inset-block: 0; inset-inline: 0; cursor: pointer; content: ""; } /* basic article elements styling */ article h2 { margin: 0 0 18px 0; font-family: "Bebas Neue", cursive; font-size: 1.9rem; letter-spacing: 0.06em; color: var(--title-color); transition: color 0.3s ease-out; } figure { margin: 0; padding: 0; aspect-ratio: 16 / 9; overflow: hidden; } article img { max-width: 100%; transform-origin: center; transform: scale(var(--img-scale)); transition: transform 0.4s ease-in-out; } .article-body { padding: 24px; } article a { display: inline-flex; align-items: center; text-decoration: none; color: #28666e; } article a:focus { outline: 1px dotted #28666e; } article a .icon { min-width: 24px; width: 24px; height: 24px; margin-left: 5px; transform: translateX(var(--link-icon-translate)); opacity: var(--link-icon-opacity); transition: all 0.3s; } /* using the has() relational pseudo selector to update our custom properties */ article:has(:hover, :focus) { --img-scale: 1.1; --title-color: #28666e; --link-icon-translate: 0; --link-icon-opacity: 1; box-shadow: rgba(0, 0, 0, 0.16) 0px 10px 36px 0px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px; } /************************ Generic layout (demo looks) **************************/ *, *::before, *::after { box-sizing: border-box; } body { margin: 0; padding: 48px 0; font-family: "Figtree", sans-serif; font-size: 1.2rem; line-height: 1.6rem; background-image: linear-gradient(45deg, #7c9885, #b5b682); min-height: 100vh; } @media screen and (max-width: 960px) { article { container: card/inline-size; } .article-body p { display: none; } } @container card (min-width: 380px) { .article-wrapper { display: grid; grid-template-columns: 100px 1fr; gap: 16px; } .article-body { padding-left: 0; } figure { width: 100%; height: 100%; overflow: hidden; } figure img { height: 100%; aspect-ratio: 1; object-fit: cover; } } .sr-only:not(:focus):not(:active) { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } </style>
/src/pages/HomePage.vue使用Card.vue
<template> <Header /> <Banner /> <h1 class="title text-center mt-5 mb-3 fw-bold">熱門文章</h1> <section class="articles"> <Card /> </section> </template> <script> import Header from '/src/components/Header.vue' import Banner from '/src/components/Banner.vue' import Card from '/src/components/Card.vue' export default { name: 'HomePage', components: { Header, Banner, Card } } </script> <style scoped> .articles { display: grid; max-width: 1200px; margin-inline: auto; padding-inline: 24px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; } .title { } </style>

補充:
加上了文章標題與樣式

目前畫面:

設計卡片資料結構並提供假資料測試

/src/pages/HomePage.vue
<template> <Header /> <Banner /> <h1 class="title text-center mt-5 mb-3 fw-bold">熱門文章</h1> <section class="articles"> <Card v-for="item in articles" :content="item" /> </section> </template> <script> import Header from '/src/components/Header.vue' import Banner from '/src/components/Banner.vue' import Card from '/src/components/Card.vue' export default { name: 'HomePage', components: { Header, Banner, Card }, data() { return { articles: [ { id: 0, imgUrl: 'https://picsum.photos/id/1011/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 1, imgUrl: 'https://picsum.photos/id/1005/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 2, imgUrl: 'https://picsum.photos/id/103/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, ] } } } </script> <style scoped> .articles { display: grid; max-width: 1200px; margin-inline: auto; padding-inline: 24px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; } .title { } </style>
/src/components/Card.vue
<template> <article> <div class="article-wrapper"> <figure> <img :src="content.imgUrl" alt="" /> </figure> <div class="article-body"> <h2>{{ content.title }}</h2> <p>{{ content.content }}</p> <a href="#" class="read-more"> Read more <span class="sr-only">about this is some title</span> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" /> </svg> </a> </div> </div> </article> </template> <script> export default { name: 'Card', props: ["content"], } </script> <style scoped> article { --img-scale: 1.001; --title-color: black; --link-icon-translate: -20px; --link-icon-opacity: 0; position: relative; border-radius: 16px; box-shadow: none; background: #fff; transform-origin: center; transition: all 0.4s ease-in-out; overflow: hidden; } article a::after { position: absolute; inset-block: 0; inset-inline: 0; cursor: pointer; content: ""; } /* basic article elements styling */ article h2 { margin: 0 0 18px 0; font-family: "Bebas Neue", cursive; font-size: 1.9rem; letter-spacing: 0.06em; color: var(--title-color); transition: color 0.3s ease-out; } figure { margin: 0; padding: 0; aspect-ratio: 16 / 9; overflow: hidden; } article img { max-width: 100%; transform-origin: center; transform: scale(var(--img-scale)); transition: transform 0.4s ease-in-out; } .article-body { padding: 24px; } article a { display: inline-flex; align-items: center; text-decoration: none; color: #28666e; } article a:focus { outline: 1px dotted #28666e; } article a .icon { min-width: 24px; width: 24px; height: 24px; margin-left: 5px; transform: translateX(var(--link-icon-translate)); opacity: var(--link-icon-opacity); transition: all 0.3s; } /* using the has() relational pseudo selector to update our custom properties */ article:has(:hover, :focus) { --img-scale: 1.1; --title-color: #28666e; --link-icon-translate: 0; --link-icon-opacity: 1; box-shadow: rgba(0, 0, 0, 0.16) 0px 10px 36px 0px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px; } /************************ Generic layout (demo looks) **************************/ *, *::before, *::after { box-sizing: border-box; } body { margin: 0; padding: 48px 0; font-family: "Figtree", sans-serif; font-size: 1.2rem; line-height: 1.6rem; background-image: linear-gradient(45deg, #7c9885, #b5b682); min-height: 100vh; } @media screen and (max-width: 960px) { article { container: card/inline-size; } .article-body p { display: none; } } @container card (min-width: 380px) { .article-wrapper { display: grid; grid-template-columns: 100px 1fr; gap: 16px; } .article-body { padding-left: 0; } figure { width: 100%; height: 100%; overflow: hidden; } figure img { height: 100%; aspect-ratio: 1; object-fit: cover; } } .sr-only:not(:focus):not(:active) { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } </style>

18. 新增Footer元件

/src/components/Footer.vue
<template> <svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> <symbol id="bootstrap" viewBox="0 0 118 94"> <title>Bootstrap</title> <path fill-rule="evenodd" clip-rule="evenodd" d="M24.509 0c-6.733 0-11.715 5.893-11.492 12.284.214 6.14-.064 14.092-2.066 20.577C8.943 39.365 5.547 43.485 0 44.014v5.972c5.547.529 8.943 4.649 10.951 11.153 2.002 6.485 2.28 14.437 2.066 20.577C12.794 88.106 17.776 94 24.51 94H93.5c6.733 0 11.714-5.893 11.491-12.284-.214-6.14.064-14.092 2.066-20.577 2.009-6.504 5.396-10.624 10.943-11.153v-5.972c-5.547-.529-8.934-4.649-10.943-11.153-2.002-6.484-2.28-14.437-2.066-20.577C105.214 5.894 100.233 0 93.5 0H24.508zM80 57.863C80 66.663 73.436 72 62.543 72H44a2 2 0 01-2-2V24a2 2 0 012-2h18.437c9.083 0 15.044 4.92 15.044 12.474 0 5.302-4.01 10.049-9.119 10.88v.277C75.317 46.394 80 51.21 80 57.863zM60.521 28.34H49.948v14.934h8.905c6.884 0 10.68-2.772 10.68-7.727 0-4.643-3.264-7.207-9.012-7.207zM49.948 49.2v16.458H60.91c7.167 0 10.964-2.876 10.964-8.281 0-5.406-3.903-8.178-11.425-8.178H49.948z"> </path> </symbol> <symbol id="facebook" viewBox="0 0 16 16"> <path d="M16 8.049c0-4.446-3.582-8.05-8-8.05C3.58 0-.002 3.603-.002 8.05c0 4.017 2.926 7.347 6.75 7.951v-5.625h-2.03V8.05H6.75V6.275c0-2.017 1.195-3.131 3.022-3.131.876 0 1.791.157 1.791.157v1.98h-1.009c-.993 0-1.303.621-1.303 1.258v1.51h2.218l-.354 2.326H9.25V16c3.824-.604 6.75-3.934 6.75-7.951z" /> </symbol> <symbol id="instagram" viewBox="0 0 16 16"> <path d="M8 0C5.829 0 5.556.01 4.703.048 3.85.088 3.269.222 2.76.42a3.917 3.917 0 0 0-1.417.923A3.927 3.927 0 0 0 .42 2.76C.222 3.268.087 3.85.048 4.7.01 5.555 0 5.827 0 8.001c0 2.172.01 2.444.048 3.297.04.852.174 1.433.372 1.942.205.526.478.972.923 1.417.444.445.89.719 1.416.923.51.198 1.09.333 1.942.372C5.555 15.99 5.827 16 8 16s2.444-.01 3.298-.048c.851-.04 1.434-.174 1.943-.372a3.916 3.916 0 0 0 1.416-.923c.445-.445.718-.891.923-1.417.197-.509.332-1.09.372-1.942C15.99 10.445 16 10.173 16 8s-.01-2.445-.048-3.299c-.04-.851-.175-1.433-.372-1.941a3.926 3.926 0 0 0-.923-1.417A3.911 3.911 0 0 0 13.24.42c-.51-.198-1.092-.333-1.943-.372C10.443.01 10.172 0 7.998 0h.003zm-.717 1.442h.718c2.136 0 2.389.007 3.232.046.78.035 1.204.166 1.486.275.373.145.64.319.92.599.28.28.453.546.598.92.11.281.24.705.275 1.485.039.843.047 1.096.047 3.231s-.008 2.389-.047 3.232c-.035.78-.166 1.203-.275 1.485a2.47 2.47 0 0 1-.599.919c-.28.28-.546.453-.92.598-.28.11-.704.24-1.485.276-.843.038-1.096.047-3.232.047s-2.39-.009-3.233-.047c-.78-.036-1.203-.166-1.485-.276a2.478 2.478 0 0 1-.92-.598 2.48 2.48 0 0 1-.6-.92c-.109-.281-.24-.705-.275-1.485-.038-.843-.046-1.096-.046-3.233 0-2.136.008-2.388.046-3.231.036-.78.166-1.204.276-1.486.145-.373.319-.64.599-.92.28-.28.546-.453.92-.598.282-.11.705-.24 1.485-.276.738-.034 1.024-.044 2.515-.045v.002zm4.988 1.328a.96.96 0 1 0 0 1.92.96.96 0 0 0 0-1.92zm-4.27 1.122a4.109 4.109 0 1 0 0 8.217 4.109 4.109 0 0 0 0-8.217zm0 1.441a2.667 2.667 0 1 1 0 5.334 2.667 2.667 0 0 1 0-5.334z" /> </symbol> <symbol id="twitter" viewBox="0 0 16 16"> <path d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z" /> </symbol> </svg> <div class="container"> <footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top"> <div class="col-md-4 d-flex align-items-center"> <a href="/" class="mb-3 me-2 mb-md-0 text-body-secondary text-decoration-none lh-1"> <img src="/src/assets/vue.svg" /> </a> <span class="mb-3 mb-md-0 text-body-secondary">&copy; 2023 Company, Inc</span> </div> <ul class="nav col-md-4 justify-content-end list-unstyled d-flex"> <li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"> <use xlink:href="#twitter" /> </svg></a></li> <li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"> <use xlink:href="#instagram" /> </svg></a></li> <li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"> <use xlink:href="#facebook" /> </svg></a></li> </ul> </footer> </div> </template>
將Footer元件放到HomePage.vue中
<template> <Header /> <Banner /> <h1 class="title text-center mt-5 mb-3 fw-bold">熱門文章</h1> <section class="articles"> <Card v-for="item in articles" :content="item" /> </section> <Footer /> </template> <script> import Header from '/src/components/Header.vue' import Banner from '/src/components/Banner.vue' import Card from '/src/components/Card.vue' import Footer from '/src/components/Footer.vue' export default { name: 'HomePage', components: { Header, Banner, Card, Footer }, data() { return { articles: [ { id: 0, imgUrl: 'https://picsum.photos/id/1011/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 1, imgUrl: 'https://picsum.photos/id/1005/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 2, imgUrl: 'https://picsum.photos/id/103/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 3, imgUrl: 'https://picsum.photos/id/103/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, ] } } } </script> <style scoped> .articles { display: grid; max-width: 1200px; margin-inline: auto; padding-inline: 24px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; } .title { } </style>

更新Banner.vue樣式

/src/components/Banner.vue
<template> <svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> <symbol id="aperture" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" /> <path d="M14.31 8l5.74 9.94M9.69 8h11.48M7.38 12l5.74-9.94M9.69 16L3.95 6.06M14.31 16H2.83m13.79-4l-5.74 9.94" /> </symbol> <symbol id="cart" viewBox="0 0 16 16"> <path d="M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" /> </symbol> <symbol id="chevron-right" viewBox="0 0 16 16"> <path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" /> </symbol> </svg> <div style="background-size: cover; background-image: url(https://png.pngtree.com/background/20210709/original/pngtree-simple-technology-business-line-picture-image_938374.jpg);" class="position-relative overflow-hidden p-md-5 p-lg-0 text-center bg-body-tertiary"> <div class="col-md-6 p-lg-5 mx-auto my-5"> <h1 class="text-white display-3 fw-bold">Designed for engineers</h1> <h3 class="text-white fw-normal mb-3">Build anything you want with Aperture</h3> <div class="d-flex gap-3 justify-content-center lead fw-normal"> <button type="button" class="btn btn-info">Learn More</button> <button type="button" class="btn btn-info">Buy</button> </div> </div> <div class="product-device shadow-sm d-none"></div> <div class="product-device product-device-2 shadow-sm d-none"></div> </div> </template> <script></script> <style scoped> @import '/src/css/product.css'; button[type="button"] { color: #fff; box-shadow: 2px 2px 5px #777; } h1, h3 { text-shadow: 5px 5px 5px #000; } </style>

19. 新增Blog文章頁Article.vue

來源: https://getbootstrap.com/docs/5.3/examples/product/

/src/components/Article.vue
<template> <div class="w-100 mt-5"> <div class="bg-body-tertiary pt-3 px-3 pt-md-5 px-md-5 text-center overflow-hidden"> <div class="my-3 p-3"> <h2 class="display-5">Another headline</h2> <p class="lead">And an even wittier subheading.</p> </div> <div class="bg-dark shadow-sm mx-auto p-5" style="width: 80%; height: 300px; border-radius: 21px 21px 21px 21px;"> <h5 class="text-white-50 text-start">2023-08-01</h5> <h5 class="text-white-50 text-start">Aaron</h5> <p class="text-white text-start text-break">dsafjlkdsajflkjdlfkajsldfkjalkfjlkadjflkajdslfkajdfjlkfdasjdsafjlkdsajflkjdlfkajsldfkjalkfjlkadjflkajdslfkajdfjlkfdasjdsafjlkdsajflkjdlfkajsldfkjalkfjlkadjflkajdslfkajdfjlkfdasjdsafjlkdsajflkjdlfkajsldfkjalkfjlkadjflkajdslfkajdfjlkfdasjdsafjlkdsajflkjdlfkajsldfkjalkfjlkadjflkajdslfkajdfjlkfdasj</p> </div> </div> </div> </template>
更新Header.vue
<template> <svg xmlns="http://www.w3.org/2000/svg" style="display: none;"> <symbol id="bootstrap" viewBox="0 0 118 94"> <title>Bootstrap</title> <path fill-rule="evenodd" clip-rule="evenodd" fill="#ffffff" d="M24.509 0c-6.733 0-11.715 5.893-11.492 12.284.214 6.14-.064 14.092-2.066 20.577C8.943 39.365 5.547 43.485 0 44.014v5.972c5.547.529 8.943 4.649 10.951 11.153 2.002 6.485 2.28 14.437 2.066 20.577C12.794 88.106 17.776 94 24.51 94H93.5c6.733 0 11.714-5.893 11.491-12.284-.214-6.14.064-14.092 2.066-20.577 2.009-6.504 5.396-10.624 10.943-11.153v-5.972c-5.547-.529-8.934-4.649-10.943-11.153-2.002-6.484-2.28-14.437-2.066-20.577C105.214 5.894 100.233 0 93.5 0H24.508zM80 57.863C80 66.663 73.436 72 62.543 72H44a2 2 0 01-2-2V24a2 2 0 012-2h18.437c9.083 0 15.044 4.92 15.044 12.474 0 5.302-4.01 10.049-9.119 10.88v.277C75.317 46.394 80 51.21 80 57.863zM60.521 28.34H49.948v14.934h8.905c6.884 0 10.68-2.772 10.68-7.727 0-4.643-3.264-7.207-9.012-7.207zM49.948 49.2v16.458H60.91c7.167 0 10.964-2.876 10.964-8.281 0-5.406-3.903-8.178-11.425-8.178H49.948z"> </path> </symbol> <symbol id="home" viewBox="0 0 16 16"> <path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146zM2.5 14V7.707l5.5-5.5 5.5 5.5V14H10v-4a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v4H2.5z" /> </symbol> <symbol id="speedometer2" viewBox="0 0 16 16"> <path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z" /> <path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z" /> </symbol> <symbol id="table" viewBox="0 0 16 16"> <path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2h-4v3h4V4zm0 4h-4v3h4V8zm0 4h-4v3h3a1 1 0 0 0 1-1v-2zm-5 3v-3H6v3h4zm-5 0v-3H1v2a1 1 0 0 0 1 1h3zm-4-4h4V8H1v3zm0-4h4V4H1v3zm5-3v3h4V4H6zm4 4H6v3h4V8z" /> </symbol> <symbol id="people-circle" viewBox="0 0 16 16"> <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" /> <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z" /> </symbol> <symbol id="grid" viewBox="0 0 16 16"> <path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3z" /> </symbol> </svg> <header class="p-3 text-bg-dark my-header"> <div class="container"> <div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start"> <a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none"> <svg class="bi me-2" width="40" height="32" role="img" aria-label="Bootstrap"> <use xlink:href="#bootstrap" /> </svg> </a> <ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0"> <li><a href="#" class="nav-link px-2 text-secondary">Home</a></li> <li><a href="#" class="nav-link px-2 text-white">Features</a></li> <li><a href="#" class="nav-link px-2 text-white">Pricing</a></li> <li><a href="#" class="nav-link px-2 text-white">FAQs</a></li> <li><a href="#" class="nav-link px-2 text-white">About</a></li> </ul> <form class="col-12 col-lg-auto mb-3 mb-lg-0 me-lg-3" role="search"> <input type="search" class="form-control form-control-dark text-bg-dark" placeholder="Search..." aria-label="Search"> </form> <div class="text-end"> <button type="button" class="btn btn-outline-light me-2">Login</button> <button type="button" class="btn btn-warning">Sign-up</button> </div> </div> </div> </header> </template> <style scoped> @import '/src/css/headers.css'; .my-header { position: fixed; top: 0; left: 0; z-index: 1; width: 100%; } </style>

20. 將Header和Footer移到App.vue成為全站每一頁都會出現的元件

/src/App.vue
<script setup> import Header from '/src/components/Header.vue' import Footer from '/src/components/Footer.vue' </script> <template> <Header /> <router-view /> <Footer /> </template> <style scoped> </style>
/src/pages/HomePage.vue

因為已經移到App.vue, 所以HomePage.vue刪除Header和Footer

<template> <Banner /> <h1 class="title text-center mt-5 mb-3 fw-bold">熱門文章</h1> <section class="articles"> <Card v-for="item in articles" :content="item" /> </section> </template> <script> import Banner from '/src/components/Banner.vue' import Card from '/src/components/Card.vue' export default { name: 'HomePage', components: { Banner, Card, }, data() { return { articles: [ { id: 0, imgUrl: 'https://picsum.photos/id/1011/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 1, imgUrl: 'https://picsum.photos/id/1005/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 2, imgUrl: 'https://picsum.photos/id/103/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, { id: 3, imgUrl: 'https://picsum.photos/id/103/800/450', title: 'This is some title', content: 'Curabitur convallis ac quam vitae laoreet. Nulla mauris ante, euismod sed lacus sit amet, congue bibendum eros. Etiam mattis lobortis porta. Vestibulum ultrices iaculis enim imperdiet egestas.', }, ] } } } </script> <style scoped> .articles { display: grid; max-width: 1200px; margin-inline: auto; padding-inline: 24px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; } .title { } </style>

21. 將Article.vue加到vue-router

/src/router.js
// 引入vue-router import {createRouter, createWebHistory} from 'vue-router' import HelloWorld from './components/HelloWorld.vue' import HomePage from '/src/pages/HomePage.vue' import Article from '/src/pages/Article.vue' // 定義路由 const routes = [ { path: '/helloworld', component: HelloWorld }, // 首頁 { path: '/', component: HomePage }, // 文章頁 { path: '/article', component: Article }, ]; // 建立VueRouter const router = createRouter({ mode: 'history', history: createWebHistory(), routes, // short for `routes: routes` }); export default { router }

完成後的文章畫面:

22. 將HomePage.vue上的文章卡片點擊跳轉Article.vue

/src/pages/Card.vue
<template> <article> <div class="article-wrapper"> <figure> <img :src="content.imgUrl" alt="" /> </figure> <div class="article-body"> <h2>{{ content.title }}</h2> <p>{{ content.content }}</p> <a :href="'/article?id=' + content.id" class="read-more"> Read more <span class="sr-only">about this is some title</span> <svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" /> </svg> </a> </div> </div> </article> </template> <script> export default { name: 'Card', props: ["content"], } </script> <style scoped> article { --img-scale: 1.001; --title-color: black; --link-icon-translate: -20px; --link-icon-opacity: 0; position: relative; border-radius: 16px; box-shadow: none; background: #fff; transform-origin: center; transition: all 0.4s ease-in-out; overflow: hidden; } article a::after { position: absolute; inset-block: 0; inset-inline: 0; cursor: pointer; content: ""; } /* basic article elements styling */ article h2 { margin: 0 0 18px 0; font-family: "Bebas Neue", cursive; font-size: 1.9rem; letter-spacing: 0.06em; color: var(--title-color); transition: color 0.3s ease-out; } figure { margin: 0; padding: 0; aspect-ratio: 16 / 9; overflow: hidden; } article img { max-width: 100%; transform-origin: center; transform: scale(var(--img-scale)); transition: transform 0.4s ease-in-out; } .article-body { padding: 24px; } article a { display: inline-flex; align-items: center; text-decoration: none; color: #28666e; } article a:focus { outline: 1px dotted #28666e; } article a .icon { min-width: 24px; width: 24px; height: 24px; margin-left: 5px; transform: translateX(var(--link-icon-translate)); opacity: var(--link-icon-opacity); transition: all 0.3s; } /* using the has() relational pseudo selector to update our custom properties */ article:has(:hover, :focus) { --img-scale: 1.1; --title-color: #28666e; --link-icon-translate: 0; --link-icon-opacity: 1; box-shadow: rgba(0, 0, 0, 0.16) 0px 10px 36px 0px, rgba(0, 0, 0, 0.06) 0px 0px 0px 1px; } /************************ Generic layout (demo looks) **************************/ *, *::before, *::after { box-sizing: border-box; } body { margin: 0; padding: 48px 0; font-family: "Figtree", sans-serif; font-size: 1.2rem; line-height: 1.6rem; background-image: linear-gradient(45deg, #7c9885, #b5b682); min-height: 100vh; } @media screen and (max-width: 960px) { article { container: card/inline-size; } .article-body p { display: none; } } @container card (min-width: 380px) { .article-wrapper { display: grid; grid-template-columns: 100px 1fr; gap: 16px; } .article-body { padding-left: 0; } figure { width: 100%; height: 100%; overflow: hidden; } figure img { height: 100%; aspect-ratio: 1; object-fit: cover; } } .sr-only:not(:focus):not(:active) { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } </style>

23. 建立文章頁的資料結構

/src/pages/Article.vue
<template> <div class="w-100 mt-5"> <div class="bg-body-tertiary pt-3 px-3 pt-md-5 px-md-5 text-center overflow-hidden"> <div class="my-3 p-3"> <h2 class="display-5">{{ content.title }}</h2> <p class="lead">{{ content.subtitle }}</p> </div> <div class="bg-dark shadow-sm mx-auto p-5" style="width: 80%; height: fit-content; border-radius: 21px 21px 21px 21px;"> <h5 class="text-white-50 text-start">{{ content.datetime }}</h5> <h5 class="text-white-50 text-start">{{ content.author }}</h5> <p class="text-white text-start text-break">{{ content.content }}</p> </div> </div> </div> </template> <script> export default { name: 'Article', data() { return{ content: { id: this.$route.query.id, // 把id從網址列上取下來 title: '文章標題', subtitle: '文章副標題', content: '文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容文章內容', author: '作者', datetime: '2023-08-01' } } } } </script>

完成後點擊手頁的文章卡片會切換到內容頁,且網址上會有該篇文章的id。