1. 程式人生 > 實用技巧 >樂優商城--品牌管理(新增、圖片上傳、FastDNS、修改)

樂優商城--品牌管理(新增、圖片上傳、FastDNS、修改)

0.學習目標

  • 獨立實現品牌新增

  • 實現圖片上傳

  • 瞭解FastDFS的安裝

  • 使用FastDFS客戶端實現上傳

1.品牌的新增

昨天我們完成了品牌的查詢,接下來就是新增功能。點選新增品牌按鈕

Brand.vue頁面有一個提交按鈕:

點選觸發addBrand方法:

把資料模型之的show置為true,而頁面中有一個彈窗與show繫結:

彈窗中有一個表單子元件,並且是一個區域性子元件,有頁面可以找到該元件:

1.1.頁面實現

1.1.1.重置表單

重置表單相對簡單,因為v-form元件已經提供了reset方法,用來清空表單資料。只要我們拿到表單元件物件,就可以呼叫方法了。

我們可以通過$refs

內建物件來獲取表單元件。

首先,在表單上定義ref屬性:

然後,在頁面檢視this.$refs屬性:

  
  reset(){
        // 重置表單
        console.log(this);
      }

檢視如下:

看到this.$refs中只有一個屬性,就是myBrandForm

我們在clear中來獲取表單物件並呼叫reset方法:

要注意的是,這裡我們還手動把this.categories清空了,因為我寫的級聯選擇元件並沒有跟表單結合起來。需要手動清空。

1.1.2.表單校驗

1.1.2.1.校驗規則

Vuetify的表單校驗,是通過rules屬性來指定的:

校驗規則的寫法:

說明:

  • 規則是一個數組

  • 陣列中的元素是一個函式,該函式接收表單項的值作為引數,函式返回值兩種情況:

    • 返回true,代表成功,

    • 返回錯誤提示資訊,代表失敗

1.1.2.2.編寫校驗

我們有四個欄位:

  • name:做非空校驗和長度校驗,長度必須大於1

  • letter:首字母,校驗長度為1,非空。

  • image:圖片,不做校驗,圖片可以為空

  • categories:非空校驗,自定義元件已經幫我們完成,不用寫了

首先,我們定義規則:

然後,在頁面標籤中指定:

<v-text-field v-model="brand.name" label="請輸入品牌名稱" hint="例如:oppo" :rules="[rules.required, rules.nameLength]"></v-text-field>
<v-text-field v-model="brand.letter" label="請輸入品牌首字母" hint="例如:O" :rules="[rules.letter]"></v-text-field>

效果:


1.1.3.表單提交

在submit方法中新增表單提交的邏輯:

submit() {
    console.log(this.$qs);
    // 表單校驗
    if (this.$refs.myBrandForm.validate()) {
        // 定義一個請求引數物件,通過解構表示式來獲取brand中的屬性{categories letter name image}
        const {categories, letter, ...params} = this.brand; // params:{name, image, cids, letter}
        // 資料庫中只要儲存分類的id即可,因此我們對categories的值進行處理,只保留id,並轉為字串
        params.cids = categories.map(c => c.id).join(",");
        // 將字母都處理為大寫
        params.letter = letter.toUpperCase();
        // 將資料提交到後臺
        // this.$http.post('/item/brand', this.$qs.stringify(params))
        this.$http({
            method: this.isEdit ? 'put' : 'post',
            url: '/item/brand',
            data: params
        }).then(() => {
            // 關閉視窗
            this.$emit("close");
            this.$message.success("儲存成功!");
        })
            .catch(() => {
            this.$message.error("儲存失敗!");
        });
    }
}

  1. 通過this.$refs.myBrandForm選中表單,然後呼叫表單的validate方法,進行表單校驗。返回boolean值,true代表校驗通過

  2. 通過解構表示式來獲取brand中的值,categories需要處理,單獨獲取。其它的存入params物件中

  3. 品牌和商品分類的中間表只儲存兩者的id,而brand.categories中儲存的是物件陣列,裡面有id和name屬性,因此這裡通過陣列的map功能轉為id陣列,然後通過join方法拼接為字串

  4. 發起請求

  5. 彈窗提示成功還是失敗,這裡用到的是我們的自定義元件功能message元件:


這個外掛把$message物件繫結到了Vue的原型上,因此我們可以通過this.$message來直接呼叫。

包含以下常用方法:

  • info、error、success、warning等,彈出一個帶有提示資訊的視窗,色調與為普通(灰)、錯誤(紅色)、成功(綠色)和警告(黃色)。使用方法:this.$message.info("msg")

  • confirm:確認框。用法:this.$message.confirm("確認框的提示資訊"),返回一個Promise。

1.2.後臺實現新增

1.2.1.controller

還是一樣,先分析四個內容:

  • 請求方式:POST

  • 請求路徑:/brand

  • 請求引數:brand物件,外加商品分類的id陣列cids

  • 返回值:無,只需要響應狀態碼

程式碼:

 
 /**
     * 新增品牌
     * @param brand
     * @param cids
     */
    @PostMapping
    public ResponseEntity<Void> saveBrand(Brand brand, @RequestParam("cids") List<Long> cids){
        this.brandService.saveBrand(brand, cids);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

1.2.2.Service

這裡要注意,我們不僅要新增品牌,還要維護品牌和商品分類的中間表。

 
  /**
     * 新增品牌
     *
     * @param brand
     * @param cids
     */
    @Transactional
    public void saveBrand(Brand brand, List<Long> cids) {
​
        // 先新增brand
        this.brandMapper.insertSelective(brand);
​
        // 在新增中間表
        cids.forEach(cid -> {
            this.brandMapper.insertCategoryAndBrand(cid, brand.getId());
        });
    }

這裡呼叫了brandMapper中的一個自定義方法,來實現中間表的資料新增

1.2.3.Mapper

通用Mapper只能處理單表,也就是Brand的資料,因此我們手動編寫一個方法及sql,實現中間表的新增:

public interface BrandMapper extends Mapper<Brand> {
​
    /**
     * 新增商品分類和品牌中間表資料
     * @param cid 商品分類id
     * @param bid 品牌id
     * @return
     */
    @Insert("INSERT INTO tb_category_brand(category_id, brand_id) VALUES (#{cid},#{bid})")
    int insertBrandAndCategory(@Param("cid") Long cid, @Param("bid") Long bid);
}

1.2.4.測試

400:請求引數不合法

1.3.解決400

1.3.1.原因分析

我們填寫表單並提交,發現報錯了。檢視控制檯的請求詳情:

發現請求的資料格式是JSON格式。

原因分析:

axios處理請求體的原則會根據請求資料的格式來定:

  • 如果請求體是物件:會轉為json傳送

  • 如果請求體是String:會作為普通表單請求傳送,但需要我們自己保證String的格式是鍵值對。

    如:name=jack&age=12

1.3.2.QS工具

QS是一個第三方庫,我們可以用npm install qs --save來安裝。不過我們在專案中已經集成了,大家無需安裝:

這個工具的名字:QS,即Query String,請求引數字串。

什麼是請求引數字串?例如: name=jack&age=21

QS工具可以便捷的實現 JS的Object與QueryString的轉換。

在我們的專案中,將QS注入到了Vue的原型物件中,我們可以通過this.$qs來獲取這個工具:

我們將this.$qs物件列印到控制檯:

created(){
    console.log(this.$qs);
}

發現其中有3個方法:

這裡我們要使用的方法是stringify,它可以把Object轉為QueryString。

測試一下,使用瀏覽器工具,把qs物件儲存為一個臨時變數temp1,然後呼叫stringify方法:

成功將person物件變成了 name=zhangsan&age=30的字串了

1.3.3.解決問題

修改頁面,對引數處理後傳送:

然後再次發起請求,發現請求成功:

1.4.新增完成後關閉視窗

我們發現有一個問題:新增不管成功還是失敗,視窗都一致在這裡,不會關閉。

這樣很不友好,我們希望如果新增失敗,視窗保持;但是新增成功,視窗關閉才對。

因此,我們需要在新增的ajax請求完成以後,關閉視窗

但問題在於,控制視窗是否顯示的標記在父元件:MyBrand.vue中。子元件如何才能操作父元件的屬性?或者告訴父元件該關閉視窗了?

之前我們講過一個父子元件的通訊,有印象嗎?

  • 第一步:在父元件中定義一個函式,用來關閉視窗,不過之前已經定義過了。父元件在使用子元件時,繫結事件,關聯到這個函式:Brand.vue

<!--對話方塊的內容,表單-->
<v-card-text class="px-5" style="height:400px">
    <brand-form @close="closeWindow" :oldBrand="oldBrand" :isEdit="isEdit"/>
</v-card-text>

  • 第二步,子元件通過this.$emit呼叫父元件的函式:BrandForm.vue

測試一下,儲存成功:

我們優化一下,關閉的同時重新載入資料:

closeWindow(){
    // 關閉視窗
    this.show = false;
    // 重新載入資料
    this.getDataFromServer();
}

2.實現圖片上傳

剛才的新增實現中,我們並沒有上傳圖片,接下來我們一起完成圖片上傳邏輯。

檔案的上傳並不只是在品牌管理中有需求,以後的其它服務也可能需要,因此我們建立一個獨立的微服務,專門處理各種上傳。

2.1.搭建專案

2.1.1.建立module

2.1.2.依賴

我們需要EurekaClient和web依賴:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>leyou</artifactId>
        <groupId>com.leyou.parent</groupId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion><groupId>com.leyou.upload</groupId>
    <artifactId>leyou-upload</artifactId>
    <version>1.0.0-SNAPSHOT</version><dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
</project>

2.1.3.編寫配置

server:
  port: 8082
spring:
  application:
    name: upload-service
  servlet:
    multipart:
      max-file-size: 5MB # 限制檔案上傳的大小
# Eureka
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
  instance:
    lease-renewal-interval-in-seconds: 5 # 每隔5秒傳送一次心跳
    lease-expiration-duration-in-seconds: 10 # 10秒不傳送就過期

需要注意的是,我們應該添加了限制檔案大小的配置

2.1.4.引導類

@SpringBootApplication
@EnableDiscoveryClient
public class LeyouUploadApplication {
​
    public static void main(String[] args) {
        SpringApplication.run(LeyouUploadApplication.class, args);
    }
}

結構:


2.2.編寫上傳功能

檔案上傳功能,也是自定義元件完成的,參照自定義元件用法指南:

在頁面中的使用:

2.2.1.controller

編寫controller需要知道4個內容:結合用法指南

  • 請求方式:上傳肯定是POST

  • 請求路徑:/upload/image

  • 請求引數:檔案,引數名是file,SpringMVC會封裝為一個介面:MultipartFile

  • 返回結果:上傳成功後得到的檔案的url路徑,也就是返回String

程式碼如下:

@Controller
@RequestMapping("upload")
public class UploadController {
​
    @Autowired
    private UploadService uploadService;
​
    /**
     * 圖片上傳
     * @param file
     * @return
     */
    @PostMapping("image")
    public ResponseEntity<String> uploadImage(@RequestParam("file") MultipartFile file){
        String url = this.uploadService.upload(file);
        if (StringUtils.isBlank(url)) {
            return ResponseEntity.badRequest().build();
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(url);
    }
}

2.2.2.service

在上傳檔案過程中,我們需要對上傳的內容進行校驗:

  1. 校驗檔案大小

  2. 校驗檔案的媒體型別

  3. 校驗檔案的內容

檔案大小在Spring的配置檔案中設定,因此已經會被校驗,我們不用管。

具體程式碼:

@Service
public class UploadService {
​
    private static final List<String> CONTENT_TYPES = Arrays.asList("image/jpeg", "image/gif");
​
    private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class);
​
    public String upload(MultipartFile file) {
​
        String originalFilename = file.getOriginalFilename();
        // 校驗檔案的型別
        String contentType = file.getContentType();
        if (!CONTENT_TYPES.contains(contentType)){
            // 檔案型別不合法,直接返回null
            LOGGER.info("檔案型別不合法:{}", originalFilename);
            return null;
        }
​
        try {
            // 校驗檔案的內容
            BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
            if (bufferedImage == null){
                LOGGER.info("檔案內容不合法:{}", originalFilename);
                return null;
            }
​
            // 儲存到伺服器
            file.transferTo(new File("C:\\leyou\\images\\" + originalFilename));
​
            // 生成url地址,返回
            return "http://image.leyou.com/" + originalFilename;
        } catch (IOException e) {
            LOGGER.info("伺服器內部錯誤:{}", originalFilename);
            e.printStackTrace();
        }
        return null;
    }
}

這裡有一個問題:為什麼圖片地址需要使用另外的url?

  • 圖片不能儲存在伺服器內部,這樣會對伺服器產生額外的載入負擔

  • 一般靜態資源都應該使用獨立域名,這樣訪問靜態資源時不會攜帶一些不必要的cookie,減小請求的資料量

2.2.3.測試上傳

我們通過RestClient工具來測試:

結果:

去目錄下檢視:

上傳成功!

2.3.繞過閘道器

圖片上傳是檔案的傳輸,如果也經過Zuul閘道器的代理,檔案就會經過多次網路傳輸,造成不必要的網路負擔。在高併發時,可能導致網路阻塞,Zuul閘道器不可用。這樣我們的整個系統就癱瘓了。

所以,我們上傳檔案的請求就不經過閘道器來處理了。

2.3.1.Zuul的路由過濾

Zuul中提供了一個ignored-patterns屬性,用來忽略不希望路由的URL路徑,示例:

zuul.ignored-patterns: /upload/**

路徑過濾會對一切微服務進行判定。

Zuul還提供了ignored-services屬性,進行服務過濾:

zuul.ignored-services: upload-servie

我們這裡採用忽略服務:

zuul:
  ignored-services:
    - upload-service # 忽略upload-service服務

上面的配置採用了集合語法,代表可以配置多個。

2.3.2.Nginx的rewrite指令

現在,我們修改頁面的訪問路徑:

<v-upload
      v-model="brand.image" 
      url="/upload/image" 
      :multiple="false" 
      :pic-width="250" :pic-height="90"
      />

檢視頁面的請求路徑:

可以看到這個地址不對,依然是去找Zuul閘道器,因為我們的系統全域性配置了URL地址。怎麼辦?

有同學會想:修改頁面請求地址不就好了。

注意:原則上,我們是不能把除了閘道器以外的服務對外暴露的,不安全。

既然不能修改頁面請求,那麼就只能在Nginx反向代理上做文章了。

我們修改nginx配置,將以/api/upload開頭的請求攔截下來,轉交到真實的服務地址:

location /api/upload {
    proxy_pass http://127.0.0.1:8082;
    proxy_connect_timeout 600;
    proxy_read_timeout 600;
}

這樣寫大家覺得對不對呢?

顯然是不對的,因為ip和埠雖然對了,但是路徑沒變,依然是:http://127.0.0.1:8002/api/upload/image

前面多了一個/api

Nginx提供了rewrite指令,用於對地址進行重寫,語法規則:

rewrite "用來匹配路徑的正則" 重寫後的路徑 [指令];

我們的案例:

server {
        listen       80;
        server_name  api.leyou.com;

        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 上傳路徑的對映
        location /api/upload {    
            proxy_pass http://127.0.0.1:8082;
            proxy_connect_timeout 600;
            proxy_read_timeout 600;
            
            rewrite "^/api/(.*)$" /$1 break; 
        }
        
        location / {
            proxy_pass http://127.0.0.1:10010;
            proxy_connect_timeout 600;
            proxy_read_timeout 600;
        }
    }

  • 首先,我們對映路徑是/api/upload,而下面一個對映路徑是 / ,根據最長路徑匹配原則,/api/upload優先順序更高。也就是說,凡是以/api/upload開頭的路徑,都會被第一個配置處理

  • proxy_pass:反向代理,這次我們代理到8082埠,也就是upload-service服務

  • rewrite "^/api/(.*)$" /$1 break,路徑重寫:

    • "^/api/(.*)$":匹配路徑的正則表示式,用了分組語法,把/api/以後的所有部分當做1組

    • /$1:重寫的目標路徑,這裡用$1引用前面正則表示式匹配到的分組(組編號從1開始),即/api/後面的所有。這樣新的路徑就是除去/api/以外的所有,就達到了去除/api字首的目的

    • break:指令,常用的有2個,分別是:last、break

      • last:重寫路徑結束後,將得到的路徑重新進行一次路徑匹配

      • break:重寫路徑結束後,不再重新匹配路徑。

      我們這裡不能選擇last,否則以新的路徑/upload/image來匹配,就不會被正確的匹配到8082埠了

修改完成,輸入nginx -s reload命令重新載入配置。然後再次上傳試試。

2.4.跨域問題

重啟nginx,再次上傳,發現跟上次的狀態碼已經不一樣了,但是依然報錯:

不過慶幸的是,這個錯誤已經不是第一次見了,跨域問題。

我們在upload-service中新增一個CorsFilter即可:


@Configuration
public class LeyouCorsConfiguration {

    @Bean
    public CorsFilter corsFilter() {
        //1.新增CORS配置資訊
        CorsConfiguration config = new CorsConfiguration();
        //1) 允許的域,不要寫*,否則cookie就無法使用了
        config.addAllowedOrigin("http://manage.leyou.com");
        //3) 允許的請求方式
        config.addAllowedMethod("OPTIONS");
        config.addAllowedMethod("POST");
        // 4)允許的頭資訊
        config.addAllowedHeader("*");

        //2.新增對映路徑,我們攔截一切請求
        UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
        configSource.registerCorsConfiguration("/**", config);

        //3.返回新的CorsFilter.
        return new CorsFilter(configSource);
    }
}

再次測試:

不過,非常遺憾的是,訪問圖片地址,卻沒有響應。

這是因為我們並沒有任何伺服器對應image.leyou.com這個域名。。

這個問題,我們暫時放下,回頭再來解決。

2.5.檔案上傳的缺陷

先思考一下,現在上傳的功能,有沒有什麼問題?

上傳本身沒有任何問題,問題出在儲存檔案的方式,我們是儲存在伺服器機器,就會有下面的問題:

  • 單機器儲存,儲存能力有限

  • 無法進行水平擴充套件,因為多臺機器的檔案無法共享,會出現訪問不到的情況

  • 資料沒有備份,有單點故障風險

  • 併發能力差

這個時候,最好使用分散式檔案儲存來代替本地檔案儲存。

3.FastDFS

3.1.什麼是分散式檔案系統

分散式檔案系統(Distributed File System)是指檔案系統管理的物理儲存資源不一定直接連線在本地節點上,而是通過計算機網路與節點相連。

通俗來講:

  • 傳統檔案系統管理的檔案就儲存在本機。

  • 分散式檔案系統管理的檔案儲存在很多機器,這些機器通過網路連線,要被統一管理。無論是上傳或者訪問檔案,都需要通過管理中心來訪問

3.2.什麼是FastDFS

FastDFS是由淘寶的餘慶先生所開發的一個輕量級、高效能的開源分散式檔案系統。用純C語言開發,功能豐富:

  • 檔案儲存

  • 檔案同步

  • 檔案訪問(上傳、下載)

  • 存取負載均衡

  • 線上擴容

適合有大容量儲存需求的應用或系統。同類的分散式檔案系統有谷歌的GFS、HDFS(Hadoop)、TFS(淘寶)等。

3.3.FastDFS的架構

3.3.1.架構圖

先上圖:


FastDFS兩個主要的角色:Tracker Server 和 Storage Server 。

  • Tracker Server:跟蹤伺服器,主要負責排程storage節點與client通訊,在訪問上起負載均衡的作用,和記錄storage節點的執行狀態,是連線client和storage節點的樞紐。

  • Storage Server:儲存伺服器,儲存檔案和檔案的meta data(元資料),每個storage server會啟動一個單獨的執行緒主動向Tracker cluster中每個tracker server報告其狀態資訊,包括磁碟使用情況,檔案同步情況及檔案上傳下載次數統計等資訊

  • Group:檔案組,多臺Storage Server的叢集。上傳一個檔案到同組內的一臺機器上後,FastDFS會將該檔案即時同步到同組內的其它所有機器上,起到備份的作用。不同組的伺服器,儲存的資料不同,而且相互獨立,不進行通訊。

  • Tracker Cluster:跟蹤伺服器的叢集,有一組Tracker Server(跟蹤伺服器)組成。

  • Storage Cluster :儲存叢集,有多個Group組成。

3.3.2.上傳和下載流程

上傳


  1. Client通過Tracker server查詢可用的Storage server。

  2. Tracker server向Client返回一臺可用的Storage server的IP地址和埠號。

  3. Client直接通過Tracker server返回的IP地址和埠與其中一臺Storage server建立連線並進行檔案上傳。

  4. 上傳完成,Storage server返回Client一個檔案ID,檔案上傳結束。

下載


  1. Client通過Tracker server查詢要下載檔案所在的的Storage server。

  2. Tracker server向Client返回包含指定檔案的某個Storage server的IP地址和埠號。

  3. Client直接通過Tracker server返回的IP地址和埠與其中一臺Storage server建立連線並指定要下載檔案。

  4. 下載檔案成功。

3.4.安裝和使用

參考課前資料的:

3.5.java客戶端

餘慶先生提供了一個Java客戶端,但是作為一個C程式設計師,寫的java程式碼可想而知。而且已經很久不維護了。

這裡推薦一個開源的FastDFS客戶端,支援最新的SpringBoot2.0。

配置使用極為簡單,支援連線池,支援自動生成縮圖,狂拽酷炫吊炸天啊,有木有。

地址:tobato/FastDFS_client


接下來,我們就用FastDFS改造leyou-upload工程。

3.5.1.引入依賴

在父工程中,我們已經管理了依賴,版本為:

<fastDFS.client.version>1.26.2</fastDFS.client.version>

因此,這裡我們直接在taotao-upload工程的pom.xml中引入座標即可:

<dependency>
    <groupId>com.github.tobato</groupId>
    <artifactId>fastdfs-client</artifactId>
</dependency>

3.5.2.引入配置類


純java配置:

@Configuration
@Import(FdfsClientConfig.class)
// 解決jmx重複註冊bean的問題
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {
    
}

3.5.3.編寫FastDFS屬性

在application.yml配置檔案中追加如下內容:

fdfs:
  so-timeout: 1501 # 超時時間
  connect-timeout: 601 # 連線超時時間
  thumb-image: # 縮圖
    width: 60
    height: 60
  tracker-list: # tracker地址:你的虛擬機器伺服器地址+埠(預設是22122)
    - 192.168.56.101:22122

3.5.4.配置hosts

將來通過域名:image.leyou.com這個域名訪問fastDFS伺服器上的圖片資源。所以,需要代理到虛擬機器地址:

配置hosts檔案,使image.leyou.com可以訪問fastDFS伺服器


3.5.5.測試

建立測試類:


把以下內容copy進去:

@SpringBootTest
@RunWith(SpringRunner.class)
public class FastDFSTest {

    @Autowired
    private FastFileStorageClient storageClient;

    @Autowired
    private ThumbImageConfig thumbImageConfig;

    @Test
    public void testUpload() throws FileNotFoundException {
        // 要上傳的檔案
        File file = new File("C:\\Users\\joedy\\Pictures\\xbx1.jpg");
        // 上傳並儲存圖片,引數:1-上傳的檔案流 2-檔案的大小 3-檔案的字尾 4-可以不管他
        StorePath storePath = this.storageClient.uploadFile(
                new FileInputStream(file), file.length(), "jpg", null);
        // 帶分組的路徑
        System.out.println(storePath.getFullPath());
        // 不帶分組的路徑
        System.out.println(storePath.getPath());
    }

    @Test
    public void testUploadAndCreateThumb() throws FileNotFoundException {
        File file = new File("C:\\Users\\joedy\\Pictures\\xbx1.jpg");
        // 上傳並且生成縮圖
        StorePath storePath = this.storageClient.uploadImageAndCrtThumbImage(
                new FileInputStream(file), file.length(), "png", null);
        // 帶分組的路徑
        System.out.println(storePath.getFullPath());
        // 不帶分組的路徑
        System.out.println(storePath.getPath());
        // 獲取縮圖路徑
        String path = thumbImageConfig.getThumbImagePath(storePath.getPath());
        System.out.println(path);
    }
}

結果:

group1/M00/00/00/wKg4ZVsWl5eAdLNZAABAhya2V0c424.jpg
M00/00/00/wKg4ZVsWl5eAdLNZAABAhya2V0c424.jpg
group1/M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png
M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png
M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772_60x60.png

訪問第二組第一個路徑:

訪問最後一個路徑(縮圖路徑),注意加組名(group1)

3.5.6.改造上傳邏輯

@Service
public class UploadService {

    @Autowired
    private FastFileStorageClient storageClient;

    private static final List<String> CONTENT_TYPES = Arrays.asList("image/jpeg", "image/gif");

    private static final Logger LOGGER = LoggerFactory.getLogger(UploadService.class);

    public String upload(MultipartFile file) {

        String originalFilename = file.getOriginalFilename();
        // 校驗檔案的型別
        String contentType = file.getContentType();
        if (!CONTENT_TYPES.contains(contentType)){
            // 檔案型別不合法,直接返回null
            LOGGER.info("檔案型別不合法:{}", originalFilename);
            return null;
        }

        try {
            // 校驗檔案的內容
            BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
            if (bufferedImage == null){
                LOGGER.info("檔案內容不合法:{}", originalFilename);
                return null;
            }

            // 儲存到伺服器
            // file.transferTo(new File("C:\\leyou\\images\\" + originalFilename));
            String ext = StringUtils.substringAfterLast(originalFilename, ".");
            StorePath storePath = this.storageClient.uploadFile(file.getInputStream(), file.getSize(), ext, null);

            // 生成url地址,返回
            return "http://image.leyou.com/" + storePath.getFullPath();
        } catch (IOException e) {
            LOGGER.info("伺服器內部錯誤:{}", originalFilename);
            e.printStackTrace();
        }
        return null;
    }
}

只需要把原來儲存檔案的邏輯去掉,然後上傳到FastDFS即可。

3.5.7.測試

通過RestClient測試:

3.6.頁面測試上傳

發現上傳成功:


4.修改品牌(作業)

修改的難點在於回顯。

當我們點選編輯按鈕,希望彈出視窗的同時,看到原來的資料:

4.1.點選編輯出現彈窗

這個比較簡單,修改show屬性為true即可實現,我們繫結一個點選事件:

<v-icon small class="mr-2" @click="editItem(props.item)">
    edit
</v-icon>

然後編寫事件,改變show 的狀態:

如果僅僅是這樣,編輯按鈕與新增按鈕將沒有任何區別,關鍵在於,如何回顯呢?

4.2.回顯資料

回顯資料,就是把當前點選的品牌資料傳遞到子元件(MyBrandForm)。而父元件給子元件傳遞資料,通過props屬性。

  • 第一步:在編輯時獲取當前選中的品牌資訊,並且記錄到data中

    先在data中定義屬性,用來接收用來編輯的brand資料:

    我們在頁面觸發編輯事件時,把當前的brand傳遞給editBrand方法:

    <v-btn color="info" @click="editBrand(props.item)">編輯</v-btn>

    然後在editBrand中接收資料,賦值給oldBrand:

    editItem(oldBrand){
        // 使編輯視窗可見
        this.dialog = true;
        // 初始化編輯的資料
        this.oldBrand = oldBrand;
    }

  • 第二步:把獲取的brand資料 傳遞給子元件

    <!--對話方塊內容-->
    <v-card-text class="px-5">
        <!--這是一個表單-->
        <my-brand-form @close="close" :oldBrand="oldBrand"></my-brand-form>
    </v-card-text>

  • 第三步:在子元件(MyBrandForm.vue)中通過props接收要編輯的brand資料,Vue會自動完成回顯

    接收資料:

    通過watch函式監控oldBrand的變化,把值copy到本地的brand:

    watch: {
        oldBrand: {// 監控oldBrand的變化
            handler(val) {
                if(val){
                    // 注意不要直接賦值,否則這邊的修改會影響到父元件的資料,copy屬性即可
                    this.brand =  Object.deepCopy(val)
                }else{
                    // 為空,初始化brand
                    this.brand = {
                        name: '',
                        letter: '',
                        image: '',
                        categories: []
                    }
                }
            },
                deep: true
        }
    }

    • Object.deepCopy 自定義的物件進行深度複製的方法。

    • 需要判斷監聽到的是否為空,如果為空,應該進行初始化

測試:發現數據回顯了,除了商品分類以外:


4.3.商品分類回顯

為什麼商品分類沒有回顯?

因為品牌中並沒有商品分類資料。我們需要在進入編輯頁面之前,查詢商品分類資訊:

4.3.1.後臺提供介面

controller

/**
     * 通過品牌id查詢商品分類
     * @param bid
     * @return
     */
@GetMapping("bid/{bid}")
public ResponseEntity<List<Category>> queryByBrandId(@PathVariable("bid") Long bid) {
    List<Category> list = this.categoryService.queryByBrandId(bid);
    if (list == null || list.size() < 1) {
        return new ResponseEntity<>(HttpStatus.NOT_FOUND);
    }
    return ResponseEntity.ok(list);
}

Service

public List<Category> queryByBrandId(Long bid) {
    return this.categoryMapper.queryByBrandId(bid);
}

mapper

因為需要通過中間表進行子查詢,所以這裡要手寫Sql:

/**
     * 根據品牌id查詢商品分類
     * @param bid
     * @return
     */
@Select("SELECT * FROM tb_category WHERE id IN (SELECT category_id FROM tb_category_brand WHERE brand_id = #{bid})")
List<Category> queryByBrandId(Long bid);

4.3.2.前臺查詢分類並渲染

我們在編輯頁面開啟之前,先把資料查詢完畢:

editBrand(oldBrand){
    // 根據品牌資訊查詢商品分類
    this.$http.get("/item/category/bid/" + oldBrand.id)
        .then(({data}) => {
        // 控制彈窗可見:
        this.dialog = true;
        // 獲取要編輯的brand
        this.oldBrand = oldBrand
        // 回顯商品分類
        this.oldBrand.categories = data;
    })
}

再次測試:資料成功回顯了


4.3.3.新增視窗資料干擾

但是,此時卻產生了新問題:新增視窗竟然也有資料?

原因:

如果之前開啟過編輯,那麼在父元件中記錄的oldBrand會保留。下次再開啟視窗,如果是編輯視窗到沒問題,但是新增的話,就會再次顯示上次開啟的品牌資訊了。

解決:

新增視窗開啟前,把資料置空。
addBrand() {
    // 控制彈窗可見:
    this.dialog = true;
    // 把oldBrand變為null
    this.oldBrand = null;
}

4.3.4.提交表單時判斷是新增還是修改

新增和修改是同一個頁面,我們該如何判斷?

父元件中點選按鈕彈出新增或修改的視窗,因此父元件非常清楚接下來是新增還是修改。

因此,最簡單的方案就是,在父元件中定義變數,記錄新增或修改狀態,當彈出頁面時,把這個狀態也傳遞給子元件。

第一步:在父元件中記錄狀態:


第二步:在新增和修改前,更改狀態:


第三步:傳遞給子元件


第四步,子元件接收標記:


標題的動態化:


表單提交動態:

axios除了除了get和post外,還有一個通用的請求方式:

// 將資料提交到後臺
// this.$http.post('/item/brand', this.$qs.stringify(params))
this.$http({
    method: this.isEdit ? 'put' : 'post', // 動態判斷是POST還是PUT
    url: '/item/brand',
    data: this.$qs.stringify(this.brand)
}).then(() => {
    // 關閉視窗
    this.$emit("close");
    this.$message.success("儲存成功!");
})
    .catch(() => {
    this.$message.error("儲存失敗!");
});

5.刪除(作業)