W9_[ BE101 ] PHP 與 MySQL 實作練習 + w9 直播檢討


Posted by Christy on 2021-07-09

本篇筆記使用 XAMPP 來學習後端,會學到的有:

  1. PHP 基本語法
  2. PHP 背後運作原理簡單介紹
  3. phpmyadmin 資料庫介面管理 MySQL
  4. MySQL 語法基礎
  5. 實作練習:用 PHP 操作 MySQL 裡的資料
    5.1 初探 PHP: PHP 裡有哪些函式?
    5.2 PHP 連線到 MySQL 資料庫
    5.3 讀取資料:Read
    5.4 新增資料:Create / 包含動態新增
    5.5 刪除資料:Delete 待續...
    5.6 編輯資料:Update 待續...

  6. 真正的實戰:留言板 - 初階實作篇
    6.1 串接資料庫顯示留言
    6.2 新增留言功能
    6.3 實作註冊功能
    6.4 實作登入功能
    6.5 如何讓瀏覽器用 COOKIE 記錄登入狀態
    6.6 自己實作通行證機制
    6.7 PHP 內建 session 機制

備註:這是 Lidemy 線上課程 [ BE101 ] 的筆記

1. PHP 基本語法

  • 一定要用標籤 <?php?> 包住,看下面範例
  • 結尾都要加分號
  • 變數前面要加 $
  • 要印出 arr 或資料型態不是單純數字加字串時,用 var_dump or print_r
  • 字串拼接是用 .
