laravel使用fortify和Sanctum为SPA提供注册登录和api身份验证

Laravel 文档里面关于Sanctum 和Fortify的介绍

Laravel Sanctum 为 SPA(单页应用程序)、移动应用程序和基于令牌的、简单的 API 提供轻量级身份验证系统。Sanctum 允许应用程序的每个用户为他们的帐户生成多个 API 令牌。这些令牌可以被授予指定允许令牌执行哪些操作的能力 / 范围。

API 令牌:通过将用户 API 令牌存储在单个数据库表中,并通过包含了有效 API 令牌的 Authorization 标识头对传入的请求进行身份验证而实现的。

SPA 身份验证:Sanctum 提供了一种简单的方法来认证需要与基于 Laravel 的 API 进行通信的单页应用程序 (SPAs)。这些 SPAs 可能与 Laravel 应用程序存在于同一仓库中,也可能是一个完全独立的仓库,例如使用 Vue CLI 或者 Next.js 创建的单页应用。对于此功能,Sanctum 不使用任何类型的令牌。相反,Sanctum 使用 Laravel 内置的基于 cookie 的会话身份验证服务。这提供了 CSRF 保护,会话身份验证以及防止因 XSS 攻击而泄漏身份验证凭据。仅当传入请求来自您自己的 SPA 前端时,Sanctum 才会尝试使用 Cookie 进行身份验证。Sanctum 处理你自己的 SPA 前端的请求时,只会尝试使用 cookie 进行身份验证。当 Sanctum 检查传入的 HTTP 请求时,它将首先检查验证身份的 cookie,如果不存在,Sanctum 将检查 Authorization 标识头以获取有效的 API 令牌。

Lravel Fortify是一个与前端无关的身份认证后端实现。Fortify注册了所有实现Laravel身份验证功能所需的路由和控制器,包括登录,注册,重置密码,邮件认证等。由于Fortify不提供其自己的用户界面,因此应与你自己的用户界面配对,该用户界面向其注册的路由发出请求。

Laravel Fortify & Laravel Sanctum

 Laravel Sanctum 只关心管理 API 令牌和使用会话 cookie 或令牌来认证现有用户。 Sanctum 不提供任何处理用户注册,重置密码等相关的路由。

如果你尝试为提供 API 或用作单页应用的后端的应用手动构建身份认证层,那么完全有可能同时使用 Laravel Fortify(用于用户注册,重置密码等)和 Laravel Sanctum(API 令牌管理,会话身份认证)。

 

看了文档里面关于Sanctum 和Fortify的介绍,尝试应用下,给以前写好的vue3 SPA练手项目配上登录注册后台逻辑。

将vue3项目放入resources目录,并加上登录注册逻辑

package.json

查看代码
 {
    "private": true,
    "scripts": {
        "dev": "npm run development",
        "development": "mix",
        "watch": "mix watch",
        "watch-poll": "mix watch -- --watch-options-poll=1000",
        "hot": "mix watch --hot",
        "prod": "npm run production",
        "production": "mix --production"
    },
    "devDependencies": {
        "@popperjs/core": "^2.10.2",
        "axios": "^0.21.4",
        "better-scroll": "^2.5.0",
        "bootstrap": "^5.1.3",
        "echarts": "^5.4.1",
        "laravel-mix": "^6.0.6",
        "lodash": "^4.17.19",
        "postcss": "^8.1.14",
        "resolve-url-loader": "^3.1.2",
        "sass": "^1.32.11",
        "sass-loader": "^11.0.1",
        "sweetalert2": "^11.6.16",
        "vue": "^3.2.38",
        "vue-loader": "^16.2.0",
        "vue-router": "^4.1.5"
    }
}

resources\js\utils\http.js

查看代码
 import axios from "axios";
import { swal2 } from "../utils/sweetalert2.js";

const http = axios.create({
    withCredentials: true,
    timeout: 30000,
    baseURL: "http://127.0.0.1:8000",
    headers: {
        Accept: "application/json",
    },
});

