├── composables ├── localStorage.ts ├── usePopularity.ts ├── common.ts └── useImages.ts ├── server ├── types │ ├── index.ts │ └── search.ts ├── config │ ├── index.ts │ └── database.ts ├── tsconfig.json ├── utils │ ├── search │ │ ├── index.ts │ │ ├── searchAlgorithm.ts │ │ └── searchEngine.ts │ ├── index.ts │ ├── dataProcessing.ts │ ├── sorting.ts │ └── dataLoader.ts ├── repositories │ ├── index.ts │ ├── mongoRepository.ts │ └── fileRepository.ts ├── api │ ├── ping.ts │ ├── v1 │ │ ├── health.get.ts │ │ └── images │ │ │ ├── index.get.ts │ │ │ ├── [id].get.ts │ │ │ ├── search.get.ts │ │ │ ├── popularity.post.ts │ │ │ ├── random.get.ts │ │ │ └── popular.get.ts │ └── mygo │ │ ├── all_img.ts │ │ ├── random_img.ts │ │ └── img.ts ├── services │ ├── index.ts │ ├── popularityService.ts │ ├── searchService.ts │ └── imageService.ts └── algo │ └── levenshtein.ts ├── .npmrc ├── public ├── .well-known │ └── appspecific │ │ └── com.chrome.devtools.json ├── error.jpg ├── favicon.ico ├── loading.gif ├── mygo.svg └── data │ └── custom_keymap.json ├── types ├── index.ts ├── image.ts ├── filter.ts ├── components.ts └── api.ts ├── styles ├── Theme.ts └── default.css ├── components ├── card │ ├── alt-description.vue │ ├── download-button.vue │ └── copy-button.vue ├── Footer.vue ├── Header.vue ├── button │ ├── top.vue │ ├── sort.vue │ └── filter.vue ├── search-bar.vue ├── image-view-card.vue ├── popup │ ├── sort.vue │ ├── filter.vue │ └── notification.vue └── main-view-panel.vue ├── apis ├── base.ts ├── images.ts └── client.ts ├── plugins └── vue-lazyload.ts ├── Dockerfile ├── .env.example ├── docker-run.sh ├── tsconfig.json ├── app.vue ├── LICENSE ├── uno.config.ts ├── package.json ├── pages └── index.vue ├── README.md ├── docs ├── Contributing.md ├── Architecture.md ├── Technical.md └── API.md ├── CHANGELOG.md ├── .dockerignore ├── nuxt.config.ts └── .gitignore /composables/localStorage.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './search'; 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | node-linker=isolated 3 | -------------------------------------------------------------------------------- /public/.well-known/appspecific/com.chrome.devtools.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /server/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './database'; 2 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /public/error.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miyago9267/MyGO-Searcher/HEAD/public/error.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miyago9267/MyGO-Searcher/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miyago9267/MyGO-Searcher/HEAD/public/loading.gif -------------------------------------------------------------------------------- /server/utils/search/index.ts: -------------------------------------------------------------------------------- 1 | export * from './searchEngine'; 2 | export * from './searchAlgorithm'; -------------------------------------------------------------------------------- /server/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mongoRepository'; 2 | export * from './fileRepository'; 3 | -------------------------------------------------------------------------------- /server/api/ping.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((_event) => { 2 | return { 3 | statusCode: 200, 4 | body: "pong" 5 | }; 6 | }); -------------------------------------------------------------------------------- /server/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './imageService'; 2 | export * from './popularityService'; 3 | export * from './searchService'; 4 | -------------------------------------------------------------------------------- /server/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dataLoader' 2 | export * from './sorting' 3 | export * from './dataProcessing' 4 | export * from './search' -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | // Re-export all types from different modules 2 | export * from './api' 3 | export * from './image' 4 | export * from './components' 5 | -------------------------------------------------------------------------------- /styles/Theme.ts: -------------------------------------------------------------------------------- 1 | export const CustomTheme = ([, value]: RegExpMatchArray, ) => { 2 | if (value == "dark") { 3 | return { 4 | 'background-color': '#515151', 5 | }; 6 | } else { 7 | return { 8 | 'background-color': '#d3d3d380', 9 | }; 10 | } 11 | } -------------------------------------------------------------------------------- /components/card/alt-description.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /apis/base.ts: -------------------------------------------------------------------------------- 1 | // Re-export the new API modules for backward compatibility 2 | export { getApiClient, createApiClient, ApiClient } from './client' 3 | export * from './images' 4 | export * from '~/types' 5 | 6 | // Legacy exports (deprecated) 7 | export { getAllImageList } from './images' -------------------------------------------------------------------------------- /plugins/vue-lazyload.ts: -------------------------------------------------------------------------------- 1 | import VueLazyload from 'vue-lazyload' 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | nuxtApp.vueApp.use(VueLazyload, { 5 | preLoad: 1.3, 6 | error: '/error.jpg', // 錯誤時顯示的圖片路徑 7 | loading: '/loading.gif', // 載入中的圖片 8 | attempt: 1, // 嘗試次數 9 | }); 10 | }); -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 AS build-stage 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock ./ 6 | RUN yarn install 7 | 8 | COPY . . 9 | RUN yarn build 10 | 11 | FROM node:18-alpine AS production-stage 12 | 13 | WORKDIR /app 14 | 15 | COPY --from=build-stage /app/.output ./ 16 | 17 | CMD ["node", "server/index.mjs"] 18 | 19 | -------------------------------------------------------------------------------- /server/api/v1/health.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler } from 'h3'; 2 | 3 | /** 4 | * GET /api/v1/health 5 | * 健康檢查端點 6 | */ 7 | export default defineEventHandler((_event) => { 8 | return { 9 | status: 'ok', 10 | timestamp: new Date().toISOString(), 11 | service: 'MyGO Searcher API', 12 | version: '1.0.0' 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # .env.example 2 | 3 | # API URL 4 | API_BASE_URL=http://localhost:3000/api/v1 5 | 6 | # 圖床 URL 7 | NUXT_IMG_BASE_URL=https://imgur.com/my-batch/ 8 | 9 | # MongoDB 連接字串 10 | MONGODB_CONNECT_URL=mongodb://root:rootroot@localhost:27017/mygo-searcher?authSource=admin&directConnection=true 11 | 12 | # MongoDB Collection 名稱 13 | MONGODB_COLLECTION=images 14 | -------------------------------------------------------------------------------- /types/image.ts: -------------------------------------------------------------------------------- 1 | // Image related types 2 | export interface ImageItem { 3 | url: string 4 | alt: string 5 | id?: string 6 | author?: string 7 | episode?: string 8 | filename?: string 9 | tags?: string[] 10 | popularity?: number 11 | } 12 | 13 | // Re-export FilterOptions from filter.ts to maintain compatibility 14 | export type { FilterOptions } from './filter' 15 | -------------------------------------------------------------------------------- /components/Footer.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | user="mygo" 3 | name="mygo-searcher" 4 | port="4001" 5 | 6 | docker build \ 7 | $@ -t $user/$name:latest . || exit 8 | [ "$(docker ps | grep $name)" ] && docker kill $name 9 | [ "$(docker ps -a | grep $name)" ] && docker rm $name 10 | 11 | docker run \ 12 | -itd \ 13 | -u $(id -u):$(id -g) \ 14 | --name $name \ 15 | --network bridge \ 16 | -p $port:3000 \ 17 | --restart=always \ 18 | $user/$name:latest 19 | -------------------------------------------------------------------------------- /types/filter.ts: -------------------------------------------------------------------------------- 1 | export interface FilterOption { 2 | label: string 3 | value: string 4 | } 5 | 6 | export interface FilterCategory { 7 | [key: string]: FilterOption[] 8 | } 9 | 10 | // 使用 FilterOptions 替代 SelectedFilters 以保持一致性 11 | export interface FilterOptions { 12 | MyGO集數: string[] 13 | AveMujica集數: string[] 14 | 人物: string[] 15 | } 16 | 17 | export interface FilterPopupProps { 18 | filters: FilterCategory 19 | selectedFilters: FilterOptions 20 | } 21 | -------------------------------------------------------------------------------- /components/Header.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json", 3 | "compilerOptions": { 4 | "skipLibCheck": true, 5 | "strict": false, // 關閉所有嚴格檢查 6 | "noImplicitAny": false, // 關閉隱式 any 檢查 7 | "strictNullChecks": false, // 關閉 null 檢查 8 | "strictPropertyInitialization": false, // 關閉屬性初始化檢查 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "baseUrl": ".", 12 | "paths": { 13 | "~/*": ["./*"], 14 | "@/*": ["./*"], 15 | "~~/*": ["./*"], 16 | "@@/*": ["./*"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /types/components.ts: -------------------------------------------------------------------------------- 1 | // Component props and interfaces 2 | export interface UseImagesOptions { 3 | initialQuery?: string 4 | pageSize?: number 5 | fuzzySearch?: boolean 6 | sortOrder?: string 7 | } 8 | 9 | // Component emits 10 | export interface SearchBarEmits { 11 | (event: 'update:search', value: string): void 12 | (event: 'search', value: string): void 13 | } 14 | 15 | export interface FilterEmits { 16 | (event: 'update:filter', value: FilterOptions): void 17 | } 18 | 19 | // Re-export filter options from image types 20 | import type { FilterOptions } from './image' 21 | -------------------------------------------------------------------------------- /server/api/mygo/all_img.ts: -------------------------------------------------------------------------------- 1 | import { getJsonData } from '../../utils/dataLoader'; 2 | import { defineEventHandler } from 'h3'; 3 | 4 | const baseURL = useRuntimeConfig().NUXT_IMG_BASE_URL; 5 | 6 | export const getPicList = async () => { 7 | try { 8 | const data_mapping = await getJsonData(); 9 | const allFiles = data_mapping.map((item: any) => ({ 10 | url: baseURL + item.filename, 11 | alt: item.alt, 12 | author: item.author, 13 | episode: item.episode, 14 | })); 15 | return { statusCode: 200, urls: allFiles }; 16 | } catch (error) { 17 | return { statusCode: 400, error: 'Fail to fetch images library.' }; 18 | } 19 | }; 20 | 21 | export default defineEventHandler(async(event) => { 22 | return await getPicList(); 23 | }); 24 | -------------------------------------------------------------------------------- /server/algo/levenshtein.ts: -------------------------------------------------------------------------------- 1 | export function leven_distance(s1: string, s2: string): number { 2 | const lenS1 = s1.length; 3 | const lenS2 = s2.length; 4 | const dp: number[][] = Array.from({ length: lenS1 + 1 }, () => new Array(lenS2 + 1).fill(0)); 5 | 6 | for (let i = 0; i <= lenS1; i++) { 7 | for (let j = 0; j <= lenS2; j++) { 8 | if (i === 0) { 9 | dp[i][j] = j; 10 | } else if (j === 0) { 11 | dp[i][j] = i; 12 | } else if (s1[i - 1] === s2[j - 1]) { 13 | dp[i][j] = dp[i - 1][j - 1]; 14 | } else { 15 | dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); 16 | } 17 | } 18 | } 19 | 20 | return dp[lenS1][lenS2]; 21 | } -------------------------------------------------------------------------------- /components/button/top.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /server/api/mygo/random_img.ts: -------------------------------------------------------------------------------- 1 | import { getJsonData } from '../../utils/dataLoader' 2 | import { defineEventHandler } from 'h3'; 3 | 4 | const baseURL = useRuntimeConfig().NUXT_IMG_BASE_URL; 5 | 6 | export const getRandomPic = async (amount: number) => { 7 | try { 8 | const data_mapping = await getJsonData(); 9 | if (amount > data_mapping.length) { 10 | throw new Error('Requested amount exceeds available images.'); 11 | } 12 | const rngPics = data_mapping.sort(() => 0.5 - Math.random()).slice(0, amount); 13 | const picFiles = rngPics.map((item: any) => ({ 14 | url: baseURL + item.filename, 15 | alt: item.name, 16 | })); 17 | return { statusCode: 200, urls: picFiles }; 18 | } catch (error) { 19 | console.error(error); 20 | return { statusCode: 400, error: 'Fail to fetch images library.' }; 21 | } 22 | }; 23 | 24 | export default defineEventHandler(async (event) => { 25 | const query = getQuery(event); 26 | const amount = parseInt(query.amount as string) || 1; 27 | return getRandomPic(amount); 28 | }); -------------------------------------------------------------------------------- /components/search-bar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /server/types/search.ts: -------------------------------------------------------------------------------- 1 | export interface SearchResult { 2 | id: string; 3 | url: string; 4 | alt: string; 5 | author?: string; 6 | episode?: string; 7 | score: number; 8 | popularity?: number; 9 | } 10 | 11 | export interface SearchParams { 12 | query: string; 13 | fuzzy: boolean; 14 | page: number; 15 | limit: number; 16 | order: string; 17 | } 18 | 19 | export interface SearchResponseItem { 20 | id: string; 21 | url: string; 22 | alt: string; 23 | author?: string; 24 | episode?: string; 25 | } 26 | 27 | export interface SearchResponse { 28 | data: SearchResponseItem[]; 29 | meta: { 30 | query: string; 31 | fuzzy: boolean; 32 | total: number; 33 | page: number; 34 | limit: number; 35 | totalPages: number; 36 | hasNext: boolean; 37 | hasPrev: boolean; 38 | }; 39 | } 40 | 41 | export interface ImageData { 42 | id?: number; 43 | alt: string; 44 | file_name?: string; 45 | filename?: string; 46 | author?: string; 47 | episode?: string; 48 | tags?: string[]; 49 | popularity?: number; 50 | } 51 | -------------------------------------------------------------------------------- /components/card/download-button.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 33 | 34 | -------------------------------------------------------------------------------- /types/api.ts: -------------------------------------------------------------------------------- 1 | // API response types 2 | export interface ApiResponse { 3 | data: T 4 | meta?: PaginationMeta | SearchMeta | RandomMeta 5 | } 6 | 7 | export interface ApiError { 8 | statusCode: number 9 | statusMessage: string 10 | } 11 | 12 | export interface PaginationMeta { 13 | total: number 14 | page: number 15 | limit: number 16 | totalPages: number 17 | hasNext: boolean 18 | hasPrev: boolean 19 | } 20 | 21 | export interface SearchMeta extends PaginationMeta { 22 | query: string 23 | fuzzy: boolean 24 | } 25 | 26 | export interface RandomMeta { 27 | count: number 28 | requested: number 29 | } 30 | 31 | // Query parameters 32 | export interface SearchParams extends Record { 33 | q: string 34 | fuzzy?: boolean 35 | page?: number 36 | limit?: number 37 | order?: string 38 | } 39 | 40 | export interface PaginationParams extends Record { 41 | page?: number 42 | limit?: number 43 | order?: string 44 | } 45 | 46 | export interface RandomParams extends Record { 47 | count?: number 48 | } 49 | -------------------------------------------------------------------------------- /composables/usePopularity.ts: -------------------------------------------------------------------------------- 1 | import { ImagesApi } from '~/apis/images' 2 | 3 | /** 4 | * 組合式函數:處理圖片人氣統計 5 | */ 6 | export function usePopularity() { 7 | /** 8 | * 記錄複製動作 9 | */ 10 | const recordCopy = async (imageId?: string, imageUrl?: string) => { 11 | try { 12 | console.log('recordCopy called with:', { imageId, imageUrl }); 13 | await ImagesApi.updatePopularity({ 14 | imageId, 15 | imageUrl, 16 | action: 'copy' 17 | }) 18 | console.log('Copy action recorded successfully') 19 | } catch (error) { 20 | console.warn('Failed to record copy action:', error) 21 | // 不拋出錯誤,讓用戶操作繼續進行 22 | } 23 | } 24 | 25 | /** 26 | * 記錄下載動作 27 | */ 28 | const recordDownload = async (imageId?: string, imageUrl?: string) => { 29 | try { 30 | await ImagesApi.updatePopularity({ 31 | imageId, 32 | imageUrl, 33 | action: 'download' 34 | }) 35 | console.log('Download action recorded successfully') 36 | } catch (error) { 37 | console.warn('Failed to record download action:', error) 38 | // 不拋出錯誤,讓用戶操作繼續進行 39 | } 40 | } 41 | 42 | return { 43 | recordCopy, 44 | recordDownload 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /components/card/copy-button.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 15 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [2025] [@miyago9267] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, subject to the following conditions: 10 | 11 | 1. The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | 2. Any modifications or derivative works of the Software must also be licensed 15 | under the same MIT License, and must include proper attribution to the 16 | original author(s) of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'unocss'; 2 | import { CustomTheme } from './styles/Theme'; 3 | 4 | export default defineConfig({ 5 | rules: [ 6 | [/^bg-color-([a-z\d]+)$/, ([_, value]) => ({ 'background-color': `#${value}` })], 7 | [/^color-([a-z\d]+)$/, ([_, value]) => ({ color: `#${value}` })], 8 | [/^fs-(\d+px)$/, ([_, value]) => ({ 'font-size': value })], 9 | [/^wp-([0-9]+)$/, ([_, value]) => ({ 'width': `${value}%` })], 10 | [/^hp-([0-9]+)$/, ([_, value]) => ({ 'height': `${value}%` })], 11 | [/^main-content-([a-z]+)$/, CustomTheme], 12 | [/^main$/, ([_]) => ({ 'height': `calc(100vh - 60px)` })], 13 | ], 14 | shortcuts: { 15 | 'flex-middle': 'flex items-center justify-center', 16 | 'h-s-screen': 'h-100svh', 17 | 'w-s-screen': 'w-100svw', 18 | 'wh-full': 'h-full w-full', 19 | 'wh-s-screen': 'h-s-screen w-s-screen', 20 | 'header-content': 'self-center b-unset! mr-2 cursor-pointer hover:color-7194e5' 21 | }, 22 | theme: { 23 | colors: { 24 | 'tggray': { 25 | 50: '#1e1e1e', 26 | 75: '#232323', 27 | 100: '#2a2a2a', 28 | 200: '#2f2f2f', 29 | 300: '#3e3e3e', 30 | 400: '#505050', 31 | 500: '#b4b4b4', 32 | 600: '#dcdcdc', 33 | 700: '#f5f5f5', 34 | 800: '#fafafa', 35 | 900: '#fff', 36 | } 37 | } 38 | } 39 | }); 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "license": "MIT", 5 | "name": "mygo-searcher", 6 | "version": "2.0.1", 7 | "scripts": { 8 | "build": "nuxt build --dotenv ./.env.production", 9 | "build:docker": "./docker-run.sh", 10 | "dev": "nuxt dev --dotenv ./.env.development", 11 | "generate": "nuxt generate --dotenv ./.env.production", 12 | "postinstall": "nuxt prepare", 13 | "preview": "nuxt preview" 14 | }, 15 | "devDependencies": { 16 | "@element-plus/nuxt": "^1.0.9", 17 | "@kikiutils/types": "^1.0.1", 18 | "@types/opencc-js": "^1.0.3", 19 | "@unocss/nuxt": "^0.58.0", 20 | "@vueuse/nuxt": "^10.7.0", 21 | "nuxt": "^3.8.2", 22 | "nuxt-purgecss": "^2.0.0", 23 | "sass": "^1.69.5", 24 | "vite-plugin-remove-console": "^2.2.0", 25 | "vue": "^3.3.10", 26 | "vue-router": "^4.2.5", 27 | "vue-tsc": "^2.1.6" 28 | }, 29 | "dependencies": { 30 | "@volar/typescript": "^2.4.5", 31 | "add": "^2.0.6", 32 | "axios": "^1.6.3", 33 | "element-plus": "^2.7.6", 34 | "mongodb": "^6.18.0", 35 | "opencc-js": "^1.0.5", 36 | "typescript": "^5.6.2", 37 | "vite-plugin-checker": "^0.8.0", 38 | "vue-lazyload": "^3.0.0", 39 | "yarn": "^1.22.22" 40 | }, 41 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 42 | } 43 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | -------------------------------------------------------------------------------- /server/api/v1/images/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, getQuery, createError } from 'h3'; 2 | import { ImageService } from '../../../services/imageService'; 3 | import type { SortOrder } from '../../../utils/sorting'; 4 | 5 | const baseURL = useRuntimeConfig().NUXT_IMG_BASE_URL; 6 | 7 | /** 8 | * GET /api/v1/images 9 | * 獲取所有圖片列表,支援分頁 10 | * 針對無限滾動優化的 API 11 | * 12 | * Query parameters: 13 | * - page: 頁碼 (預設1,從1開始) 14 | * - limit: 每頁數量 (預設20,建議10-50之間) 15 | * - order: 排序方式 (預設按ID升序) 16 | * - id: 按 ID 數字順序排序 17 | * - random: 隨機排序 18 | * - episode: 按集數排序 (mygo_x 優先於 mujica_x) 19 | * - alphabetical: 按字典序排序 (依據 alt 屬性) 20 | * - popularity: 按人氣排序 (最熱門的在前面) 21 | */ 22 | export default defineEventHandler(async (event) => { 23 | try { 24 | // 解析查詢參數 25 | const query = getQuery(event); 26 | const page = Math.max(parseInt(query.page as string) || 1, 1); 27 | const limit = Math.min(Math.max(parseInt(query.limit as string) || 20, 1), 100); 28 | const order = (query.order as string || 'id') as SortOrder; 29 | 30 | // 使用圖片服務處理業務邏輯 31 | const imageService = new ImageService(baseURL); 32 | const result = await imageService.getPaginatedImages({ 33 | page, 34 | limit, 35 | order 36 | }); 37 | 38 | return result; 39 | 40 | } catch (error: any) { 41 | console.error('Error in /api/v1/images:', error); 42 | throw createError({ 43 | statusCode: 500, 44 | statusMessage: 'Failed to fetch images library' 45 | }); 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /server/api/v1/images/[id].get.ts: -------------------------------------------------------------------------------- 1 | import { getJsonData } from '../../../utils/dataLoader'; 2 | import { defineEventHandler, getRouterParam, createError } from 'h3'; 3 | 4 | const baseURL = useRuntimeConfig().NUXT_IMG_BASE_URL; 5 | 6 | /** 7 | * GET /api/v1/images/{id} 8 | * 獲取特定圖片詳情 9 | */ 10 | export default defineEventHandler(async (event) => { 11 | try { 12 | const data_mapping = await getJsonData(); 13 | const id = getRouterParam(event, 'id'); 14 | 15 | if (!id) { 16 | throw createError({ 17 | statusCode: 400, 18 | statusMessage: 'Image ID is required' 19 | }); 20 | } 21 | 22 | // 查找圖片 (通過file_name去除副檔名比對ID) 23 | const imageItem = data_mapping.find((item: any) => { 24 | const itemId = item.id; 25 | return itemId === id; 26 | }); 27 | 28 | if (!imageItem) { 29 | throw createError({ 30 | statusCode: 404, 31 | statusMessage: 'Image not found' 32 | }); 33 | } 34 | 35 | return { 36 | data: { 37 | id: imageItem.id, 38 | url: baseURL + imageItem.filename, 39 | alt: imageItem.alt, 40 | author: imageItem.author, 41 | episode: imageItem.episode, 42 | filename: imageItem.filename 43 | } 44 | }; 45 | 46 | } catch (error: any) { 47 | if (error.statusCode) { 48 | throw error; 49 | } 50 | throw createError({ 51 | statusCode: 500, 52 | statusMessage: 'Failed to fetch image details' 53 | }); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /components/image-view-card.vue: -------------------------------------------------------------------------------- 1 | 15 | 24 | 25 | -------------------------------------------------------------------------------- /server/api/v1/images/search.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, getQuery, createError } from 'h3'; 2 | import { SearchService } from '../../../services/searchService'; 3 | import { customKeyMap } from '../../../utils/dataLoader'; 4 | import type { SearchParams } from '../../../types/search'; 5 | 6 | const baseURL = useRuntimeConfig().NUXT_IMG_BASE_URL; 7 | 8 | /** 9 | * GET /api/v1/images/search 10 | * 搜尋圖片 11 | * Query parameters: 12 | * - q: 搜尋關鍵字 (必填) 13 | * - fuzzy: 是否啟用模糊搜尋 (true/false, 預設false) 14 | * - page: 頁碼 (預設1) 15 | * - limit: 每頁數量 (預設20) 16 | * - order: 排序方式 (預設按ID升序) 17 | */ 18 | export default defineEventHandler(async (event) => { 19 | try { 20 | const query = getQuery(event); 21 | 22 | const searchParams: SearchParams = { 23 | query: query.q as string || query.keyword as string || '', 24 | fuzzy: query.fuzzy === 'true', 25 | page: parseInt(query.page as string) || 1, 26 | limit: parseInt(query.limit as string) || 20, 27 | order: query.order as string || 'id' 28 | }; 29 | 30 | if (!searchParams.query.trim()) { 31 | throw createError({ 32 | statusCode: 400, 33 | statusMessage: 'Search query is required. Use "q" parameter.' 34 | }); 35 | } 36 | 37 | const searchService = new SearchService(baseURL, customKeyMap); 38 | return await searchService.search(searchParams); 39 | 40 | } catch (error: any) { 41 | if (error.statusCode) { 42 | throw error; 43 | } 44 | throw createError({ 45 | statusCode: 500, 46 | statusMessage: 'Failed to search images' 47 | }); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /server/utils/dataProcessing.ts: -------------------------------------------------------------------------------- 1 | import type { ImageItem } from '../../types/image'; 2 | 3 | /** 4 | * 處理從 dataLoader 拿到的資料 5 | * 將 file_name 修改為 episode + file_name 的格式 6 | * 將 episode 中的底線替換為斜線 7 | */ 8 | export async function getProcessedImageData(rawData: any[]): Promise { 9 | try { 10 | return rawData.map((item: any) => { 11 | if (!item.episode || !item.filename) { 12 | return item; 13 | } 14 | 15 | // 將 episode 中的底線替換為斜線 16 | const processedEpisode = item.episode.replace(/_/g, '/'); 17 | 18 | // 將 file_name 修改為 episode + file_name 19 | const processedFileName = `/${processedEpisode}/${item.filename}`; 20 | 21 | return { 22 | ...item, 23 | filename: processedFileName 24 | }; 25 | }); 26 | } catch (error) { 27 | console.error('Failed to process image data:', error); 28 | throw error; 29 | } 30 | } 31 | 32 | /** 33 | * 同步版本的資料處理函數(用於向後兼容) 34 | */ 35 | export function getProcessedImageDataSync(rawData: any[]): ImageItem[] { 36 | try { 37 | return rawData.map((item: any) => { 38 | if (!item.episode || !item.filename) { 39 | return item; 40 | } 41 | 42 | // 將 episode 中的底線替換為斜線 43 | const processedEpisode = item.episode.replace(/_/g, '/'); 44 | 45 | // 將 filename 修改為 episode + filename 46 | const processedFileName = `/${processedEpisode}/${item.filename}`; 47 | 48 | return { 49 | ...item, 50 | filename: processedFileName 51 | }; 52 | }); 53 | } catch (error) { 54 | console.error('Failed to process image data (sync):', error); 55 | throw error; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server/api/v1/images/popularity.post.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, readBody, createError } from 'h3'; 2 | import { PopularityService } from '../../../services/popularityService'; 3 | 4 | /** 5 | * POST /api/v1/images/popularity 6 | * 更新圖片人氣統計 7 | * Body: 8 | * - imageId: 圖片ID (必填) 9 | * - action: 'copy' | 'download' (必填) - 兩者加分相同 10 | * - imageUrl?: 圖片URL (用於備用識別) 11 | */ 12 | export default defineEventHandler(async (event) => { 13 | try { 14 | const body = await readBody(event); 15 | const { imageId, action, imageUrl } = body; 16 | 17 | // 驗證必要參數 18 | if (!action || !['copy', 'download'].includes(action)) { 19 | throw createError({ 20 | statusCode: 400, 21 | statusMessage: 'Invalid action. Must be "copy" or "download".' 22 | }); 23 | } 24 | 25 | if ((imageId === undefined || imageId === null) && !imageUrl) { 26 | throw createError({ 27 | statusCode: 400, 28 | statusMessage: 'Either imageId or imageUrl is required.' 29 | }); 30 | } 31 | 32 | // 使用人氣統計服務處理業務邏輯 33 | const popularityService = new PopularityService(); 34 | const result = await popularityService.updatePopularity({ 35 | imageId, 36 | imageUrl, 37 | action 38 | }); 39 | 40 | // 如果都失敗了,至少返回成功響應(避免影響用戶體驗) 41 | if (!result.success) { 42 | return { 43 | success: true, 44 | action, 45 | imageId: imageId || imageUrl, 46 | updated: false, 47 | method: 'none', 48 | note: 'Statistics could not be persisted' 49 | }; 50 | } 51 | 52 | return result; 53 | 54 | } catch (error: any) { 55 | if (error.statusCode) { 56 | throw error; 57 | } 58 | throw createError({ 59 | statusCode: 500, 60 | statusMessage: 'Failed to update popularity statistics' 61 | }); 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /server/api/v1/images/random.get.ts: -------------------------------------------------------------------------------- 1 | import { getJsonData } from '../../../utils/dataLoader'; 2 | import { defineEventHandler, getQuery, createError } from 'h3'; 3 | 4 | const baseURL = useRuntimeConfig().NUXT_IMG_BASE_URL; 5 | 6 | /** 7 | * GET /api/v1/images/random 8 | * 獲取隨機圖片 9 | * Query parameters: 10 | * - count: 圖片數量 (預設1, 最大100) 11 | */ 12 | export default defineEventHandler(async(event) => { 13 | try { 14 | const data_mapping = await getJsonData(); 15 | const query = getQuery(event); 16 | const count = Math.min(parseInt(query.count as string) || parseInt(query.amount as string) || 1, 100); 17 | 18 | if (count <= 0) { 19 | throw createError({ 20 | statusCode: 400, 21 | statusMessage: 'Count must be greater than 0' 22 | }); 23 | } 24 | 25 | if (count > data_mapping.length) { 26 | throw createError({ 27 | statusCode: 400, 28 | statusMessage: `Requested count (${count}) exceeds available images (${data_mapping.length})` 29 | }); 30 | } 31 | 32 | const randomImages = data_mapping 33 | .sort(() => 0.5 - Math.random()) 34 | .slice(0, count) 35 | .map((item: any) => ({ 36 | id: item.id, 37 | url: baseURL + item.filename, 38 | alt: item.alt, 39 | author: item.author, 40 | episode: item.episode 41 | })); 42 | 43 | return { 44 | data: randomImages, 45 | meta: { 46 | count, 47 | requested: count 48 | } 49 | }; 50 | 51 | } catch (error: any) { 52 | if (error.statusCode) { 53 | throw error; 54 | } 55 | throw createError({ 56 | statusCode: 500, 57 | statusMessage: 'Failed to fetch random images' 58 | }); 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /composables/common.ts: -------------------------------------------------------------------------------- 1 | export const StringEmpty = (str: string | null | undefined) => { 2 | return !str || str.trim().length == 0; 3 | } 4 | 5 | export const copyToClipboard = (content: string) => { 6 | try { 7 | const copyPng = async () => { 8 | const jpegImageResponse = await fetch(content + '?t=' + new Date().getTime(), { 9 | method: 'GET', 10 | }); 11 | const jpegBlob = await jpegImageResponse.blob(); 12 | 13 | return new Promise((resolve, reject) => { 14 | const img = new Image(); 15 | img.onload = () => { 16 | try { 17 | const canvas = document.createElement('canvas'); 18 | const ctx = canvas.getContext('2d'); 19 | 20 | canvas.width = img.naturalWidth || img.width; 21 | canvas.height = img.naturalHeight || img.height; 22 | ctx.drawImage(img, 0, 0); 23 | canvas.toBlob((resultBlob) => { 24 | if (resultBlob) resolve(resultBlob); 25 | else reject(new Error('canvas toBlob failed')); 26 | }, 'image/png'); 27 | } catch (e) { 28 | reject(e); 29 | } 30 | }; 31 | img.onerror = reject; 32 | img.src = URL.createObjectURL(jpegBlob); 33 | }); 34 | }; 35 | const clipboardItem = new ClipboardItem({ ['image/png']: copyPng() }); 36 | navigator.clipboard.write([clipboardItem]); 37 | } catch (error) { 38 | console.error('複製圖片失敗', error); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MyGo貼圖搜尋器 2 | 3 | 你願意一輩子跟我一起MyGO嗎? 4 | 5 | MyGO 搜尋器,滿足你上網戰鬥的各種圖戰需求! 6 | 7 | [網頁連結](https://mygo.miyago9267.com) 8 | 9 | ## 疊甲 10 | 11 | 本人為17年入坑之老邦人,對於BGD企劃一直都十分支持,手遊上位多AP,動畫企劃、真人樂隊也都有持續關注,歌單更是不斷重複收聽,對老邦和新團的愛是一樣的。 12 | 13 | 本專案雖推廣雞狗,但也呼籲大家多多支持舊7團,讓邦邦企劃有辦法繼續走下去,武士道已經夠糟蹋自家企劃了,敬請大家玩梗需謹慎,別讓雞狗破壞掉邦邦的名聲。 14 | 15 | ## 使用技術 16 | 17 | - 前端框架 - `Vue3` with `Nuxt3` 18 | - 後端框架 - `NuxtAPI` with `Nuxt3` 19 | - [API文檔](./docs/API.md) 20 | - 獨立後端[API](https://github.com/miyago9267/mygoapi)服務 - `FastAPI` (已棄用) 21 | 22 | ## 部署指南 23 | 24 | 1. 確保你有`node.js`及`yarn`環境 25 | 2. clone本專案Repo 26 | 3. 安裝dependencies 27 | 28 | ```bash 29 | cd MyGo_Searcher 30 | yarn install 31 | ``` 32 | 33 | 4. 複製環境變數範本並配置 34 | 35 | ```bash 36 | cp .env.example .env.development 37 | ``` 38 | 39 | 5. 啟動及部署Nuxt 40 | 41 | ```bash 42 | yarn dev # with devmode 43 | yarn build # for production 44 | yarn build:docker # for docker 45 | ``` 46 | 47 | ## 說明文檔 48 | 49 | - [API 文檔](./docs/API.md) 50 | - [技術實現](./docs/Technical.md) 51 | - [架構設計](./docs/Architecture.md) 52 | - [貢獻指南(包含意見回饋)](./docs/Contributing.md) 53 | 54 | ~~偷偷說,除了這個根目錄Readme以外其他都是Claude幫我寫的~~ 55 | 56 | ## 未來計劃(TodoList) 57 | 58 | - [ ] 前端優化 59 | - [ ] 增加亮暗色 60 | - [X] 增加排序 61 | - [X] 優化後端 62 | - [X] 改善api並開放 63 | - [ ] 增加標籤(趕工中) 64 | - [X] 以集數作為tag 65 | - [ ] 以人物為tag(WIP) 66 | - [ ] 增加敘述(趕工中) 67 | 68 | ## 更新紀錄 69 | 70 | 請詳見[CHANGELOG](./CHANGELOG.md)及[RELEASE](https://github.com/miyago9267/MyGo_Searcher/releases) 71 | 72 | ## License 73 | 74 | [MIT](./LICENSE) 75 | 76 | 本專案以MIT規範開源,歡迎大家自由使用、修改及分支開發,但請務必保留原作者署名。 77 | 78 | ## 特別銘謝 79 | 80 | - 靈感來源於[@lekoOwO](https://github.com/lekoOwO)大大製作之[海綿寶寶高清貼圖搜尋器](https://sb.leko.moe/#!) 81 | - 感謝 [@ShiriNmi1520](https://github.com/ShiriNmi1520) 協作解決Safari的複製協議問題 82 | -------------------------------------------------------------------------------- /styles/default.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Noto Sans TC,SF Pro TC,SF Pro Text,SF Pro Icons,PingFang TC,Helvetica Neue,Helvetica,Arial,Microsoft JhengHei,wf_SegoeUI,Segoe UI,Segoe,Segoe WP,Tahoma,Verdana,Ubuntu,Bitstream Vera Sans,DejaVu Sans,微軟正黑體,LiHei Pro,WenQuanYi Micro Hei,Droid Sans Fallback,AR PL UMing TW,Roboto,Hiragino Maru Gothic ProN,メイリオ,ヒラギノ丸ゴ ProN W4,Meiryo,Droid Sans,sans-serif,Avenir,system-ui; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #1e1e1e; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | min-width: 320px; 29 | min-height: 100vh; 30 | } 31 | 32 | h1 { 33 | font-size: 3.2em; 34 | line-height: 1.1; 35 | } 36 | 37 | button { 38 | border-radius: 8px; 39 | border: 1px solid transparent; 40 | padding: 0.6em 1.2em; 41 | font-size: 1em; 42 | font-weight: 500; 43 | font-family: inherit; 44 | background-color: #1a1a1a; 45 | cursor: pointer; 46 | transition: border-color 0.25s; 47 | } 48 | button:hover { 49 | border-color: #646cff; 50 | } 51 | button:focus, 52 | button:focus-visible { 53 | outline: 4px auto -webkit-focus-ring-color; 54 | } 55 | 56 | .card { 57 | padding: 2em; 58 | } 59 | 60 | #app { 61 | max-width: 1280px; 62 | } 63 | 64 | @media (prefers-color-scheme: light) { 65 | :root { 66 | color: #213547; 67 | background-color: #ffffff; 68 | } 69 | a:hover { 70 | color: #747bff; 71 | } 72 | button { 73 | background-color: #f9f9f9; 74 | } 75 | } -------------------------------------------------------------------------------- /docs/Contributing.md: -------------------------------------------------------------------------------- 1 | # 開發者貢獻指南 2 | 3 | MyGO Searcher 是一個開源項目,我們歡迎任何形式的貢獻,包括但不限於: 4 | 5 | - Bug 修復 6 | - 功能開發 7 | - 文檔改進 8 | - UI/UX 改進 9 | - 效能優化 10 | - 各類Issue及意見 11 | 12 | ## 注意事項 13 | 14 | ### 如何提出Issue 15 | 16 | 在提交 Issue 前,請先檢查以下事項: 17 | 18 | - 是否已經有相同的 Issue 存在 19 | - 是否已經有相關的討論或 PR 20 | - 提供足夠的細節,包括重現步驟、預期行為和實際行為 21 | - 如果是 Bug,請提供相關的日誌或錯誤訊息 22 | - 如果是功能請求,請描述清楚你的需求和使用場景 23 | - 如果是資料(圖源)相關的增刪改建議,請提供集數、時間戳和相關的圖片連結 24 | 25 | ### 提交規範 26 | 27 | 我使用 [Conventional Commits](https://www.conventionalcommits.org/) 規範: 28 | 29 | ```bash 30 | # 格式:type(scope): description 31 | 32 | # 類型: 33 | feat: 新功能 34 | fix: Bug 修復 35 | docs: 文檔更新 36 | style: 代碼格式(不影響功能) 37 | refactor: 重構 38 | test: 測試 39 | chore: 構建過程或輔助工具的變動 40 | ``` 41 | 42 | #### PR 模板 43 | 44 | 提交 PR 時請包含以下資訊: 45 | 46 | ```markdown 47 | ## 變更描述 48 | 49 | 50 | ## 變更類型 51 | - [ ] Bug 修復 52 | - [ ] 新功能 53 | - [ ] 文檔更新 54 | - [ ] 重構 55 | - [ ] 效能改進 56 | - [ ] 其他 57 | 58 | ## 測試 59 | - [ ] 我已經運行了現有的測試 60 | - [ ] 我已經為新功能添加了測試 61 | - [ ] 所有測試都通過了 62 | 63 | ## 截圖(如適用) 64 | 65 | 66 | ## 相關 Issue 67 | 68 | Closes #123 69 | ``` 70 | 71 | ## 社群資源 72 | 73 | ### 獲取幫助 74 | 75 | - **GitHub Issues**: 報告 Bug 或請求功能 76 | - **GitHub Discussions**: 技術討論和問題求助 77 | 78 | ### 參與社群 79 | 80 | - 參與 Issue 討論 81 | - 回答其他開發者的問題 82 | - 分享你的使用經驗 83 | - 提供功能建議 84 | 85 | ## 發布流程 86 | 87 | ### 版本號規則 88 | 89 | 我們遵循 [Semantic Versioning](https://semver.org/): 90 | 91 | - `MAJOR.MINOR.PATCH` 92 | - `2.1.0` → `2.1.1` (patch: bug fixes) 93 | - `2.1.0` → `2.2.0` (minor: new features) 94 | - `2.1.0` → `3.0.0` (major: breaking changes) 95 | 96 | ### 發布檢查清單 97 | 98 | - [ ] 所有測試通過 99 | - [ ] 文檔已更新 100 | - [ ] CHANGELOG.md 已更新 101 | - [ ] 版本號已更新 102 | - [ ] 建立 Release Notes 103 | -------------------------------------------------------------------------------- /server/services/popularityService.ts: -------------------------------------------------------------------------------- 1 | import { MongoRepository } from '../repositories/mongoRepository'; 2 | import { FileRepository } from '../repositories/fileRepository'; 3 | 4 | /** 5 | * 人氣統計服務類 6 | */ 7 | export class PopularityService { 8 | private mongoRepo = new MongoRepository(); 9 | private fileRepo = new FileRepository(); 10 | 11 | /** 12 | * 人氣統計 13 | */ 14 | async updatePopularity(params: { 15 | imageId?: string; 16 | imageUrl?: string; 17 | action: 'copy' | 'download'; 18 | }): Promise<{ 19 | success: boolean; 20 | action: string; 21 | imageId: string; 22 | updated: boolean; 23 | methods: string[]; 24 | mongoUpdated: boolean; 25 | localUpdated: boolean; 26 | }> { 27 | const { imageId, imageUrl, action } = params; 28 | 29 | let mongoUpdated = false; 30 | let localUpdated = false; 31 | const results: string[] = []; 32 | 33 | // 方法 1: MongoDB 更新 34 | try { 35 | if (imageId !== undefined && imageId !== null) { 36 | mongoUpdated = await this.mongoRepo.updatePopularity(imageId, imageUrl); 37 | if (mongoUpdated) { 38 | results.push('mongodb'); 39 | } 40 | } 41 | } catch (error) { 42 | console.warn('MongoDB update failed:', error); 43 | } 44 | 45 | try { 46 | if (imageId !== undefined && imageId !== null) { 47 | localUpdated = await this.fileRepo.updatePopularity(imageId); 48 | if (localUpdated) { 49 | results.push('local-file'); 50 | } 51 | } 52 | } catch (error) { 53 | console.warn('Local file update failed:', error); 54 | } 55 | 56 | return { 57 | success: mongoUpdated || localUpdated, 58 | action, 59 | imageId: imageId || imageUrl || '', 60 | updated: mongoUpdated || localUpdated, 61 | methods: results, 62 | mongoUpdated, 63 | localUpdated 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Version 2.0.1] - 2025-10-13 8 | 9 | ### Fixed (Version 2.0.1) 10 | 11 | - 修正Safari剪貼簿調用的同異步問題(感謝 @ShiriNmi1520 協助) 12 | 13 | ### Todo (Version 2.0.1) 14 | 15 | - 完善人物分類 16 | - 增加暗色模式 17 | - 實裝人氣排序 18 | - 語意化嵌入模型測試 19 | 20 | 21 | ## [Version 2.0.0] - 2025-08-06 22 | 23 | ### Features (Version 2.0.0) 24 | 25 | - 重構API路由,統一使用RESTful格式,舊版API暫時保留作為過渡,未來則不再更新及支援 26 | - `GET /api/v1/images/{id}` 獲取特定id圖片 27 | - `GET /api/v1/images` 獲取所有圖片 28 | - `GET /api/v1/search` 搜尋圖片 29 | - `GET /api/v1/random` 隨機獲取圖片 30 | > 詳見 [API Documentation](docs/API.md) 31 | - 加入篩選器功能,可選擇MyGO集數、AveMujica集數和人物(人物分類尚在製作中) 32 | - 加入排序功能,可選擇四種排序方式(包含預設、集數、字典序、隨機) 33 | - 加入更新提示 34 | - 正式加入AveMujica圖包 35 | 36 | ### Changed (Version 2.0.0) 37 | 38 | - 重新設計前端Component及Nuxt後端架構 39 | - 更新前端載入邏輯,從全部圖片載入改為動態滾動載入 40 | - 優化SSR效能 41 | - 連線MongoDB 42 | - 重構文檔 43 | 44 | ### Todo (Version 2.0.0) 45 | 46 | - 完善人物分類 47 | - 完善API文檔 48 | - 加入人氣排序 49 | 50 | ## [Version 1.1.2] - 2024-11-14 51 | 52 | ### Features (Version 1.1.2) 53 | 54 | - 支援簡體中文搜尋 55 | - 優化搜尋關鍵字 56 | 57 | ## [Version 1.1.1] - 2024-11-08 58 | 59 | ### Features (Version 1.1.1) 60 | 61 | - 關鍵字支援多關鍵字查詢 62 | - 查詢算法改進,更精準的搜尋結果(應該吧) 63 | 64 | ### Changed 65 | 66 | - 使用Nuxt3重構前端 67 | - 前後端合一,API分離之版本依舊保留 68 | 69 | ## [Version 1.1.0] - 2024-08-05 70 | 71 | ### Features (Version 1.1.0) 72 | 73 | - 前端功能 74 | - 增加下載功能 75 | - 增加複製功能 76 | - 搜尋欄增加清除字串按鍵 77 | - 前端優化 78 | - 增加LazyLoad 79 | - 資料 80 | - 修正圖源清晰度 81 | 82 | ### Removed 83 | 84 | - 移除部分非動畫本家貼圖 85 | 86 | ### Known Issues 87 | 88 | - 使用ios複製功能會有問題 89 | 90 | ## [Version 1.0.0] - 2024-05-01 91 | 92 | ### Features (Version 1.0.0) 93 | 94 | - 基本功能 95 | - 搜尋貼圖 96 | - 顯示貼圖 97 | -------------------------------------------------------------------------------- /components/popup/sort.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | -------------------------------------------------------------------------------- /server/services/searchService.ts: -------------------------------------------------------------------------------- 1 | import { SearchEngine } from '../utils/search/searchEngine'; 2 | import { sortImages, type SortOrder } from '../utils/sorting'; 3 | import { FileRepository } from '../repositories/fileRepository'; 4 | import type { SearchParams, SearchResponse, SearchResponseItem } from '../types/'; 5 | 6 | /** 7 | * 搜索服務類 8 | */ 9 | export class SearchService { 10 | private fileRepo = new FileRepository(); 11 | private baseURL: string; 12 | private customKeyMap: any; 13 | 14 | constructor(baseURL: string, customKeyMap: any) { 15 | this.baseURL = baseURL; 16 | this.customKeyMap = customKeyMap; 17 | } 18 | 19 | async search(params: SearchParams): Promise { 20 | // 載入數據 21 | const data = await this.fileRepo.getSearchData(); 22 | 23 | // 初始化搜索引擎 24 | const searchEngine = new SearchEngine(this.baseURL, this.customKeyMap); 25 | 26 | // 執行搜索 27 | const searchResults = await searchEngine.searchInData( 28 | data, 29 | params.query, 30 | params.fuzzy 31 | ); 32 | 33 | // 排序結果 34 | const sortedResults = await sortImages(searchResults, params.order as SortOrder); 35 | 36 | // 分頁處理 37 | const totalCount = sortedResults.length; 38 | const totalPages = Math.ceil(totalCount / params.limit); 39 | const offset = (params.page - 1) * params.limit; 40 | const paginatedResults = sortedResults.slice(offset, offset + params.limit); 41 | 42 | // 構建響應 43 | return { 44 | data: paginatedResults.map((item): SearchResponseItem => ({ 45 | id: item.id!, 46 | url: item.url, 47 | alt: item.alt, 48 | author: item.author, 49 | episode: item.episode 50 | })), 51 | meta: { 52 | query: params.query, 53 | fuzzy: params.fuzzy, 54 | total: totalCount, 55 | page: params.page, 56 | limit: params.limit, 57 | totalPages, 58 | hasNext: params.page < totalPages, 59 | hasPrev: params.page > 1 60 | } 61 | }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /server/utils/sorting.ts: -------------------------------------------------------------------------------- 1 | import type { ImageItem } from '~/types'; 2 | 3 | export type SortOrder = 'id' | 'random' | 'episode' | 'alphabetical' | 'popularity'; 4 | 5 | /** 6 | * 對圖片陣列進行排序 7 | * @param allImages - 要排序的圖片陣列 8 | * @param order - 排序方式 9 | * @returns Promise - 排序後的圖片陣列 10 | */ 11 | 12 | export async function sortImages(allImages: ImageItem[], order: SortOrder = 'id'): Promise { 13 | return new Promise((resolve) => { 14 | const sortedImages = allImages.sort((a, b) => { 15 | if (order === 'id') { 16 | // ID 排序:轉換為數字進行比較 17 | return parseInt(a.id ?? '') - parseInt(b.id ?? ''); 18 | } else if (order === 'random') { 19 | // 隨機排序 20 | return Math.random() - 0.5; 21 | } else if (order === 'episode') { 22 | // 集數排序:mygo_x 優先於 mujica_x 23 | const aEpisode = a.episode || ''; 24 | const bEpisode = b.episode || ''; 25 | 26 | // 解析集數信息 27 | const parseEpisode = (episode: string) => { 28 | const match = episode.match(/^(mygo|mujica)_(\d+)$/); 29 | if (!match) return { series: 'zzz', number: 0 }; // 未知的放最後 30 | return { 31 | series: match[1] === 'mygo' ? 'a' : 'b', // mygo 優先 32 | number: parseInt(match[2]) 33 | }; 34 | }; 35 | 36 | const aParsed = parseEpisode(aEpisode); 37 | const bParsed = parseEpisode(bEpisode); 38 | 39 | // 先按系列排序(mygo 優先),再按集數排序 40 | if (aParsed.series !== bParsed.series) { 41 | return aParsed.series.localeCompare(bParsed.series); 42 | } 43 | return aParsed.number - bParsed.number; 44 | } else if (order === 'alphabetical') { 45 | // 字典序排序(按 alt 屬性) 46 | const aAlt = a.alt || ''; 47 | const bAlt = b.alt || ''; 48 | return aAlt.localeCompare(bAlt, 'zh', { numeric: true }); 49 | } else if (order === 'popularity') { 50 | // 人氣排序(人氣高的在前面) 51 | const aPopularity = a.popularity || 0; 52 | const bPopularity = b.popularity || 0; 53 | return bPopularity - aPopularity; // 降序排列 54 | } else { 55 | return 0; // 默認不排序 56 | } 57 | }); 58 | 59 | resolve(sortedImages); 60 | }); 61 | } -------------------------------------------------------------------------------- /components/button/sort.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | -------------------------------------------------------------------------------- /server/repositories/mongoRepository.ts: -------------------------------------------------------------------------------- 1 | import { getImagesCollection, isMongoConfigured } from '../config/database'; 2 | import { getProcessedImageData } from '../utils/dataProcessing'; 3 | 4 | export class MongoRepository { 5 | async getImages() { 6 | if (!isMongoConfigured()) { 7 | throw new Error('MongoDB not configured'); 8 | } 9 | 10 | const collection = await getImagesCollection(); 11 | const rawData = await collection.find({}).toArray(); 12 | 13 | // 使用 getProcessedImageData 處理原始資料 14 | const processedData = await getProcessedImageData(rawData); 15 | 16 | return processedData.map((item: any) => ({ 17 | id: item.id, 18 | url: item.url, 19 | alt: item.alt, 20 | author: item.author, 21 | episode: item.episode, 22 | filename: item.filename, 23 | tags: item.tags || [], 24 | popularity: item.popularity || 0 25 | })); 26 | } 27 | 28 | async updatePopularity(imageId?: string, imageUrl?: string): Promise { 29 | if (!isMongoConfigured()) { 30 | return false; 31 | } 32 | 33 | try { 34 | const collection = await getImagesCollection(); 35 | 36 | let query: any = {}; 37 | if (imageId !== undefined && imageId !== null) { 38 | const numericId = parseInt(imageId); 39 | if (!isNaN(numericId)) { 40 | query = { id: numericId }; 41 | } else { 42 | query = { _id: imageId }; 43 | } 44 | } else if (imageUrl) { 45 | query = { url: imageUrl }; 46 | } 47 | 48 | const result = await collection.updateOne(query, { 49 | $inc: { popularity: 1 } 50 | }); 51 | 52 | return result.modifiedCount > 0; 53 | } catch (error) { 54 | console.error('MongoDB update failed:', error); 55 | return false; 56 | } 57 | } 58 | 59 | async getPopularityStats(): Promise> { 60 | if (!isMongoConfigured()) { 61 | throw new Error('MongoDB not configured'); 62 | } 63 | 64 | const collection = await getImagesCollection(); 65 | const images = await collection.find({}, { 66 | projection: { _id: 1, id: 1, popularity: 1 } 67 | }).toArray(); 68 | 69 | const stats: Record = {}; 70 | for (const image of images) { 71 | const imageId = image.id?.toString(); 72 | if (imageId !== undefined && image.popularity) { 73 | stats[imageId] = image.popularity; 74 | } 75 | } 76 | 77 | return stats; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /server/api/v1/images/popular.get.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, getQuery, createError } from 'h3'; 2 | import { getImagesCollection, isMongoConfigured } from '../../../config'; 3 | import { promises as fs } from 'fs'; 4 | import { join } from 'path'; 5 | 6 | /** 7 | * GET /api/v1/images/popular 8 | * 獲取熱門圖片列表 9 | * Query parameters: 10 | * - limit: 數量限制 (預設20) 11 | */ 12 | export default defineEventHandler(async (event) => { 13 | try { 14 | const query = getQuery(event); 15 | const limit = parseInt(query.limit as string) || 20; 16 | 17 | // 如果有MongoDB配置,從數據庫查詢 18 | if (isMongoConfigured()) { 19 | try { 20 | const collection = await getImagesCollection(); 21 | 22 | // 查詢熱門圖片,按人氣降序排列 23 | const popularImages = await collection 24 | .find({}) 25 | .sort({ popularity: -1 }) 26 | .limit(limit) 27 | .toArray(); 28 | 29 | return { 30 | statusCode: 200, 31 | data: popularImages, 32 | meta: { 33 | total: popularImages.length, 34 | limit, 35 | sortBy: 'popularity' 36 | } 37 | }; 38 | 39 | } catch (error: any) { 40 | console.error('MongoDB query error:', error); 41 | throw createError({ 42 | statusCode: 500, 43 | statusMessage: 'Database query failed' 44 | }); 45 | } 46 | } 47 | 48 | // 如果沒有MongoDB,嘗試從本地文件讀取 49 | try { 50 | const STATS_FILE_PATH = join(process.cwd(), 'data', 'popularity-stats.json'); 51 | const statsData = JSON.parse(await fs.readFile(STATS_FILE_PATH, 'utf-8')); 52 | 53 | // 將統計數據轉換為數組並排序 54 | const sortedStats = Object.entries(statsData) 55 | .map(([imageId, stats]: [string, any]) => ({ 56 | id: imageId, 57 | popularity: stats.popularity || 0 58 | })) 59 | .sort((a, b) => b.popularity - a.popularity) 60 | .slice(0, limit); 61 | 62 | return { 63 | statusCode: 200, 64 | data: sortedStats, 65 | meta: { 66 | total: sortedStats.length, 67 | limit, 68 | sortBy: 'popularity', 69 | source: 'local-file' 70 | } 71 | }; 72 | } catch (error) { 73 | console.warn('Local stats file not found or unreadable'); 74 | } 75 | 76 | // 如果都沒有,返回空結果 77 | return { 78 | statusCode: 200, 79 | data: [], 80 | meta: { 81 | total: 0, 82 | limit, 83 | sortBy: 'popularity', 84 | note: 'No statistics data available' 85 | } 86 | }; 87 | 88 | } catch (error: any) { 89 | if (error.statusCode) { 90 | throw error; 91 | } 92 | throw createError({ 93 | statusCode: 500, 94 | statusMessage: 'Failed to fetch popular images' 95 | }); 96 | } 97 | }); 98 | -------------------------------------------------------------------------------- /server/repositories/fileRepository.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import { join } from 'path'; 3 | import { getProcessedImageData } from '../utils/dataProcessing'; 4 | 5 | export class FileRepository { 6 | private readonly IMAGE_MAP_PATH = join(process.cwd(), 'public', 'data', 'image_map.json'); 7 | 8 | async getImages(): Promise { 9 | try { 10 | const data = JSON.parse(await fs.readFile(this.IMAGE_MAP_PATH, 'utf-8')); 11 | return await getProcessedImageData(data); 12 | } catch (error) { 13 | console.error('Failed to load from file repository:', error); 14 | throw error; 15 | } 16 | } 17 | 18 | async getSearchData(): Promise { 19 | return await this.getImages(); 20 | } 21 | 22 | async updatePopularity(imageId: string): Promise { 23 | try { 24 | // 只更新原始圖片檔案(避免重複計分) 25 | const imageMapUpdated = await this.updateImageMapFile(imageId); 26 | 27 | return imageMapUpdated; 28 | } catch (error) { 29 | console.error('File repository update failed:', error); 30 | return false; 31 | } 32 | } 33 | 34 | async getPopularityStats(): Promise> { 35 | const stats: Record = {}; 36 | 37 | // 從原始圖片檔案獲取 popularity 數據 38 | try { 39 | const imageMapData = JSON.parse(await fs.readFile(this.IMAGE_MAP_PATH, 'utf-8')); 40 | for (const image of imageMapData) { 41 | if (image.id !== undefined && image.popularity) { 42 | stats[image.id.toString()] = image.popularity; 43 | } 44 | } 45 | } catch (error) { 46 | console.warn('Failed to read image map file:', error); 47 | } 48 | 49 | return stats; 50 | } 51 | 52 | private async updateImageMapFile(imageId: string): Promise { 53 | try { 54 | const imageMapData = JSON.parse(await fs.readFile(this.IMAGE_MAP_PATH, 'utf-8')); 55 | 56 | const imageIndex = imageMapData.findIndex((img: any) => 57 | img.id.toString() === imageId.toString() 58 | ); 59 | 60 | if (imageIndex === -1) { 61 | return false; 62 | } 63 | 64 | if (!imageMapData[imageIndex].popularity) { 65 | imageMapData[imageIndex].popularity = 0; 66 | } 67 | imageMapData[imageIndex].popularity += 1; 68 | 69 | await fs.writeFile(this.IMAGE_MAP_PATH, JSON.stringify(imageMapData, null, 2)); 70 | return true; 71 | } catch (error) { 72 | console.error('Failed to update image_map.json:', error); 73 | return false; 74 | } 75 | } 76 | 77 | private async ensureStatsFile(): Promise { 78 | try { 79 | await fs.access(this.IMAGE_MAP_PATH); 80 | } catch { 81 | await fs.mkdir(join(process.cwd(), 'data'), { recursive: true }); 82 | await fs.writeFile(this.IMAGE_MAP_PATH, JSON.stringify({})); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /server/utils/search/searchAlgorithm.ts: -------------------------------------------------------------------------------- 1 | import { leven_distance } from '../../algo/levenshtein'; 2 | import * as OpenCC from 'opencc-js'; 3 | 4 | // 初始化轉換器 5 | const converter = OpenCC.Converter({ from: 'cn', to: 'tw' }); 6 | 7 | // 模糊替換規則 8 | const fuzzyReplacements: Record = { 9 | "你": ["姊"], 10 | "姊": ["你"], 11 | "他": ["她"], 12 | "她": ["他"], 13 | "欸": ["耶"], 14 | "耶": ["欸"], 15 | }; 16 | 17 | /** 18 | * 生成模糊匹配的變體 19 | */ 20 | export function generateFuzzyVariants(keyword: string): Set { 21 | const variants = new Set([keyword]); 22 | for (let i = 0; i < keyword.length; i++) { 23 | const char = keyword[i]; 24 | if (fuzzyReplacements[char]) { 25 | for (const replacement of fuzzyReplacements[char]) { 26 | const newVariant = keyword.substring(0, i) + replacement + keyword.substring(i + 1); 27 | variants.add(newVariant); 28 | } 29 | } 30 | } 31 | return variants; 32 | } 33 | 34 | /** 35 | * 處理搜索關鍵詞 36 | */ 37 | export function processSearchKeyword(queryKeyword: string): string[] { 38 | const keyword = converter(queryKeyword); 39 | return keyword.split(' ').filter(k => k.trim()); 40 | } 41 | 42 | /** 43 | * 計算關鍵詞匹配分數 44 | */ 45 | export function calculateMatchScore( 46 | text: string, 47 | keyword: string, 48 | fuzzy: boolean = false 49 | ): { score: number; matched: boolean } { 50 | // 轉換為小寫進行比較 51 | const lowerText = text.toLowerCase(); 52 | const lowerKeyword = keyword.toLowerCase(); 53 | const variants = fuzzy ? generateFuzzyVariants(lowerKeyword) : new Set([lowerKeyword]); 54 | 55 | for (const variant of variants) { 56 | // 完全包含匹配 57 | if (lowerText.includes(variant)) { 58 | return { 59 | score: variant.length >= 2 ? 10 : 5, 60 | matched: true 61 | }; 62 | } 63 | 64 | // 模糊匹配 65 | if (fuzzy && variant.length > 2 && lowerText.length > 2) { 66 | const dist = leven_distance(variant, lowerText); 67 | const ratio = (variant.length - dist) / variant.length; 68 | if (dist <= 2 && ratio >= 0.5) { 69 | return { 70 | score: 3, 71 | matched: true 72 | }; 73 | } 74 | } 75 | } 76 | 77 | return { score: 0, matched: false }; 78 | } 79 | 80 | /** 81 | * 計算文本與多個關鍵詞的總分數 82 | */ 83 | export function calculateTotalScore( 84 | text: string, 85 | keywords: string[], 86 | fuzzy: boolean = false 87 | ): number { 88 | let totalScore = 0; 89 | let matchedCount = 0; 90 | 91 | for (const keyword of keywords) { 92 | const { score, matched } = calculateMatchScore(text, keyword, fuzzy); 93 | if (matched) { 94 | totalScore += score; 95 | matchedCount++; 96 | } 97 | } 98 | 99 | // 如果沒有任何關鍵詞匹配,返回0 100 | // 如果有匹配,返回總分數 101 | return matchedCount > 0 ? totalScore : 0; 102 | } 103 | 104 | /** 105 | * 檢查是否為精準匹配 106 | */ 107 | export function isExactMatch(text: string, keywords: string[]): boolean { 108 | const lowerText = text.toLowerCase(); 109 | return keywords.some(keyword => lowerText.includes(keyword.toLowerCase())); 110 | } 111 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ### Docker ### 2 | .dockerignore 3 | docker-build.sh 4 | docker-build-and-run.sh 5 | docker-run.sh 6 | Dockerfile 7 | 8 | ### Git ### 9 | .git 10 | .gitignore 11 | .gitkeep 12 | 13 | ### Node ### 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | lerna-debug.log* 22 | .pnpm-debug.log* 23 | 24 | # Diagnostic reports (https://nodejs.org/api/report.html) 25 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 26 | 27 | # Runtime data 28 | pids 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | 33 | # Directory for instrumented libs generated by jscoverage/JSCover 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | coverage 38 | *.lcov 39 | 40 | # nyc test coverage 41 | .nyc_output 42 | 43 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 44 | .grunt 45 | 46 | # Bower dependency directory (https://bower.io/) 47 | bower_components 48 | 49 | # node-waf configuration 50 | .lock-wscript 51 | 52 | # Compiled binary addons (https://nodejs.org/api/addons.html) 53 | build/Release 54 | 55 | # Dependency directories 56 | node_modules/ 57 | jspm_packages/ 58 | 59 | # Snowpack dependency directory (https://snowpack.dev/) 60 | web_modules/ 61 | 62 | # TypeScript cache 63 | *.tsbuildinfo 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Optional stylelint cache 72 | .stylelintcache 73 | 74 | # Microbundle cache 75 | .rpt2_cache/ 76 | .rts2_cache_cjs/ 77 | .rts2_cache_es/ 78 | .rts2_cache_umd/ 79 | 80 | # Optional REPL history 81 | .node_repl_history 82 | 83 | # Output of 'npm pack' 84 | *.tgz 85 | 86 | # Yarn Integrity file 87 | .yarn-integrity 88 | 89 | # parcel-bundler cache (https://parceljs.org/) 90 | .cache 91 | .parcel-cache 92 | 93 | # Next.js build output 94 | .next 95 | out 96 | 97 | # Misc 98 | .DS_Store 99 | .fleet 100 | .idea 101 | 102 | # Nitro build / generate output 103 | .nitro 104 | .output 105 | 106 | # Nuxt dev / build / generate output 107 | .cache 108 | .data 109 | .nuxt 110 | .output 111 | dist 112 | 113 | # Gatsby files 114 | .cache/ 115 | # Comment in the public line in if your project uses Gatsby and not Next.js 116 | # https://nextjs.org/blog/next-9-1#public-directory-support 117 | # public 118 | 119 | # vuepress build output 120 | .vuepress/dist 121 | 122 | # vuepress v2.x temp and cache directory 123 | .temp 124 | 125 | # Docusaurus cache and generated files 126 | .docusaurus 127 | 128 | # Serverless directories 129 | .serverless/ 130 | 131 | # FuseBox cache 132 | .fusebox/ 133 | 134 | # DynamoDB Local files 135 | .dynamodb/ 136 | 137 | # TernJS port file 138 | .tern-port 139 | 140 | # Stores VSCode versions used for testing VSCode extensions 141 | .vscode-test 142 | 143 | # yarn v2 144 | .yarn/cache 145 | .yarn/unplugged 146 | .yarn/build-state.yml 147 | .yarn/install-state.gz 148 | .pnp.* 149 | 150 | ### Node Patch ### 151 | # Serverless Webpack directories 152 | .webpack/ 153 | 154 | # Optional stylelint cache 155 | 156 | # SvelteKit build / generate output 157 | .svelte-kit 158 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // import removeConsole from 'vite-plugin-remove-console'; // 先註解掉 2 | 3 | // https://nuxt.com/docs/api/configuration/nuxt-config 4 | export default defineNuxtConfig({ 5 | app: { 6 | head: { 7 | link: [{ rel: 'icon', type: 'image/svg+xml', href: '/mygo.svg' }], 8 | noscript: [{ children: 'Javascript is required.' }], 9 | title: '' 10 | }, 11 | keepalive: true 12 | }, 13 | 14 | devServer: { 15 | host: process.env.DEV_SERVER_HOST, 16 | port: Number(process.env.DEV_SERVER_PORT) || undefined 17 | }, 18 | 19 | devtools: { enabled: false }, 20 | 21 | experimental: { 22 | headNext: true, 23 | // inlineSSRStyles: true 24 | }, 25 | 26 | imports: { 27 | dirs: ['./composables/**/*.ts'] 28 | }, 29 | 30 | modules: [ 31 | '@unocss/nuxt', 32 | '@vueuse/nuxt', 33 | 'nuxt-purgecss', 34 | '@element-plus/nuxt' 35 | ], 36 | 37 | nitro: { 38 | compressPublicAssets: true, 39 | experimental: { 40 | wasm: true 41 | } 42 | }, 43 | 44 | purgecss: { 45 | enabled: false, 46 | safelist: { 47 | deep: [], 48 | standard: [ 49 | /-(appear|enter|leave)(|-(active|from|to))$/, 50 | /--unocss--/, 51 | /-\[\S+\]/, 52 | /.*data-v-.*/, 53 | /:deep/, 54 | /:global/, 55 | /:slotted/, 56 | /^(?!cursor-move).+-move$/, 57 | /^nuxt-link(|-exact)-active$/, 58 | /__uno_hash_(\w{6})/, 59 | '__nuxt', 60 | 'body', 61 | 'html', 62 | 'nuxt-progress' 63 | ] 64 | } 65 | }, 66 | 67 | plugins: [ 68 | '~/plugins/vue-lazyload.ts' 69 | ], 70 | 71 | ssr: true, 72 | 73 | typescript: { 74 | tsConfig: { 75 | compilerOptions: { 76 | noImplicitOverride: true, 77 | noUncheckedIndexedAccess: true, 78 | noUnusedLocals: true, 79 | noUnusedParameters: true, 80 | "types": ["element-plus/global"] 81 | } 82 | }, 83 | typeCheck: true 84 | }, 85 | 86 | vite: { 87 | // plugins: process.env.NODE_ENV === 'production' ? [] : [removeConsole()] 88 | }, 89 | 90 | elementPlus: { 91 | themes: ['dark'], 92 | }, 93 | 94 | runtimeConfig: { 95 | // 私有配置 (僅服務器端可用) 96 | mongodbConnectUrl: process.env.MONGODB_CONNECT_URL, 97 | mongodbCollection: process.env.MONGODB_COLLECTION, 98 | 99 | // 公共配置 (客戶端和服務器端都可用) 100 | NUXT_IMG_BASE_URL: process.env.NUXT_IMG_BASE_URL, 101 | 102 | public: { 103 | apiBase: process.env.API_BASE_URL || '/api/v1', 104 | } 105 | }, 106 | 107 | compatibilityDate: '2024-10-27', 108 | 109 | css: [ 110 | '~/styles/default.css' 111 | ] 112 | }); -------------------------------------------------------------------------------- /server/utils/search/searchEngine.ts: -------------------------------------------------------------------------------- 1 | import type { SearchResult, ImageData } from '../../types'; 2 | import { calculateTotalScore, isExactMatch, processSearchKeyword } from './searchAlgorithm'; 3 | 4 | /** 5 | * 搜索引擎類 6 | */ 7 | export class SearchEngine { 8 | private baseURL: string; 9 | private customKeyMap: any; 10 | 11 | constructor(baseURL: string, customKeyMap: any) { 12 | this.baseURL = baseURL; 13 | this.customKeyMap = customKeyMap; 14 | } 15 | 16 | /** 17 | * 在數據中搜索匹配項 18 | */ 19 | async searchInData( 20 | data: ImageData[], 21 | queryKeyword: string, 22 | fuzzy: boolean = false 23 | ): Promise { 24 | const keywords = processSearchKeyword(queryKeyword); 25 | const scoredResults: SearchResult[] = []; 26 | const fullMatchResults: SearchResult[] = []; 27 | const customKeymapResults: SearchResult[] = []; 28 | 29 | // 主要搜索邏輯 30 | data.forEach((item, index) => { 31 | const totalScore = calculateTotalScore(item.alt, keywords, fuzzy); 32 | 33 | const imageItem: SearchResult = { 34 | id: index.toString(), 35 | url: this.baseURL + (item.filename || item.file_name || ''), 36 | alt: item.alt, 37 | author: item.author, 38 | episode: item.episode, 39 | score: totalScore 40 | }; 41 | 42 | // 評分匹配 43 | if (totalScore > 0) { 44 | scoredResults.push(imageItem); 45 | } 46 | 47 | // 精準匹配 48 | if (isExactMatch(item.alt, keywords)) { 49 | fullMatchResults.push({ ...imageItem, score: 15 }); 50 | } 51 | }); 52 | 53 | // 自定義關鍵字映射 - 使用原始查詢關鍵詞 54 | const customResults = this.searchCustomKeyMap(data, queryKeyword); 55 | customKeymapResults.push(...customResults); 56 | 57 | // 合併結果並去重 58 | return this.mergeAndDeduplicateResults([ 59 | ...scoredResults, 60 | ...fullMatchResults, 61 | ...customKeymapResults 62 | ]); 63 | } 64 | 65 | /** 66 | * 搜索自定義關鍵字映射 67 | */ 68 | private searchCustomKeyMap(data: ImageData[], queryKeyword: string): SearchResult[] { 69 | // 使用原始查詢關鍵詞,而不是處理過的關鍵詞 70 | if (!this.customKeyMap.hasOwnProperty(queryKeyword)) { 71 | return []; 72 | } 73 | 74 | const keywordValue = this.customKeyMap[queryKeyword]?.value || []; 75 | const results: SearchResult[] = []; 76 | 77 | data.forEach((item, index) => { 78 | if (keywordValue.includes(item.alt)) { 79 | results.push({ 80 | id: index.toString(), 81 | url: this.baseURL + (item.filename || item.file_name || ''), 82 | alt: item.alt, 83 | author: item.author, 84 | episode: item.episode, 85 | score: 15, 86 | }); 87 | } 88 | }); 89 | 90 | return results; 91 | } 92 | 93 | /** 94 | * 合併結果並去重 95 | */ 96 | private mergeAndDeduplicateResults(results: SearchResult[]): SearchResult[] { 97 | const combinedResultsMap = new Map(); 98 | 99 | results.forEach((result) => { 100 | const existing = combinedResultsMap.get(result.url); 101 | if (!existing || result.score > existing.score) { 102 | combinedResultsMap.set(result.url, result); 103 | } 104 | }); 105 | 106 | return Array.from(combinedResultsMap.values()) 107 | .sort((a, b) => b.score - a.score); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development 82 | .env.test 83 | .env.production 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Misc 95 | .DS_Store 96 | .fleet 97 | .idea 98 | 99 | # Nuxt build / generate output 100 | .cache 101 | .data 102 | .output 103 | .nitro 104 | .nuxt 105 | dist 106 | 107 | # Gatsby files 108 | .cache/ 109 | # Comment in the public line in if your project uses Gatsby and not Next.js 110 | # https://nextjs.org/blog/next-9-1#public-directory-support 111 | # public 112 | 113 | # vuepress build output 114 | .vuepress/dist 115 | 116 | # vuepress v2.x temp and cache directory 117 | .temp 118 | 119 | # Docusaurus cache and generated files 120 | .docusaurus 121 | 122 | # Serverless directories 123 | .serverless/ 124 | 125 | # FuseBox cache 126 | .fusebox/ 127 | 128 | # DynamoDB Local files 129 | .dynamodb/ 130 | 131 | # TernJS port file 132 | .tern-port 133 | 134 | # Stores VSCode versions used for testing VSCode extensions 135 | .vscode-test 136 | 137 | # yarn v2 138 | .yarn/cache 139 | .yarn/unplugged 140 | .yarn/build-state.yml 141 | .yarn/install-state.gz 142 | .pnp.* 143 | 144 | ### Node Patch ### 145 | # Serverless Webpack directories 146 | .webpack/ 147 | 148 | # Optional stylelint cache 149 | 150 | # SvelteKit build / generate output 151 | .svelte-kit 152 | 153 | # End of https://www.toptal.com/developers/gitignore/api/node 154 | -------------------------------------------------------------------------------- /apis/images.ts: -------------------------------------------------------------------------------- 1 | import { getApiClient } from './client' 2 | import type { 3 | ImageItem, 4 | ApiResponse, 5 | SearchParams, 6 | PaginationParams, 7 | RandomParams 8 | } from '~/types' 9 | 10 | /** 11 | * Images API service 12 | */ 13 | export class ImagesApi { 14 | /** 15 | * Get all images with pagination 16 | */ 17 | static async getAll(params?: PaginationParams): Promise> { 18 | const apiClient = getApiClient() 19 | return apiClient.get>('/images', params) 20 | } 21 | 22 | /** 23 | * Search images 24 | */ 25 | static async search(params: SearchParams): Promise> { 26 | const apiClient = getApiClient() 27 | return apiClient.get>('/images/search', params) 28 | } 29 | 30 | /** 31 | * Get random images 32 | */ 33 | static async getRandom(params?: RandomParams): Promise> { 34 | const apiClient = getApiClient() 35 | return apiClient.get>('/images/random', params) 36 | } 37 | 38 | /** 39 | * Get specific image by ID 40 | */ 41 | static async getById(id: string): Promise> { 42 | const apiClient = getApiClient() 43 | return apiClient.get>(`/images/${id}`) 44 | } 45 | 46 | /** 47 | * Update image popularity statistics 48 | */ 49 | static async updatePopularity(params: { 50 | imageId?: string 51 | imageUrl?: string 52 | action: 'copy' | 'download' 53 | }): Promise<{ success: boolean; action: string; imageId: string; updated: boolean }> { 54 | const apiClient = getApiClient() 55 | return apiClient.post<{ success: boolean; action: string; imageId: string; updated: boolean }>( 56 | '/images/popularity', 57 | params 58 | ) 59 | } 60 | } 61 | 62 | /** 63 | * Legacy API functions for backward compatibility 64 | * @deprecated Use ImagesApi class instead 65 | */ 66 | 67 | /** 68 | * @deprecated Use ImagesApi.search() or ImagesApi.getAll() instead 69 | */ 70 | export const getAllImageList = async (query: string): Promise => { 71 | try { 72 | if (query.trim()) { 73 | const response = await ImagesApi.search({ q: query }) 74 | return response.data 75 | } else { 76 | const response = await ImagesApi.getAll() 77 | return response.data 78 | } 79 | } catch (error) { 80 | console.error('Error fetching image list:', error) 81 | return [] 82 | } 83 | } 84 | 85 | /** 86 | * Get random images 87 | */ 88 | export const getRandomImages = async (count = 1): Promise => { 89 | try { 90 | const response = await ImagesApi.getRandom({ count }) 91 | return response.data 92 | } catch (error) { 93 | console.error('Error fetching random images:', error) 94 | return [] 95 | } 96 | } 97 | 98 | /** 99 | * Search images with fuzzy matching 100 | */ 101 | export const searchImages = async ( 102 | query: string, 103 | fuzzy = false, 104 | page = 1, 105 | limit = 20 106 | ): Promise<{ images: ImageItem[]; meta: any }> => { 107 | try { 108 | const response = await ImagesApi.search({ q: query, fuzzy, page, limit }) 109 | return { images: response.data, meta: response.meta } 110 | } catch (error) { 111 | console.error('Error searching images:', error) 112 | return { images: [], meta: null } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /docs/Architecture.md: -------------------------------------------------------------------------------- 1 | # MyGO Searcher 架構文檔 2 | 3 | ## 系統架構概覽 4 | 5 | MyGO Searcher 是一個基於 Nuxt 3 的全端應用。 6 | 7 | 以下是技術架構文檔,我叫Claude寫的,怎樣我就懶得寫文檔嘛。 8 | 9 | ## Tech Stack 10 | 11 | ### 前端 12 | 13 | - **框架**: Vue 3 + Nuxt 3 14 | - **樣式**: UnoCSS + 自定義主題 15 | - **UI組件**: Element Plus 16 | - **狀態管理**: Nuxt 3 Composables 17 | - **類型系統**: TypeScript 18 | 19 | ### 後端 20 | 21 | - **運行時**: Nuxt 3 Server (基於 H3) 22 | - **資料庫**: MongoDB (可選) + 本地JSON文件 23 | - **搜尋引擎**: 自定義實現 (Levenshtein距離算法) 24 | - **快取**: 內存快取 + 檔案快取 25 | 26 | ### 部署 27 | 28 | - **容器化**: Docker 29 | - **靜態資源**: 本地檔案系統 30 | - **環境配置**: 環境變數管理 31 | - **資料庫**: MongoDB (可選,支援本地文件fallback) 32 | 33 | ## 目錄結構 34 | 35 | ```tree 36 | MyGO-Searcher/ 37 | ├── apis/ # 前端API客戶端 38 | │ ├── base.ts # 基礎API調用 39 | │ └── client.ts # HTTP客戶端封裝 40 | ├── components/ # Vue組件 41 | │ ├── popup/ # 彈窗組件 42 | │ ├── card/ # 卡片組件 43 | │ └── *.vue # 其他通用組件 44 | ├── composables/ # Vue Composables 45 | │ └── common.ts # 通用邏輯 46 | ├── pages/ # 頁面路由 47 | ├── server/ # 後端服務 48 | │ ├── api/ # API路由 49 | │ │ ├── v1/ # 新版API (RESTful) 50 | │ │ └── mygo/ # 舊版API (向後兼容) 51 | │ ├── services/ # 業務邏輯層 52 | │ ├── repositories/ # 資料存取層 53 | │ ├── config/ # 配置管理 54 | │ ├── types/ # 後端類型定義 55 | │ ├── utils/ # 工具函數 56 | │ └── algo/ # 演算法實現 57 | ├── types/ # 前端類型定義 58 | ├── styles/ # 樣式文件 59 | ├── public/ # 靜態資源 60 | │ └── data/ # 圖片資料 61 | └── docs/ # 文檔 62 | ``` 63 | 64 | ## 分層架構設計 65 | 66 | ### 1. 展示層 (Presentation Layer) 67 | 68 | **位置**: `components/`, `pages/` 69 | **職責**: 70 | 71 | - 用戶介面渲染 72 | - 用戶交互處理 73 | - 狀態管理 74 | 75 | **主要組件**: 76 | 77 | - `SearchBar.vue`: 搜尋功能 78 | - `Filter.vue`: 篩選功能 79 | - `ViewPanel.vue`: 圖片展示 80 | - `ImageView.vue`: 單張圖片組件 81 | 82 | ### 2. API層 (API Layer) 83 | 84 | **位置**: `server/api/` 85 | **職責**: 86 | 87 | - HTTP請求處理 88 | - 參數驗證 89 | - 回應格式化 90 | - 錯誤處理 91 | 92 | **版本管理**: 93 | 94 | - `v1/`: 新版RESTful API 95 | - `mygo/`: 舊版API (向後兼容) 96 | 97 | ### 3. 服務層 (Service Layer) 98 | 99 | **位置**: `server/services/` 100 | **職責**: 101 | 102 | - 業務邏輯實現 103 | - 資料處理 104 | - 快取管理 105 | - 第三方服務集成 106 | 107 | **主要服務**: 108 | 109 | - `ImageService`: 圖片管理 110 | - `SearchService`: 搜尋邏輯 111 | - `PopularityService`: 人氣統計 112 | 113 | ### 4. 存儲庫層 (Repository Layer) 114 | 115 | **位置**: `server/repositories/` 116 | **職責**: 117 | 118 | - 資料存取抽象 119 | - 資料來源切換 120 | - 資料一致性保證 121 | 122 | **實現**: 123 | 124 | - `FileRepository`: 本地檔案存取 125 | - `MongoRepository`: MongoDB存取 126 | 127 | ### 5. 配置層 (Configuration Layer) 128 | 129 | **位置**: `server/config/` 130 | **職責**: 131 | 132 | - 環境配置管理 133 | - 資料庫連接 134 | - 運行時配置 135 | 136 | ## 資料流 137 | 138 | ```mermaid 139 | graph TD; 140 | A[User Input] --> B[Components]; 141 | B --> C[API Client]; 142 | C --> D[Server API]; 143 | D --> E[Services]; 144 | E --> F[Repositories]; 145 | F --> G[Data Sources]; 146 | G --> H[Raw Data]; 147 | H --> E; 148 | E --> D; 149 | D --> C; 150 | C --> B; 151 | B --> A; 152 | ``` 153 | 154 | ## 核心功能模組 155 | 156 | ### 搜尋引擎 157 | 158 | **實現**: `server/algo/levenshtein.ts` 159 | **特性**: 160 | 161 | - 中文繁簡體轉換 (OpenCC) 162 | - 模糊匹配算法 163 | - 相似度評分 164 | - 自定義關鍵字映射 165 | 166 | ### 圖片管理系統 167 | 168 | **實現**: `server/services/imageService.ts` 169 | **特性**: 170 | 171 | - 分頁支援 172 | - 多種排序方式 173 | - 快取優化 174 | - 篩選功能 175 | -------------------------------------------------------------------------------- /components/button/filter.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 122 | -------------------------------------------------------------------------------- /server/api/mygo/img.ts: -------------------------------------------------------------------------------- 1 | import { getJsonData, customKeyMap } from '../../utils/dataLoader'; 2 | import { leven_distance } from '../../algo/levenshtein'; 3 | import * as OpenCC from 'opencc-js'; 4 | import { defineEventHandler } from 'h3'; 5 | 6 | const baseURL = useRuntimeConfig().NUXT_IMG_BASE_URL; 7 | const custom_keymap = customKeyMap; 8 | const converter = OpenCC.Converter({ from: 'cn', to: 'tw' }); 9 | 10 | const fuzzyReplacements: Record = { 11 | "你": ["姊"], 12 | "姊": ["你"], 13 | "他": ["她"], 14 | "她": ["他"], 15 | "欸": ["耶"], 16 | "耶": ["欸"], 17 | }; 18 | 19 | function generateFuzzyVariants(keyword: string): Set { 20 | const variants = new Set([keyword]); 21 | for (let i = 0; i < keyword.length; i++) { 22 | const char = keyword[i]; 23 | if (fuzzyReplacements[char]) { 24 | for (const replacement of fuzzyReplacements[char]) { 25 | const newVariant = keyword.substring(0, i) + replacement + keyword.substring(i + 1); 26 | variants.add(newVariant); 27 | } 28 | } 29 | } 30 | return variants; 31 | } 32 | 33 | export default defineEventHandler(async (event) => { 34 | const data_mapping = await getJsonData(); 35 | const query = getQuery(event); 36 | const queryKeyword: string = query.keyword as string ?? ''; 37 | const keyword = converter(queryKeyword) 38 | const keywords: string[] = keyword.split(' '); 39 | const fuzzy = query.fuzzy === 'true'; 40 | 41 | let scoredResults: Array<{ url: string; alt: string; score: number }> = []; 42 | let fullMatchResults: Array<{ url: string; alt: string; score: number }> = []; 43 | const customKeymapResults: Array<{ url: string; alt: string; score: number }> = []; 44 | 45 | for (const item of data_mapping) { 46 | const name = item.alt; 47 | let totalScore = 0; 48 | 49 | for (const keyword of keywords) { 50 | const variants = fuzzy ? generateFuzzyVariants(keyword) : new Set([keyword]); 51 | let matched = false; 52 | 53 | for (const variant of variants) { 54 | if (name.includes(variant)) { 55 | totalScore += variant.length >= 2 ? 10 : 5; 56 | matched = true; 57 | break; 58 | } 59 | 60 | if (fuzzy && variant.length > 2 && name.length > 2) { 61 | const dist = leven_distance(variant, name); 62 | const ratio = (variant.length - dist) / variant.length; 63 | if (dist <= 2 && ratio >= 0.5) { 64 | totalScore += 3; 65 | matched = true; 66 | break; 67 | } 68 | } 69 | } 70 | 71 | if (!matched) { 72 | totalScore = 0; 73 | break; 74 | } 75 | } 76 | 77 | if (totalScore > 0) { 78 | scoredResults.push({ url: baseURL + item.filename, alt: item.alt, score: totalScore }); 79 | } 80 | 81 | // 保留精準匹配(不重複) 82 | if (keywords.some(k => name.includes(k))) { 83 | fullMatchResults.push({ url: baseURL + item.filename, alt: item.alt, score: 15 }); 84 | } 85 | } 86 | 87 | if (custom_keymap.hasOwnProperty(keyword)) { 88 | const keywordValue = custom_keymap[keyword]?.value || []; 89 | customKeymapResults.push( 90 | ...data_mapping 91 | .filter((item) => keywordValue.includes(item.alt)) 92 | .map((item) => ({ 93 | url: baseURL + item.filename, 94 | alt: item.alt, 95 | score: 15, 96 | })) 97 | ); 98 | } 99 | 100 | // Merge scored results and full match results, remove duplicates 101 | const combinedResultsMap = new Map(); 102 | [...scoredResults, ...fullMatchResults, ...customKeymapResults].forEach((result) => { 103 | combinedResultsMap.set(result.url, result); 104 | }); 105 | 106 | const sortedResults = Array.from(combinedResultsMap.values()).sort((a, b) => b.score - a.score); 107 | 108 | return { urls: sortedResults }; 109 | 110 | }); 111 | -------------------------------------------------------------------------------- /server/services/imageService.ts: -------------------------------------------------------------------------------- 1 | import { sortImages, type SortOrder } from '../utils/sorting'; 2 | import { MongoRepository } from '../repositories/mongoRepository'; 3 | import { FileRepository } from '../repositories/fileRepository'; 4 | 5 | /** 6 | * 圖片服務類,處理所有圖片相關的業務邏輯 7 | */ 8 | export class ImageService { 9 | private mongoRepo = new MongoRepository(); 10 | private fileRepo = new FileRepository(); 11 | private baseURL: string; 12 | 13 | constructor(baseURL?: string) { 14 | this.baseURL = baseURL || ''; 15 | } 16 | 17 | /** 18 | * 獲取分頁圖片列表 19 | */ 20 | async getPaginatedImages(params: { 21 | page: number; 22 | limit: number; 23 | order: SortOrder; 24 | }): Promise<{ 25 | data: any[]; 26 | meta: { 27 | total: number; 28 | page: number; 29 | limit: number; 30 | totalPages: number; 31 | hasNext: boolean; 32 | hasPrev: boolean; 33 | }; 34 | }> { 35 | const { page, limit, order } = params; 36 | 37 | // 獲取所有圖片 38 | let allImages = await this.getAllImages(); 39 | 40 | if (allImages.length === 0) { 41 | return { 42 | data: [], 43 | meta: { 44 | total: 0, 45 | page: 1, 46 | limit, 47 | totalPages: 0, 48 | hasNext: false, 49 | hasPrev: false 50 | } 51 | }; 52 | } 53 | 54 | // 如果是人氣排序,獲取最新的人氣統計數據 55 | if (order === 'popularity') { 56 | const popularityStats = await this.getPopularityStats(); 57 | allImages = allImages.map(image => ({ 58 | ...image, 59 | popularity: popularityStats[image.id?.toString()] || image.popularity || 0 60 | })); 61 | } 62 | 63 | const totalCount = allImages.length; 64 | const totalPages = Math.ceil(totalCount / limit); 65 | 66 | // 檢查頁碼是否超出範圍 67 | if (page > totalPages && totalPages > 0) { 68 | return { 69 | data: [], 70 | meta: { 71 | total: totalCount, 72 | page, 73 | limit, 74 | totalPages, 75 | hasNext: false, 76 | hasPrev: page > 1 77 | } 78 | }; 79 | } 80 | 81 | const offset = (page - 1) * limit; 82 | const sortedImages = await sortImages(allImages, order); 83 | const paginatedImages = sortedImages.slice(offset, offset + limit); 84 | 85 | return { 86 | data: paginatedImages, 87 | meta: { 88 | total: totalCount, 89 | page, 90 | limit, 91 | totalPages, 92 | hasNext: page < totalPages, 93 | hasPrev: page > 1 94 | } 95 | }; 96 | } 97 | 98 | /** 99 | * 獲取所有圖片並轉換格式 100 | */ 101 | private async getAllImages(): Promise { 102 | try { 103 | // 優先從 MongoDB 獲取 104 | const mongoImages = await this.mongoRepo.getImages(); 105 | return mongoImages.map((item: any) => ({ 106 | id: item.id, 107 | url: this.baseURL + item.filename, 108 | alt: item.alt, 109 | author: item.author, 110 | episode: item.episode, 111 | filename: item.filename, 112 | tags: item.tags || [], 113 | popularity: item.popularity || 0 114 | })); 115 | } catch { 116 | // 失敗則使用本地文件 117 | const fileImages = await this.fileRepo.getImages(); 118 | return fileImages.map((item: any) => ({ 119 | id: item.id, 120 | url: this.baseURL + item.filename, 121 | alt: item.alt, 122 | author: item.author, 123 | episode: item.episode, 124 | filename: item.filename, 125 | tags: item.tags || [], 126 | popularity: item.popularity || 0 127 | })); 128 | } 129 | } 130 | 131 | /** 132 | * 獲取人氣統計數據 133 | */ 134 | private async getPopularityStats(): Promise> { 135 | try { 136 | return await this.mongoRepo.getPopularityStats(); 137 | } catch { 138 | return await this.fileRepo.getPopularityStats(); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /server/config/database.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, Db, Collection } from 'mongodb'; 2 | import type { Document } from 'mongodb'; 3 | 4 | // MongoDB 客戶端實例 5 | let client: MongoClient | null = null; 6 | let db: Db | null = null; 7 | 8 | /** 9 | * 獲取 MongoDB 連接 10 | */ 11 | export async function getMongoClient(): Promise { 12 | // 使用 useRuntimeConfig() 獲取配置 13 | const config = useRuntimeConfig(); 14 | const MONGODB_URL = config.mongodbConnectUrl; 15 | 16 | if (!MONGODB_URL) { 17 | const errorMsg = '[MongoDB] URL not configured'; 18 | process.stderr.write(`[ERROR] ${errorMsg}\n`); 19 | throw new Error(errorMsg); 20 | } 21 | 22 | // 檢查現有連接是否仍然有效 23 | if (client) { 24 | try { 25 | await client.db('admin').command({ ping: 1 }); 26 | process.stderr.write('[INFO] [MongoDB] Existing connection is alive\n'); 27 | return client; 28 | } catch (error) { 29 | process.stderr.write('[WARN] [MongoDB] Existing connection is invalid, creating new connection\n'); 30 | client = null; 31 | db = null; 32 | } 33 | } 34 | 35 | try { 36 | const maskedUrl = MONGODB_URL.replace(/\/\/.*@/, '//***:***@'); 37 | process.stderr.write(`[INFO] [MongoDB] Connecting to: ${maskedUrl}\n`); 38 | 39 | client = new MongoClient(MONGODB_URL, { 40 | connectTimeoutMS: 10000, 41 | serverSelectionTimeoutMS: 5000, 42 | authSource: 'admin', 43 | directConnection: true, 44 | }); 45 | 46 | await client.connect(); 47 | process.stderr.write('[INFO] [MongoDB] Successfully connected to database\n'); 48 | 49 | // 測試連接 50 | await client.db('admin').command({ ping: 1 }); 51 | process.stderr.write('[INFO] [MongoDB] Ping test successful\n'); 52 | 53 | return client; 54 | } catch (error: any) { 55 | const errorMsg = `[MongoDB] Connection failed: ${error.message}`; 56 | process.stderr.write(`[ERROR] ${errorMsg}\n`); 57 | client = null; 58 | throw error; 59 | } 60 | } 61 | 62 | /** 63 | * 獲取資料庫實例 64 | */ 65 | export async function getDatabase(): Promise { 66 | if (!db) { 67 | const mongoClient = await getMongoClient(); 68 | db = mongoClient.db(); 69 | process.stderr.write('[INFO] [MongoDB] Database instance created\n'); 70 | } 71 | return db; 72 | } 73 | 74 | /** 75 | * 獲取指定集合 76 | */ 77 | export async function getCollection(collectionName: string): Promise> { 78 | const database = await getDatabase(); 79 | return database.collection(collectionName); 80 | } 81 | 82 | /** 83 | * 獲取圖片集合 84 | */ 85 | export async function getImagesCollection(): Promise { 86 | const config = useRuntimeConfig(); 87 | const MONGODB_COLLECTION = config.mongodbCollection; 88 | 89 | if (!MONGODB_COLLECTION) { 90 | const errorMsg = '[MongoDB] Collection name not configured'; 91 | process.stderr.write(`[ERROR] ${errorMsg}\n`); 92 | throw new Error(errorMsg); 93 | } 94 | 95 | process.stderr.write(`[INFO] [MongoDB] Using collection: ${MONGODB_COLLECTION}\n`); 96 | return getCollection(MONGODB_COLLECTION); 97 | } 98 | 99 | /** 100 | * 檢查 MongoDB 是否可用 101 | */ 102 | export function isMongoConfigured(): boolean { 103 | const config = useRuntimeConfig(); 104 | const isConfigured = !!(config.mongodbConnectUrl && config.mongodbCollection); 105 | process.stderr.write(`[INFO] [MongoDB] Configuration status: ${isConfigured ? 'configured' : 'not configured'}\n`); 106 | return isConfigured; 107 | } 108 | 109 | /** 110 | * 關閉 MongoDB 連接 111 | */ 112 | export async function closeMongoConnection(): Promise { 113 | if (client) { 114 | await client.close(); 115 | client = null; 116 | db = null; 117 | process.stderr.write('[INFO] [MongoDB] Connection closed\n'); 118 | } 119 | } 120 | 121 | /** 122 | * 檢查集合是否存在 123 | */ 124 | export async function collectionExists(collectionName: string): Promise { 125 | try { 126 | const database = await getDatabase(); 127 | const collections = await database.listCollections({ name: collectionName }).toArray(); 128 | const exists = collections.length > 0; 129 | process.stderr.write(`[INFO] [MongoDB] Collection '${collectionName}' exists: ${exists}\n`); 130 | return exists; 131 | } catch (error) { 132 | const errorMsg = `[MongoDB] Error checking collection existence: ${error}`; 133 | process.stderr.write(`[ERROR] ${errorMsg}\n`); 134 | return false; 135 | } 136 | } -------------------------------------------------------------------------------- /components/main-view-panel.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 140 | 141 | -------------------------------------------------------------------------------- /components/popup/filter.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | -------------------------------------------------------------------------------- /server/utils/dataLoader.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { getImagesCollection, isMongoConfigured, collectionExists } from '../config/database'; 4 | import { getProcessedImageData, getProcessedImageDataSync } from './dataProcessing'; 5 | 6 | // 從 MongoDB 載入資料 7 | export const loadFromMongoDB = async (): Promise => { 8 | if (!isMongoConfigured()) { 9 | throw new Error('MongoDB URL or Collection not configured'); 10 | } 11 | 12 | try { 13 | console.log('Attempting to connect to MongoDB...'); 14 | 15 | // 檢查集合是否存在 16 | const MONGODB_COLLECTION = process.env.MONGODB_COLLECTION || ''; 17 | const exists = await collectionExists(MONGODB_COLLECTION); 18 | if (!exists) { 19 | console.warn(`Collection '${MONGODB_COLLECTION}' does not exist`); 20 | throw new Error(`Collection '${MONGODB_COLLECTION}' does not exist`); 21 | } 22 | 23 | // 獲取圖片集合 24 | const collection = await getImagesCollection(); 25 | const data = await collection.find({}).toArray(); 26 | 27 | console.log(`Loaded ${data.length} records from MongoDB`); 28 | return data; 29 | } catch (error: any) { 30 | // 檢查各種連接錯誤 31 | let errorType = 'Unknown error'; 32 | if (error.message.includes('Authentication failed')) { 33 | errorType = 'Authentication failed - Check username/password in connection string'; 34 | } else if (error.message.includes('ENOTFOUND') || error.message.includes('getaddrinfo')) { 35 | errorType = 'Host not found - Check MongoDB server address'; 36 | } else if (error.message.includes('ECONNREFUSED')) { 37 | errorType = 'Connection refused - Check if MongoDB server is running'; 38 | } else if (error.message.includes('Timeout')) { 39 | errorType = 'Connection timeout - Check network and server status'; 40 | } else if (error.message.includes('does not exist')) { 41 | errorType = 'Collection not found'; 42 | } 43 | 44 | console.error(`MongoDB connection error (${errorType}):`, error.message); 45 | throw new Error(`MongoDB connection failed: ${errorType}`); 46 | } 47 | } 48 | 49 | // 從本地檔案載入資料 50 | export function loadFromLocalFile(): any[] { 51 | try { 52 | const dataPath = join(process.cwd(), 'public', 'data', 'image_map.json'); 53 | const data = JSON.parse(readFileSync(dataPath, 'utf-8')); 54 | console.log(`Loaded ${data.length} records from local file`); 55 | return data; 56 | } catch (error: any) { 57 | console.error('Failed to load local file:', error.message); 58 | throw new Error(`Failed to load local data file: ${error.message}`); 59 | } 60 | } 61 | 62 | // 緩存載入的資料 63 | let cachedData: any[] | null = null; 64 | let dataLoadPromise: Promise | null = null; 65 | 66 | // 異步資料載入函數 67 | async function loadData(): Promise { 68 | if (cachedData) { 69 | return cachedData; 70 | } 71 | 72 | if (dataLoadPromise) { 73 | return dataLoadPromise; 74 | } 75 | 76 | dataLoadPromise = (async () => { 77 | try { 78 | // 如果有設定 MongoDB 參數,優先從 MongoDB 讀取 79 | if (isMongoConfigured()) { 80 | console.log('Attempting to load data from MongoDB...'); 81 | const data = await loadFromMongoDB(); 82 | cachedData = data; 83 | return data; 84 | } else { 85 | console.log('No MongoDB configuration found, using local file...'); 86 | } 87 | } catch (error: any) { 88 | console.warn('Failed to load from MongoDB, falling back to local file:', error.message); 89 | } 90 | 91 | // 否則從本地檔案讀取 92 | console.log('Loading data from local file...'); 93 | const data = loadFromLocalFile(); 94 | cachedData = data; 95 | return data; 96 | })(); 97 | 98 | return dataLoadPromise; 99 | } 100 | 101 | // 提供異步獲取資料的函數 102 | export async function getJsonData(): Promise { 103 | try { 104 | // 先獲取原始資料 105 | const rawData = await loadData(); 106 | 107 | // 統一使用 special.ts 處理資料 108 | const processedData = await getProcessedImageData(rawData); 109 | 110 | return processedData; 111 | } catch (error) { 112 | console.error('Failed to get processed image data:', error); 113 | throw error; 114 | } 115 | } 116 | 117 | // 為了向後兼容,保留同步版本(使用本地檔案) 118 | export const jsonData = (() => { 119 | try { 120 | // 如果沒有 MongoDB 設定,直接使用本地檔案並處理 121 | if (!isMongoConfigured()) { 122 | const rawData = loadFromLocalFile(); 123 | return getProcessedImageDataSync(rawData); 124 | } 125 | 126 | // 如果有 MongoDB 設定,先返回空陣列,讓 API 使用 getJsonData() 127 | return []; 128 | } catch (error) { 129 | console.warn('Failed to load local file, returning empty array:', error); 130 | return []; 131 | } 132 | })(); 133 | 134 | export const customKeyMap = (() => { 135 | const dataPath = join(process.cwd(), 'public', 'data', 'custom_keymap.json'); 136 | return JSON.parse(readFileSync(dataPath, 'utf-8')); 137 | })(); -------------------------------------------------------------------------------- /components/popup/notification.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 96 | 97 | -------------------------------------------------------------------------------- /docs/Technical.md: -------------------------------------------------------------------------------- 1 | # 技術實現細節 2 | 3 | ## 搜尋引擎實現 4 | 5 | ### Levenshtein 距離算法 6 | 7 | MyGO Searcher 使用 Levenshtein 距離算法來實現模糊搜尋功能。 8 | 9 | **核心函數** (`server/algo/levenshtein.ts`): 10 | 11 | - `leven_distance(str1: string, str2: string): number` - 計算兩字串間的編輯距離 12 | - `calculateSimilarity(str1: string, str2: string): number` - 計算相似度 (0-1) 13 | 14 | ### 中文處理 15 | 16 | **繁簡體轉換**: 17 | 18 | - 使用 OpenCC 進行自動繁簡體轉換 19 | - 支援台灣正體和簡體中文互轉 20 | 21 | **模糊替換**: 22 | 23 | - `generateFuzzyVariants(keyword: string): Set` - 生成模糊匹配變體 24 | - 支援常見錯字和同音字替換 25 | 26 | ## 資料層架構 27 | 28 | ### Repository 模式實現 29 | 30 | **Repository 基礎介面** (`server/types/repository.ts`): 31 | 32 | - `findAll(): Promise` - 獲取所有資料 33 | - `findById(id: string): Promise` - 根據ID查找 34 | - `findByQuery(query: object): Promise` - 條件查詢 35 | - `save(entity: T): Promise` - 儲存實體 36 | - `update(id: string, updates: Partial): Promise` - 更新實體 37 | - `delete(id: string): Promise` - 刪除實體 38 | 39 | **實現類型**: 40 | 41 | - 本地 JSON 檔案存取實現 42 | - MongoDB 資料庫實現 43 | 44 | ### 快取策略 45 | 46 | - 多層快取系統:記憶體快取 + 查詢結果快取 47 | - 自動快取失效機制 48 | 49 | ## 服務層設計 50 | 51 | ### ImageService 類別 52 | 53 | **主要屬性**: 54 | 55 | - `repository: Repository` - 資料存取層 56 | - `cache: CacheManager` - 快取管理器 57 | 58 | **核心方法**: 59 | 60 | - `getPaginatedImages(params): Promise>` - 分頁獲取圖片 61 | - `sortImages(images, order): ImageItem[]` - 圖片排序 62 | - 支援排序類型:id、popularity、random、episode、alphabetical 63 | 64 | ### SearchService 類別 65 | 66 | **主要屬性**: 67 | 68 | - `repository: Repository` - 資料存取 69 | - `customKeyMap: Record` - 自定義關鍵字映射 70 | 71 | **核心方法**: 72 | 73 | - `search(params): Promise` - 執行搜尋 74 | - `fuzzySearch(images, query): SearchResult[]` - 模糊搜尋 75 | - `exactSearch(images, query): SearchResult[]` - 精確搜尋 76 | - 相似度閾值:0.3 77 | 78 | ## 資料庫設計 79 | 80 | ### MongoDB 集合結構 81 | 82 | #### 圖片集合 (images) 83 | 84 | **主要欄位**: 85 | 86 | - `id: string` - 唯一標識符 (例:mygo_01_001) 87 | - `filename: string` - 檔案名稱 88 | - `alt: string` - 圖片描述文字 89 | - `author: string` - 作者名稱 90 | - `episode: string` - 集數標識 91 | - `tags: string[]` - 標籤陣列 92 | - `popularity: number` - 人氣值 93 | - `createdAt/updatedAt: Date` - 時間戳 94 | 95 | #### 統計集合 (statistics) 96 | 97 | **主要欄位**: 98 | 99 | - `imageId: string` - 關聯圖片ID 100 | - `action: string` - 動作類型 (copy, view, etc.) 101 | - `timestamp: Date` - 操作時間 102 | - `userAgent: string` - 用戶代理 103 | - `ip: string` - IP位址(匿名化) 104 | 105 | ### 索引策略 106 | 107 | **圖片集合索引**: 108 | 109 | - `{ "id": 1 }` - 主鍵索引 110 | - `{ "popularity": -1 }` - 人氣排序索引 111 | - `{ "episode": 1 }` - 集數篩選索引 112 | - `{ "alt": "text" }` - 全文搜尋索引 113 | - `{ "tags": 1 }` - 標籤篩選索引 114 | 115 | **統計集合索引**: 116 | 117 | - `{ "imageId": 1 }` - 圖片關聯索引 118 | - `{ "timestamp": -1 }` - 時間排序索引 119 | - `{ "imageId": 1, "timestamp": -1 }` - 複合索引 120 | 121 | ## 前端架構 122 | 123 | ### Composables 設計 124 | 125 | #### useImages Composable 126 | 127 | **返回屬性**: 128 | 129 | - `images: Ref` - 圖片列表 (只讀) 130 | - `loading: Ref` - 載入狀態 (只讀) 131 | - `error: Ref` - 錯誤信息 (只讀) 132 | 133 | **核心方法**: 134 | 135 | - `fetchImages(params): Promise` - 獲取圖片列表 136 | - `searchImages(query, fuzzy): Promise` - 搜尋圖片 137 | 138 | #### useInfiniteScroll Composable 139 | 140 | **返回屬性**: 141 | 142 | - `items: Ref` - 項目列表 (只讀) 143 | - `loading: Ref` - 載入狀態 (只讀) 144 | - `hasMore: Ref` - 是否有更多資料 (只讀) 145 | 146 | **核心方法**: 147 | 148 | - `loadMore(): Promise` - 載入更多資料 149 | - `reset(): void` - 重置狀態 150 | 151 | ### 狀態管理 152 | 153 | #### imageStore (Pinia) 154 | 155 | **狀態屬性**: 156 | 157 | - `images: ImageItem[]` - 圖片資料 158 | - `filters: FilterOptions` - 篩選條件 159 | - `searchQuery: string` - 搜尋關鍵字 160 | 161 | **計算屬性**: 162 | 163 | - `filteredImages: ComputedRef` - 篩選後的圖片列表 164 | 165 | **動作方法**: 166 | 167 | - `setImages(newImages)` - 設定圖片資料 168 | - `setFilters(newFilters)` - 設定篩選條件 169 | - `setSearchQuery(query)` - 設定搜尋關鍵字 170 | 171 | ## 效能優化策略 172 | 173 | ### 前端優化 174 | 175 | **圖片懶載入** (`components/LazyImage.vue`): 176 | 177 | - 使用 `IntersectionObserver` API 178 | - 支援載入/錯誤狀態處理 179 | - 平滑淡入動畫效果 180 | 181 | **虛擬滾動** (`composables/useVirtualScroll.ts`): 182 | 183 | - 計算可見範圍和項目位置 184 | - 減少 DOM 節點數量 185 | - 提升大列表渲染效能 186 | 187 | ### 後端優化 188 | 189 | #### 查詢優化 190 | 191 | **OptimizedSearchService 類別**: 192 | 193 | - 搜尋結果快取機制 194 | - 並行執行精確和模糊搜尋 195 | - 結果合併和去重處理 196 | 197 | #### 資料庫連接池 198 | 199 | **DatabaseManager 類別** (單例模式): 200 | 201 | **連接池配置**: 202 | 203 | - `maxPoolSize: 10` - 最大連接數 204 | - `minPoolSize: 2` - 最小連接數 205 | - `serverSelectionTimeoutMS: 5000` - 服務器選擇超時 206 | - `socketTimeoutMS: 45000` - Socket 超時 207 | - `maxIdleTimeMS: 30000` - 最大空閒時間 208 | 209 | ## 監控和日誌 210 | 211 | ### 效能監控 212 | 213 | **PerformanceMiddleware**: 214 | 215 | - 請求處理時間記錄 216 | - 慢查詢警告 (>1000ms) 217 | - 記憶體使用量監控 218 | 219 | ### 錯誤處理 220 | 221 | **ErrorHandler 類別**: 222 | 223 | **錯誤信息記錄**: 224 | 225 | - 錯誤訊息和堆疊追蹤 226 | - 操作上下文和時間戳 227 | - Node.js 版本和記憶體使用情況 228 | 229 | **生產環境整合**: 230 | 231 | - 支援第三方錯誤追蹤服務 (如 Sentry) 232 | - 統一錯誤格式和狀態碼回應 233 | -------------------------------------------------------------------------------- /apis/client.ts: -------------------------------------------------------------------------------- 1 | import { useRuntimeConfig } from 'nuxt/app' 2 | import type { ApiResponse, ApiError } from '~/types/api' 3 | 4 | class ApiClient { 5 | private baseUrl: string 6 | 7 | constructor(baseUrl?: string) { 8 | if (baseUrl) { 9 | this.baseUrl = baseUrl 10 | } else { 11 | try { 12 | const config = useRuntimeConfig() 13 | this.baseUrl = config.public?.apiBase || '/api/v1' 14 | } catch (error) { 15 | // Fallback for server-side rendering or when runtime config is not available 16 | this.baseUrl = '/api/v1' 17 | } 18 | } 19 | 20 | // 確保 baseUrl 不以斜線結尾 21 | this.baseUrl = this.baseUrl.replace(/\/$/, '') 22 | 23 | // 使用 stderr 在服務器端輸出日誌 24 | if (process.server) { 25 | process.stderr.write(`[ApiClient] Initialized with baseUrl: ${this.baseUrl}\n`) 26 | } else { 27 | console.log('[ApiClient] Initialized with baseUrl:', this.baseUrl) 28 | } 29 | } 30 | 31 | private getFullUrl(endpoint: string): string { 32 | // 確保 endpoint 以斜線開頭 33 | const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}` 34 | 35 | if (process.server) { 36 | // 在服務器端 SSR 時,使用完整的內部 URL,包含 baseUrl 37 | const fullPath = `${this.baseUrl}${normalizedEndpoint}` 38 | const url = `http://localhost:3000${fullPath}` 39 | 40 | process.stderr.write(`[ApiClient] SSR URL construction - baseUrl: ${this.baseUrl}, endpoint: ${normalizedEndpoint}, full URL: ${url}\n`) 41 | 42 | return url 43 | } else { 44 | // 在客戶端,使用相對路徑 45 | const url = `${this.baseUrl}${normalizedEndpoint}` 46 | console.log('[ApiClient] Client URL construction - baseUrl:', this.baseUrl, 'endpoint:', normalizedEndpoint, 'full URL:', url) 47 | return url 48 | } 49 | } 50 | 51 | private async request( 52 | endpoint: string, 53 | options: RequestInit = {} 54 | ): Promise { 55 | const url = this.getFullUrl(endpoint) 56 | 57 | // 使用適當的日誌輸出方式 58 | if (process.server) { 59 | process.stderr.write(`[ApiClient] Making API request to: ${url}\n`) 60 | } else { 61 | console.log('[ApiClient] Making API request to:', url) 62 | } 63 | 64 | const defaultOptions: RequestInit = { 65 | method: 'GET', 66 | headers: { 67 | 'Content-Type': 'application/json', 68 | ...options.headers, 69 | }, 70 | ...options, 71 | } 72 | 73 | try { 74 | const response = await fetch(url, defaultOptions) 75 | 76 | if (process.server) { 77 | process.stderr.write(`[ApiClient] Response status: ${response.status} for URL: ${url}\n`) 78 | } else { 79 | console.log('[ApiClient] Response status:', response.status, 'for URL:', url) 80 | } 81 | 82 | if (!response.ok) { 83 | let errorMessage = `HTTP ${response.status}: ${response.statusText}` 84 | 85 | try { 86 | const errorData = await response.json() 87 | errorMessage = errorData.statusMessage || errorData.message || errorMessage 88 | } catch (parseError) { 89 | // 如果無法解析錯誤響應,使用默認錯誤信息 90 | if (process.server) { 91 | process.stderr.write(`[ApiClient] Failed to parse error response: ${parseError}\n`) 92 | } else { 93 | console.warn('[ApiClient] Failed to parse error response:', parseError) 94 | } 95 | } 96 | 97 | throw new Error(errorMessage) 98 | } 99 | 100 | const data = await response.json() 101 | return data 102 | } catch (error) { 103 | const errorMsg = `API request failed for ${url}: ${error}` 104 | if (process.server) { 105 | process.stderr.write(`[ERROR] ${errorMsg}\n`) 106 | } else { 107 | console.error(errorMsg) 108 | } 109 | throw error 110 | } 111 | } 112 | 113 | async get(endpoint: string, params?: Record): Promise { 114 | let url = endpoint 115 | 116 | if (params) { 117 | const searchParams = new URLSearchParams() 118 | Object.entries(params).forEach(([key, value]) => { 119 | if (value !== undefined && value !== null) { 120 | searchParams.append(key, String(value)) 121 | } 122 | }) 123 | 124 | const queryString = searchParams.toString() 125 | if (queryString) { 126 | url += `?${queryString}` 127 | } 128 | } 129 | 130 | return this.request(url) 131 | } 132 | 133 | async post(endpoint: string, data?: any): Promise { 134 | const logMsg = `POST request to: ${endpoint} with data: ${JSON.stringify(data)}` 135 | if (process.server) { 136 | process.stderr.write(`[ApiClient] ${logMsg}\n`) 137 | } else { 138 | console.log('[ApiClient]', logMsg) 139 | } 140 | 141 | return this.request(endpoint, { 142 | method: 'POST', 143 | body: data ? JSON.stringify(data) : undefined, 144 | }) 145 | } 146 | 147 | // Health check 148 | async health(): Promise<{ status: string; timestamp: string; service: string; version: string }> { 149 | return this.get('/health') 150 | } 151 | } 152 | 153 | // Singleton instance 154 | let apiClientInstance: ApiClient | null = null 155 | 156 | export const getApiClient = (): ApiClient => { 157 | if (!apiClientInstance) { 158 | apiClientInstance = new ApiClient() 159 | } 160 | return apiClientInstance 161 | } 162 | 163 | // For backward compatibility 164 | export const createApiClient = (baseUrl?: string): ApiClient => { 165 | return new ApiClient(baseUrl) 166 | } 167 | 168 | export { ApiClient } -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # MyGO Searcher RESTful API Documentation 2 | 3 | ## API 版本 4 | 5 | - **Version**: v1 6 | - **Base URL**: `/api/v1` 7 | - **Response 格式**: JSON 8 | - **Encode**: UTF-8 9 | 10 | ## 通用回應格式 11 | 12 | ### 成功回應 13 | 14 | ```json 15 | { 16 | "data": [...], 17 | "meta": { 18 | // 你媽 19 | } 20 | } 21 | ``` 22 | 23 | ### 錯誤回應 24 | 25 | ```json 26 | { 27 | "statusCode": 400, 28 | "statusMessage": "Error message" 29 | } 30 | ``` 31 | 32 | ## API Route 33 | 34 | ### 1. 存活狀態 (舊版Ping) 35 | 36 | 功能同舊版的`/api/ping` 37 | 38 | ```http 39 | GET /api/v1/health 40 | ``` 41 | 42 | 檢查 API 服務狀態和版本資訊。 43 | 44 | **回應:** 45 | 46 | ```json 47 | { 48 | "status": "ok", 49 | "timestamp": "2025-08-05T12:00:00.000Z", 50 | "service": "MyGO Searcher API", 51 | "version": "1.0.0" 52 | } 53 | ``` 54 | 55 | ### 2. 獲取圖片列表 56 | 57 | ```http 58 | GET /api/v1/images 59 | ``` 60 | 61 | 獲取圖片列表,支援分頁和多種排序方式,針對無限滾動優化。 62 | 63 | **查詢參數:** 64 | 65 | - `page` (number, 可選): 頁碼,預設 1,從 1 開始 66 | - `limit` (number, 可選): 每頁數量,預設 20,範圍 1-100 67 | - `order` (string, 可選): 排序方式,預設 `id` 68 | - `id`: 按 ID 數字順序排序 69 | - `random`: 隨機排序 70 | - `episode`: 按集數排序 (MyGO 集數優先於 AveMujica 集數) 71 | - `alphabetical`: 按字典序排序 (依據 alt 屬性) 72 | - `popularity`: 按人氣排序 (最熱門的在前面) 73 | 74 | **回應:** 75 | 76 | ```json 77 | { 78 | "data": [ 79 | { 80 | "id": "114514", 81 | "url": "https://example.com/images/mygo/01/為什麼要演奏春日影_001.jpg", 82 | "alt": "為什麼要演奏春日影", 83 | "author": "長崎素食", 84 | "episode": "長崎素食導致的", 85 | "filename": "為什麼要演奏春日影_001.jpg", 86 | "popularity": 42 87 | } 88 | ], 89 | "meta": { 90 | "total": 1250, 91 | "page": 1, 92 | "limit": 20, 93 | "totalPages": 63, 94 | "hasNext": true, 95 | "hasPrev": false 96 | } 97 | } 98 | ``` 99 | 100 | ### 3. 搜尋圖片 101 | 102 | ```http 103 | GET /api/v1/images/search 104 | ``` 105 | 106 | 根據關鍵字搜尋圖片,支援模糊搜尋和中文繁簡體轉換。 107 | 108 | **查詢參數:** 109 | 110 | - `q` (string, 必填): 搜尋關鍵字 111 | - `fuzzy` (boolean, 可選): 是否啟用模糊搜尋,預設 `false` 112 | - `page` (number, 可選): 頁碼,預設 1 113 | - `limit` (number, 可選): 每頁數量,預設 20,範圍 1-100 114 | - `order` (string, 可選): 排序方式,預設 `id` 115 | 116 | **搜尋特性:** 117 | 118 | - 支援繁體中文和簡體中文搜尋 119 | - 模糊搜尋支援常見字詞替換 (如:你/姊、他/她、欸/耶) 120 | - 使用編輯距離進行相似度匹配 121 | - 支援自定義關鍵字映射 122 | 123 | **回應:** 124 | 125 | ```json 126 | { 127 | "data": [ 128 | { 129 | "id": "114514", 130 | "url": "https://example.com/images/mygo/01/為什麼要演奏春日影_001.jpg", 131 | "alt": "為什麼要演奏春日影", 132 | "author": "長崎素食", 133 | "episode": "長崎素食導致的", 134 | "similarity": 0.95 135 | } 136 | ], 137 | "meta": { 138 | "query": "搜尋關鍵字", 139 | "fuzzy": false, 140 | "total": 15, 141 | "page": 1, 142 | "limit": 20, 143 | "totalPages": 1, 144 | "hasNext": false, 145 | "hasPrev": false 146 | } 147 | } 148 | ``` 149 | 150 | ### 4. 獲取隨機圖片 151 | 152 | ```http 153 | GET /api/v1/images/random 154 | ``` 155 | 156 | 獲取指定數量的隨機圖片。 157 | 158 | **查詢參數:** 159 | 160 | - `count` (number, 可選): 圖片數量,預設 1,最大 100 161 | 162 | **回應:** 163 | 164 | ```json 165 | { 166 | "data": [ 167 | { 168 | "id": "114514", 169 | "url": "https://example.com/images/mygo/01/為什麼要演奏春日影_001.jpg", 170 | "alt": "為什麼要演奏春日影", 171 | "author": "長崎素食", 172 | "episode": "長崎素食導致的" 173 | } 174 | ], 175 | "meta": { 176 | "count": 1, 177 | "requested": 1 178 | } 179 | } 180 | ``` 181 | 182 | ### 5. 獲取特定圖片 183 | 184 | ```http 185 | GET /api/v1/images/{id} 186 | ``` 187 | 188 | 獲取指定 ID 圖片的詳細資訊。 189 | 現在沒啥用,因為圖片的ID完全是我整理的順序,沒有什麼參考性,只能在有表的狀況下使用。 190 | 191 | **路徑參數:** 192 | 193 | - `id` (string): 圖片ID,例如 `mygo_01_001` 194 | 195 | **回應:** 196 | 197 | ```json 198 | { 199 | "data": { 200 | "id": "114514", 201 | "url": "https://example.com/images/mygo/01/為什麼要演奏春日影_001.jpg", 202 | "alt": "為什麼要演奏春日影", 203 | "author": "長崎素食", 204 | "episode": "長崎素食導致的", 205 | "filename": "為什麼要演奏春日影_001.jpg", 206 | "popularity": 42 207 | } 208 | } 209 | ``` 210 | 211 | ## 錯誤代碼 212 | 213 | | 狀態碼 | 說明 | 常見原因 | 214 | |--------|------|----------| 215 | | `400` | 請求參數錯誤 | 缺少必要參數、參數格式錯誤 | 216 | | `404` | 資源不存在 | 圖片ID不存在 | 217 | | `500` | 服務器內部錯誤 | 資料庫連接失敗、文件讀取錯誤 | 218 | 219 | ## 舊版API兼容 220 | 221 | ### 遷移對照表 222 | 223 | | 舊版API | 新版API | 主要變更 | 224 | |---------|---------|----------| 225 | | `GET /api/mygo/img?keyword={q}` | `GET /api/v1/images/search?q={q}` | 參數名稱從 `keyword` 改為 `q` | 226 | | `GET /api/mygo/all_img` | `GET /api/v1/images` | 新增分頁支援 | 227 | | `GET /api/mygo/random_img?amount={count}` | `GET /api/v1/images/random?count={count}` | 參數名稱從 `amount` 改為 `count` | 228 | | `GET /api/ping` | `GET /api/v1/health` | 回應格式更豐富 | 229 | 230 | ### 新版本主要改進 231 | 232 | 1. **語義化URL設計**: 採用資源導向的RESTful設計 233 | 2. **統一回應格式**: 所有API使用一致的 `data`/`meta` 結構 234 | 3. **錯誤處理**: 使用HTTP status code和結構化錯誤訊息 235 | 4. **分頁支援**: 支援分頁機制,避免舊版的載入首頁就要一次大量傳輸資料問題 236 | 5. **參數標準化**: 更新參數命名標準 237 | 6. **版本控制**: 分離舊版和新版API路由,便於未來升級 238 | 7. **多種排序**: 支援ID、隨機、集數、字母等多種排序方式 239 | 240 | ### 舊版API狀態 241 | 242 | **重要通知**: 舊版API (`/api/mygo/*`, `/api/ping`) 目前仍然可用,但已進入維護模式,預計再兩三個版本內會移除,且不會再新增功能。建議所有新專案使用新版API,現有專案請儘早規劃遷移。 243 | 244 | ## 使用範例 245 | 246 | ### JavaScript/TypeScript 247 | 248 | ```javascript 249 | // 搜尋圖片 250 | const searchImages = async (query) => { 251 | const response = await fetch(`/api/v1/images/search?q=${encodeURIComponent(query)}&fuzzy=true`); 252 | const result = await response.json(); 253 | return result; 254 | }; 255 | 256 | // 獲取分頁圖片 257 | const getImages = async (page = 1, limit = 20, order = 'id') => { 258 | const response = await fetch(`/api/v1/images?page=${page}&limit=${limit}&order=${order}`); 259 | const result = await response.json(); 260 | return result; 261 | }; 262 | 263 | // 更新人氣統計 264 | const updatePopularity = async (imageId, action) => { 265 | const response = await fetch('/api/v1/images/popularity', { 266 | method: 'POST', 267 | headers: { 268 | 'Content-Type': 'application/json', 269 | }, 270 | body: JSON.stringify({ 271 | imageId, 272 | action 273 | }) 274 | }); 275 | const result = await response.json(); 276 | return result; 277 | }; 278 | ``` 279 | 280 | ### cURL 281 | 282 | ```bash 283 | # 健康檢查 284 | curl -X GET "/api/v1/health" 285 | 286 | # 搜尋圖片 287 | curl -X GET "/api/v1/images/search?q=MyGO&fuzzy=true&page=1&limit=10" 288 | 289 | # 獲取隨機圖片 290 | curl -X GET "/api/v1/images/random?count=5" 291 | ``` 292 | 293 | ## 技術實現 294 | 295 | - **應用框架**: Nuxt3 + H3 296 | - **Database**: MongoDB (可選,支援本地文件fallback) 297 | - **搜尋引擎**: 自幹,用編輯距離進行模糊匹配,未來仍有拓展算法空間 298 | - **圖片儲存**: 寫死靜態路徑,可透過修改映射(就第二點的DB)進行路徑的修改 299 | - **快取策略**: 開機快取 + 文件快取 300 | 301 | ## 效能考量 302 | 303 | - 所有API都有適當的快取機制 304 | - 分頁查詢優化,避免大量資料傳輸 305 | - 搜尋結果按相關性排序 306 | - 支援HTTP Keep-Alive連接複用 307 | -------------------------------------------------------------------------------- /composables/useImages.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' 2 | import { ImagesApi, searchImages } from '~/apis/images' 3 | import type { ImageItem, SearchParams, FilterOptions, UseImagesOptions } from '~/types' 4 | 5 | /** 6 | * Composable for managing image search and filtering 7 | */ 8 | export function useImages(options: UseImagesOptions = {}) { 9 | const { 10 | initialQuery = '', 11 | pageSize = 20, 12 | fuzzySearch = false, 13 | sortOrder = 'id' 14 | } = options 15 | 16 | // State 17 | const images = ref([]) 18 | const loading = ref(false) 19 | const error = ref(null) 20 | const hasMore = ref(false) 21 | const currentPage = ref(1) 22 | const totalCount = ref(0) 23 | 24 | // Search state 25 | const searchQuery = ref(initialQuery) 26 | const isFuzzyEnabled = ref(fuzzySearch) 27 | const currentSortOrder = ref(sortOrder) 28 | 29 | // Infinite scroll state 30 | const loadMoreTrigger = ref() 31 | let observer: IntersectionObserver | null = null 32 | 33 | /** 34 | * Fetch images based on query 35 | */ 36 | const fetchImages = async (query = '', page = 1, append = false) => { 37 | if (loading.value) return 38 | 39 | loading.value = true 40 | error.value = null 41 | 42 | try { 43 | const params: SearchParams = { 44 | q: query, 45 | fuzzy: isFuzzyEnabled.value, 46 | page, 47 | limit: pageSize, 48 | order: currentSortOrder.value 49 | } 50 | 51 | const response = query.trim() 52 | ? await ImagesApi.search(params) 53 | : await ImagesApi.getAll({ page, limit: pageSize, order: currentSortOrder.value }) 54 | 55 | if (append) { 56 | images.value = [...images.value, ...response.data] 57 | } else { 58 | images.value = response.data 59 | } 60 | 61 | if (response.meta && 'total' in response.meta) { 62 | totalCount.value = response.meta.total 63 | hasMore.value = response.meta.hasNext 64 | currentPage.value = page 65 | } 66 | } catch (err) { 67 | error.value = err instanceof Error ? err.message : 'Failed to fetch images' 68 | console.error('Error fetching images:', err) 69 | } finally { 70 | loading.value = false 71 | } 72 | } 73 | 74 | /** 75 | * Search images with new query 76 | */ 77 | const search = async (query: string) => { 78 | searchQuery.value = query 79 | currentPage.value = 1 80 | await fetchImages(query, 1, false) 81 | } 82 | 83 | /** 84 | * Load more images (pagination) 85 | */ 86 | const loadMore = async () => { 87 | if (hasMore.value && !loading.value) { 88 | await fetchImages(searchQuery.value, currentPage.value + 1, true) 89 | } 90 | } 91 | 92 | /** 93 | * Toggle fuzzy search 94 | */ 95 | const toggleFuzzy = async () => { 96 | isFuzzyEnabled.value = !isFuzzyEnabled.value 97 | await fetchImages(searchQuery.value, 1, false) 98 | } 99 | 100 | /** 101 | * Set sort order 102 | */ 103 | const setSortOrder = async (order: string) => { 104 | currentSortOrder.value = order 105 | currentPage.value = 1 106 | await fetchImages(searchQuery.value, 1, false) 107 | } 108 | 109 | /** 110 | * Get random images 111 | */ 112 | const getRandomImages = async (count = 1) => { 113 | loading.value = true 114 | error.value = null 115 | 116 | try { 117 | const response = await ImagesApi.getRandom({ count }) 118 | images.value = response.data 119 | totalCount.value = response.data.length 120 | hasMore.value = false 121 | currentPage.value = 1 122 | searchQuery.value = '' 123 | } catch (err) { 124 | error.value = err instanceof Error ? err.message : 'Failed to fetch random images' 125 | console.error('Error fetching random images:', err) 126 | } finally { 127 | loading.value = false 128 | } 129 | } 130 | 131 | /** 132 | * Reset to initial state 133 | */ 134 | const reset = () => { 135 | images.value = [] 136 | searchQuery.value = '' 137 | currentPage.value = 1 138 | totalCount.value = 0 139 | hasMore.value = false 140 | error.value = null 141 | 142 | // 清理觀察器 143 | if (observer) { 144 | observer.disconnect() 145 | observer = null 146 | } 147 | } 148 | 149 | /** 150 | * Setup infinite scroll observer 151 | */ 152 | const setupInfiniteScroll = () => { 153 | if (!loadMoreTrigger.value) return 154 | 155 | // 先清理舊的觀察器 156 | if (observer) { 157 | observer.disconnect() 158 | observer = null 159 | } 160 | 161 | observer = new IntersectionObserver( 162 | (entries) => { 163 | const entry = entries[0] 164 | if (entry.isIntersecting && hasMore.value && !loading.value) { 165 | console.log('觸發自動載入更多') 166 | loadMore() 167 | } 168 | }, 169 | { 170 | rootMargin: '50px', // 提前 50px 觸發載入 171 | threshold: 0.1 172 | } 173 | ) 174 | 175 | observer.observe(loadMoreTrigger.value) 176 | console.log('無限滾動觀察器已設置') 177 | } 178 | 179 | /** 180 | * Initialize infinite scroll when trigger element is ready 181 | */ 182 | const initInfiniteScroll = async (triggerElement: HTMLElement) => { 183 | loadMoreTrigger.value = triggerElement 184 | await nextTick() 185 | setupInfiniteScroll() 186 | } 187 | 188 | /** 189 | * Cleanup observer 190 | */ 191 | const cleanupInfiniteScroll = () => { 192 | if (observer) { 193 | observer.disconnect() 194 | observer = null 195 | } 196 | } 197 | 198 | // Auto-search when query changes 199 | watch(searchQuery, (newQuery) => { 200 | fetchImages(newQuery, 1, false) 201 | }) 202 | 203 | return { 204 | // State 205 | images, 206 | loading: readonly(loading), 207 | error: readonly(error), 208 | hasMore: readonly(hasMore), 209 | currentPage: readonly(currentPage), 210 | totalCount: readonly(totalCount), 211 | searchQuery, 212 | isFuzzyEnabled: readonly(isFuzzyEnabled), 213 | 214 | // Actions 215 | search, 216 | fetchImages, 217 | loadMore, 218 | toggleFuzzy, 219 | setSortOrder, 220 | getRandomImages, 221 | reset, 222 | 223 | // Infinite scroll 224 | initInfiniteScroll, 225 | cleanupInfiniteScroll 226 | } 227 | } 228 | 229 | /** 230 | * Composable for filtering images 231 | */ 232 | export function useImageFilter(images: Ref, filters: Ref) { 233 | const filteredImages = computed(() => { 234 | if (!filters.value.MyGO集數?.length && 235 | !filters.value.AveMujica集數?.length && 236 | !filters.value.人物?.length) { 237 | return images.value 238 | } 239 | 240 | return images.value.filter((image) => { 241 | const matchesMyGOEpisode = !filters.value.MyGO集數?.length || 242 | filters.value.MyGO集數.includes(image.episode || '') 243 | 244 | const matchesAveMujicaEpisode = !filters.value.AveMujica集數?.length || 245 | filters.value.AveMujica集數.includes(image.episode || '') 246 | 247 | const matchesCharacter = !filters.value.人物?.length || 248 | filters.value.人物.includes(image.author || '') 249 | 250 | return matchesMyGOEpisode && matchesAveMujicaEpisode && matchesCharacter 251 | }) 252 | }) 253 | 254 | const filteredCount = computed(() => filteredImages.value.length) 255 | 256 | return { 257 | filteredImages, 258 | filteredCount 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /public/mygo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 31 | 86 | 90 | 94 | 98 | 100 | 108 | 112 | 114 | 117 | 120 | 122 | 124 | 126 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /public/data/custom_keymap.json: -------------------------------------------------------------------------------- 1 | { 2 | "春日影": { 3 | "value": [ 4 | "我不會再演奏春日影了", 5 | "因為春日影是一首好歌", 6 | "為什麼要演奏春日影" 7 | ] 8 | }, 9 | "ハルヒカゲ": { 10 | "value": [ 11 | "我不會再演奏春日影了", 12 | "因為春日影是一首好歌", 13 | "為什麼要演奏春日影" 14 | ] 15 | }, 16 | "はるひかげ": { 17 | "value": [ 18 | "我不會再演奏春日影了", 19 | "因為春日影是一首好歌", 20 | "為什麼要演奏春日影" 21 | ] 22 | }, 23 | "haruhikage": { 24 | "value": [ 25 | "我不會再演奏春日影了", 26 | "因為春日影是一首好歌", 27 | "為什麼要演奏春日影" 28 | ] 29 | }, 30 | "愛慕虛榮": { 31 | "value": [ 32 | "我愛慕虛榮啦" 33 | ] 34 | }, 35 | "找我吵架":{ 36 | "value": [ 37 | "妳是來找我吵架的嗎" 38 | ] 39 | }, 40 | "是這樣嗎": { 41 | "value": [ 42 | "是這樣嗎", 43 | "是這樣嗎" 44 | ] 45 | }, 46 | "又怎樣": { 47 | "value": [ 48 | "是又怎樣" 49 | ] 50 | }, 51 | "是嗎": { 52 | "value": [ 53 | "是嗎" 54 | ] 55 | }, 56 | "是啊": { 57 | "value": [ 58 | "是啊", 59 | "是這樣沒錯", 60 | "這樣啊" 61 | ] 62 | }, 63 | "無趣": { 64 | "value": [ 65 | "全是一些無趣的女人", 66 | "無趣的女孩子" 67 | ] 68 | }, 69 | "有趣": { 70 | "value": [ 71 | "有趣的女人" 72 | ] 73 | }, 74 | "沒有結束": { 75 | "value": [ 76 | "它沒有結束" 77 | ] 78 | }, 79 | "徹底失敗": { 80 | "value": [ 81 | "我已經徹底失敗了" 82 | ] 83 | }, 84 | "可喜可賀": { 85 | "value": [ 86 | "那真是可喜可賀" 87 | ] 88 | }, 89 | "搞砸": { 90 | "value": [ 91 | "事情都搞砸了" 92 | ] 93 | }, 94 | "先入為主": { 95 | "value": [ 96 | "太先入為主了喔" 97 | ] 98 | }, 99 | "洗澡": { 100 | "value": [ 101 | "妳要不要先去洗澡" 102 | ] 103 | }, 104 | "沒有時間玩": { 105 | "value": [ 106 | "現在沒時間玩了吧" 107 | ] 108 | }, 109 | "祝你幸福": { 110 | "value": [ 111 | "真的毫無品味" 112 | ] 113 | }, 114 | "認真的嗎": { 115 | "value": [ 116 | "你這話是認真的嗎" 117 | ] 118 | }, 119 | "虛情假意": { 120 | "value": [ 121 | "真是會虛情假意呢" 122 | ] 123 | }, 124 | "高高在上": { 125 | "value": [ 126 | "還真是高高在上呢" 127 | ] 128 | }, 129 | "開玩笑嗎": { 130 | "value": [ 131 | "你在開玩笑嗎" 132 | ] 133 | }, 134 | "忍不住": { 135 | "value": [ 136 | "對不起,忍不住就" 137 | ] 138 | }, 139 | "騙人": { 140 | "value": [ 141 | "那當然是騙人的啊" 142 | ] 143 | }, 144 | "逃避": { 145 | "value": [ 146 | "我看妳只是在逃避吧" 147 | ] 148 | }, 149 | "早知道": { 150 | "value": [ 151 | "我早知道會這樣了" 152 | ] 153 | }, 154 | "早就知道": { 155 | "value": [ 156 | "我早知道會這樣了" 157 | ] 158 | }, 159 | "不需要我": { 160 | "value": [ 161 | "你不是不需要我了嗎" 162 | ] 163 | }, 164 | "不懂": { 165 | "value": [ 166 | "坦白說我都聽得一頭霧水", 167 | "我完全不懂妳的意思", 168 | "我已經搞不懂了" 169 | ] 170 | }, 171 | "講什麼": { 172 | "value": [ 173 | "這是在講什麼" 174 | ] 175 | }, 176 | "只想到自己": { 177 | "value": [ 178 | "滿腦子都只想到自己呢" 179 | ] 180 | }, 181 | "就是這個": { 182 | "value": [ 183 | "就是這個" 184 | ] 185 | }, 186 | "毫無品味": { 187 | "value": [ 188 | "真的毫無品味", 189 | "大家真沒品味" 190 | ] 191 | }, 192 | "不講話": { 193 | "value": [ 194 | "為什麼都不講話" 195 | ] 196 | }, 197 | "運氣真好": { 198 | "value": [ 199 | "運氣真好" 200 | ] 201 | }, 202 | "謝謝你": { 203 | "value": [ 204 | "謝謝你", 205 | "謝謝妳" 206 | ] 207 | }, 208 | "不要": { 209 | "value": [ 210 | "不要" 211 | ] 212 | }, 213 | "抱歉": { 214 | "value": [ 215 | "抱歉" 216 | ] 217 | }, 218 | "誤會": { 219 | "value": [ 220 | "你誤會了" 221 | ] 222 | }, 223 | "一輩子": { 224 | "value": [ 225 | "一輩子跟我一起組樂團嗎", 226 | "我願意一輩子和燈在一起", 227 | "是一輩子喔 一輩子", 228 | "讓我們一起迷失吧" 229 | ] 230 | }, 231 | "無法回頭": { 232 | "value": [ 233 | "一旦加入就無法回頭了喔" 234 | ] 235 | }, 236 | "不是這樣": { 237 | "value": [ 238 | "不是這樣", 239 | "不是這樣的" 240 | ] 241 | }, 242 | "不行": { 243 | "value": [ 244 | "不行" 245 | ] 246 | }, 247 | "不要講這種話": { 248 | "value": [ 249 | "不要講這種話" 250 | ] 251 | }, 252 | "沒圖說個雞巴": { 253 | "value": [ 254 | "不讓我們看看怎麼知道" 255 | ] 256 | }, 257 | "不錯": { 258 | "value": [ 259 | "不錯吧" 260 | ] 261 | }, 262 | "交給我": { 263 | "value": [ 264 | "交給我吧" 265 | ] 266 | }, 267 | "什麼意思": { 268 | "value": [ 269 | "什麼意思" 270 | ] 271 | }, 272 | "別這樣啦": { 273 | "value": [ 274 | "爽世:別這樣啦", 275 | "喵夢:別這樣啦" 276 | ] 277 | }, 278 | "執著於過去": { 279 | "value": [ 280 | "到現在還執著於過去,真難看" 281 | ] 282 | }, 283 | "新人": { 284 | "value": [ 285 | "又來了一個新人" 286 | ] 287 | }, 288 | "什麼都願意": { 289 | "value": [ 290 | "只要是我能做的,我什麼都願意做" 291 | ] 292 | }, 293 | "別再提起那件事": { 294 | "value": [ 295 | "可以請妳別提起那件事嗎" 296 | ] 297 | }, 298 | "刪掉": { 299 | "value": [ 300 | "可以請妳刪除剛才的影片嗎" 301 | ] 302 | }, 303 | "很想要": { 304 | "value": [ 305 | "因為我很想要嘛" 306 | ] 307 | }, 308 | "一頭霧水": { 309 | "value": [ 310 | "坦白說我都聽得一頭霧水" 311 | ] 312 | }, 313 | "太過分了": { 314 | "value": [ 315 | "太過分了" 316 | ] 317 | }, 318 | "好厲害": { 319 | "value": [ 320 | "好厲害", 321 | "好厲害...", 322 | "好厲害喔" 323 | ] 324 | }, 325 | "沒有那麼厲害": { 326 | "value": [ 327 | "我沒有那麼厲害啦" 328 | ] 329 | }, 330 | "好溫柔": { 331 | "value": [ 332 | "她真的好溫柔喔" 333 | ] 334 | }, 335 | "好可愛": { 336 | "value": [ 337 | "好可愛喔" 338 | ] 339 | }, 340 | "hardcore": { 341 | "value": [ 342 | "好硬派" 343 | ] 344 | }, 345 | "說謊": { 346 | "value": [ 347 | "妳不僅表裡不一,又滿口謊言" 348 | ] 349 | }, 350 | "謊言": { 351 | "value": [ 352 | "妳不僅表裡不一,又滿口謊言" 353 | ] 354 | }, 355 | "想幹嘛": { 356 | "value": [ 357 | "妳到底想幹嘛", 358 | "妳想幹嘛" 359 | ] 360 | }, 361 | "到底是怎樣": { 362 | "value": [ 363 | "妳到底是怎樣啊" 364 | ] 365 | }, 366 | "想說什麼": { 367 | "value": [ 368 | "妳想說什麼" 369 | ] 370 | }, 371 | "很煩": { 372 | "value": [ 373 | "妳很煩欸" 374 | ] 375 | }, 376 | "怎麼會這麼想": { 377 | "value": [ 378 | "妳怎麼會這麼想" 379 | ] 380 | }, 381 | "覺悟": { 382 | "value": [ 383 | "妳是抱著多大的覺悟說出這種話的" 384 | ] 385 | }, 386 | "看訊息": { 387 | "value": [ 388 | "妳有好好看訊息嗎" 389 | ] 390 | }, 391 | "不看訊息啊": { 392 | "value": [ 393 | "妳再不看訊息啊" 394 | ] 395 | }, 396 | "將它結束": { 397 | "value": [ 398 | "就由我來將它結束掉" 399 | ] 400 | }, 401 | "結束掉": { 402 | "value": [ 403 | "就由我來將它結束掉" 404 | ] 405 | }, 406 | "結束了": { 407 | "value": [ 408 | "就由我來將它結束掉" 409 | ] 410 | }, 411 | "差勁": { 412 | "value": [ 413 | "差勁" 414 | ] 415 | }, 416 | "死了": { 417 | "value": [ 418 | "已經死了" 419 | ] 420 | }, 421 | "死透了": { 422 | "value": [ 423 | "已經死了" 424 | ] 425 | }, 426 | "怎麼了": { 427 | "value": [ 428 | "怎麼了嗎" 429 | ] 430 | }, 431 | "不敢相信": { 432 | "value": [ 433 | "怎麼會直不敢相信", 434 | "真不敢相信" 435 | ] 436 | }, 437 | "怎麼這麼問": { 438 | "value": [ 439 | "怎麼這麼問" 440 | ] 441 | }, 442 | "好喜歡": { 443 | "value": [ 444 | "愛音愛心" 445 | ] 446 | }, 447 | "態度好差": { 448 | "value": [ 449 | "態度好差喔" 450 | ] 451 | }, 452 | "不參加": { 453 | "value": [ 454 | "我不參加" 455 | ] 456 | }, 457 | "我不知道": { 458 | "value": [ 459 | "燈:我不知道", 460 | "愛音:我不知道" 461 | ] 462 | }, 463 | "我也": { 464 | "value": [ 465 | "我也一樣" 466 | ] 467 | }, 468 | "我也一樣": { 469 | "value": [ 470 | "我也一樣" 471 | ] 472 | }, 473 | "亂說的": { 474 | "value": [ 475 | "我亂說的" 476 | ] 477 | }, 478 | "哪有辦法": { 479 | "value": [ 480 | "我哪有可能有辦法" 481 | ] 482 | }, 483 | "哪有興奮": { 484 | "value": [ 485 | "我哪裡有興奮了" 486 | ] 487 | }, 488 | "好難受": { 489 | "value": [ 490 | "我好難受...好難受..." 491 | ] 492 | }, 493 | "怕受傷": { 494 | "value": [ 495 | "我害怕受到傷害" 496 | ] 497 | }, 498 | "應該不是": { 499 | "value": [ 500 | "我想應該不是" 501 | ] 502 | }, 503 | "我懂": { 504 | "value": [ 505 | "我懂" 506 | ] 507 | }, 508 | "我有啊": { 509 | "value": [ 510 | "我有啊" 511 | ] 512 | }, 513 | "沒說過": { 514 | "value": [ 515 | "我沒說過那種話" 516 | ] 517 | }, 518 | "聽我說": { 519 | "value": [ 520 | "我現在要講很感性的話,聽我說嘛" 521 | ] 522 | }, 523 | "我的人生已經失敗了吧": { 524 | "value": [ 525 | "我覺得我的人生已經失敗了吧" 526 | ] 527 | }, 528 | "我考慮一下":{ 529 | "value": [ 530 | "我考慮一下吧" 531 | ] 532 | }, 533 | "我要": { 534 | "value": [ 535 | "我要" 536 | ] 537 | }, 538 | "我還是會繼續": { 539 | "value": [ 540 | "我還是會繼續下去" 541 | ] 542 | }, 543 | "全力以赴": { 544 | "value": [ 545 | "我都會全力以赴的" 546 | ] 547 | }, 548 | "我說討厭": { 549 | "value": [ 550 | "我都說了討厭了吧" 551 | ] 552 | }, 553 | "找不到": { 554 | "value": [ 555 | "找不到耶" 556 | ] 557 | }, 558 | "受不了大人": { 559 | "value": [ 560 | "所以我才受不了大人" 561 | ] 562 | }, 563 | "掰掰": { 564 | "value": [ 565 | "掰掰" 566 | ] 567 | }, 568 | "再見": { 569 | "value": [ 570 | "掰掰" 571 | ] 572 | }, 573 | "為什麼呢": { 574 | "value": [ 575 | "是啊,到底為什麼呢" 576 | ] 577 | }, 578 | "你先的": { 579 | "value": [ 580 | "是妳先來找我麻煩的吧" 581 | ] 582 | }, 583 | "是沒錯啊": { 584 | "value": [ 585 | "是沒錯啊" 586 | ] 587 | }, 588 | "不太能想像": { 589 | "value": [ 590 | "有點不太能想像呢" 591 | ] 592 | }, 593 | "沒錯": { 594 | "value": [ 595 | "沒錯沒錯", 596 | "真是太對了" 597 | ] 598 | }, 599 | "為什麼不行": { 600 | "value": [ 601 | "為什麼不行" 602 | ] 603 | }, 604 | "不回答我": { 605 | "value": [ 606 | "為什麼都不回答我啊" 607 | ] 608 | }, 609 | "太好了": { 610 | "value": [ 611 | "真是太好了" 612 | ] 613 | }, 614 | "真是遺憾": { 615 | "value": [ 616 | "真是遺憾" 617 | ] 618 | }, 619 | "真的嗎": { 620 | "value": [ 621 | "真的嗎" 622 | ] 623 | }, 624 | "莫名其妙": { 625 | "value": [ 626 | "真的很莫名其妙" 627 | ] 628 | }, 629 | "真不容易": { 630 | "value": [ 631 | "真不容易" 632 | ] 633 | }, 634 | "聽起來好棒": { 635 | "value": [ 636 | "聽起來好棒喔" 637 | ] 638 | }, 639 | "考慮看看": { 640 | "value": [ 641 | "能否請妳積極考慮我的提案呢" 642 | ] 643 | }, 644 | "這": { 645 | "value": [ 646 | "這..." 647 | ] 648 | }, 649 | "不用了": { 650 | "value": [ 651 | "這個不用了" 652 | ] 653 | }, 654 | "夢嗎": { 655 | "value": [ 656 | "這是夢嗎" 657 | ] 658 | }, 659 | "沒教養": { 660 | "value": [ 661 | "這樣很沒有教養喔" 662 | ] 663 | }, 664 | "這不重要": { 665 | "value": [ 666 | "這種事不重要吧", 667 | "那些不是重點吧" 668 | ] 669 | }, 670 | "這算什麼": { 671 | "value": [ 672 | "這算什麼" 673 | ] 674 | }, 675 | "不負責任": { 676 | "value": [ 677 | "還有這樣太不負責了吧" 678 | ] 679 | }, 680 | "那我呢": { 681 | "value": [ 682 | "那我呢" 683 | ] 684 | }, 685 | "go":{ 686 | "value": [ 687 | "我們是MyGO", 688 | "It's my go耶, it's my go" 689 | ] 690 | }, 691 | "好痛苦": { 692 | "value": [ 693 | "我好難受...好難受..." 694 | ] 695 | } 696 | } --------------------------------------------------------------------------------