<?php
  $a = 'foo'; // 宣告變數 a = foo
  $b = 2; 
  echo $a; // 印出 變數 a
  echo $a . $b; // 印出 a + b -> foo2

  // if...else... 語法
  $score = 60;
  if ($score >= 60) {
    echo 'pass';
  } else {
    echo 'fail';
  }

  // for loop
  for ($i = 0; $i <= 10; $i++) {
    echo $i;
    echo $i . '<br>';
  }

  echo $i . '<br>'; // 會印出
  0
  1
  2
  3
  4
  5
  6
  7
  8
  9
  10

  $arr = array(1, 2, 3, 4, 5);

  echo $arr[0];
  // 會印出 1;特別注意括號用法

  echo $arr[sizeof($arr) - 1];
  // 會印出 5
  // sizeof() 取得 arr 長度

  echo $arr; // 印不出 arr 內容

  var_dump($arr) // 比較詳細,會印出
  array(5) { [0]=> int(1) [1]=> int(2) [2]=> int(3) [3]=> int(4) [4]=> int(5)

  print_r($arr) // 不會有 type,會印出
  Array ( [0] => 1 [1] => 2 [2] => 3 [3] => 4 [4] => 5 )

  function add($a, $b) {
    return $a + $b;
  }

  echo add(1, 2); // 會印出 3
?>

2. Apache 與 PHP 原理簡介

  • 背後流程:
    • request -> apache(server) -> php -> output -> apache -> response

資料庫系統簡介

  • 什麼是資料庫?
    • 專門來處理資料的一個程式
  • 關聯式資料庫:MySQL, PostgreSQL 這兩個最有名
    • 如果用 XAMPP 的話,裡面用的不是 MySQL 而是 MariaDB,當初 MySQL 被買走了,怕以後不開源,所以 MariaDB 是永遠開源,為了跟 MySQL 完全相容。
    • 但是 MySQL 跟 MariaDB 使用上差不多
  • 非關聯式資料庫:例如 MongoDB

3. 如何管理資料庫? phpmyadmin 簡介

  • phpmyadmin: 一個管理資料庫的介面

    • 之前有過安全性的問題,不過功能完整
    • 或者使用 Adminer 也是用 php 寫的,介面比較簡單一點
  • Index、Unique 這些有什麼用?

    • Index 索引
      • 之後要查詢資料比較快
      • 像是目錄或標籤一樣
      • 可以是組合的欄位,例如 username + password
    • Unique
      • Primary Key (PK 又稱主鍵)不能是空的且不能重複
      • 或也可以設定 unique,可以防止寫入重複的資料

4. MySQL 語法基礎

  • select 查詢資料: SELECT id FROM data

    • SELECT id as name FROM data
      • 改變欄位名稱叫做 name,但還是一樣是 id 內容
    • SELECT id FROM data WHERE id = 2
    • SELECT * FROM data WHERE username = 'yoyoyo' and id = 1,data 有沒有反引號都可以
  • insert 新增資料:

    • sql 指令永遠都大寫
    • 查詢的資料可用反引號包起來或不包,但如果你的名稱是跟 sql 內建指令一樣的話要包,不然會搞混
INSERT INTO `data`(`id`, `username`, `content`, `created_at`) VALUES ('[value-1]','[value-2]','[value-3]','[value-4]')
  • update 修改資料:
UPDATE `data` SET `id`='[value-1]',`username`='[value-2]',`content`='[value-3]',`created_at`='[value-4]' WHERE 1
  • UPDATE data SET username = qoo WHERE id = 1; 如果沒有 WHERE 後面的指定選項的話,就會更改所有的資料

  • delete 刪除資料:

    DELETE FROM data WHERE id = 1

  • 補充說明:有時候會設定 is_deleted 的標籤(選boolean,只有 0 or 1)

    • 如果前台選刪除,那就改成 1,這樣如果誤刪的話,資料可以救回來
    • 如果真的用 delete 且沒有備份,那就救不回來了
    • 或者拿來決定是否要呈現在使用者面前

5. 實作練習:用 PHP 操作 MySQL 裡的資料

5.1 初探 PHP
  • 從前端傳資料給後端:GET 與 POST

    • 這個影片要好好複習,這裡的語法都要會
    • 主要有兩大部分:
      • 利用網址上的 query string 拿資料
      • 透過表單的 method 及 action
        • 通常比較常用的有 GET 和 POST
  • 影片裡的函式有:

    • exit(): 類似 js 裡面 return 的概念
    • isset():
      • isset($_GET['name']) 如果有填寫 name 的內容
    • empty(): 如果內容為空的話
  • 0 - 4'30": 印出網址上 query string 的資料

// 例如網址是:http://localhost:8080/christy/test.php?a=1  這裡的 a 是 key, 1 是 value

執行 print_r($_GET)
會印出 Array ( [a] => 1 )

// 或者是 http://localhost:8080/christy/test.php?a=1&b=3

echo 'a:' . $_GET['a'] . '<br>';
echo 'b:' . $_GET['b'] . '<br>';

會印出
a:1
b:5

// 如果網址有 a 的值,就把 a 印出來
if (isset($_GET['a'])) {
  echo 'a:' . $_GET['a'] . '<br>';
}

會印出 a:1
  • 4'33" - 9'33": 用表單的方式傳資料到後端(GET)
// 用網址傳參數的方式叫做 GET,跟 http 有關
// action 把表單的內容傳到 data.php 去

<form method="GET" action="data.php">
  a: <input name="a" />
  age: <input name="age" />
  <input type="submit" />
</form>

// 這裡的 form 不用包在 <?php?> 裡面喔
  • 另一種寫法
<?php

  if (!isset($_GET['name']) || !isset($_GET['age'])) {
        echo '資料有缺,請再次填寫<br>';
        exit();
    }

    echo 'Hello!' . $_GET['name'] . '<br>';
    echo 'Your age is' . $_GET['age'] . '<br>';
?>

<?php

  if (empty($_GET['name']) || empty($_GET['age'])) {
        echo '資料有缺,請再次填寫<br>';
        exit();
    }

    echo 'Hello! ' . $_GET['name'] . '<br>';
    echo 'Your age is ' . $_GET['age'] . '<br>';
?>
  • 9'40" - 最後:用 POST 交換資料:
<form method="POST" action="data.php">
  a: <input name="a" />
  age: <input name="age" />
  <input type="submit" />
</form>
  • 從 PHP 連線到 MySQL 資料庫

    • 千萬不要把連線設定檔放上 GitHub,一定要把他排除在版本控制裡面,不然會有帳號密碼資料外洩的問題
// 如何讓 MySQL 跟 PHP 做連線

<?php
$server_name = 'localhost';
$username = '****';
$password = '***';
$db_name = 'christy';

$conn = new mysqli($server_name, $username, $password, $db_name);

// die(): 印出括號內的東西以後,停止執行
if ($conn->connect_error) {
    die('資料庫連線錯誤:' . $conn->connect_error);
}

// 用 UTF8 中文就不會是亂碼
// 把時區設為台灣
$conn->query('SET NAMES UTF8');
$conn->query('SET time_zone = "+8:00"');
?>
  • 通常會把連線的部分分開寫,建立一個 conn.php 檔案,裡面只有連線相關資訊(就上面那幾行程式碼)

  • 在其他檔案裡第一行寫,就可以連接了 require_once('conn.php');

5.2 讀取資料:Read
  • PHP 與 MySQL 互動
    • 讀取資料 「讀取 users 資料庫裡面所有的資料」
      • 'select now() from users;' 選取現在時間(MySQL 內建函式)
      • 大方向就是:引入連線 -> query 資料庫 -> 處理錯誤 -> fetch 結果 -> 拿到想要的內容
<?php
    require_once('conn.php');

    $result = $conn->query('SELECT * from users;');

    if (!$result) {
        die($conn->error);
    }

    $row = $result->fetch_assoc();
    print_r($row);

    while ($row = $result->fetch_assoc()) {
        print_r($row);
    }   
?>
  • 詳細解釋
<?php
// 引入連線
require_once('conn.php');

// 設一個變數來裝結果,用 $conn->query('這裡放 sql 語法') 執行
  $result = $conn->query('select * from users;');

  // 如果沒有結果,就把錯誤印出來並結束程式
  if (!$result) {
      die($conn->error);
  }

  // fetch 拿資料,每執行一次就有一筆資料
  // 這樣寫就會有兩筆資料

  $row = $result->fetch_assoc();
  print_r($row['id']);
  print_r($row['username']);

  $row = $result->fetch_assoc();
  print_r($row['id']);
  print_r($row['username']);

  // 可以用 while 迴圈把資料都拿出來
  // 會印出 Array ( [id] => 1 [username] => Peter ) Array ( [id] => 2 [username] => Nick ) Array ( [id] => 3 [username] => Diana )

  while ($row = $result->fetch_assoc()) {
    print_r($row);
  }

  // 會印出
  // 1 Peter
  // 2 Nick
  // 3 Diana

  while ($row = $result->fetch_assoc()) {
    print_r($row['id'] . ' ' . $row['username'] . '<br>');
  }
?>
5.3 新增資料:Create / 包含動態新增
  • 大方向一樣是: 建立連線 -> query 資料庫,使用新增資料的 sql 語法 -> 處理錯誤 -> 拿到想要的資料

  • sql 原本的語法:「在 users 這個資料庫裡,新增 username 叫 'apple' 的資料」

<?php
    require_once('conn.php');

    // 實例長這樣
    // 但是寫這樣資料一多很容易搞混   
        $result = $conn->query("INSERT INTO users(username) VALUES('apple');");

    if (!$result) {
        die($conn->error);
    }

  print_r($result); // 會印出 1,代表有新增成功
?>
  • 也可以這樣寫,但是非常難懂,不推薦
<?php
    require_once('conn.php');

    $username = 'apple';

    // 也可以寫成拼接式,但很難一眼看懂
    $sql = "INSERT INTO users(username) VALUES ('" .  $username ."')";

    echo $sql;

    $result = $conn->query($sql);

    if (!$result) {
        die($conn->error);
    }

  print_r($result);
?>
  • 最推薦的方法:用 sprintf() 來實現「在 users 這個資料庫裡,新增 username 叫 'apple' 的資料」

    • 基本架構:建立連線 -> 設變數 $username = 想要的資料 -> 設變數sql = sprintf("sql 新增語法", $username) -> query 資料庫 -> 處理錯誤 -> 拿到想要的資料
<?php
    require_once('conn.php');

    $username = 'apple';

    $sql = sprintf(
        "INSERT INTO users(username) VALUES('%s')",
        $username
    );

    $result = $conn->query($sql);

    if (!$result) {
        die($conn->error);
    }

    print_r($result);
?>
  • 詳細解釋
<?php
    require_once('conn.php');

    $username = 'apple';

    // 用 sprintf()這個函式來新增資料
    // %d is used for numbers (integers)
    // %s is used for letters (strings)
    // 字串要包起來
    // 會按造順序新增,id -> 13, username -> $username

    $sql = sprintf(
        "INSERT INTO users(id, username) VALUES (%d, '%s')",
        13,
        $username
    );

    echo $sql;

    $result = $conn->query($sql);

    if (!$result) {
        die($conn->error);
    }

  print_r($result);
?>
  • 「利用 POST 的方式動態新增 username」
<?php
    require_once('conn.php');

    if (empty($_POST['username'])) {
        die('請輸入 username');
    }

    $username = $_POST['username'];

    $sql = sprintf(
        "INSERT INTO users(username) VALUES('%s')",
        $username
    );

    $result = $conn->query($sql);

    if (!$result) {
        die($conn->error);
    }
    // 跳轉回原本頁面
    header('Location: test.php');
?>

6. 真正的實戰:留言板 - 初階實作篇

  • 在實作前:
    • 要先想好有哪些資料內容
    • 建置資料庫
    • 切版

6.1 串接資料庫顯示留言
6.2 新增留言功能
6.3 實作註冊功能
6.4 實作登入功能

6.1 串接資料庫顯示留言
  • 把 PHP 寫在 html 最上面
  • PHP 程式碼可以放在任何地方
<?php
    require_once('conn.php');

    $result = $conn->query('SELECT * from comments ORDER BY id DESC');
    if (!$result) {
        die('error:' . $conn->connect_error);
    }

    // 下面這一段放在 html 的標籤裡面
    while  ($row = $result->fetch_assoc()) {
        print_r($row);
    }
?>
  • 在 html 裡面長這樣
<section>
  <?php
    while ($row = $result->fetch_assoc()) { 
  ?>
    <div class="comment">
      <div class="comment__avatar">
    </div>

    <div class="comment__info">
      <div class="comment__data">
        <div class="comment__nickname">
          <?php echo $row['nickname']; ?>
        </div>
        <div class="comment__time">
          <?php echo $row['created_at']; ?>
        </div>
      </div>

    // 這裡縮成一行,留言就不會有空白的問題
    // 這兩個 css 屬性很常用
    // word-wrap: break-word;
    // white-space: pre-line;
    <div class="comment__content"><?php echo $row['content']; ?></div>
    </div>
  </div>
<?php } ?>      
</section>
6.2 新增留言功能
  • 其實就是動態新增的應用啦
  • 要注意幾點:
    • 在 form 標籤裡面 action 要寫檔案名稱 <form class="bulletin__post" method="POST" action="handle_new_post.php"></form>
    • 留言區塊的 name 要取跟資料庫一樣 <textarea name="content" rows="10"></textarea>
// handle_new_post.php 檔案內容
<?php
    require_once('conn.php');

    if (
        empty($_POST['nickname']) || 
        empty($_POST['content'])
    ) {
        die('請填寫資料');
    }

    $nickname = $_POST['nickname'];
    $content = $_POST['content'];

    $sql = sprintf(
        "INSERT INTO comments(nickname, content) VALUES('%s', '%s')", 
        $nickname, $content
    );

    $result = $conn->query($sql);

    if (!$result) {
        die($conn->error);
    }

    header('Location: index.php');
?>
  • 如果沒有暱稱、留言內容的情況
    • 先把錯誤訊息用 GET 導到另一個網址
    • 在 html 裡面放上 php 的程式碼
// handle_new_post.php 檔案內容

<?php
    require_once('conn.php');

    if (empty($_POST['nickname']) || empty($_POST['content'])) {

        // 先把頁面用 GET 的方式導到下面網址
        header('Location: index.php?errMsg=資料不齊全');
        die('請填寫資料');
    }

    $nickname = $_POST['nickname'];
    $content = $_POST['content'];

    $sql = sprintf("INSERT INTO comments (nickname, content) VALUES ('%s', '%s')", $nickname, $content);

    $result = $conn->query($sql);

    if (!$result) {
        die($conn->error);
    }

    header('Location: index.php');
?>
// 這樣寫的缺點就是網址後面隨便改個內容,網頁顯示的提示就會被取代
<?php
    if (!empty($_GET['errMsg'])) {
        $msg = $_GET['errMsg'];
        echo '<h2>' . $msg . '<h2>';
    }
?>
  • 比較好的做法(但真正好的做法是在前端用 js 監聽按鈕的方式先處理)
// 在 handle_new_post.php 檔案內容把頁面導到下面網址
if (empty($_POST['nickname']) || empty($_POST['content'])) {
    header('Location: index.php?errCode=1');
    die('請填寫資料');
}

// 在 html 加上這段程式碼
<?php
    if (!empty($_GET['errCode'])) {
        $code = $_GET['errCode'];

        // 不懂為什麼要放下面這行,因為不寫也可以運作
        $msg = 'Error';
        if ($code === '1') {
            $msg = '資料不齊全';
        }
        echo '<h2 class="error">' . $msg . '</h2>';
    }
?>
6.3 實作註冊功能
  • 頁面有:register.php, handle_register.php
  • 把註冊頁面切出來
  • 註冊功能邏輯:
    • 先引入連線 -> 新增註冊的資料(暱稱、使用者名稱、密碼)-> 最後導回首頁
  • 要注意的地方:
    • 發生錯誤的情形有兩種:
      • 帳號已被註冊(資料庫要把 username 選成唯一)
      • 請填寫資料(檢查所有欄位是否為空)
// handle_register.php 檔案內容
<?php
    require_once('conn.php');

    if (empty($_POST['nickname']) || empty($_POST['username']) || empty($_POST['password'])) {
        // 檢查欄位是否為空
        header('Location: register.php?errCode=1');
        die();
    }

    $nickname = $_POST['nickname'];
    $username = $_POST['username'];
    $password = $_POST['password'];

    $sql = sprintf("INSERT INTO users (nickname, username, password) VALUES ('%s', '%s', '%s')", $nickname, $username, $password);
    $result = $conn->query($sql);

    if (!$result) {
        // errno 是經過 mysql 定義的
        // 1062 不用包起來
        $code = $conn->errno;
        if ($code === 1062) {
            header('Location: register.php?errCode=2');
        }
        die($conn->error);
    }

    header('Location: index.php');
?>
6.4 實作登入功能
  • 頁面有:login.php, handle_login.php
  • 把登入頁面切出來
  • 登入功能邏輯:
    • 先引入連線 -> 判斷資料庫裡面是否有一樣的使用者跟密碼 -> 處理錯誤(資料不齊全、查無帳號或密碼)-> 登入成功,導回首頁
  • 要注意的地方:
    • 登入的邏輯是 $sql = sprintf("SELECT * FROM users WHERE username = '%s' and password = '%s'", $username, $password);
    • 如何判斷登入有誤的情形?
      • 利用 echo $result->num_rows;,會輸出資料庫裡面有幾筆資料,輸出為零代表零筆資料,表示登入帳號或密碼有誤
      • 輸出為一,代表有一筆資料,表示有找到帳號密碼
// handle_login.php 內容

<?php
    require_once('conn.php');

    if (empty($_POST['username']) || empty($_POST['password'])) {
        header('Location: login.php?errCode=1');
        die();
    }
    // 登入時輸入的帳密,也是使用 $_POST 的方式
    $username = $_POST['username'];
    $password = $_POST['password'];

    $sql = sprintf("SELECT * FROM users WHERE username = '%s' and password = '%s'", $username, $password);

    $result = $conn->query($sql);
    if (!$result) {
        die($conn->error);
    }
    // num_rows 是 sql 內建的語法,判斷結果有多少筆資料
    // 當結果為 1,就是有一筆資料
    if ($result->num_rows) {
        echo '登入成功!';
        header('Location: index.php');
    } else {
        header('Location: login.php?errCode=2');
    }
?>
6.5 如何讓瀏覽器用 COOKIE 記錄登入狀態
<?php
$cookie_name = "user";
$cookie_value = "John Doe";
setcookie($cookie_name, $cookie_value, time() + (86400 * 30), "/"); // 86400 = 1 day
?>
// handle_login.php 內容
<?php
    require_once('conn.php');

    if (empty($_POST['username']) || empty($_POST['password'])) {
        header('Location: login.php?errCode=1');
        die();
    }

    $username = $_POST['username'];
    $password = $_POST['password'];

    $sql = sprintf("SELECT * FROM users WHERE username = '%s' and password = '%s'", $username, $password);

    $result = $conn->query($sql);
    if (!$result) {
        die($conn->error);
    }

    if ($result->num_rows) {
        echo '登入成功!';
        // 主要寫下面三行
        $expire = time() + 3600 * 24 * 30;
        setcookie('username', $username, $expire);
        header('Location: index.php');
    } else {
        header('Location: login.php?errCode=2');
    }
?>
  • 在首頁拿到登入的狀態及資訊:
    • 可以先把 Cookie 印出來觀察,會發現是一個 array
    • print_r($_COOKIE);
    • 接著到index.php 的內容,執行下面的程式碼
$username = NULL;
if (!empty($_COOKIE['username'])) {
    $username = $_COOKIE['username'];
}
  • 登出功能:
    • 讓 cookie 過期,把 username 清空
// logout.php 檔案內容
<?php
    setcookie('username', '', time() - 3600);
    header('Location: index.php');
?>
  • 使用 cookie 紀錄 username 登入情形,去資料庫裡面找到相對應的暱稱
  • 關鍵是這兩行
  • 把使用者存在 cookie 裡面的風險就是可以隨意偽造身份
$username = $_COOKIE['username'];
    $user_sql = sprintf("SELECT nickname FROM users WHERE username = '%s'", $username);
// handle_new-post.php 內容
<?php
    require_once('conn.php');

    if (empty($_POST['content'])) {
        header('Location: index.php?errCode=1');
        die('請填寫資料');
    }
    // 使用 cookie 紀錄 username 登入情形,去資料庫裡面找到 username 的 暱稱
    $username = $_COOKIE['username'];
    $user_sql = sprintf("SELECT nickname FROM users WHERE username = '%s'", $username);

    $user_result = $conn->query($user_sql);
    $row = $user_result->fetch_assoc();
    $nickname = $row['nickname'];

    $content = $_POST['content'];

    $sql = sprintf("INSERT INTO comments (nickname, content) VALUES ('%s', '%s')", $nickname, $content);
    $result = $conn->query($sql);

    if (!$result) {
        die($conn->error);
    }

    header('Location: index.php');
?>
6.6 自己實作通行證機制
  • 實作 token 機制

先跳過,之後再回頭學。

6.7 PHP 內建 session 機制
  • 要用 session 的話,要在加檔案第一行 -> session_start();
  • 只要寫 $_SESSION['username'] = $username;
    • 這樣就把 username 存在 session 裡面了
    • 一行程式碼做三件事
      • 產生 session id (token)
      • 把 username 寫入檔案
      • set-cookie: 把 session-id 設定到 client 端去
    • 登出用 session_destroy();
  • PHP 最高!❤️❤️❤️
7. 做個 reddit 自己玩

7.1 版面建置

  • 資料庫: redditt_users/reddit_comments
    • reddit_users: id/username/password/created_at
      • id: int/不用編碼
      • username: varchar(64)/utf8mb4_general_ci
      • password: varchar(128)/utf8mb4_general_ci
      • created_at: datetime/預設值 current_timestamp()
    • reddit_comments: id/username/content/created_at
      • content: text/utf8mb4_general_ci
  • 犯的錯誤:
    • 登入後跳轉到首頁,發現新增留言時無法一起新增 username,原來是我 handle_new_post.php 功能少寫了抓取 username,這裡要取兩次資料,有點不太習慣語法
    • sig up 之後跳轉到首頁,沒辦法拿到 username,所以我要在 handle_signup.php 設 setcookie()。
    • 解決了使用者名稱太長,在首頁破版的問題,加上兩行就好了
      • word-break: break-all;
      • white-space: pre-line;
    • 把 RWD 做好了,多虧了這兩行
      • width: 100%;
      • min-width: 768px;
  • 沒想到我能夠自己做出一個來,好開心!
8. 作業參考資料

[教學] 什麼是 Cookie?如何用 JS 讀取/修改 document.cookie?

9. W9 直播檢討
  • 還是有點搞不清楚,以註冊登入系統來說,哪些部分是前端需要驗證而哪些又是後端驗證的呢?

    • 前端驗證是為了使用者體驗
    • 後端驗證是為了安全性
  • LIOJ 1053 走迷宮會用到BFS(廣度優先搜尋法),查了一下發現還是看不太懂在寫什麼,老師會建議先去讀演算法的資料,還是等課程在往後學幾週再回來解呢?謝謝

    • 可以課程結束後再回來寫,優先順序沒有這麼高
  • 老師解釋前後端概念










Related Posts

[BE101]  留言板(上-基礎實作篇)

[BE101] 留言板(上-基礎實作篇)

[Math] 如何於CoderBridge撰寫數學公式

[Math] 如何於CoderBridge撰寫數學公式

學會 HTML & CSS (關於 CSS 的部份)

學會 HTML & CSS (關於 CSS 的部份)


Comments