http.interceptors.request.use(
    function (config) {
        const token = localStorage.getItem("token");
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }

        return config;
    },
    function (error) {
        return Promise.reject(error);
    }
);

http.interceptors.response.use(
    function (response) {
        return response;
    },
    function (error) {
        const errors =
            (error.response &&
                error.response.data &&
                error.response.data.errors) ||
            {};
        if (errors) {
            let str = "";
            for (let key in errors) {
                str += key + ":" + errors[key] + "\r\n";
            }
            swal2.showErrorMsg(str);
            return Promise.reject(str);
        }
        const data = (error.response && error.response.data) || {};
        if (data.message) {
            swal2.showErrorMsg(data.message);
            return Promise.reject(data.message);
        }
        const status = (error.response && error.response.status) || "";
        return Promise.reject(status);
    }
);

export default http;

resources\js\utils\store.js

查看代码
 import { reactive } from "vue";

export const store = reactive({
    userinfo: "",
    updateUserinfo(userinfo) {
        this.userinfo = userinfo;
    },
    isLoading: false,
    updateLoadingStatue(status) {
        this.isLoading = status;
    },
});

resources\js\utils\sweetalert2.js

查看代码
 import swal from "sweetalert2";

export const swal2 = {
    confirm: function (title, text, callback) {
        swal.fire({
            title: title,
            text: text,
            showCancelButton: true,
            confirmButtonColor: "#3085d6",
            cancelButtonColor: "#d33",
            confirmButtonText: "确定",
            cancelButtonText: "取消",
        }).then((result) => {
            if (result.isConfirmed && callback) {
                callback();
            }
        });
    },
    showErrorMsg: function (title) {
        swal.mixin({
            toast: true,
            showConfirmButton: false,
            timer: 3000,
        }).fire({
            icon: "error",
            title: title,
        });
    },
    showSuccMsg: function (title) {
        swal.mixin({
            toast: true,
            showConfirmButton: false,
            timer: 3000,
        }).fire({
            icon: "success",
            title: title,
        });
    },
    showWaringMsg: function (title) {
        swal.mixin({
            toast: true,
            showConfirmButton: false,
            timer: 3000,
        }).fire({
            icon: "warning",
            title: title,
        });
    },
};

resources\js\app.js

查看代码
 import router from "./router";
import App from "./layouts/App.vue";
import { createApp } from "vue";
import http from "./utils/http.js";

const app = createApp(App);

app.config.globalProperties.http = http;
app.use(router);
app.mount("#app");

\resources\js\layouts\App.vue

查看代码
 <script setup>
import "bootstrap/dist/css/bootstrap.min.css";
import { RouterLink, RouterView } from "vue-router";
import { store } from "../utils/store.js";
import { getCurrentInstance, ref } from "vue";
import { swal2 } from "../utils/sweetalert2.js";
import { useRouter } from "vue-router";

//获取全局的http
const { http } = getCurrentInstance().appContext.config.globalProperties;
const router = useRouter();

//如果已经登陆过,则从localStorage取出userinfo存入store
const userinfo = localStorage.getItem("userinfo");
if (userinfo) {
  store.updateUserinfo(JSON.parse(userinfo));
} else {
  router.push("/login");
}

http.get("/sanctum/csrf-cookie").then(function (response) { });

function logout() {
  http.post("/logout").then(function (response) {
    console.log("/logout", response);
    if (response.status == 204) {
      localStorage.removeItem("userinfo");
      localStorage.removeItem("token");
      store.updateUserinfo("");
    } else {
      swal2.showErrorMsg("logout error");
    }
  });
}

function getinfo() {
  console.log('token', localStorage.getItem("token"));
  console.log('userinfo', localStorage.getItem("userinfo"));

}
</script>

