SyuanYu 3 weeks ago
parent
commit
226c047cc5

BIN
src/assets/img/arrow_l.png


BIN
src/assets/img/arrow_r.png


BIN
src/assets/img/confirm-solid.png


BIN
src/assets/img/confirm.png


BIN
src/assets/img/female.png


BIN
src/assets/img/male.png


+ 572 - 0
src/components/Photo.vue

@@ -0,0 +1,572 @@
+<script setup>
+import { ref, onMounted, defineEmits } from "vue";
+import { useMainStore } from "@/stores/store";
+// Axios
+import axios from "axios";
+// i18n
+import { useI18n } from "vue-i18n";
+// Qrcode.vue
+// import QrcodeVue from "qrcode.vue";
+
+const { t } = useI18n();
+const store = useMainStore();
+const vlogVideo = ref(null);
+const canvas = ref(null);
+const photo = ref(null);
+let showVideo = ref(true);
+
+const emit = defineEmits(["closeDialog"]);
+
+// 關閉父組件的 funFilterDialog
+function closeDialog() {
+  emit("closeDialog");
+}
+
+onMounted(() => {
+  // 請求使用者的攝影機
+  navigator.mediaDevices
+    .getUserMedia({ video: true })
+    .then((stream) => {
+      vlogVideo.value.srcObject = stream;
+    })
+    .catch((error) => {
+      console.error("無法開啟攝影機", error);
+    });
+});
+
+let loading = ref(false);
+let havePhoto = ref(false); // 拍照完成
+let isCompleted = ref(false); // 照片確認
+let countdown = ref(0);
+
+// 倒數五秒拍攝
+function startCountdown() {
+  countdown.value = 5;
+  loading.value = true;
+
+  const interval = setInterval(() => {
+    countdown.value -= 1;
+    if (countdown.value === 0) {
+      clearInterval(interval);
+      takePhoto();
+    }
+  }, 1000);
+}
+
+startCountdown();
+
+let photoFile = ref(null);
+
+// 拍攝照片
+const takePhoto = () => {
+  const context = canvas.value.getContext("2d");
+  canvas.value.width = vlogVideo.value.videoWidth;
+  canvas.value.height = vlogVideo.value.videoHeight;
+  context.drawImage(
+    vlogVideo.value,
+    0,
+    0,
+    vlogVideo.value.videoWidth,
+    vlogVideo.value.videoHeight
+  );
+
+  // 將畫布轉換成 base64 格式的 JPG 照片,不調整畫質
+  photo.value = canvas.value.toDataURL("image/jpeg"); // 不設置壓縮品質
+
+  if (photo.value) {
+    showVideo.value = false;
+
+    // 將 base64 轉換成 Blob
+    const photoBlob = dataURLtoBlob(photo.value);
+
+    // 將 Blob 轉換成 File
+    photoFile.value = blobToFile(photoBlob, "photo.jpg"); // 設定檔案名稱為 JPG 格式
+
+    havePhoto.value = true;
+    loading.value = false;
+  }
+};
+
+// 重新拍攝
+function reshoot() {
+  // 清空照片和檔案資料
+  photo.value = null;
+  photoFile.value = null;
+
+  // 顯示影片重新準備拍攝
+  showVideo.value = true;
+  havePhoto.value = false;
+  loading.value = false;
+
+  startCountdown();
+
+  console.log("重新拍攝,重設狀態");
+}
+
+// base64 轉 Blob
+function dataURLtoBlob(dataURL) {
+  const byteString = atob(dataURL.split(",")[1]);
+  const mimeString = dataURL.split(",")[0].split(":")[1].split(";")[0];
+  const ab = new ArrayBuffer(byteString.length);
+  const ia = new Uint8Array(ab);
+  for (let i = 0; i < byteString.length; i++) {
+    ia[i] = byteString.charCodeAt(i);
+  }
+  return new Blob([ab], { type: mimeString });
+}
+
+// Blob 轉 File
+function blobToFile(blob, fileName) {
+  return new File([blob], fileName, { type: blob.type });
+}
+
+let swapVideo = ref(null);
+let swapVideoSrc = ref(null);
+let form = ref(false);
+let email = ref(null);
+let formLoading = ref(false);
+let showAlert = ref(false);
+// let imgUrl = ref(null); // 明信片圖
+// const required = (v) => !!v || "請輸入您的 email";
+const required = (v) => {
+  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+  return emailPattern.test(v) || "請輸入有效的 email 格式";
+};
+
+async function onSubmit() {
+  formLoading.value = true;
+
+  let url = `https://cmm.ai/postcard/fs/swap-face-video/${store.assignGender}?email_to=${email.value}`;
+
+  const formData = new FormData();
+  formData.append("file", photoFile.value);
+
+  console.log("url", url);
+  try {
+    const response = await axios.post(url, formData);
+    console.log("換臉影片 response", response);
+
+    if (response.data === "OK") {
+      showAlert.value = true;
+
+      setTimeout(() => {
+        showAlert.value = false;
+        closeDialog();
+      }, 3000);
+    }
+
+    formLoading.value = false;
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+
+let isUploading = ref(false);
+let imageUrl = ref(null); // 圖片網址
+
+// 上傳
+async function upload() {
+  isUploading.value = true;
+
+  // isCompleted.value = true;
+  if (!photoFile.value) {
+    return;
+  }
+
+  store.imgPath = "";
+  loading.value = true;
+
+  let url = `https://cmm.ai/postcard/fs/swap-face/${store.assignGender}/${store.assignRace}/${store.assignBgImg}`;
+
+  // 人物圖
+  const formData = new FormData();
+  formData.append("file", photoFile.value);
+
+  try {
+    let response = await axios.post(url, formData);
+    console.log("runImg", response);
+
+    if (response.status === 200) {
+      isUploading.value = false;
+      isCompleted.value = true;
+      // store.imgPath = response.data[0].path;
+
+      const filePath = response.data;
+      const fileName = filePath.match(/results\/(.+)$/)[1];
+      console.log("fileName", fileName);
+
+      imageUrl.value = `https://cmm.ai/postcard/fs/result-image/${fileName}`;
+
+      // 圖檔轉網址
+      // let blob = new Blob([response.data], { type: "image/png" }); // 創建 blob
+      // let imageUrl = URL.createObjectURL(blob); // 創建圖片 URL
+      // store.imgFile = blob; // 圖片檔案
+      // store.imgPath = imageUrl; // 圖片網址
+      // loading.value = false;
+      // alert("完成");
+      // router.push("/step7");
+    }
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+
+let vlogEmail = ref("");
+let vlogLoading = ref(false);
+let showVlogAlert = ref(false);
+let emailError = ref(false);
+
+// 取得 vlog
+async function getVlog() {
+  console.log("getVlog");
+  console.log("vlogEmail", vlogEmail.value);
+
+  vlogLoading.value = true;
+
+  // isCompleted.value = true;
+  // if (!photoFile.value) {
+  //   return;
+  // }
+
+  // store.imgPath = "";
+  // loading.value = true;
+
+  let url = `https://cmm.ai/postcard/fs/swap-face-slide/${store.assignGender}/${store.assignRace}/${store.assignBgImg}?email_to=${vlogEmail.value}`;
+
+  // 人物圖
+  const formData = new FormData();
+  formData.append("file", photoFile.value);
+
+  try {
+    let response = await axios.post(url, formData);
+
+    vlogLoading.value = false;
+    console.log("取得 vlog", response);
+
+    if (response.data === "OK") {
+      alert(`${t("vlog.submit_success")}!${t("vlog.processing_message")}`);
+      // showVlogAlert.value = true;
+
+      // setTimeout(() => {
+      //   showVlogAlert.value = false;
+      // }, 5000);
+    }
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+
+// 驗證 email 格式
+function validateAndSubmit() {
+  const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
+  if (!emailPattern.test(vlogEmail.value)) {
+    emailError.value = true;
+    return;
+  }
+  emailError.value = false;
+
+  getVlog();
+}
+</script>
+
+<template>
+  <div
+    v-if="store.funFilterDialog"
+    class="d-flex flex-column align-center justify-center"
+  >
+    <video v-show="showVideo" ref="vlogVideo" autoplay playsinline></video>
+
+    <div
+      v-if="photo && !isCompleted && !isUploading"
+      class="w-100 d-flex flex-column align-center"
+    >
+      <img :src="photo" alt="captured image" class="captured-img" />
+
+      <p class="prompt-text">
+        {{ t("remove_mask_result") }}
+      </p>
+
+      <div class="d-flex flex-column mt-10">
+        <v-btn variant="tonal" @click="reshoot()" class="mb-5">
+          {{ t("retake") }}
+        </v-btn>
+        <v-btn @click="upload()" color="primary" variant="elevated">
+          {{ t("confirm") }}
+        </v-btn>
+      </div>
+    </div>
+
+    <video v-show="swapVideoSrc" ref="swapVideo" preload playsinline>
+      <source :src="swapVideoSrc" type="video/mp4" />
+    </video>
+
+    <!-- 倒數計時動畫 -->
+    <div v-if="countdown > 0" class="countdown">{{ countdown }}</div>
+
+    <div
+      class="d-flex flex-column align-center justify-center"
+      v-if="isUploading"
+    >
+      <v-progress-circular
+        :size="100"
+        color="primary"
+        indeterminate
+        class="my-10"
+      ></v-progress-circular>
+
+      <p class="mb-10 text-center">{{ t("postcard_being_created") }}</p>
+    </div>
+
+    <div v-if="isCompleted && imageUrl" class="my-10 position-relative">
+      <div class="d-flex justify-center">
+        <img :src="imageUrl" alt="" class="w-100" style="object-fit: contain" />
+
+        <!-- <span class="ms-15 d-flex flex-column align-center justify-center">
+          <p class="mb-10 text-h5 font-weight-bold">
+            {{ t("qrcode_save_image") }}
+          </p>
+
+          <qrcode-vue :value="imageUrl" class="mb-2" size="300" level="H" />
+        </span> -->
+      </div>
+
+      <div class="vlog-item">
+        <h4>
+          <span>{{ t("vlog.input_email") }}</span>
+        </h4>
+        <p>
+          {{ t("vlog.description") }}
+        </p>
+
+        <v-text-field
+          v-model="vlogEmail"
+          :label="t('vlog.enter_email')"
+          variant="solo"
+          :error-messages="emailError ? [`${t('vlog.invalid_email')}`] : []"
+        ></v-text-field>
+        <v-btn
+          @click="validateAndSubmit()"
+          :loading="vlogLoading"
+          color="primary"
+          type="submit"
+          variant="elevated"
+          block
+        >
+          {{ t("submit") }}
+        </v-btn>
+      </div>
+
+      <v-alert
+        v-show="showVlogAlert"
+        class="vlog-alert"
+        :text="t('vlog.processing_message')"
+        :title="t('vlog.submit_success')"
+        type="success"
+      ></v-alert>
+    </div>
+
+    <!-- <v-progress-circular
+      v-else-if="loading"
+      :size="50"
+      color="primary"
+      indeterminate
+    ></v-progress-circular> -->
+
+    <!-- <div v-if="isCompleted" class="form-content">
+      <div
+        v-if="loading"
+        class="d-flex flex-column align-center justify-center py-15"
+      >
+        <v-progress-circular
+          :size="70"
+          :width="7"
+          color="primary"
+          indeterminate
+        ></v-progress-circular>
+      </div>
+
+      <div v-else></div>
+
+      <p class="mb-10 text-center">
+        拍攝完成!<br />
+        請提供您的電子郵件以取得影片
+        <br />影片約 1-2 分鐘後將傳送至您的信箱<br />
+      </p>
+
+      <v-form v-model="form" @submit.prevent="onSubmit">
+        <v-text-field
+          v-model="email"
+          :rules="[required]"
+          variant="solo"
+          class="mb-2"
+          label="email"
+          clearable
+        ></v-text-field>
+
+        <br />
+
+        <v-btn
+          :disabled="!form"
+          :loading="formLoading"
+          color="primary"
+          size="large"
+          type="submit"
+          variant="elevated"
+          block
+        >
+          送出
+        </v-btn>
+      </v-form>
+    </div> -->
+
+    <!-- <div v-if="showAlert" class="alert-item">
+      <v-alert
+        density="compact"
+        text="您的 vlog 影片已為您寄送,如尚未收到請耐心等候"
+        title="送出成功!"
+        type="success"
+      ></v-alert>
+    </div> -->
+
+    <!-- <div v-if="havePhoto" class="d-flex flex-column align-center">
+      <p class="text-center">拍照完成!<br />請掃描以下 QR Code 取得影片</p>
+      <img src="../assets/img/video-results.png" alt="" />
+    </div> -->
+
+    <!-- <button>
+      <span v-if="countdown > 0">拍照倒數 {{ countdown }} 秒</span>
+      <span v-else class="text-center"
+        >製作影片中,請稍候…</span
+      >
+    </button> -->
+
+    <canvas ref="canvas" style="display: none"></canvas>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.captured-img {
+  max-width: 100%;
+  margin: 1rem auto;
+}
+
+video {
+  width: 100%;
+  max-width: 750px;
+  height: auto;
+  margin: 1rem auto;
+}
+
+button {
+  // width: 100%;
+  margin: auto;
+  padding: 1.5rem 3rem;
+  display: flex;
+  justify-content: center;
+  border-radius: 5px;
+  letter-spacing: 3px;
+  // color: #fff;
+  font-size: 1.25rem;
+  // background-color: var(--main-color);
+}
+
+.countdown {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  font-size: 4em;
+  font-weight: bold;
+  color: #fff;
+}
+
+.result {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  img {
+    width: 50%;
+  }
+}
+
+.form-content {
+  width: 100%;
+  max-width: 500px;
+  margin-bottom: 1.5rem;
+
+  p {
+    line-height: 2;
+    letter-spacing: 1px;
+  }
+}
+
+.alert-item {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  z-index: 100;
+  transform: translate(-50%, -50%);
+
+  .v-alert {
+    width: 100%;
+    padding: 1rem;
+
+    .v-alert-title {
+      margin-bottom: 0.5rem;
+    }
+  }
+}
+
+.prompt-text {
+  color: #939393;
+}
+
+.vlog-item {
+  .v-input {
+    margin: 1.5rem auto 1rem;
+  }
+
+  h4 {
+    position: relative;
+    text-align: center;
+    margin: 3rem auto 1rem;
+    color: var(--main-color);
+    letter-spacing: 2px;
+
+    span {
+      display: inline-block;
+      font-weight: 600;
+      font-size: 1rem;
+      background-color: #fff;
+    }
+  }
+
+  h4::before,
+  h4::after {
+    content: "";
+    position: absolute;
+    z-index: -1;
+    top: 50%;
+    width: 30%;
+    height: 1px;
+    background-color: var(--main-color);
+  }
+
+  h4::before {
+    left: 0; /* 將左邊的線對齊到左側 */
+  }
+
+  h4::after {
+    right: 0; /* 將右邊的線對齊到右側 */
+  }
+}
+
+.vlog-alert {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+}
+</style>

+ 342 - 0
src/components/Photo101.vue

@@ -0,0 +1,342 @@
+<script setup>
+import { ref, onMounted, defineEmits } from "vue";
+import { useMainStore } from "@/stores/store";
+// Axios
+import axios from "axios";
+// i18n
+import { useI18n } from "vue-i18n";
+// Qrcode.vue
+// import QrcodeVue from "qrcode.vue";
+
+const { t } = useI18n();
+const store = useMainStore();
+const vlogVideo = ref(null);
+const canvas = ref(null);
+const photo = ref(null);
+let showVideo = ref(true);
+
+const emit = defineEmits(["closeDialog"]);
+
+// 關閉父組件的 funFilterDialog
+function closeDialog() {
+  emit("closeDialog");
+}
+
+onMounted(() => {
+  // 請求使用者的攝影機
+  navigator.mediaDevices
+    .getUserMedia({ video: true })
+    .then((stream) => {
+      vlogVideo.value.srcObject = stream;
+    })
+    .catch((error) => {
+      console.error("無法開啟攝影機", error);
+    });
+});
+
+let loading = ref(false);
+let havePhoto = ref(false); // 拍照完成
+let isCompleted = ref(false); // 照片確認
+let countdown = ref(0);
+
+// 倒數五秒拍攝
+function startCountdown() {
+  countdown.value = 5;
+  loading.value = true;
+
+  const interval = setInterval(() => {
+    countdown.value -= 1;
+    if (countdown.value === 0) {
+      clearInterval(interval);
+      takePhoto();
+    }
+  }, 1000);
+}
+
+startCountdown();
+
+let photoFile = ref(null);
+
+// 拍攝照片
+const takePhoto = () => {
+  const context = canvas.value.getContext("2d");
+  canvas.value.width = vlogVideo.value.videoWidth;
+  canvas.value.height = vlogVideo.value.videoHeight;
+  context.drawImage(
+    vlogVideo.value,
+    0,
+    0,
+    vlogVideo.value.videoWidth,
+    vlogVideo.value.videoHeight
+  );
+
+  // 將畫布轉換成 base64 格式的 JPG 照片,不調整畫質
+  photo.value = canvas.value.toDataURL("image/jpeg"); // 不設置壓縮品質
+
+  if (photo.value) {
+    showVideo.value = false;
+
+    // 將 base64 轉換成 Blob
+    const photoBlob = dataURLtoBlob(photo.value);
+
+    // 將 Blob 轉換成 File
+    photoFile.value = blobToFile(photoBlob, "photo.jpg"); // 設定檔案名稱為 JPG 格式
+
+    havePhoto.value = true;
+    loading.value = false;
+  }
+};
+
+// 重新拍攝
+function reshoot() {
+  // 清空照片和檔案資料
+  photo.value = null;
+  photoFile.value = null;
+
+  // 顯示影片重新準備拍攝
+  showVideo.value = true;
+  havePhoto.value = false;
+  loading.value = false;
+
+  startCountdown();
+
+  console.log("重新拍攝,重設狀態");
+}
+
+// base64 轉 Blob
+function dataURLtoBlob(dataURL) {
+  const byteString = atob(dataURL.split(",")[1]);
+  const mimeString = dataURL.split(",")[0].split(":")[1].split(";")[0];
+  const ab = new ArrayBuffer(byteString.length);
+  const ia = new Uint8Array(ab);
+  for (let i = 0; i < byteString.length; i++) {
+    ia[i] = byteString.charCodeAt(i);
+  }
+  return new Blob([ab], { type: mimeString });
+}
+
+// Blob 轉 File
+function blobToFile(blob, fileName) {
+  return new File([blob], fileName, { type: blob.type });
+}
+
+let swapVideo = ref(null);
+let swapVideoSrc = ref(null);
+let form = ref(false);
+let email = ref(null);
+let formLoading = ref(false);
+let showAlert = ref(false);
+const required = (v) => {
+  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+  return emailPattern.test(v) || "請輸入有效的 email 格式";
+};
+
+async function onSubmit() {
+  formLoading.value = true;
+
+  let url = `https://cmm.ai/postcard/fs/swap-face-video/${store.assignGender}?email_to=${email.value}`;
+
+  const formData = new FormData();
+  formData.append("file", photoFile.value);
+
+  console.log("url", url);
+  try {
+    const response = await axios.post(url, formData);
+    console.log("換臉影片 response", response);
+
+    if (response.data === "OK") {
+      showAlert.value = true;
+
+      setTimeout(() => {
+        showAlert.value = false;
+        closeDialog();
+      }, 3000);
+    }
+
+    formLoading.value = false;
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+
+let isUploading = ref(false);
+let imageUrl = ref(null); // 圖片網址
+
+// 上傳
+async function upload() {
+  isUploading.value = true;
+  let url = `https://cmm.ai/postcard/fs/swap-face-capture/${store.assignGender}`;
+
+  const formData = new FormData();
+  formData.append("file", photoFile.value);
+
+  console.log("url", url);
+  try {
+    const response = await axios.post(url, formData);
+    console.log("換臉影片 response", response);
+
+    if (response.status === 200) {
+      isUploading.value = false;
+      isCompleted.value = true;
+
+      console.log("response.data", response.data);
+
+      const filePath = response.data;
+      const fileName = filePath.match(/results\/(.+)$/)[1];
+      console.log("fileName", fileName);
+
+      imageUrl.value = `https://cmm.ai/postcard/fs/result-image/${fileName}`;
+    }
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+</script>
+
+<template>
+  <div
+    v-if="store.funFilterDialog"
+    class="d-flex flex-column align-center justify-center"
+  >
+    <video v-show="showVideo" ref="vlogVideo" autoplay playsinline></video>
+
+    <div
+      v-if="photo && !isCompleted && !isUploading"
+      class="w-100 d-flex flex-column align-center"
+    >
+      <img :src="photo" alt="captured image" class="captured-img" />
+
+      <p class="prompt-text">
+        {{ t("remove_mask_result") }}
+      </p>
+
+      <div class="d-flex flex-column mt-10">
+        <v-btn variant="tonal" @click="reshoot()" class="mb-5">
+          {{ t("retake") }}
+        </v-btn>
+        <v-btn
+          @click="upload()"
+          color="primary"
+          variant="elevated"
+          :loading="isUploading"
+        >
+          {{ t("confirm") }}
+        </v-btn>
+      </div>
+    </div>
+
+    <video v-show="swapVideoSrc" ref="swapVideo" preload playsinline>
+      <source :src="swapVideoSrc" type="video/mp4" />
+    </video>
+
+    <!-- 倒數計時動畫 -->
+    <div v-if="countdown > 0" class="countdown">{{ countdown }}</div>
+
+    <v-progress-circular
+      v-else-if="loading"
+      :size="50"
+      color="primary"
+      indeterminate
+    ></v-progress-circular>
+
+    <div
+      class="d-flex flex-column align-center justify-center"
+      v-if="isUploading"
+    >
+      <v-progress-circular
+        :size="100"
+        color="primary"
+        indeterminate
+        class="my-10"
+      ></v-progress-circular>
+
+      <p class="mb-10 text-center">{{ t("postcard_being_created") }}</p>
+    </div>
+
+    <div v-if="isCompleted && imageUrl" class="my-10 d-flex justify-center">
+      <img :src="imageUrl" alt="" class="w-100" style="object-fit: contain" />
+    </div>
+
+    <canvas ref="canvas" style="display: none"></canvas>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.captured-img {
+  max-width: 100%;
+  margin: 1rem auto;
+}
+
+video {
+  width: 100%;
+  max-width: 750px;
+  height: auto;
+  margin: 1rem auto;
+}
+
+button {
+  // width: 100%;
+  margin: auto;
+  padding: 1.5rem 3rem;
+  display: flex;
+  justify-content: center;
+  border-radius: 5px;
+  letter-spacing: 3px;
+  // color: #fff;
+  font-size: 1.25rem;
+  // background-color: var(--main-color);
+}
+
+.countdown {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  font-size: 4em;
+  font-weight: bold;
+  color: #fff;
+}
+
+.result {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  img {
+    width: 50%;
+  }
+}
+
+.form-content {
+  width: 100%;
+  max-width: 500px;
+  margin-bottom: 1.5rem;
+
+  p {
+    line-height: 2;
+    letter-spacing: 1px;
+  }
+}
+
+.alert-item {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  z-index: 100;
+  transform: translate(-50%, -50%);
+
+  .v-alert {
+    width: 500px;
+    padding: 1rem;
+
+    .v-alert-title {
+      margin-bottom: 0.5rem;
+    }
+  }
+}
+
+.prompt-text {
+  color: #939393;
+}
+</style>

+ 43 - 1
src/language/en.json

@@ -30,6 +30,21 @@
   "second": "second",
   "stop_recording": "After the conversation is complete,<br>please press the stop button to end the recording.",
   "speech_error": "Speech recognition error. Please re-record.",
+  "male": "Male",
+  "female": "Female",
+  "confirm": "Confirm",
+  "retake": "Retake",
+  "submit": "Submit",
+  "remove_mask_result": "(Each postcard is for one user only. If you are wearing a mask, please remove it before taking the photo. Thank you for your cooperation.)",
+  "qrcode_save_image": "Scan QR Code to save image",
+  "taipei_101_postcard": "Stunning View of Taipei 101",
+  "taiwan_landmark_postcard": "Taiwan Landmark / Vlog",
+  "select_gender": "Please select your gender",
+  "select_filter": "Select Filter",
+  "select_character": "Please choose your favorite character",
+  "select_background": "Please select your favorite background (single choice)",
+  "postcard_being_created": "Postcard is being created, please wait...",
+  "wait_a_moment": "Please wait a moment...",
   "service_info": {
     "title": "Service information",
     "inquiry_prompt": "What are you searching for?<br>Or please enter your question",
@@ -134,5 +149,32 @@
     "car_supplies": "Car Supplies",
     "entertainment": "Entertainment",
     "pet_supplies": "Pet Supplies"
-  }
+  },
+  "vlog": {
+    "input_email": "Enter your email to get a postcard VLOG",
+    "description": "We will create a personalized VLOG short video for you, featuring iconic Taiwanese landmarks, custom music, and dynamic effects. Whether it's a keepsake for yourself or something to share with family and friends, this VLOG will be the perfect way to cherish your memories of Taiwan!",
+    "submit_success": "Submission successful",
+    "processing_message": "Your personalized postcard VLOG is being created. Once completed, we will send the video to your email. Please check for an email from us, and have a wonderful day!",
+    "enter_email": "Enter email",
+    "invalid_email": "Please enter a valid email"
+  },
+  "台東嘉明湖": "Taitung Jiaming Lake",
+  "台南孔廟": "Tainan Confucius Temple",
+  "臺北中正紀念堂-2": "Taipei Chiang Kai-shek Memorial Hall-2",
+  "南投日月潭": "Nantou Sun Moon Lake",
+  "臺北故宮-2": "Taipei National Palace Museum-2",
+  "台中歌劇院": "Taichung Opera House",
+  "南投清境農場": "Nantou Qingjing Farm",
+  "花蓮金針花山": "Hualien Golden Needle Flower Mountain",
+  "台南鹽田": "Tainan Salt Field",
+  "中秋節": "Mid-Autumn Festival",
+  "天東86牛肉麵": "Tiandong 86 Beef Noodles",
+  "鉅洋髮藝": "Juyang Hair Art",
+  "新竹尖石鄉": "Hsinchu Jianshi Township",
+  "美哉清水斷崖": "Magnificent Qingshui Cliff",
+  "大崙山": "Dalu Mountain",
+  "雪山北峰": "Snow Mountain North Peak",
+  "玄武岩": "Basalt",
+  "合歡山東峰": "Hehuanshan East Peak",
+  "說好的改變呢": "What about the promised change?"
 }

+ 43 - 1
src/language/ja.json

@@ -30,6 +30,21 @@
   "second": "秒",
   "stop_recording": "録音を終了するには停止ボタンを押してください",
   "speech_error": "音声認識エラーです。再度録音してください。",
+  "male": "男性",
+  "female": "女性",
+  "confirm": "確定",
+  "retake": "撮り直し",
+  "submit": "送信",
+  "remove_mask_result": "(各明はがきは一人の使用者のみご利用いただけます。マスクを着用されている場合は、撮影前にお外しいただきますようお願い申し上げます。ご協力ありがとうございます。)",
+  "qrcode_save_image": "QRコードをスキャンして画像を保存",
+  "taipei_101_postcard": "台北101の絶景仰角",
+  "taiwan_landmark_postcard": "台湾の名所 / Vlog",
+  "select_gender": "性別を選択してください",
+  "select_filter": "フィルターを選択",
+  "select_character": "お好きなキャラクターを選んでください",
+  "select_background": "お好きな背景をお選びください(単選)",
+  "postcard_being_created": "明信片を作成中です。しばらくお待ちください…",
+  "wait_a_moment": "少々お待ちください…",
   "service_info": {
     "title": "サービス情報",
     "inquiry_prompt": "サービス情報を選択<br>下のメッセージボックスにご質問を入力してください。",
@@ -134,5 +149,32 @@
     "car_supplies": "車用品",
     "entertainment": "レジャー&エンターテインメント",
     "pet_supplies": "ペット用品"
-  }
+  },
+  "vlog": {
+    "input_email": "メールアドレスを入力してポストカード VLOG を取得",
+    "description": "私たちはあなたのために専用の VLOG 短編動画を作成します。台湾を代表する名所の背景を加え、専用の音楽と動的効果を組み合わせます。自分の記念として、または家族や友人と共有するため、この VLOG は台湾の思い出を大切にする最高の方法となるでしょう!",
+    "submit_success": "送信完了",
+    "processing_message": "あなた専用のポストカード VLOG を製作中です。完成後、動画をあなたのメールアドレスにお送りいたします。私たちからのメールをぜひご確認ください。素敵な一日をお過ごしください!",
+    "enter_email": "メールアドレスを入力",
+    "invalid_email": "有効なメールアドレスを入力してください"
+  },
+  "台東嘉明湖": "台東ジャミン湖",
+  "台南孔廟": "台南孔子廟",
+  "臺北中正紀念堂-2": "台北中正記念堂-2",
+  "南投日月潭": "南投日月潭",
+  "臺北故宮-2": "国立故宮博物院-2",
+  "台中歌劇院": "台中国家歌劇院",
+  "南投清境農場": "南投清境農場",
+  "花蓮金針花山": "花蓮金針花の山",
+  "台南鹽田": "台南塩田",
+  "中秋節": "中秋節",
+  "天東86牛肉麵": "天東86牛肉麺",
+  "鉅洋髮藝": "鉅洋ヘアサロン",
+  "新竹尖石鄉": "新竹尖石郷",
+  "美哉清水斷崖": "美しい清水断崖",
+  "大崙山": "大崙山",
+  "雪山北峰": "雪山北峰",
+  "玄武岩": "玄武岩",
+  "合歡山東峰": "合歓山東峰",
+  "說好的改變呢": "約束した変化はどこへ行った?"
 }

+ 43 - 1
src/language/ko.json

@@ -30,6 +30,21 @@
   "second": "초",
   "stop_recording": "대화가 끝나면 녹음을 중지하려면 버튼을 누르세요",
   "speech_error": "음성 인식 오류가 발생했습니다. 다시 녹음해 주세요.",
+  "male": "남성",
+  "female": "여성",
+  "confirm": "확인",
+  "retake": "다시 찍기",
+  "submit": "제출",
+  "remove_mask_result": "(각 명신카드는 한 명의 사용자만 사용 가능합니다. 마스크를 착용한 경우, 촬영 전에 마스크를 벗어 주시기 바랍니다. 협조해 주셔서 감사합니다)",
+  "qrcode_save_image": "QR 코드를 스캔하여 이미지를 저장하세요",
+  "taipei_101_postcard": "타이베이 101 절경 앵글",
+  "taiwan_landmark_postcard": "대만 명소 / Vlog",
+  "select_gender": "성별을 선택하세요",
+  "select_filter": "필터 선택",
+  "select_character": "선호하는 캐릭터를 선택하세요",
+  "select_background": "가장 마음에 드는 배경을 선택해주세요 (단일 선택)",
+  "postcard_being_created": "엽서를 제작 중입니다. 잠시만 기다려 주세요…",
+  "wait_a_moment": "잠시만 기다려 주세요…",
   "service_info": {
     "title": "서비스 정보",
     "inquiry_prompt": "조회하고 싶은 정보가 있습니까?",
@@ -134,5 +149,32 @@
     "car_supplies": "자동차 용품",
     "entertainment": "레저 오락",
     "pet_supplies": "애완용품"
-  }
+  },
+  "vlog": {
+    "input_email": "이메일을 입력하여 엽서 VLOG 받기",
+    "description": "저희는 여러분만의 특별한 VLOG 영상을 제작해 드립니다. 대만의 대표적인 명소 배경과 함께 맞춤형 음악 및 동적 효과를 추가하여, 자신만의 추억을 간직하거나 가족 및 친구들과 공유하기에 완벽한 대만의 추억을 담을 수 있습니다!",
+    "submit_success": "전송 성공",
+    "processing_message": "고객님만의 엽서 VLOG를 제작 중입니다. 완성되면 이메일로 동영상을 발송해 드리겠습니다. 저희로부터 오는 메일을 확인해 주시고, 좋은 하루 되시길 바랍니다!",
+    "enter_email": "이메일 입력",
+    "invalid_email": "유효한 이메일을 입력해 주세요"
+  },
+  "台東嘉明湖": "타이둥 지아밍 호수",
+  "台南孔廟": "타이난 공자묘",
+  "臺北中正紀念堂-2": "타이베이 중정기념당-2",
+  "南投日月潭": "난터우 일월담",
+  "臺北故宮-2": "타이베이 고궁박물관-2",
+  "台中歌劇院": "타이중 오페라하우스",
+  "南投清境農場": "난터우 칭징 농장",
+  "花蓮金針花山": "화롄 금침화 산",
+  "台南鹽田": "타이난 염전",
+  "中秋節": "중추절",
+  "天東86牛肉麵": "톈둥 86 소고기 국수",
+  "鉅洋髮藝": "쥬양 헤어 아트",
+  "新竹尖石鄉": "신주 지앤스 시앙",
+  "美哉清水斷崖": "아름다운 칭수이 단애",
+  "大崙山": "다룬 산",
+  "雪山北峰": "설산 북봉",
+  "玄武岩": "현무암",
+  "合歡山東峰": "허환산 동봉",
+  "說好的改變呢": "약속한 변화는 어디에 있나요?"
 }

+ 43 - 1
src/language/zh.json

@@ -30,6 +30,21 @@
   "second": "秒",
   "stop_recording": "對話完畢後,請按下停止按鈕結束錄音",
   "speech_error": "語音辨識有誤,請重新錄製。",
+  "male": "男性",
+  "female": "女性",
+  "confirm": "確定",
+  "retake": "重拍",
+  "submit": "送出",
+  "remove_mask_result": "(每張明信片僅限一位使用者使用,若有配戴口罩,請在拍攝前先取下,感謝您的配合。)",
+  "qrcode_save_image": "掃描 QR Code 儲存圖片",
+  "taipei_101_postcard": "台北101絕美仰角",
+  "taiwan_landmark_postcard": "台灣名勝景點 / Vlog",
+  "select_gender": "請選擇性別",
+  "select_filter": "請選擇濾鏡",
+  "select_character": "請選擇您喜歡的角色",
+  "select_background":"請選擇一張您最愛的背景(單選)",
+  "postcard_being_created": "明信片製作中,請稍候…",
+  "wait_a_moment": "請稍候…",
   "service_info": {
     "title": "服務資訊",
     "inquiry_prompt": "請問您想查詢?<br>或於下方文字框輸入您的問題",
@@ -134,5 +149,32 @@
     "car_supplies": "汽車用品",
     "entertainment": "休閒娛樂",
     "pet_supplies": "寵物用品"
-  }
+  },
+  "vlog": {
+    "input_email": "輸入信箱取得明信片 VLOG",
+    "description": "我們會為您製作一段專屬 VLOG 短片,加入台灣具代表性的名勝背景,並搭配專屬的音樂和動態特效,無論是為自己留念,還是與家人朋友分享,這段 VLOG 都將成為您珍藏台灣回憶的最佳方式!",
+    "submit_success": "送出成功",
+    "processing_message": "您的專屬明信片 VLOG 正在製作中,完成後我們會將影片寄送至您的信箱。請留意來自我們的信件,祝您有美好的一天!",
+    "enter_email": "輸入 email",
+    "invalid_email": "請輸入有效的 email"
+  },
+  "台東嘉明湖": "台東嘉明湖",
+  "台南孔廟": "台南孔廟",
+  "臺北中正紀念堂-2": "臺北中正紀念堂-2",
+  "南投日月潭": "南投日月潭",
+  "臺北故宮-2": "臺北故宮-2",
+  "台中歌劇院": "台中歌劇院",
+  "南投清境農場": "南投清境農場",
+  "花蓮金針花山": "花蓮金針花山",
+  "台南鹽田": "台南鹽田",
+  "中秋節": "中秋節",
+  "天東86牛肉麵": "天東86牛肉麵",
+  "鉅洋髮藝": "鉅洋髮藝",
+  "新竹尖石鄉": "新竹尖石鄉",
+  "美哉清水斷崖": "美哉清水斷崖",
+  "大崙山": "大崙山",
+  "雪山北峰": "雪山北峰",
+  "玄武岩": "玄武岩",
+  "合歡山東峰": "合歡山東峰",
+  "說好的改變呢": "說好的改變呢"
 }

+ 8 - 0
src/stores/store.js

@@ -2,6 +2,14 @@ import { defineStore } from 'pinia'
 
 export const useMainStore = defineStore('mainStore', {
   state: () => ({
+    assignFilter: "", // 濾鏡
+    assignGender: "", // 性別
+    assignRace: "", // 種族
+    assignBgImg: "", // 背景
+    imgFile: "", // 圖片檔案
+    imgPath: "", // 圖片網址
+    funFilterDialog: false, // 創意濾鏡視窗
+    cameraRequested: false // 是否已經請求過攝影機權限
   }),
   getters: {
     isMobile: () => {

+ 724 - 7
src/views/HomeView.vue

@@ -8,6 +8,7 @@ import {
   nextTick,
   onBeforeUnmount,
 } from "vue";
+import { useMainStore } from "@/stores/store";
 import { useRoute, useRouter } from "vue-router";
 // VR
 import "aframe";
@@ -37,13 +38,15 @@ import { QrcodeStream, QrcodeDropZone, QrcodeCapture } from "vue-qrcode-reader";
 // import "videojs-vr"; // 確保正確引入 VR 插件
 // Components
 import Navbar from "../components/Navbar.vue";
-import TicketPurchase from "../components/TicketPurchase.vue";
+import Photo from "../components/Photo.vue";
+import Photo101 from "../components/Photo101.vue";
 
 const { t, locale } = useI18n();
 
 const route = useRoute();
 const router = useRouter();
 const routeParam = ref(null);
+const store = useMainStore();
 
 // AI 客服回覆訊息
 let messages = ref([]);
@@ -1519,6 +1522,37 @@ let videoSrc = ref("");
 let hideAnchorPrologue = ref(false); // 顯示開場白 or 點頭影片
 let videoIndex = ref(null); // 影片編號
 
+watch(
+  () => store.funFilterDialog,
+  (val) => {
+    if (!val) {
+      store.assignFilter = "";
+      store.assignGender = "";
+      store.assignRace = "";
+      store.assignBgImg = "";
+
+      parameterRace.value.length = 0;
+      parameterBg.value.length = 0;
+      currentIndex.value = 0;
+      bgCurrentIndex.value = 0;
+      showBg.value = false;
+    } else {
+      // 請求使用者的攝影機(僅限第一次)
+      if (!store.cameraRequested) {
+        store.cameraRequested = true;
+        navigator.mediaDevices
+          .getUserMedia({ video: true })
+          .then((stream) => {
+            vlogVideo.value.srcObject = stream;
+          })
+          .catch((error) => {
+            console.error("無法開啟攝影機", error);
+          });
+      }
+    }
+  }
+);
+
 // 選擇類別
 async function selectCategory(value, index) {
   assignCategory.value = value;
@@ -1583,6 +1617,8 @@ async function selectCategory(value, index) {
   // }
   else if (value === "附近有什麼") {
     window.open("https://cmm.ai/101-aiv1/#/brand-search", "_blank"); // 另開頁面
+  } else if (value === "趣味濾鏡") {
+    store.funFilterDialog = true;
   } else if (value === "秘境花園觀景台") {
     messages.value.push({
       label: "text",
@@ -1797,18 +1833,18 @@ const menuList = reactive([
     //   text: "customer_show",
     //   value: "叫出真人客服",
     // },
-    { imgSrc: "素材-11.png", text: "what_around", value: "附近有什麼" },
-    // { imgSrc: "素材-11.png", text: "fun_filter", value: "趣味濾鏡" },
-    { imgSrc: "素材-06.png", text: "service_information", value: "服務資訊" },
-    { imgSrc: "素材-07.png", text: "shopping_discounts", value: "購物及優惠" },
-  ],
-  [
+    // { imgSrc: "素材-11.png", text: "what_around", value: "附近有什麼" },
     {
       imgSrc: "素材-08.png",
       text: "observation_deck",
       value: "秘境花園觀景台",
     },
+    { imgSrc: "素材-07.png", text: "shopping_discounts", value: "購物及優惠" },
+    { imgSrc: "素材-06.png", text: "service_information", value: "服務資訊" },
+  ],
+  [
     { imgSrc: "素材-09.png", text: "food_souvenirs", value: "美食/伴手禮" },
+    { imgSrc: "素材-17.png", text: "fun_filter", value: "趣味濾鏡" },
     { imgSrc: "素材-10.png", text: "location_guide", value: "位置導引" },
   ],
 ]);
@@ -2632,6 +2668,224 @@ async function getVideoCache(messages) {
     console.log("error", error);
   }
 }
+
+const getImgUrl = (imgPath) => {
+  return new URL(`../assets/img/${imgPath}`, import.meta.url).href;
+};
+
+let genderList = reactive([
+  {
+    value: "male",
+    icon: "mdi-gender-male",
+    img: "male.png",
+  },
+  {
+    value: "female",
+    icon: "mdi-gender-female",
+    img: "female.png",
+  },
+]);
+
+let filterList = reactive([
+  {
+    text: "taipei_101_postcard",
+    value: "台北101明信片",
+  },
+  {
+    text: "taiwan_landmark_postcard",
+    value: "台灣名勝景點明信片",
+  },
+]);
+
+let imgLoading = ref(false);
+
+// 取得角色清單
+async function getIconImageList(gender) {
+  imgLoading.value = true;
+  let url = `https://cmm.ai/postcard/fs/icon-image-list/${gender}`;
+
+  try {
+    let response = await axios.get(url);
+    let imagePromises = response.data.map((item, index) => {
+      let imageUrl = `https://cmm.ai/postcard/fs/icon-image/${gender}/${item}`;
+      return getIconImage(imageUrl, index); // 取得角色圖片
+    });
+
+    await Promise.all(imagePromises);
+
+    imgLoading.value = false;
+    store.assignGender = gender;
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+
+let parameterRace = ref([]); // 角色圖片
+
+// 取得角色圖片
+async function getIconImage(url, index) {
+  try {
+    // 設定 responseType 為 arraybuffer 以取得二進位數據
+    let response = await axios.get(url, { responseType: "arraybuffer" });
+
+    let blob = new Blob([response.data], { type: "image/png" }); // 創建 blob
+    let imageUrl = URL.createObjectURL(blob); // 創建圖片 URL
+    parameterRace.value.push({ imgUrl: imageUrl, race: `${index}` });
+    console.log("取得角色圖片 parameterRace.value", parameterRace.value);
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+
+const currentRacePhotos = computed(() => {
+  const start = currentIndex.value;
+  const end = start + perPage.value;
+  return parameterRace.value.slice(start, end);
+});
+
+let currentIndex = ref(0);
+let perPage = ref(2);
+
+function prevRace() {
+  if (currentIndex.value > 0) {
+    currentIndex.value -= perPage.value;
+  }
+}
+
+function nextRace() {
+  if (currentIndex.value + perPage.value < parameterRace.value.length) {
+    currentIndex.value += perPage.value;
+  }
+}
+
+// 計算頁數
+const totalPages = computed(() =>
+  Math.ceil(parameterRace.value.length / perPage.value)
+);
+
+const currentPage = computed(
+  () => Math.floor(currentIndex.value / perPage.value) + 1
+);
+
+let alertShow = ref(false);
+let showBg = ref(false);
+
+function checkRaceImg() {
+  console.log("checkRaceImg");
+
+  if (store.assignRace && store.assignRace !== "") {
+    alertShow.value = false;
+    showBg.value = true;
+    getTargetImageList(); // 取得背景
+  } else {
+    alertShow.value = true;
+    setTimeout(() => {
+      alertShow.value = false;
+    }, 2000);
+  }
+}
+
+function handleRaceImg(race) {
+  store.assignRace = race;
+}
+
+let bgImgLoading = ref(false);
+let parameterBg = ref([]);
+
+// 取得背景清單
+async function getTargetImageList() {
+  console.log("取得背景清單");
+
+  let race = store.assignRace; // 角色
+  let gender = store.assignGender; // 性別
+
+  bgImgLoading.value = true;
+  let url = `https://cmm.ai/postcard/fs/target-image-list/${gender}/${race}`;
+
+  try {
+    let response = await axios.get(url);
+    console.log("取得背景清單", response);
+
+    let imagePromises = response.data.map((item, index) => {
+      let imageUrl = `https://cmm.ai/postcard/fs/target-image/${gender}/${race}/${item}`;
+      return getTargetImage(imageUrl, item); // 取得角色圖片
+    });
+
+    await Promise.all(imagePromises);
+
+    // 組合陣列
+    parameterBg.value = parameterBg.value.map((item, index) => ({
+      imgUrl: item.imgUrl,
+      name: item.name,
+      title: item.name.replace(/\.[^/.]+$/, ""), // 移除副檔名 .jpg
+    }));
+    bgImgLoading.value = false;
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+
+// 取得背景圖片
+async function getTargetImage(url, name) {
+  try {
+    // 設定 responseType 為 arraybuffer 以取得二進位數據
+    let response = await axios.get(url, { responseType: "arraybuffer" });
+    let blob = new Blob([response.data], { type: "image/png" }); // 創建 blob
+    let imageUrl = URL.createObjectURL(blob); // 創建圖片 URL
+    parameterBg.value.push({ imgUrl: imageUrl, name: name });
+  } catch (error) {
+    console.log("error", error);
+  }
+}
+
+function handleBgImg(item) {
+  store.assignBgImg = item.name;
+}
+
+const bgCurrentPhotos = computed(() => {
+  const start = bgCurrentIndex.value;
+  const end = start + bgPerPage.value;
+  return parameterBg.value.slice(start, end);
+});
+
+let bgCurrentIndex = ref(0);
+let bgPerPage = ref(2);
+
+function prevBg() {
+  if (bgCurrentIndex.value > 0) {
+    bgCurrentIndex.value -= bgPerPage.value;
+  }
+}
+
+function nextBg() {
+  if (bgCurrentIndex.value + bgPerPage.value < parameterBg.value.length) {
+    bgCurrentIndex.value += bgPerPage.value;
+  }
+}
+
+// 計算頁數
+const bgTotalPages = computed(() =>
+  Math.ceil(parameterBg.value.length / bgPerPage.value)
+);
+
+const bgCurrentPage = computed(
+  () => Math.floor(bgCurrentIndex.value / bgPerPage.value) + 1
+);
+
+let bgAlertShow = ref(false);
+let showPhoto = ref(false); // 顯示拍攝介面
+
+function checkBgImg() {
+  if (store.assignBgImg && store.assignBgImg !== "") {
+    bgAlertShow.value = false;
+    showPhoto.value = true;
+  } else {
+    bgAlertShow.value = true;
+    setTimeout(() => {
+      bgAlertShow.value = false;
+    }, 2000);
+  }
+}
 </script>
 
 <template>
@@ -4257,6 +4511,303 @@ async function getVideoCache(messages) {
     </v-card>
   </v-dialog>
 
+  <!-- 創意濾鏡視窗 -->
+  <v-dialog v-model="store.funFilterDialog" width="1000">
+    <v-card class="pa-5">
+      <v-card-title class="pa-0">
+        <button @click="store.funFilterDialog = false" class="d-flex ml-auto">
+          <v-icon size="small" icon="mdi-close"></v-icon>
+        </button>
+      </v-card-title>
+
+      <v-card-text class="pb-10 px-0">
+        <!-- 選擇濾鏡 -->
+        <div v-if="store.assignFilter === ''" class="px-5 options-btn">
+          <!-- <p class="text-h5 my-10">請選擇濾鏡</p> -->
+          <div v-for="item in filterList">
+            <button @click="store.assignFilter = item.value" class="my-5">
+              <p>{{ t(item.text) }}</p>
+            </button>
+          </div>
+        </div>
+
+        <!-- 選擇性別 (景點) -->
+        <div
+          v-if="
+            store.funFilterDialog &&
+            store.assignFilter !== '' &&
+            store.assignGender === '' &&
+            store.assignFilter === '台灣名勝景點明信片'
+          "
+          class="options-btn"
+        >
+          <p class="my-10">
+            <!-- {{ t("select_filter") }} -->
+            {{ t("select_gender") }}
+          </p>
+
+          <div class="d-flex flex-column">
+            <div
+              v-for="item in genderList"
+              class="px-5 d-flex flex-column align-center"
+            >
+              <!-- <img
+                @click="store.assignGender = item.value"
+                :src="getImgUrl(item.img)"
+                alt=""
+              /> -->
+
+              <!-- @click="store.assignGender = item.value" -->
+
+              <div
+                v-if="imgLoading"
+                class="d-flex flex-column align-center justify-center pt-15"
+              >
+                <v-progress-circular
+                  :size="70"
+                  :width="7"
+                  color="white"
+                  indeterminate
+                ></v-progress-circular>
+              </div>
+
+              <button
+                v-else
+                @click="getIconImageList(item.value)"
+                class="mb-10"
+                :class="{ assign: store.assignGender === item.value }"
+              >
+                <v-icon :icon="item.icon" color="white"></v-icon>
+                <p>{{ t(item.value) }}</p>
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <!-- 選擇濾鏡 (101) -->
+        <div
+          v-if="
+            store.funFilterDialog &&
+            store.assignFilter !== '' &&
+            store.assignGender === '' &&
+            store.assignFilter === '台北101明信片'
+          "
+          class="options-btn"
+        >
+          <p class="my-10">
+            {{ t("select_filter") }}
+            <!-- {{ t("select_gender") }} -->
+          </p>
+
+          <div class="d-flex w-100">
+            <div
+              v-for="item in genderList"
+              class="w-50 px-2 d-flex flex-column align-center"
+            >
+              <img
+                @click="store.assignGender = item.value"
+                :src="getImgUrl(item.img)"
+                alt=""
+              />
+
+              <!-- @click="store.assignGender = item.value" -->
+
+              <div
+                v-if="imgLoading"
+                class="d-flex flex-column align-center justify-center pt-15"
+              >
+                <v-progress-circular
+                  :size="70"
+                  :width="7"
+                  color="white"
+                  indeterminate
+                ></v-progress-circular>
+              </div>
+
+              <button
+                v-else
+                @click="getIconImageList(item.value)"
+                class="my-10 gender-btn"
+                :class="{ assign: store.assignGender === item.value }"
+              >
+                <span class="w-100 d-flex justify-center align-center">
+                  <v-icon :icon="item.icon" color="white"></v-icon>
+                  <p>{{ t(item.value) }}</p>
+                </span>
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <!-- 台北101明信片 -->
+        <div
+          v-if="
+            store.assignGender !== '' && store.assignFilter === '台北101明信片'
+          "
+        >
+          <!-- 拍照介面 -->
+          <div>
+            <Photo101 @closeDialog="store.funFilterDialog = false" />
+          </div>
+        </div>
+
+        <!-- 台灣名勝景點明信片 -->
+        <div
+          v-if="
+            store.assignGender !== '' &&
+            store.assignFilter === '台灣名勝景點明信片'
+          "
+        >
+          <div v-if="!showBg" class="menu-content">
+            <p class="text-h5 my-5">{{ t("select_character") }}</p>
+
+            <div class="slider-btn">
+              <button class="prev" @click="prevRace">
+                <img class="arrow" src="../assets/img/arrow_l.png" alt="" />
+              </button>
+              <button class="next" @click="nextRace">
+                <img class="arrow" src="../assets/img/arrow_r.png" alt="" />
+              </button>
+            </div>
+
+            <div
+              @click="handleRaceImg(item.race)"
+              v-for="item in currentRacePhotos"
+              class="bg-img mt-5"
+            >
+              <v-img
+                cover
+                class="cover"
+                :lazy-src="item.imgUrl"
+                :src="item.imgUrl"
+              >
+                <template v-slot:placeholder>
+                  <div class="d-flex align-center justify-center fill-height">
+                    <v-progress-circular
+                      color="grey-lighten-4"
+                      indeterminate
+                    ></v-progress-circular>
+                  </div>
+                </template>
+              </v-img>
+
+              <img
+                v-if="item.race === store.assignRace && store.assignRace !== ''"
+                class="icon active"
+                src="../assets/img/confirm.png"
+                alt=""
+              />
+              <img
+                v-else
+                class="icon"
+                src="../assets/img/confirm.png"
+                alt=""
+                style="opacity: 0.3"
+              />
+            </div>
+
+            <span class="page-num">{{ currentPage }} / {{ totalPages }}</span>
+
+            <button @click="checkRaceImg()" class="main-btn">
+              {{ t("confirm") }}
+            </button>
+          </div>
+
+          <!-- 選擇背景 -->
+          <div v-if="showBg && !showPhoto">
+            <div
+              v-if="bgImgLoading"
+              class="d-flex flex-column align-center justify-center py-15"
+            >
+              <v-progress-circular
+                :size="70"
+                :width="7"
+                color="white"
+                indeterminate
+              ></v-progress-circular>
+            </div>
+
+            <div v-else class="menu-content bg-list">
+              <p class="my-10">{{ t("select_background") }}</p>
+
+              <div class="slider-btn">
+                <button class="prev" @click="prevBg">
+                  <img class="arrow" src="../assets/img/arrow_l.png" alt="" />
+                </button>
+                <button class="next" @click="nextBg">
+                  <img class="arrow" src="../assets/img/arrow_r.png" alt="" />
+                </button>
+              </div>
+
+              <div
+                @click="handleBgImg(item)"
+                v-for="item in bgCurrentPhotos"
+                class="bg-img"
+              >
+                <v-img
+                  cover
+                  class="cover"
+                  width="55vw"
+                  :lazy-src="item.imgUrl"
+                  :src="item.imgUrl"
+                >
+                  <template v-slot:placeholder>
+                    <div class="d-flex align-center justify-center fill-height">
+                      <v-progress-circular
+                        color="grey-lighten-4"
+                        indeterminate
+                      ></v-progress-circular>
+                    </div>
+                  </template>
+                </v-img>
+
+                <p>{{ $t(item.title) }}</p>
+
+                <img
+                  v-if="item.name === store.assignBgImg"
+                  class="icon active"
+                  src="../assets/img/confirm.png"
+                  alt=""
+                />
+                <img
+                  v-else
+                  class="icon"
+                  src="../assets/img/confirm.png"
+                  alt=""
+                  style="opacity: 0.3"
+                />
+              </div>
+
+              <span class="page-num"
+                >{{ bgCurrentPage }} / {{ bgTotalPages }}</span
+              >
+
+              <button @click="checkBgImg()" class="main-btn">
+                {{ t("confirm") }}
+              </button>
+
+              <div v-if="bgAlertShow" class="alert-item">
+                <v-alert
+                  border="top"
+                  type="warning"
+                  variant="outlined"
+                  class="mt-5"
+                >
+                  尚未選擇背景
+                </v-alert>
+              </div>
+            </div>
+          </div>
+
+          <!-- 拍照介面 -->
+          <div v-if="showPhoto && store.assignBgImg !== ''">
+            <Photo @closeDialog="store.funFilterDialog = false" />
+          </div>
+        </div>
+      </v-card-text>
+    </v-card>
+  </v-dialog>
+
   <!-- 立即前往 -->
   <a
     :href="clickUrl"
@@ -5384,6 +5935,172 @@ async function getVideoCache(messages) {
   }
 }
 
+.options-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: auto;
+  font-size: 1.25rem;
+  letter-spacing: 1px;
+
+  img {
+    width: 100%;
+    cursor: pointer;
+  }
+
+  p {
+    font-weight: 600;
+    letter-spacing: 1px;
+  }
+
+  button {
+    display: flex;
+    width: 18rem;
+    padding: 0.8rem;
+    margin-bottom: 2rem;
+    border-radius: 100px;
+    border: 3px solid transparent;
+    background-color: var(--main-color);
+    background-position: center;
+    background-size: cover;
+
+    .v-icon {
+      // position: absolute;
+      margin-right: -15px;
+    }
+
+    p {
+      margin: auto;
+      font-size: 1rem;
+      font-weight: 500;
+      letter-spacing: 3px;
+      color: #fff;
+    }
+
+    &.assign {
+      border: 3px solid white;
+      // background-color: #ae774f;
+    }
+  }
+
+  .gender-btn {
+    width: 125px;
+    padding: 0.3rem 0.8rem;
+  }
+}
+
+.menu-content {
+  height: 950px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  @media (max-width: 600px) {
+    height: 100%;
+  }
+
+  p {
+    font-weight: 600;
+  }
+
+  &.bg-list {
+    .bg-img {
+      color: #fff;
+      background-color: var(--main-color);
+      border: 1px solid var(--main-color);
+      border-radius: 0 0 5px 5px;
+      text-align: center;
+      padding-bottom: 10px;
+    }
+  }
+
+  .bg-img {
+    margin-top: 2rem;
+    cursor: pointer;
+    position: relative;
+
+    p {
+      width: 50vw;
+      margin: 0.5rem auto 0;
+    }
+
+    .cover {
+      max-width: 100%;
+      width: 12rem;
+      object-fit: cover;
+    }
+  }
+
+  // p {
+  //   margin-top: 0.5rem;
+  // }
+
+  .icon {
+    width: 3.5rem;
+    position: absolute;
+    top: -1.5rem;
+    right: -1.5rem;
+  }
+
+  .slider-btn {
+    width: 100%;
+    position: absolute;
+    z-index: 100;
+    // top: 33%;
+
+    img {
+      width: 60px;
+      transition: all 0.2s;
+
+      // @media (max-width: 600px) {
+      //   width: 50px;
+      // }
+    }
+
+    .prev,
+    .next {
+      width: 60px;
+      height: 60px;
+      position: absolute;
+      cursor: pointer;
+      border: none;
+      // border-radius: 100px;
+      // background-color: var(--main-color);
+
+      img {
+        filter: invert(65%) sepia(7%) saturate(5329%) hue-rotate(336deg)
+          brightness(112%) contrast(47%);
+      }
+
+      &:hover {
+        img {
+          opacity: 0.7;
+        }
+      }
+    }
+
+    .prev {
+      left: 0;
+    }
+
+    .next {
+      right: 0;
+    }
+  }
+
+  .page-num {
+    margin: 2rem auto;
+    letter-spacing: 0.2rem;
+  }
+
+  .content {
+    @media (max-width: 600px) {
+      min-height: 100vh;
+    }
+  }
+}
+
 .v-btn {
   // &.v-btn--density-default {
   //   height: auto !important;

+ 19 - 0
vite.config.js.timestamp-1733985885880-136ba9ddaf6c4.mjs

@@ -0,0 +1,19 @@
+// vite.config.js
+import { fileURLToPath, URL } from "node:url";
+import { defineConfig } from "file:///C:/Users/User/Desktop/AI%20%E6%99%BA%E8%83%BD%E5%AE%A2%E6%9C%8D/101-aiv1/node_modules/vite/dist/node/index.js";
+import vue from "file:///C:/Users/User/Desktop/AI%20%E6%99%BA%E8%83%BD%E5%AE%A2%E6%9C%8D/101-aiv1/node_modules/@vitejs/plugin-vue/dist/index.mjs";
+var __vite_injected_original_import_meta_url = "file:///C:/Users/User/Desktop/AI%20%E6%99%BA%E8%83%BD%E5%AE%A2%E6%9C%8D/101-aiv1/vite.config.js";
+var vite_config_default = defineConfig({
+  plugins: [
+    vue()
+  ],
+  resolve: {
+    alias: {
+      "@": fileURLToPath(new URL("./src", __vite_injected_original_import_meta_url))
+    }
+  }
+});
+export {
+  vite_config_default as default
+};
+//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJDOlxcXFxVc2Vyc1xcXFxVc2VyXFxcXERlc2t0b3BcXFxcQUkgXHU2NjdBXHU4MEZEXHU1QkEyXHU2NzBEXFxcXDEwMS1haXYxXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJDOlxcXFxVc2Vyc1xcXFxVc2VyXFxcXERlc2t0b3BcXFxcQUkgXHU2NjdBXHU4MEZEXHU1QkEyXHU2NzBEXFxcXDEwMS1haXYxXFxcXHZpdGUuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9DOi9Vc2Vycy9Vc2VyL0Rlc2t0b3AvQUklMjAlRTYlOTklQkElRTglODMlQkQlRTUlQUUlQTIlRTYlOUMlOEQvMTAxLWFpdjEvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBmaWxlVVJMVG9QYXRoLCBVUkwgfSBmcm9tICdub2RlOnVybCdcblxuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSdcbmltcG9ydCB2dWUgZnJvbSAnQHZpdGVqcy9wbHVnaW4tdnVlJ1xuXG4vLyBodHRwczovL3ZpdGVqcy5kZXYvY29uZmlnL1xuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgcGx1Z2luczogW1xuICAgIHZ1ZSgpLFxuICBdLFxuICByZXNvbHZlOiB7XG4gICAgYWxpYXM6IHtcbiAgICAgICdAJzogZmlsZVVSTFRvUGF0aChuZXcgVVJMKCcuL3NyYycsIGltcG9ydC5tZXRhLnVybCkpXG4gICAgfVxuICB9XG59KVxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFrVixTQUFTLGVBQWUsV0FBVztBQUVyWCxTQUFTLG9CQUFvQjtBQUM3QixPQUFPLFNBQVM7QUFIK0ssSUFBTSwyQ0FBMkM7QUFNaFAsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUztBQUFBLElBQ1AsSUFBSTtBQUFBLEVBQ047QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNQLE9BQU87QUFBQSxNQUNMLEtBQUssY0FBYyxJQUFJLElBQUksU0FBUyx3Q0FBZSxDQUFDO0FBQUEsSUFDdEQ7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K