SyuanYu 2 年 前
コミット
3470be12ae

+ 2 - 1
frontend/.env

@@ -1,4 +1,5 @@
-VITE_APP_DOMAIN_DEV=cloud.choozmo.com
+# VITE_APP_DOMAIN_DEV=cloud.choozmo.com # 正式
+VITE_APP_DOMAIN_DEV=192.168.192.252:8080 # 測試
 # VUE_APP_DOMAIN_DEV=local.dockertoolbox.tiangolo.com
 # VUE_APP_DOMAIN_DEV=localhost.tiangolo.com
 # VUE_APP_DOMAIN_DEV=dev.ai-anchor.com

+ 44 - 9
frontend/src/api.ts

@@ -1,6 +1,6 @@
 import axios from "axios";
 import { apiUrl } from "@/env";
-import type { IUserProfile, IUserProfileUpdate, IUserProfileCreate, Video, VideoCreate} from "@/interfaces";
+import type { IUserProfile, IUserProfileUpdate, IUserProfileCreate, Video, VideoCreate, ArticleCreate, ImageDownload } from "@/interfaces";
 
 function authHeaders(token: string) {
   return {
@@ -49,26 +49,61 @@ export const api = {
   async registerUser(data: IUserProfileCreate) {
     return axios.post(`${apiUrl}/api/v1/users/open`, data);
   },
-  async testCeleryMsg(token: string, data:{msg: string}){
-    return axios.post<{msg:string}>(`${apiUrl}/api/v1/utils/test-celery/msg`, data, authHeaders(token));
+  async testCeleryMsg(token: string, data: { msg: string }) {
+    return axios.post<{ msg: string }>(`${apiUrl}/api/v1/utils/test-celery/msg`, data, authHeaders(token));
   },
-  async testCeleryFile(token: string, file: File){
+  async testCeleryFile(token: string, file: File) {
     const formData = new FormData();
     formData.append("file", file)
-    return axios.post<{msg:string}>(`${apiUrl}/api/v1/utils/test-celery/file`, formData, authHeaders(token));
+    return axios.post<{ msg: string }>(`${apiUrl}/api/v1/utils/test-celery/file`, formData, authHeaders(token));
   },
-  async uploadPlot(token: string, video_data:VideoCreate, file: File){
+  async uploadPlot(token: string, video_data: VideoCreate, file: File) {
     const formData = new FormData();
     formData.append("title", video_data.title)
     formData.append("anchor_id", video_data.anchor_id.toString())
     formData.append("lang_id", video_data.lang_id.toString())
     formData.append("upload_file", file)
-    return axios.post<{msg:string}>(`${apiUrl}/api/v1/videos/`, formData, authHeaders(token));
+    return axios.post<{ msg: string }>(`${apiUrl}/api/v1/videos/`, formData, authHeaders(token));
+  },
+  async uploadImage(token: string, file: File[]) {
+    const formData = new FormData();
+    for (let i = 0; i < file.length; i++) {
+      const element = file[i];
+      formData.append("upload_files", element);
+    }
+    return axios.post<{ filenames: string[] }>(`${apiUrl}/api/v1/images/sr`, formData, authHeaders(token));
+  },
+  async getImage(token: string, data: ImageDownload) {
+    axios({
+      url: `${apiUrl}/api/v1/images/sr?stored_file_name=${data.stored_file_name}&file_name=${data.file_name}`,
+      method: 'GET',
+      responseType: 'blob',
+      headers: {
+        'Authorization': `Bearer ${token}`
+      },
+    }).then((response) => {
+      console.log('getImage response', response);
+      const href = URL.createObjectURL(response.data);
+      const link = document.createElement('a');
+      link.href = href;
+      link.setAttribute('download', `${data.stored_file_name}_hr.png`); //or any other extension
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      URL.revokeObjectURL(href);
+    });
+  },
+  async uploadArticle(token: string, article_data: ArticleCreate) {
+    const formData = new FormData();
+    formData.append("title", article_data.title)
+    formData.append("link", article_data.link)
+    formData.append("content", article_data.content)
+    return axios.post<{ msg: string }>(`${apiUrl}/api/v1/reputations/`, formData, authHeaders(token));
   },
   async getVideos(token: string) {
     return axios.get<Video[]>(`${apiUrl}/api/v1/videos/`, authHeaders(token));
   },
-  async googleLogin(access_token: string){
-    return axios.post(`${apiUrl}/api/v1/login/google/access-token/${access_token}`, )
+  async googleLogin(access_token: string) {
+    return axios.post(`${apiUrl}/api/v1/login/google/access-token/${access_token}`,)
   },
 };

BIN
frontend/src/assets/img/step/step-03.png


BIN
frontend/src/assets/img/step/step-08.png


+ 47 - 0
frontend/src/components/Dialog.vue

@@ -0,0 +1,47 @@
+<script setup lang="ts">
+import { computed } from "vue";
+import { useI18n } from "vue-i18n";
+
+const { t } = useI18n();
+const showDialog = computed(() => props.dialog);
+
+const props = defineProps({
+  msg: {
+    type: String,
+    default: "",
+  },
+  dialog: {
+    type: Boolean,
+    default: false,
+  },
+  state: {
+    type: String,
+  },
+});
+
+const emit = defineEmits(["close"]);
+
+function close() {
+  emit("close", false);
+}
+</script>
+<template>
+  <v-dialog
+    :value="showDialog"
+    v-model="showDialog"
+    width="400"
+    @click:outside="close()"
+  >
+    <v-card>
+      <v-card-text>
+        <section class="d-flex flex-column align-center">
+          <v-icon style="font-size: 70px" icon="info" :color="state" />
+          <p class="mt-3">{{ msg }}</p>
+        </section>
+      </v-card-text>
+      <v-card-actions>
+        <v-btn @click="close()" class="mx-auto"> {{ t("close") }}</v-btn>
+      </v-card-actions>
+    </v-card>
+  </v-dialog>
+</template>

+ 34 - 16
frontend/src/interfaces/index.ts

@@ -1,25 +1,25 @@
 export interface IUserProfile {
-    email: string;
-    is_active: boolean;
-    is_superuser: boolean;
-    full_name?: string;
-    id: number;
+  email: string;
+  is_active: boolean;
+  is_superuser: boolean;
+  full_name?: string;
+  id: number;
 }
 
 export interface IUserProfileUpdate {
-    email?: string;
-    full_name?: string;
-    password?: string;
-    is_active?: boolean;
-    is_superuser?: boolean;
+  email?: string;
+  full_name?: string;
+  password?: string;
+  is_active?: boolean;
+  is_superuser?: boolean;
 }
 
 export interface IUserProfileCreate {
-    email: string;
-    full_name?: string;
-    password: string;
-    is_active?: boolean;
-    is_superuser?: boolean;
+  email: string;
+  full_name?: string;
+  password: string;
+  is_active?: boolean;
+  is_superuser?: boolean;
 }
 
 export interface AppNotification {
@@ -37,6 +37,7 @@ export interface MainState {
   dashboardShowDrawer: boolean;
   notifications: AppNotification[];
   videos: Video[];
+  images: Image[];
 };
 
 export interface Video {
@@ -46,8 +47,25 @@ export interface Video {
   progress_state: string;
 }
 
-export interface VideoCreate{
+export interface VideoCreate {
   title: string;
   anchor_id: number;
   lang_id: number;
+}
+
+export interface ArticleCreate {
+  title: string;
+  link: string;
+  content: string;
+}
+
+export interface Image {
+  file_name: string;
+  stored_file_name: string;
+  content: string;
+}
+
+export interface ImageDownload {
+  file_name: string;
+  stored_file_name: string;
 }

+ 75 - 3
frontend/src/stores/main.ts

@@ -4,7 +4,7 @@ import { api } from "@/api"
 import router from "@/router"
 import { getLocalToken, removeLocalToken, saveLocalToken } from "@/utils";
 import type { AppNotification } from '@/interfaces';
-import type { IUserProfile, IUserProfileCreate, IUserProfileUpdate, MainState, Video, VideoCreate } from '@/interfaces';
+import type { IUserProfile, IUserProfileCreate, IUserProfileUpdate, MainState, Video, VideoCreate, ArticleCreate, Image, ImageDownload } from '@/interfaces';
 import i18n from '@/plugins/i18n'
 import { GoogleLogin } from "vue3-google-login";
 
@@ -17,6 +17,7 @@ const defaultState: MainState = {
   dashboardShowDrawer: true,
   notifications: [],
   videos: [],
+  images: [],
 };
 
 export const useMainStore = defineStore("MainStoreId", {
@@ -255,6 +256,77 @@ export const useMainStore = defineStore("MainStoreId", {
         await mainStore.checkApiError(error);
       }
     },
+    async uploadImage(file: File[]) {
+      const mainStore = useMainStore();
+      try {
+        const loadingNotification = { content: i18n.global.t("sending"), showProgress: true };
+        mainStore.addNotification(loadingNotification);
+        const response = (
+          await Promise.all([
+            api.uploadImage(mainStore.token, file),
+            await new Promise<void>((resolve, _) => setTimeout(() => resolve(), 0)),
+          ]).then(data => {
+            for (let i = 0; i < data[0].data.filenames.length; i++) {
+              const element = data[0].data.filenames[i];
+              const tmpImage: Image = {
+                file_name: file[i].name,
+                stored_file_name: element,
+                content: "sr"
+              };
+              this.addImage(tmpImage);
+            }
+            localStorage.setItem('imagesList',JSON.stringify(this.images));
+            console.log('this.images',this.images);
+          })
+        );
+        mainStore.removeNotification(loadingNotification);
+        mainStore.addNotification({
+          content: i18n.global.t("fileReceived"),
+          color: "success",
+        })
+        // this.actionGetVideos();
+      } catch (error) {
+        await mainStore.checkApiError(error);
+      }
+    },
+    async getImage(data: ImageDownload) {
+      console.log('getImage data', data);
+      const mainStore = useMainStore();
+      try {
+        const response = (
+          await Promise.all([
+            api.getImage(mainStore.token, data),
+            await new Promise<void>((resolve, _) => setTimeout(() => resolve(), 0)),
+          ])
+        );
+      } catch (error) {
+        await mainStore.checkApiError(error);
+      }
+    },
+    addImage(payload: Image) {
+      this.images.push(payload);
+    },
+    async uploadArticle(article_data: ArticleCreate) {
+      const mainStore = useMainStore();
+      try {
+        const loadingNotification = { content: i18n.global.t("sending"), showProgress: true };
+        mainStore.addNotification(loadingNotification);
+        const response = (
+          await Promise.all([
+            api.uploadArticle(mainStore.token, article_data),
+            await new Promise<void>((resolve, _) => setTimeout(() => resolve(), 0)),
+          ])
+        );
+        mainStore.removeNotification(loadingNotification);
+        mainStore.addNotification({
+          content: i18n.global.t("fileReceived"),
+          color: "success",
+        })
+        // this.actionGetVideos();
+      } catch (error) {
+        await mainStore.checkApiError(error);
+      }
+    },
     async actionGetVideos() {
       const mainStore = useMainStore();
       try {
@@ -266,8 +338,8 @@ export const useMainStore = defineStore("MainStoreId", {
         await mainStore.checkApiError(error);
       }
     },
-    async googleLogin(access_token:string) {
-      try{
+    async googleLogin(access_token: string) {
+      try {
         const response = await api.googleLogin(access_token)
         if (response) {
           console.log(response)

+ 1 - 1
frontend/src/views/Login.vue

@@ -137,7 +137,7 @@ onMounted(() => {
               <span></span>
             </section>
 
-            <div class="w-100 mx-auto mt-5" style="max-width: 280px">
+            <div class="mx-auto mt-5" style="max-width: 235px">
               <GoogleLogin
                 :callback="callback"
                 prompt

+ 40 - 29
frontend/src/views/main/Article.vue

@@ -1,23 +1,42 @@
 <script setup lang="ts">
-import { ref } from "vue";
+import { ref, reactive } from "vue";
 import { useMainStore } from "@/stores/main";
 import { required } from "@/utils";
 import { useI18n } from "vue-i18n";
+import type { ArticleCreate } from "@/interfaces";
+// import Dialog from "@/components/Dialog.vue";
 
 const mainStore = useMainStore();
 const { t } = useI18n();
 const valid = ref(true);
-const dialog = ref(false);
-const title = ref("");
 const Form = ref();
+let title = ref("");
+let link = ref("");
+let content = ref("");
+
+// props
+// let dialog = reactive({
+//   msg: "上傳成功!",
+//   state: "success",
+//   show: false,
+// });
 
 async function Submit() {
-  setTimeout(() => {
-    dialog.value = true;
-  }, 2000);
+  // setTimeout(() => {
+  //   dialog.show = true;
+  // }, 2000);
   await (Form as any).value.validate();
   if (valid.value) {
     valid.value = false;
+
+    const article_data: ArticleCreate = {
+      title: title.value,
+      link: link.value,
+      content: content.value,
+    };
+
+    await mainStore.uploadArticle(article_data);
+    // (Form as any).value.reset();
   }
 }
 </script>
@@ -37,9 +56,14 @@ async function Submit() {
             prepend-icon="title"
           >
           </v-text-field>
-          <v-text-field :label="$t('articleLink')" prepend-icon="link">
+          <v-text-field
+            v-model="link"
+            :label="$t('articleLink')"
+            prepend-icon="link"
+          >
           </v-text-field>
           <v-textarea
+            v-model="content"
             :label="$t('articleContent')"
             prepend-icon="edit_document"
           ></v-textarea>
@@ -53,30 +77,17 @@ async function Submit() {
       </v-card-actions>
     </v-card>
 
-    <template>
+    <!-- <template>
       <div class="text-center">
-        <v-dialog v-model="dialog" width="auto">
-          <v-card>
-            <v-card-text>
-              <section class="d-flex flex-column align-center">
-                <v-icon
-                  style="font-size: 70px"
-                  icon="info"
-                  color="orange-darken-3"
-                />
-                <p class="mt-3">文章處理需要約 5-10 分鐘,敬請耐心等候</p>
-              </section>
-            </v-card-text>
-            <v-card-actions>
-              <v-btn color="primary" block @click="dialog = false">
-              {{ t("close") }}</v-btn>
-            </v-card-actions>
-          </v-card>
-        </v-dialog>
+        <Dialog
+          :msg="dialog.msg"
+          :state="dialog.state"
+          :dialog="dialog.show"
+          @close="dialog.show = false"
+        ></Dialog>
       </div>
-    </template>
+    </template> -->
   </v-container>
 </template>
 
-<style lang="scss">
-</style>
+<style lang="scss"></style>

+ 77 - 28
frontend/src/views/main/Image.vue

@@ -1,41 +1,64 @@
 <script setup lang="ts">
-import { ref, reactive } from "vue";
+import { ref, reactive, onMounted } from "vue";
 import { useMainStore } from "@/stores/main";
 import { required } from "@/utils";
 import { useI18n } from "vue-i18n";
+import type { ImageDownload } from "@/interfaces";
+import Dialog from "@/components/Dialog.vue";
 
 const mainStore = useMainStore();
 const { t } = useI18n();
 const valid = ref(true);
-const dialog = ref(false);
-const title = ref("");
 const Form = ref();
 let imgFiles = ref();
 let imgList: any[] = reactive([]);
 
-async function upload() {
-  for (let i = 0; i < imgFiles.value.files.length; i++) {
-    const item = imgFiles.value.files[i];
-    imgList.push(item);
-    console.log("element", item.name);
+// props
+let dialog = reactive({
+  msg: "圖片處理需要幾秒鐘的時間,敬請耐心等候",
+  state: "info",
+  show: false,
+});
+
+async function Submit() {
+  setTimeout(() => {
+    dialog.show = true;
+  }, 2000);
+  await mainStore.uploadImage(imgFiles.value);
+  (Form as any).value.reset();
+  for (let i = 0; i < mainStore.images.length; i++) {
+    const element = mainStore.images[i];
+    imgList.push(element);
   }
 }
 
-// async function Submit() {
-//   setTimeout(() => {
-//     dialog.value = true;
-//   }, 2000);
-//   await (Form as any).value.validate();
-//   if (valid.value) {
-//     valid.value = false;
-//   }
-// }
+onMounted(() => {
+  if (imgList.length === 0) {
+    let images: any | null = localStorage.getItem("imagesList");
+    if (images) {
+      images = JSON.parse(images);
+      for (let i = 0; i < images.length; i++) {
+        const item = images[i];
+        imgList.push(item);
+      }
+    }
+  }
+});
+
+async function downloadImg(name: string, id: string) {
+  const data: ImageDownload = {
+    file_name: id,
+    stored_file_name: name.split(".")[0],
+  };
+
+  await mainStore.getImage(data);
+}
 
 const headers = [
   {
     title: "檔名",
     sortable: true,
-    key: "name",
+    key: "file_name",
     align: "left",
   },
   {
@@ -45,8 +68,8 @@ const headers = [
     align: "left",
   },
   {
-    title: t("preview"),
-    key: "id",
+    title: t("download"),
+    key: "stored_file_name",
   },
 ];
 </script>
@@ -58,16 +81,29 @@ const headers = [
         <h3 class="card-title mb-3">圖片優化</h3>
       </v-card-title>
       <v-card-text>
-        <v-form v-model="valid" ref="Form" class="img-form">
-          <img src="@/assets/img/icon/add-image.png" alt="" class="mb-4" />
-          <input
+        <!-- <section class="d-flex flex-column form-title">
+        <img src="@/assets/img/icon/add-image.png" alt="" class="mb-4" />
+        <p>請點擊加入圖片並開始優化</p>
+       </section> -->
+
+        <v-form v-model="valid" ref="Form">
+          <!-- <img src="@/assets/img/icon/add-image.png" alt="" class="mb-4" /> -->
+          <!-- <input
             @change="upload()"
             ref="imgFiles"
             type="file"
             multiple
             class="file-input"
-          />
-          <p>請點擊加入圖片並開始優化</p>
+          /> -->
+
+          <v-file-input
+            v-model="imgFiles"
+            multiple
+            label="請選擇圖片"
+            prepend-icon="add_photo_alternate"
+          ></v-file-input>
+
+          <!-- <p>請點擊加入圖片並開始優化</p> -->
         </v-form>
       </v-card-text>
       <v-card-actions class="justify-center">
@@ -77,7 +113,8 @@ const headers = [
           color="primary"
           class="px-5"
           prepend-icon="file_upload"
-          >上傳圖片</v-btn
+          @click="Submit()"
+          >上傳</v-btn
         >
       </v-card-actions>
     </v-card>
@@ -110,11 +147,23 @@ const headers = [
             完成
           </span>
         </template>
-        <template v-slot:item.id="{ item }">
-          <v-icon icon="crop_original" />
+        <template v-slot:item.stored_file_name="{ item }">
+          <v-btn
+            flat
+            @click="downloadImg(item.raw.file_name, item.raw.stored_file_name)"
+          >
+            <v-icon icon="crop_original" />
+          </v-btn>
         </template>
       </v-data-table>
     </v-card>
+
+    <Dialog
+      :msg="dialog.msg"
+      :state="dialog.state"
+      :dialog="dialog.show"
+      @close="dialog.show = false"
+    ></Dialog>
   </v-container>
 </template>
 

+ 90 - 47
frontend/src/views/main/Upload.vue

@@ -3,28 +3,19 @@ import { ref, reactive, watch, computed } from "vue";
 import { useMainStore } from "@/stores/main";
 import { required } from "@/utils";
 import { useI18n } from "vue-i18n";
-import { useDisplay } from "vuetify";
 import type { VideoCreate } from "@/interfaces";
 import router from "@/router";
+import Dialog from "@/components/Dialog.vue";
 
-const { t } = useI18n();
-const { name } = useDisplay();
-const width = computed(() => {
-  switch (name.value) {
-    case "xs":
-      return 6;
-    case "sm":
-      return 4;
-    case "md":
-      return 3;
-    case "lg":
-      return 2;
-  }
-  return "auto";
+// props
+let dialog = reactive({
+  msg: "影片處理需要約 5-10 分鐘,敬請耐心等候",
+  state: "info",
+  show: false,
 });
 
+const { t } = useI18n();
 const valid = ref(true);
-const dialog = ref(false);
 const title = ref("");
 const zipFiles = ref();
 const Form = ref();
@@ -35,102 +26,102 @@ const anchorList = reactive([
   {
     anchor_id: 0,
     language_id: 1,
-    name: "Peggy",
+    name: "Angela",
   },
   {
     anchor_id: 1,
     language_id: 1,
-    name: "Jocelyn",
+    name: "半身主播-1",
   },
   {
     anchor_id: 2,
     language_id: 1,
-    name: "Summer",
+    name: "半身主播-2",
   },
   {
     anchor_id: 3,
     language_id: 1,
-    name: "Angela",
+    name: "半身主播-3",
   },
   {
     anchor_id: 4,
     language_id: 1,
-    name: "半身主播-1",
+    name: "半身主播-4",
   },
   {
     anchor_id: 5,
     language_id: 1,
-    name: "半身主播-2",
+    name: "半身主播-5",
   },
   {
     anchor_id: 6,
     language_id: 1,
-    name: "半身主播-3",
+    name: "半身主播-6",
   },
   {
     anchor_id: 7,
     language_id: 1,
-    name: "半身主播-4",
+    name: "半身主播-7",
   },
   {
     anchor_id: 8,
     language_id: 1,
-    name: "半身主播-5",
+    name: "半身主播-8",
   },
   {
     anchor_id: 9,
     language_id: 1,
-    name: "半身主播-6",
+    name: "半身主播-9",
   },
   {
     anchor_id: 10,
     language_id: 1,
-    name: "半身主播-7",
+    name: "半身主播-10",
   },
   {
     anchor_id: 11,
     language_id: 1,
-    name: "半身主播-8",
+    name: "半身主播-11",
   },
   {
     anchor_id: 12,
     language_id: 1,
-    name: "半身主播-9",
+    name: "半身主播-12",
   },
   {
     anchor_id: 13,
     language_id: 1,
-    name: "半身主播-10",
+    name: "半身主播-13",
   },
   {
     anchor_id: 14,
     language_id: 1,
-    name: "半身主播-11",
+    name: "半身主播-14",
   },
   {
     anchor_id: 15,
     language_id: 1,
-    name: "半身主播-12",
+    name: "半身主播-15",
   },
   {
     anchor_id: 16,
     language_id: 1,
-    name: "半身主播-13",
+    name: "半身主播-16",
   },
   {
     anchor_id: 17,
     language_id: 1,
-    name: "半身主播-14",
+    name: "Peggy",
   },
   {
     anchor_id: 18,
     language_id: 1,
-    name: "半身主播-15",
+    name: "Jocelyn",
   },
   {
     anchor_id: 19,
     language_id: 1,
-    name: "半身主播-16",
+    name: "Summer",
   },
 ]);
 
@@ -169,20 +160,23 @@ let items = reactive([
 
 // 取得圖片路徑
 const getImageUrl = (imgFolder: string, name: string) => {
-  return new URL(`../../assets/img/${imgFolder}/${name}.webp`, import.meta.url).href;
+  return new URL(`../../assets/img/${imgFolder}/${name}.webp`, import.meta.url)
+    .href;
 };
 
 const mainStore = useMainStore();
 
 watch(dialog, (newVal, oldVal) => {
-  if (!newVal) {
-    router.push("/main/progress");
+  if (!newVal.show) {
+    setTimeout(() => {
+      router.push("/main/progress");
+    }, 500);
   }
 });
 
 async function Submit() {
   setTimeout(() => {
-    dialog.value = true;
+    dialog.show = true;
   }, 2000);
   await (Form as any).value.validate();
   if (valid.value) {
@@ -247,9 +241,22 @@ async function Submit() {
                           dark
                           @click="toggle"
                           :title="n.name"
+                          :disabled="n.anchor_id !== 0"
                         >
                           <v-scroll-y-transition>
-                            <img :src="getImageUrl('anchor', n.name)" alt="" />
+                            <div v-if="n.anchor_id !== 0" class="img-disabled">
+                              <img
+                                :src="getImageUrl('anchor', n.name)"
+                                alt=""
+                              />
+                              <p>Coming Soon</p>
+                            </div>
+
+                            <img
+                              v-else
+                              :src="getImageUrl('anchor', n.name)"
+                              alt=""
+                            />
                           </v-scroll-y-transition>
                         </v-card>
                       </v-item>
@@ -274,6 +281,7 @@ async function Submit() {
                     v-for="n in templateList"
                     :key="n.template_id"
                     v-slot="{ isSelected, toggle, selectedClass }"
+                    disabled
                   >
                     <v-card
                       color="grey-lighten-1"
@@ -286,7 +294,11 @@ async function Submit() {
                       >
                         <v-icon icon="done" color="white" />
                       </span>
-                      <img :src="getImageUrl('template', n.img)" alt="" />
+                      <div class="img-disabled">
+                        <img :src="getImageUrl('template', n.img)" alt="" />
+                        <p>Coming Soon</p>
+                      </div>
+                      <!-- <img :src="getImageUrl('template', n.img)" alt="" /> -->
                     </v-card>
                   </v-slide-group-item>
                 </v-slide-group>
@@ -340,9 +352,8 @@ async function Submit() {
                 <br />
                 (建議 10 個字內,若超過請使用換行符號)
               </li>
-              <li>2. 字幕段落勿超過中文 25 字、英文 50 字</li>
-              <li>3. 大標字數勿超過中文 15 字、英文 30 字</li>
-              <li>4. 音檔請留空白</li>
+              <li>2. 大標字數勿超過中文 15 字、英文 30 字</li>
+              <li>3. 音檔請留空白</li>
             </ul>
             <p class="mt-5 excerpt">以下為顯示效果:</p>
             <img src="@/assets/img/step/step-04.png" alt="" />
@@ -366,7 +377,7 @@ async function Submit() {
 
     <template>
       <div class="text-center">
-        <v-dialog v-model="dialog" width="auto">
+        <!-- <v-dialog v-model="dialog" width="auto">
           <v-card>
             <v-card-text>
               <section class="d-flex flex-column align-center">
@@ -384,7 +395,14 @@ async function Submit() {
               }}</v-btn>
             </v-card-actions>
           </v-card>
-        </v-dialog>
+        </v-dialog> -->
+
+        <Dialog
+          :msg="dialog.msg"
+          :state="dialog.state"
+          :dialog="dialog.show"
+          @close="dialog.show = false"
+        ></Dialog>
       </div>
     </template>
   </v-container>
@@ -516,4 +534,29 @@ async function Submit() {
     margin-bottom: 2px;
   }
 }
+
+.img-disabled {
+  position: relative;
+  z-index: 999;
+  background-color: #ccc;
+  margin-bottom: -5px;
+  img {
+    opacity: 0.7;
+  }
+  p {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    color: #fff;
+    transform: translate(-50%, -50%);
+    font-size: 16px;
+    text-align: center;
+    letter-spacing: 1px;
+    text-shadow: 2px 2px 6px #000;
+  }
+}
+
+.v-card--disabled > :not(.v-card__loader) {
+  opacity: 1 !important;
+}
 </style>