<template>
  <div class="container-fluid">
    <nav class="navbar navbar-expand-lg navbar-light bg-light rounded" aria-label="Eleventh navbar example">
      <div class="container-fluid">
        <a class="navbar-brand" href="#">句子</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarsExample09"
          aria-controls="navbarsExample09" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>

        <div class="collapse navbar-collapse" id="navbarsExample09">
          <ul class="navbar-nav me-auto mb-2 mb-lg-0">
            <li class="nav-item">
              <RouterLink to="/" class="nav-link active">唐诗</RouterLink>
            </li>
            <li class="nav-item">
              <RouterLink to="/chart" class="nav-link">统计</RouterLink>
            </li>
          </ul>

          <ul class="navbar-nav ms-auto mb-2">
            <template v-if="store.userinfo">
              <li class="nav-item">
                <a v-on:click="logout()" class="nav-link">{{ store.userinfo.name }}(Logout)</a>
              </li>
            </template>
            <template v-else>
              <li class="nav-item">
                <RouterLink to="/reg" class="nav-link ms-3">Register</RouterLink>
              </li>
              <li class="nav-item">
                <RouterLink to="/login" class="nav-link ms-3">Login</RouterLink>
              </li>
            </template>
            <li class="nav-item">
              <a v-on:click="getinfo()" class="nav-link ms-3"> info</a>
            </li>
          </ul>
        </div>
      </div>
    </nav>
    <RouterView />
  </div>

  <div class="modal fade" tabindex="-1" :class="{ show: store.isLoading, 'd-block': store.isLoading }">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content" style="border: none; background-color: transparent">
        <div class="spinner-border text-light mx-auto" role="status">
          <span class="visually-hidden">Loading...</span>
        </div>
      </div>
    </div>
  </div>
  <div v-if="store.isLoading" class="modal-backdrop fade show"></div>
</template>
<style>
a {
  cursor: pointer;
}
</style>

\resources\js\router\index.js

查看代码
 import { createRouter, createWebHistory } from "vue-router";
import Home from "../pages/Home.vue";

const router = createRouter({
    history: createWebHistory(),
    routes: [
        {
            path: "/",
            name: "home",
            component: Home,
        },
        {
            path: "/chart",
            name: "chart",
            component: () => import("../pages/Chart.vue"),
        },
        {
            path: "/reg",
            name: "reg",
            component: () => import("../pages/Reg.vue"),
        },
        ,
        {
            path: "/login",
            name: "login",
            component: () => import("../pages/Login.vue"),
        },
    ],
});

export default router;

\resources\js\pages\Home.vue

查看代码
 <script setup>
import { getCurrentInstance, reactive, onMounted } from "vue";
import { swal2 } from "../utils/sweetalert2.js";
import BScroll from "better-scroll";

//获取全局的http
const { http } = getCurrentInstance().appContext.config.globalProperties;

const list = reactive({ left: [], center1: [], center2: [], right: [] });
let p = 0;
let bscroll;
const size = 5;

async function getData() {
  p++;
  http.get("/api/data/getPoem", { params: { p: p } }).then(function (response) {
    console.log("/api/data/getPoem", response);
    if ((response.status = 200 && response.data.code == 0)) {
      response.data.data.map(function (item, index) {
        if (index >= 0 && index < 5) {
          list.left.push(item);
        } else if (index >= 5 && index < 10) {
          list.center1.push(item);
        } else if (index >= 10 && index < 15) {
          list.center2.push(item);
        } else {
          list.right.push(item);
        }
      });
    } else {
      swal2.showErrorMsg("request error");
    }
  });
}
async function pullingUpHandler() {
  await getData();
  bscroll.finishPullUp();
  bscroll.refresh();
}
onMounted(() => {
  bscroll = BScroll(".pullup-wrapper", {
    probeType: 3,
    mouseWheel: true,
    scrollbar: true,
    scrollY: true,
    pullUpLoad: {
      threshold: 50,
    },
  });
  bscroll.on("pullingUp", pullingUpHandler);
  getData();
});
</script>
<template>
  <div class="container-fluid px-5 mt-3 pullup-wrapper" style="height: 900px; overflow: hidden">
    <div class="row pullup-content">
      <section class="col-3 pullup-list-item">
        <div class="card shadow-lg mb-3" v-for="(item, index) in list.left" v-bind:key="index">
          <div class="card-header">{{ item.title }}---{{ item.author }}</div>
          <div class="card-body text-center" v-html="item.content"></div>
        </div>
      </section>
      <section class="col-3 pullup-list-item">
        <div class="card shadow-lg mb-3" v-for="(item, index) in list.center1" v-bind:key="index">
          <div class="card-header">{{ item.title }}---{{ item.author }}</div>
          <div class="card-body text-center" v-html="item.content"></div>
        </div>
      </section>
      <section class="col-3 pullup-list-item">
        <div class="card shadow-lg mb-3" v-for="(item, index) in list.center2" v-bind:key="index">
          <div class="card-header">{{ item.title }}---{{ item.author }}</div>
          <div class="card-body text-center" v-html="item.content"></div>
        </div>
      </section>
      <section class="col-3 pullup-list-item">
        <div class="card shadow-lg mb-3" v-for="(item, index) in list.right" v-bind:key="index">
          <div class="card-header">{{ item.title }}---{{ item.author }}</div>
          <div class="card-body text-center" v-html="item.content"></div>
        </div>
      </section>
    </div>
  </div>
</template>

\resources\js\pages\Login.vue

查看代码
 <script setup>
import { store } from "../utils/store.js";
import { useRouter } from "vue-router";
import { getCurrentInstance } from "vue";
import { swal2 } from "../utils/sweetalert2.js";

//获取全局的http
const { http } = getCurrentInstance().appContext.config.globalProperties;
const router = useRouter();
const userinfo = localStorage.getItem("userinfo");
if (userinfo) {
  router.push("/");
}

function login() {
  const formData = new FormData(document.getElementById("loginForm"));

  http.post("/login", formData).then(function (response) {
    console.log("/login", response.data);
    if (response.status == 200 && response.data.code == 0) {
      localStorage.setItem("token", response.data.token);
      localStorage.setItem("userinfo", JSON.stringify(response.data.userinfo));
      store.updateUserinfo(response.data.userinfo);

      http.get("/api/user").then(function (response) {
        console.log("/api/user", response.data);
      });

      router.push("/");
    } else {
      swal2.showErrorMsg("login error");
    }
  });
}
</script>

<template>
  <div id="reg">
    <div class="row">
      <div class="col-8 mx-auto" style="margin-top: 4rem">
        <div class="card p-5">
          <form id="loginForm">
            <div class="form-floating mb-3">
              <input type="text" class="form-control" id="floatingInput" name="email" placeholder="Email" />
              <label for="floatingInput">Email</label>
            </div>
            <div class="form-floating">
              <input type="password" class="form-control" id="floatingPassword" name="password"
                placeholder="Password" />
              <label for="floatingPassword">Password</label>
            </div>
            <div class="mt-3 text-center">
              <input type="button" class="btn btn-primary" value="login" v-on:click="login()" />
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

\resources\js\pages\Reg.vue

查看代码
<script setup>
import { ref } from "vue";
import { store } from "../utils/store.js";
import { useRouter } from "vue-router";
import { getCurrentInstance } from "vue";
import { swal2 } from "../utils/sweetalert2.js";

//获取全局的http
const { http } = getCurrentInstance().appContext.config.globalProperties;
const router = useRouter();
const userinfo = localStorage.getItem("userinfo");
if (userinfo) {
  router.push("/");
}

let step = ref(1);

function reg() {
  const formData = new FormData(document.getElementById("regForm"));
  http.post("/register", formData).then(function (response) {
    if (response.status == 200 && response.data.code == 0) {

      localStorage.setItem("token", response.data.token);
      localStorage.setItem("userinfo", JSON.stringify(response.data.userinfo));
      store.updateUserinfo(response.data.userinfo);

      http.get("/api/user").then(function (response) {
        console.log("/api/user", response.data);
      });

      step.value = 2;
    } else {
      swal2.showErrorMsg("register error");
    }
  });
}

</script>

<template>
  <div v-if="step == 1">
    <div class="row">
      <div class="col-8 mx-auto" style="margin-top: 10rem">
        <div class="card p-5">
          <form id="regForm">
            <div class="form-floating mb-3">
              <input type="text" class="form-control" id="floatingInput" name="name" placeholder="Name" required />
              <label for="floatingInput">Name</label>
            </div>
            <div class="form-floating mb-3">
              <input type="eamil" class="form-control" id="floatingInput" name="email" placeholder="Email" required />
              <label for="floatingInput">Email</label>
            </div>
            <div class="form-floating mb-3">
              <input type="password" class="form-control" id="floatingPassword" name="password" placeholder="Password"
                required />
              <label for="floatingPassword">Password</label>
            </div>
            <div class="form-floating mb-3">
              <input type="password" class="form-control" id="floatingPassword" name="password_confirmation"
                placeholder="password confirmation" required />
              <label for="floatingPassword">password confirmation </label>
            </div>
            <div class="text-center">
              <input type="button" class="btn btn-primary" value="register" v-on:click="reg()" />
              <a class="ms-3" v-on:click="gotoLogin()">已有账号?去登录</a>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
  <div v-if="step == 2">
    <span>注册成功</span>
  </div>
</template>

\resources\js\pages\Chart.vue

查看代码
 <script setup>
import { getCurrentInstance, ref, onMounted, watch } from "vue";
import { swal2 } from "../utils/sweetalert2.js";
import * as echarts from "echarts";

const { http } = getCurrentInstance().appContext.config.globalProperties;

function getData() {
  http.get("/api/data/getPoemNums").then(function (response) {
    console.log("/api/data/getPoemNums", response);
    if (response.status == 200 && response.data.code == 0) {
      var chartDom = document.getElementById("main");
      var myChart = echarts.init(chartDom);
      var option;
      option = {
        xAxis: {
          type: "category",
          data: response.data.xAxis,
        },
        yAxis: {
          type: "value",
        },
        series: [
          {
            data: response.data.data,
            type: "bar",
          },
        ],
      };
      option && myChart.setOption(option);
    } else {
      swal2.showErrorMsg("request error");
    }
  });
}

onMounted(() => {
  getData();
});
</script>

<template>
  <div class="container-fluid px-5 mt-3">
    <div style="width: 100%; height: 500px" id="main"></div>
  </div>
</template>

 webpack.mix.js

查看代码
 const mix = require('laravel-mix');

mix.js('resources/js/app.js', 'public/js')
    .vue()
    .sass('resources/sass/app.scss', 'public/css');

 之后每次vue3代码修改都要重新编译,编译后的文件位于public目录下的js和css文件夹

 新建项目安装Sanctum和Fortify

#新建一个项目
composer create-project --prefer-dist laravel/laravel laravel8+vue3+spa

#项目默认版本laravel8 ,默认已包含Sanctum 
#使用 vendor:publish Artisan 命令发布 Sanctum 的配置和迁移文件
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

#执行数据库迁移
php artisan migrate

#将 Sanctum 的中间件添加到应用的 app/Http/Kernel.php 文件中的 api 中间件组中
'api' => [
  \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

#安装fortify
composer require laravel/fortify

#使用 vendor:publish 命令来发布 Fortify 的资源
php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider" 

# 数据库迁移
php artisan migrate

#config/app.php 配置文件的 providers 数组中注册FortifyServiceProvider类
App\Providers\FortifyServiceProvider::class

 修改laravel配置文件

#config/auth.php   
'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],
'guards' => [
    'api' => [
         'driver' => 'session',
         'provider' => 'users',
         'hash' => false
   ]
],

#config/fortify.php
'guard' => 'api',
'views' => false

#config/sanctum.php
'guard' => ['api'],
'expiration' => 86400,

Laravel Fortify定义好的路由 :vendor\laravel\fortify\routes\routes.php

自定义/register和/login路由的返回响应

新建App\Http\Responses\RegisterResponse

<?php

namespace App\Http\Responses;

use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Symfony\Component\HttpFoundation\Response;

class RegisterResponse implements RegisterResponseContract
{
    public function toResponse($request): Response
    {
        $user = $request->user();
        if (!$user) {
            return response()->json(['token' => '', 'code' => 1]);
        }
        $userinfo = ['name' => $user->name, 'email' => $user->email];
        $token = $user->createToken('token')->plainTextToken;
        return response()->json(['token' => $token, 'code' => 0, 'userinfo' => $userinfo]);
    }
}

 新建App\Http\Responses\LoginResponse

<?php

namespace App\Http\Responses;

use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Symfony\Component\HttpFoundation\Response;

class LoginResponse implements LoginResponseContract
{
    public function toResponse($request): Response
    {
        $user = $request->user();
        if (!$user) {
            return response()->json(['token' => '', 'code' => 1]);
        }
        $userinfo = ['name' => $user->name, 'email' => $user->email];
        $token = $user->createToken('token')->plainTextToken;
        return response()->json(['token' => $token, 'code' => 0, 'userinfo' => $userinfo]);
    }
}

 在App\Providers\FortifyServiceProvider的boot()方法中添加下面代码,覆盖默认的Response

use App\Http\Responses\LoginResponse;
use App\Http\Responses\RegisterResponse;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;

public function boot(){
    
    $this->app->singleton(LoginResponseContract::class, LoginResponse::class);
    $this->app->singleton(RegisterResponseContract::class,  RegisterResponse::class);
}

 新建resources/views/app.blade.php

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>app</title>
</head>
<body>
    <div id="app"></div>
    <script src="{{ asset('js/app.js') }}"></script>
</body>
</html>

 定义SPA路由

#routes/web.php
Route::get('{path}', function () {
    return view('app');
})->where(['path' => '.*']);

后端路由

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\DataController;

Route::group(['middleware' => ['auth:sanctum']], function () {
    Route::get('/user', function (Request $request) {
        return $request->user();
    });

    Route::group(['prefix' => 'data'], function () {
        Route::get('getPoem', [DataController::class, 'getPoem']);
        Route::get('getPoemNums', [DataController::class, 'getPoemNums']);
    });
});

获取数据。

查看代码
 <?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class DataController extends Controller
{
    public function getPoem(Request $request)
    {
        $page_size = 20;
        $p = intval($request->get('p'));
        $p = $p > 0 ? $p : 1;
        $start = ($p - 1) * $page_size;

        $data = DB::table('tb_chinese_poems')
            ->select('title', 'author', 'content')
            ->offset($start)
            ->limit($page_size)
            ->get();
        $code = 0;
        return response()->json(compact('data', 'code'));
    }

    public function getPoemNums(Request $request)
    {
        $data = DB::table('tb_chinese_poems')
            ->select(DB::raw("count('*') as num"), 'author')
            ->groupBy('author')
            ->orderBy('num', 'desc')->limit(20)
            ->get()->map(function ($value) {
                return (array)$value;
            })->toArray();
        $code = 0;
        return response()->json(['data' => array_column($data, 'num'), 'xAxis' => array_column($data, 'author'), 'code' => 0]);
    }
}

初始化 CSRF 保护

axios.get('/sanctum/csrf-cookie').then(response => {});

在此请求期间,Laravel 将设置一个包含当前 CSRF 令牌的 XSRF-TOKEN cookie。然后,此令牌应在后续请求的 X-XSRF-TOKEN 请求头中传递,某些 HTTP 客户端库(如 Axios 和 Angular HttpClient)将自动为你执行此操作。

单独使用token

<script src="./plugins/axios.min.js"></script>
<script>
  axios
    .get("http://127.0.0.1:8000/api/user", {
      headers: { Authorization: "Bearer 8|9QdmKa4r470r40FFnhN4nKrUveKrruxTXOrqFieW" },
    })
    .then((response) => {
      if (response.status == "200") {
        console.log(response.data);
      }
    });
</script>

  

  

 

posted @ 2022-12-28 14:18  carol2014  阅读(698)  评论(0编辑  收藏  举报