- 新增content.config.ts文件,定义FAQ集合的结构和验证规则。 - 在nuxt.config.ts中引入@nuxt/content模块,配置内容管理功能。 - 新增多个FAQ文档,涵盖产品、购买和支持相关问题,提供多语言支持。 - 更新faq.vue页面,优化FAQ列表展示和搜索功能,提升用户体验。 - 删除旧的FAQ页面和图片本地化脚本,简化项目结构。master
# API图片本地化功能 | |||||
此功能用于将API返回的远程图片链接下载到本地并替换成本地路径,方便生成的静态站点完全脱离原始服务器。 | |||||
## 功能概述 | |||||
1. 在Nuxt静态站点生成完成后,处理所有API响应JSON文件 | |||||
2. 自动识别数据中的图片URL字段并下载图片到本地 | |||||
3. 替换JSON数据中的远程URL为本地路径 | |||||
4. 确保静态生成的网站能完全离线运行,所有图片资源都在本地 | |||||
## 使用方法 | |||||
### 基本使用 | |||||
直接使用以下命令生成静态站点并本地化图片: | |||||
```bash | |||||
npm run generate:localize | |||||
``` | |||||
这个命令会: | |||||
1. 运行标准的`nuxt generate`生成静态站点 | |||||
2. 运行图片本地化脚本处理API数据中的图片URL | |||||
### 只处理图片 | |||||
如果你已经生成了静态站点,只需要处理图片: | |||||
```bash | |||||
npm run localize-images | |||||
``` | |||||
## 自定义配置 | |||||
### 支持的图片字段 | |||||
默认情况下,系统会查找以下字段名称中的图片URL: | |||||
- image | |||||
- imageUrl | |||||
- thumbnail | |||||
- cover | |||||
- avatar | |||||
- photo | |||||
- src | |||||
如需自定义,可以在`scripts/localize-images.mjs`中修改`localizeImages`函数的默认参数。 | |||||
### 图片保存位置 | |||||
默认情况下,下载的图片会保存到`public/images/remote`目录,网站访问路径为`/images/remote/[图片文件名]`。 | |||||
## 工作原理 | |||||
1. Nuxt先正常执行静态生成过程,将API数据缓存到`.output/server/api`目录下 | |||||
2. 脚本`scripts/localize-images.mjs`在生成完成后执行,扫描并处理缓存的JSON文件 | |||||
3. 识别并下载所有图片到`public/images/remote`目录 | |||||
4. 更新JSON响应文件,将远程图片URL替换为本地路径 | |||||
## 注意事项 | |||||
1. 此方法不需要修改源代码,完全在构建后处理 | |||||
2. 下载的图片使用URL的MD5哈希作为文件名,确保相同URL的图片只下载一次 | |||||
3. 如果图片下载失败,会保留原始URL,不会破坏网站功能 | |||||
4. 图片本地化过程会在控制台输出详细日志 | |||||
## 常见问题 | |||||
### 生成后图片仍然是远程链接 | |||||
检查以下几点: | |||||
1. 确认使用了`npm run generate:localize`而不是`npm run generate` | |||||
2. 检查控制台输出中是否有错误信息 | |||||
3. 检查`public/images/remote`目录是否已创建并包含图片 | |||||
### 图片下载失败 | |||||
可能的原因: | |||||
1. 网络问题,无法访问原始图片URL | |||||
2. 原始URL返回错误,如404或403 | |||||
3. 目标目录没有写入权限 | |||||
解决方法: | |||||
1. 检查网络连接和图片URL是否可访问 | |||||
2. 确保目标目录有写入权限 | |||||
3. 手动修复失败的图片URL |
import { defineCollection, z } from '@nuxt/content' | |||||
// 定义 FAQ 集合 | |||||
const faqCollection = defineCollection({ | |||||
type: 'page', | |||||
schema: z.object({ | |||||
title: z.string(), | |||||
description: z.string().optional(), | |||||
id: z.number(), | |||||
category: z.string(), | |||||
question: z.string(), | |||||
answer: z.string() | |||||
}) | |||||
}) | |||||
export default { | |||||
faq: faqCollection | |||||
} |
--- | |||||
id: 1 | |||||
category: Products | |||||
question: How long is the warranty period for your products? | |||||
answer: Our products come with a 1-year warranty from the date of purchase. If any issues occur within the warranty period, we'll repair or replace the product free of charge. | |||||
--- |
--- | |||||
id: 2 | |||||
category: Products | |||||
question: Where can I find the product manual? | |||||
answer: Product manuals can be downloaded from our website on the product page. They are also included in the product packaging. | |||||
--- |
--- | |||||
id: 3 | |||||
category: Purchase | |||||
question: What payment methods do you accept? | |||||
answer: We accept various payment methods including credit cards, bank transfers, and convenience store payments. Please check our payment page for details. | |||||
--- |
--- | |||||
id: 4 | |||||
category: Purchase | |||||
question: Can I return or exchange products? | |||||
answer: Unused and unopened products can be returned or exchanged within 7 days of receipt. Please refer to our return and exchange policy for details. | |||||
--- |
--- | |||||
id: 5 | |||||
category: Support | |||||
question: How can I access technical support? | |||||
answer: We offer technical support through email, phone, and online chat. During business hours, we provide immediate assistance. | |||||
--- |
--- | |||||
id: 6 | |||||
category: Support | |||||
question: How do I request repairs? | |||||
answer: Please submit a repair request through the repair request form on our website. You can check the repair status on your account page. | |||||
--- |
--- | |||||
id: 1 | |||||
category: 製品について | |||||
question: 製品の保証期間はどのくらいですか? | |||||
answer: 当社の製品は購入日から1年間の保証期間があります。保証期間内に製品に問題が発生した場合は、無償で修理または交換いたします。 | |||||
--- |
--- | |||||
id: 2 | |||||
category: 製品について | |||||
question: 製品の取扱説明書はどこで入手できますか? | |||||
answer: 製品の取扱説明書は、当社ウェブサイトの製品ページからダウンロードできます。また、製品パッケージにも同梱されています。 | |||||
--- |
--- | |||||
id: 3 | |||||
category: 購入について | |||||
question: 支払い方法は何がありますか? | |||||
answer: クレジットカード、銀行振込、コンビニ決済など、様々な支払い方法をご利用いただけます。詳細はお支払いページをご確認ください。 | |||||
--- |
--- | |||||
id: 4 | |||||
category: 購入について | |||||
question: 返品・交換は可能ですか? | |||||
answer: 商品到着後7日以内であれば、未使用・未開封の商品に限り返品・交換が可能です。詳細は返品・交換ポリシーをご確認ください。 | |||||
--- |
--- | |||||
id: 5 | |||||
category: サポートについて | |||||
question: 技術サポートはどのように受けられますか? | |||||
answer: メール、電話、オンラインチャットなど、様々な方法で技術サポートをご利用いただけます。営業時間内であれば、即時対応いたします。 | |||||
--- |
--- | |||||
id: 6 | |||||
category: サポートについて | |||||
question: 修理依頼はどのように行えばよいですか? | |||||
answer: 修理依頼は、当社ウェブサイトの修理依頼フォームからお申し込みください。修理状況はマイページから確認できます。 | |||||
--- |
--- | |||||
id: 1 | |||||
category: 产品相关 | |||||
question: 产品的保修期是多长时间? | |||||
answer: 我们的产品自购买之日起提供1年保修期。如果产品在保修期内出现问题,我们将免费修理或更换。 | |||||
--- |
--- | |||||
id: 2 | |||||
category: 产品相关 | |||||
question: 在哪里可以获取产品的使用说明书? | |||||
answer: 产品说明书可以从我们的网站产品页面下载。此外,产品包装中也附带有纸质说明书。 | |||||
--- |
--- | |||||
id: 3 | |||||
category: 购买相关 | |||||
question: 有哪些支付方式可供选择? | |||||
answer: 我们接受信用卡、银行转账、便利店支付等多种支付方式。详细信息请查看支付页面。 | |||||
--- |
--- | |||||
id: 4 | |||||
category: 购买相关 | |||||
question: 可以退货或更换商品吗? | |||||
answer: 在收到商品后7天内,未使用和未拆封的商品可以退货或更换。详情请参阅我们的退换货政策。 | |||||
--- |
--- | |||||
id: 5 | |||||
category: 技术支持 | |||||
question: 如何获取技术支持? | |||||
answer: 您可以通过电子邮件、电话或在线聊天等多种方式获取技术支持。在工作时间内,我们将提供即时响应。 | |||||
--- | |||||
您可以通过电子邮件、电话或在线聊天等多种方式获取技术支持。在工作时间内,我们将提供即时响应。 |
--- | |||||
id: 6 | |||||
category: 技术支持 | |||||
question: 如何申请维修服务? | |||||
answer: 请通过我们网站上的维修申请表格提交维修请求。您可以在个人账户页面查看维修进度。 | |||||
--- |
// https://nuxt.com/docs/api/configuration/nuxt-config | // https://nuxt.com/docs/api/configuration/nuxt-config | ||||
export default defineNuxtConfig({ | export default defineNuxtConfig({ | ||||
compatibilityDate: "2024-11-01", | |||||
compatibilityDate: "2025-05-06", | |||||
devtools: { enabled: true }, | devtools: { enabled: true }, | ||||
// 添加CSS | // 添加CSS | ||||
], | ], | ||||
// 模块 | // 模块 | ||||
modules: ["@nuxtjs/i18n"], | |||||
modules: ["@nuxtjs/i18n", "@nuxt/content"], | |||||
// content模块配置 | |||||
content: { | |||||
// 基础配置 | |||||
}, | |||||
// i18n 配置 (从外部文件加载) | // i18n 配置 (从外部文件加载) | ||||
i18n: { | i18n: { | ||||
prerender: { | prerender: { | ||||
crawlLinks: true, | crawlLinks: true, | ||||
routes: ["/"], | routes: ["/"], | ||||
ignore: [ | |||||
'/api/**', | |||||
'/admin/**' | |||||
], | |||||
failOnError: false | |||||
}, | }, | ||||
// 添加图片本地化配置 | // 添加图片本地化配置 | ||||
publicAssets: [ | publicAssets: [ | ||||
{ | { | ||||
dir: 'public', | dir: 'public', | ||||
baseURL: '/' | baseURL: '/' | ||||
}, | |||||
{ | |||||
dir: 'public/images/remote', | |||||
baseURL: '/images/remote' | |||||
} | } | ||||
] | ] | ||||
}, | }, |
"build": "nuxt build", | "build": "nuxt build", | ||||
"dev": "nuxt dev", | "dev": "nuxt dev", | ||||
"generate": "nuxt generate", | "generate": "nuxt generate", | ||||
"generate:localize": "nuxt generate && node scripts/localize-images.mjs", | |||||
"localize-images": "node scripts/localize-images.mjs", | |||||
"preview": "nuxt preview", | "preview": "nuxt preview", | ||||
"postinstall": "nuxt prepare" | "postinstall": "nuxt prepare" | ||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"@nuxt/content": "^3.5.1", | |||||
"@nuxtjs/i18n": "^9.5.3", | "@nuxtjs/i18n": "^9.5.3", | ||||
"@vueuse/core": "^13.1.0", | "@vueuse/core": "^13.1.0", | ||||
"nuxt": "^3.16.2", | "nuxt": "^3.16.2", |
<nuxt-link | <nuxt-link | ||||
to="/" | to="/" | ||||
class="justify-start text-white/60 text-base font-normal" | class="justify-start text-white/60 text-base font-normal" | ||||
>{{ $t("common.home") }}</nuxt-link | |||||
>{{ t("common.home") }}</nuxt-link | |||||
> | > | ||||
<span class="text-white/60 text-base font-normal px-2"> / </span> | <span class="text-white/60 text-base font-normal px-2"> / </span> | ||||
<nuxt-link to="/faq" class="text-white text-base font-normal" | |||||
>{{ $t("faq.title") }}</nuxt-link | |||||
> | |||||
<nuxt-link to="/faq" class="text-white text-base font-normal">{{ | |||||
t("faq.title") | |||||
}}</nuxt-link> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
<div | <div | ||||
<div class="col-span-1 md:col-span-2"> | <div class="col-span-1 md:col-span-2"> | ||||
<div class="flex flex-col gap-4"> | <div class="flex flex-col gap-4"> | ||||
<div class="text-white text-3xl font-medium"> | <div class="text-white text-3xl font-medium"> | ||||
{{ $t("faq.category") }} | |||||
{{ t("faq.category") }} | |||||
</div> | </div> | ||||
<div class="flex flex-col gap-4 w-fit"> | <div class="flex flex-col gap-4 w-fit"> | ||||
<div | <div | ||||
v-for="category in categories" | |||||
v-for="category in categoriesList" | |||||
:key="category" | :key="category" | ||||
@click="handleCategoryFilter(category)" | @click="handleCategoryFilter(category)" | ||||
class="opacity-80 justify-start text-white text-base font-normal cursor-pointer hover:opacity-100 transition-all duration-300 px-4 py-2 rounded-lg inline-block" | class="opacity-80 justify-start text-white text-base font-normal cursor-pointer hover:opacity-100 transition-all duration-300 px-4 py-2 rounded-lg inline-block" | ||||
v-model="searchTerm" | v-model="searchTerm" | ||||
ref="searchInputRef" | ref="searchInputRef" | ||||
type="search" | type="search" | ||||
:placeholder="$t('faq.searchPlaceholder')" | |||||
:placeholder="t('faq.searchPlaceholder')" | |||||
class="block w-full appearance-none rounded-lg border border-gray-600 bg-zinc-800/70 px-4 py-3 text-base text-gray-100 placeholder-gray-400 shadow-inner transition duration-200 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:ring-offset-2 focus:ring-offset-zinc-900" | class="block w-full appearance-none rounded-lg border border-gray-600 bg-zinc-800/70 px-4 py-3 text-base text-gray-100 placeholder-gray-400 shadow-inner transition duration-200 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:ring-offset-2 focus:ring-offset-zinc-900" | ||||
/> | /> | ||||
<button | <button | ||||
v-if="searchTerm" | v-if="searchTerm" | ||||
@click="clearSearch" | @click="clearSearch" | ||||
class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full bg-zinc-700/80 hover:bg-blue-500/90 text-gray-300 hover:text-white transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" | class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full bg-zinc-700/80 hover:bg-blue-500/90 text-gray-300 hover:text-white transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" | ||||
:aria-label="$t('faq.clearSearch')" | |||||
:aria-label="t('faq.clearSearch')" | |||||
tabindex="0" | tabindex="0" | ||||
type="button" | type="button" | ||||
> | > | ||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> | |||||
<svg | |||||
xmlns="http://www.w3.org/2000/svg" | |||||
class="h-5 w-5" | |||||
fill="none" | |||||
viewBox="0 0 24 24" | |||||
stroke="currentColor" | |||||
stroke-width="2" | |||||
> | |||||
<path | |||||
stroke-linecap="round" | |||||
stroke-linejoin="round" | |||||
d="M6 18L18 6M6 6l12 12" | |||||
/> | |||||
</svg> | </svg> | ||||
</button> | </button> | ||||
</div> | </div> | ||||
<div class="flex flex-col gap-8"> | <div class="flex flex-col gap-8"> | ||||
<div | <div | ||||
v-for="faq in filteredFaqs" | v-for="faq in filteredFaqs" | ||||
:key="faq.id" | |||||
:key="generateFaqKey(faq)" | |||||
class="bg-zinc-900 rounded-lg p-8 transition-all duration-300 hover:bg-zinc-800 hover:shadow-lg" | class="bg-zinc-900 rounded-lg p-8 transition-all duration-300 hover:bg-zinc-800 hover:shadow-lg" | ||||
> | > | ||||
<div | <div | ||||
class="flex items-center justify-between cursor-pointer" | class="flex items-center justify-between cursor-pointer" | ||||
@click="toggleFaq(faq.id)" | |||||
@click="toggleFaq(faq)" | |||||
> | > | ||||
<div class="text-white text-xl font-medium"> | <div class="text-white text-xl font-medium"> | ||||
<template | <template | ||||
</div> | </div> | ||||
<div | <div | ||||
class="text-white text-2xl transition-transform duration-300" | class="text-white text-2xl transition-transform duration-300" | ||||
:class="{ 'rotate-180': expandedFaqs.includes(faq.id) }" | |||||
:class="{ 'rotate-180': isFaqExpanded(faq) }" | |||||
> | > | ||||
▼ | ▼ | ||||
</div> | </div> | ||||
</div> | </div> | ||||
<div | <div | ||||
v-if="expandedFaqs.includes(faq.id)" | |||||
v-if="isFaqExpanded(faq)" | |||||
class="mt-4 text-white/80 text-base font-normal leading-relaxed" | class="mt-4 text-white/80 text-base font-normal leading-relaxed" | ||||
> | > | ||||
<template | <template | ||||
v-if="filteredFaqs.length === 0" | v-if="filteredFaqs.length === 0" | ||||
class="text-center text-gray-400 py-8" | class="text-center text-gray-400 py-8" | ||||
> | > | ||||
{{ $t("faq.noResults") }} | |||||
{{ t("faq.noResults") }} | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
* 展示常见问题及其答案 | * 展示常见问题及其答案 | ||||
*/ | */ | ||||
import { useErrorHandler } from "~/composables/useErrorHandler"; | import { useErrorHandler } from "~/composables/useErrorHandler"; | ||||
import { queryCollection } from '#imports' | |||||
const { error, isLoading, wrapAsync } = useErrorHandler(); | const { error, isLoading, wrapAsync } = useErrorHandler(); | ||||
const { t, locale } = useI18n(); | const { t, locale } = useI18n(); | ||||
// FAQ数据 | // FAQ数据 | ||||
category: string; | category: string; | ||||
question: string; | question: string; | ||||
answer: string; | answer: string; | ||||
title: string; | |||||
description: string; | |||||
} | } | ||||
const faqs = ref<FAQ[]>([ | |||||
{ | |||||
id: 1, | |||||
category: "製品について", | |||||
question: "製品の保証期間はどのくらいですか?", | |||||
answer: | |||||
"当社の製品は購入日から1年間の保証期間があります。保証期間内に製品に問題が発生した場合は、無償で修理または交換いたします。", | |||||
}, | |||||
{ | |||||
id: 2, | |||||
category: "製品について", | |||||
question: "製品の取扱説明書はどこで入手できますか?", | |||||
answer: | |||||
"製品の取扱説明書は、当社ウェブサイトの製品ページからダウンロードできます。また、製品パッケージにも同梱されています。", | |||||
}, | |||||
{ | |||||
id: 3, | |||||
category: "購入について", | |||||
question: "支払い方法は何がありますか?", | |||||
answer: | |||||
"クレジットカード、銀行振込、コンビニ決済など、様々な支払い方法をご利用いただけます。詳細はお支払いページをご確認ください。", | |||||
}, | |||||
{ | |||||
id: 4, | |||||
category: "購入について", | |||||
question: "返品・交換は可能ですか?", | |||||
answer: | |||||
"商品到着後7日以内であれば、未使用・未開封の商品に限り返品・交換が可能です。詳細は返品・交換ポリシーをご確認ください。", | |||||
}, | |||||
{ | |||||
id: 5, | |||||
category: "サポートについて", | |||||
question: "技術サポートはどのように受けられますか?", | |||||
answer: | |||||
"メール、電話、オンラインチャットなど、様々な方法で技術サポートをご利用いただけます。営業時間内であれば、即時対応いたします。", | |||||
}, | |||||
{ | |||||
id: 6, | |||||
category: "サポートについて", | |||||
question: "修理依頼はどのように行えばよいですか?", | |||||
answer: | |||||
"修理依頼は、当社ウェブサイトの修理依頼フォームからお申し込みください。修理状況はマイページから確認できます。", | |||||
}, | |||||
]); | |||||
// 分类列表 | |||||
const categories = ref([ | |||||
"すべて", | |||||
"製品について", | |||||
"購入について", | |||||
"サポートについて", | |||||
]); | |||||
// 从content目录读取FAQ数据 | |||||
const faqs = ref<FAQ[]>([]); | |||||
const categoriesList = ref<string[]>([]); | |||||
// 选中的分类 | // 选中的分类 | ||||
const selectedCategory = ref("すべて"); | |||||
const selectedCategory = ref(""); | |||||
// 使用 queryCollection 加载FAQ数据 | |||||
const { data: faqData } = await useAsyncData('faqs', async () => { | |||||
console.log('Loading FAQ data for locale:', locale.value); | |||||
try { | |||||
// 使用 queryCollection 加载 FAQ 数据 | |||||
const content = await queryCollection('content').all(); | |||||
console.log('Raw content:', content); | |||||
// 在代码中过滤内容 | |||||
const filteredContent = content.filter((item: any) => { | |||||
// 检查路径是否包含当前语言 | |||||
return item.path && item.path.includes(`/faq/${locale.value}/`); | |||||
}); | |||||
// 转换数据格式 | |||||
const faqs = filteredContent.map((item: any) => ({ | |||||
id: parseInt(item.id?.split('-')[1] || '0'), | |||||
category: item.meta?.category || '', | |||||
question: item.meta?.question || item.title || '', | |||||
answer: item.meta?.answer || item.body?.value || '', | |||||
title: item.title || '', | |||||
description: item.description || '' | |||||
})); | |||||
return faqs; | |||||
} catch (error) { | |||||
console.error('Error loading FAQ content:', error); | |||||
return []; | |||||
} | |||||
}); | |||||
console.log('FAQ data:', faqData.value); | |||||
// 处理FAQ数据变化 | |||||
watchEffect(() => { | |||||
if (faqData.value) { | |||||
isLoading.value = true; | |||||
try { | |||||
console.log('Processing FAQ data:', faqData.value); | |||||
// 设置分类列表和默认选中的分类 | |||||
const allOption: string = | |||||
locale.value === "en" ? "All" : locale.value === "zh" ? "全部" : "すべて"; | |||||
// 展开的FAQ ID列表 | |||||
const expandedFaqs = ref<number[]>([]); | |||||
// 从FAQ数据中提取所有不同的分类 | |||||
const uniqueCategories = [ | |||||
...new Set(faqData.value.map((faq: FAQ) => faq.category)), | |||||
].sort(); // 对分类进行排序 | |||||
// 设置分类列表和默认选中的分类 | |||||
categoriesList.value = [allOption, ...uniqueCategories]; | |||||
selectedCategory.value = categoriesList.value[0]; | |||||
} catch (err) { | |||||
console.error("Error processing FAQ data:", err); | |||||
error.value = new Error(t('faq.processError')); | |||||
} finally { | |||||
isLoading.value = false; | |||||
} | |||||
} | |||||
}); | |||||
// 展开的FAQ标识 | |||||
const expandedFaqKey = ref<string | null>(null); | |||||
// 搜索关键词 | // 搜索关键词 | ||||
const searchTerm = ref(""); | const searchTerm = ref(""); | ||||
// 过滤后的FAQ列表 | // 过滤后的FAQ列表 | ||||
const filteredFaqs = computed(() => { | const filteredFaqs = computed(() => { | ||||
let result = faqs.value; | |||||
if (selectedCategory.value !== "すべて") { | |||||
if (!faqData.value) return []; | |||||
let result = faqData.value; | |||||
if (selectedCategory.value !== categoriesList.value[0]) { | |||||
result = result.filter( | result = result.filter( | ||||
(faq: FAQ) => faq.category === selectedCategory.value | (faq: FAQ) => faq.category === selectedCategory.value | ||||
); | ); | ||||
return result; | return result; | ||||
} | } | ||||
/** | |||||
* 生成FAQ的唯一标识 | |||||
* @param faq FAQ对象 | |||||
* @returns string 唯一标识 | |||||
*/ | |||||
function generateFaqKey(faq: FAQ): string { | |||||
return `${faq.category}-${faq.id}`; | |||||
} | |||||
/** | /** | ||||
* 切换FAQ展开状态 | * 切换FAQ展开状态 | ||||
* @param faq FAQ对象 | |||||
* @returns void | |||||
*/ | */ | ||||
function toggleFaq(id: number) { | |||||
const index = expandedFaqs.value.indexOf(id); | |||||
if (index === -1) { | |||||
expandedFaqs.value.push(id); | |||||
function toggleFaq(faq: FAQ): void { | |||||
if (!faq) return; | |||||
const faqKey = generateFaqKey(faq); | |||||
// 如果点击的是当前展开的FAQ,则关闭它 | |||||
if (expandedFaqKey.value === faqKey) { | |||||
expandedFaqKey.value = null; | |||||
} else { | } else { | ||||
expandedFaqs.value.splice(index, 1); | |||||
// 否则展开新的FAQ | |||||
expandedFaqKey.value = faqKey; | |||||
} | } | ||||
} | } | ||||
/** | |||||
* 检查FAQ是否处于展开状态 | |||||
* @param faq FAQ对象 | |||||
* @returns boolean | |||||
*/ | |||||
function isFaqExpanded(faq: FAQ): boolean { | |||||
if (!faq) return false; | |||||
return expandedFaqKey.value === generateFaqKey(faq); | |||||
} | |||||
/** | /** | ||||
* 处理分类筛选 | * 处理分类筛选 | ||||
*/ | */ | ||||
} | } | ||||
// 自动展开匹配项 | // 自动展开匹配项 | ||||
watch([ | |||||
filteredFaqs, | |||||
searchTerm | |||||
], ([faqs, keyword]: [FAQ[], string]) => { | |||||
watch([filteredFaqs, searchTerm], ([faqs, keyword]: [FAQ[], string]) => { | |||||
if (!faqs?.length) return; | |||||
if (keyword.trim()) { | if (keyword.trim()) { | ||||
expandedFaqs.value = faqs.map((faq: FAQ) => faq.id); | |||||
// 搜索时展开第一个匹配项 | |||||
expandedFaqKey.value = faqs[0] ? generateFaqKey(faqs[0]) : null; | |||||
} else { | } else { | ||||
expandedFaqs.value = []; | |||||
// 清除搜索时关闭所有展开项 | |||||
expandedFaqKey.value = null; | |||||
} | } | ||||
}); | |||||
}, { deep: true }); | |||||
function clearSearch() { | function clearSearch() { | ||||
searchTerm.value = ""; | searchTerm.value = ""; |
<template> | |||||
<div class="py-8"> | |||||
<div class="container-custom"> | |||||
<div class="mb-4"> | |||||
<NuxtLink to="/faq" class="text-blue-600 hover:underline flex items-center"> | |||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> | |||||
</svg> | |||||
{{ $t('faq.title') }} | |||||
</NuxtLink> | |||||
</div> | |||||
<ErrorBoundary :error="error"> | |||||
<div v-if="isLoading" class="flex justify-center py-12"> | |||||
<!-- 加载中 --> | |||||
<div class="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"></div> | |||||
</div> | |||||
<div v-else-if="faq" class="bg-white border border-gray-200 rounded-lg overflow-hidden"> | |||||
<div class="p-8"> | |||||
<div class="mb-2 flex items-center text-sm text-blue-600"> | |||||
<span class="px-3 py-1 bg-blue-100 rounded-full">{{ faq.category }}</span> | |||||
</div> | |||||
<h1 class="text-3xl font-bold mb-6">{{ faq.question }}</h1> | |||||
<div class="prose max-w-none text-gray-700"> | |||||
<p>{{ faq.answer }}</p> | |||||
</div> | |||||
<div class="mt-12 pt-6 border-t border-gray-200"> | |||||
<h2 class="text-xl font-semibold mb-4">相关问题</h2> | |||||
<ul class="space-y-3"> | |||||
<li v-for="relatedFaq in relatedFaqs" :key="relatedFaq.id"> | |||||
<NuxtLink | |||||
:to="`/faq/${relatedFaq.id}`" | |||||
class="text-blue-600 hover:text-blue-800 hover:underline" | |||||
> | |||||
{{ relatedFaq.question }} | |||||
</NuxtLink> | |||||
</li> | |||||
</ul> | |||||
</div> | |||||
<div class="mt-12 pt-6 border-t border-gray-200"> | |||||
<h2 class="text-xl font-semibold mb-4">没有找到您需要的答案?</h2> | |||||
<p class="text-gray-600 mb-4"> | |||||
如果您有其他问题或需要更详细的信息,请随时联系我们的客服团队。 | |||||
</p> | |||||
<NuxtLink to="/contact" class="btn btn-primary"> | |||||
联系我们 | |||||
</NuxtLink> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
<div v-else class="bg-yellow-50 border border-yellow-200 text-yellow-800 p-4 rounded-md"> | |||||
未找到该问题,可能已被删除或移动。 | |||||
</div> | |||||
</ErrorBoundary> | |||||
</div> | |||||
</div> | |||||
</template> | |||||
<script setup lang="ts"> | |||||
/** | |||||
* FAQ详情页面 | |||||
* 展示单个FAQ详细内容和相关问题 | |||||
*/ | |||||
import { ref, computed, onMounted } from 'vue'; | |||||
import { useErrorHandler } from '~/composables/useErrorHandler'; | |||||
// FAQ接口定义 | |||||
interface Faq { | |||||
id: number; | |||||
question: string; | |||||
answer: string; | |||||
category: string; | |||||
} | |||||
const route = useRoute(); | |||||
const { error, isLoading, wrapAsync } = useErrorHandler(); | |||||
const faq = ref<Faq | null>(null); | |||||
const allFaqs = ref<Faq[]>([]); | |||||
// 获取FAQ ID | |||||
const faqId = computed(() => { | |||||
const id = route.params.id; | |||||
return typeof id === 'string' ? parseInt(id, 10) : -1; | |||||
}); | |||||
// 相关问题 | |||||
const relatedFaqs = computed(() => { | |||||
if (!faq.value) return []; | |||||
// 查找同类别的其他问题,最多返回3个 | |||||
return allFaqs.value | |||||
.filter(item => item.id !== faq.value?.id && item.category === faq.value?.category) | |||||
.slice(0, 3); | |||||
}); | |||||
/** | |||||
* 加载FAQ详情数据 | |||||
*/ | |||||
async function loadFaqDetail() { | |||||
if (faqId.value <= 0) { | |||||
error.value = new Error('无效的FAQ ID'); | |||||
return; | |||||
} | |||||
await wrapAsync(async () => { | |||||
// 模拟API请求延迟 | |||||
await new Promise(resolve => setTimeout(resolve, 500)); | |||||
// 模拟数据,实际项目中应从API获取 | |||||
const mockFaqs = [ | |||||
{ | |||||
id: 1, | |||||
question: '如何使用产品?', | |||||
answer: '我们的产品设计简单直观,开箱即用。首先,打开包装并检查所有配件是否齐全。然后,按照说明书的步骤进行安装。如有任何问题,可以观看我们网站上的视频教程或联系客服。\n\n大多数产品只需简单的几个步骤即可完成安装和初始设置。我们还提供在线指南和视频教程,帮助您更好地理解产品功能和使用方法。如果您在使用过程中遇到任何困难,我们的技术支持团队随时为您提供帮助。', | |||||
category: '使用指南' | |||||
}, | |||||
{ | |||||
id: 2, | |||||
question: '产品有哪些保修政策?', | |||||
answer: '我们为所有产品提供1年的标准保修服务,覆盖制造缺陷和材料问题。部分高端产品可享受最长3年的延长保修服务。保修期内,我们提供免费维修或更换服务。请注意,人为损坏、不当使用或自行拆卸不在保修范围内。\n\n要享受保修服务,您需要提供有效的购买凭证和产品序列号。我们建议您在购买后立即注册产品,以便在需要时更方便地享受保修服务。', | |||||
category: '售后服务' | |||||
}, | |||||
{ | |||||
id: 3, | |||||
question: '如何进行退换货?', | |||||
answer: '购买后30天内,如产品未使用且包装完好,可申请无理由退换货。请保留原始包装和购买凭证,联系我们的客服团队安排退换事宜。退款将在收到退回产品并确认状态后的7个工作日内处理。\n\n对于有质量问题的产品,我们接受30天内的退换货申请。请提供产品问题的详细描述和照片证明,我们的客服团队会协助您完成退换货流程。', | |||||
category: '售后服务' | |||||
}, | |||||
{ | |||||
id: 4, | |||||
question: '产品支持哪些操作系统?', | |||||
answer: '我们的软件产品兼容Windows 10/11、macOS 10.15及以上版本、iOS 14及以上版本和Android 10及以上版本。硬件产品与大多数现代设备兼容,具体请查看产品详情页的技术规格部分。\n\n我们定期更新软件以确保与最新操作系统兼容。如果您使用的是较旧版本的操作系统,可能会遇到兼容性问题。我们建议您将操作系统更新到最新版本以获得最佳体验。', | |||||
category: '技术支持' | |||||
}, | |||||
{ | |||||
id: 5, | |||||
question: '如何联系客服?', | |||||
answer: '您可以通过多种方式联系我们的客服团队:拨打服务热线400-123-4567(工作日9:00-18:00);发送邮件至support@example.com(24小时内回复);在官网使用在线客服(工作日9:00-20:00);或填写联系表单,我们会尽快与您联系。\n\n对于紧急问题,我们建议您优先使用电话或在线客服渠道,以获得更快的响应。非工作时间提交的问题将在下一个工作日处理。', | |||||
category: '联系方式' | |||||
}, | |||||
{ | |||||
id: 6, | |||||
question: '是否提供国际配送服务?', | |||||
answer: '是的,我们提供国际配送服务,覆盖大部分国家和地区。国际订单的配送时间通常为7-15个工作日,具体取决于目的地和当地海关情况。国际订单可能产生额外的关税和进口费用,这些费用需由收件人承担。\n\n对于大批量的国际订单,我们提供特别的物流解决方案,请联系我们的销售团队了解详情。在特殊情况下(如假期、天气恶劣或当地政策限制),配送时间可能会延长。', | |||||
category: '配送信息' | |||||
}, | |||||
{ | |||||
id: 7, | |||||
question: '如何跟踪我的订单?', | |||||
answer: '下单后,您将收到一封包含订单确认和跟踪信息的电子邮件。您可以使用提供的跟踪号在我们的网站上或通过物流公司的网站查询订单状态。您也可以登录您的账户查看订单历史和当前状态。如果您有任何疑问,请联系我们的客服团队。', | |||||
category: '配送信息' | |||||
}, | |||||
{ | |||||
id: 8, | |||||
question: '产品可以升级吗?', | |||||
answer: '是的,我们的大部分产品支持软件升级,我们会定期发布更新以提供新功能和修复已知问题。对于硬件产品,部分型号支持组件升级,请参考产品说明书或联系技术支持了解详情。我们建议您定期检查更新,以保持产品处于最佳状态。', | |||||
category: '技术支持' | |||||
}, | |||||
{ | |||||
id: 9, | |||||
question: '如何获取使用指南?', | |||||
answer: '所有产品的电子版使用指南可在我们的官网下载中心获取。您也可以在产品包装内找到纸质版使用说明书。对于特定产品的详细教程和技巧,请访问我们的知识库或YouTube频道。如果您需要其他语言版本的使用指南,请联系客服。', | |||||
category: '使用指南' | |||||
} | |||||
]; | |||||
// 保存所有FAQ以便于获取相关问题 | |||||
allFaqs.value = mockFaqs; | |||||
const foundFaq = mockFaqs.find(item => item.id === faqId.value); | |||||
if (foundFaq) { | |||||
faq.value = foundFaq; | |||||
} else { | |||||
error.value = new Error('未找到该问题'); | |||||
} | |||||
return faq.value; | |||||
}); | |||||
} | |||||
// 页面加载时获取FAQ详情 | |||||
onMounted(() => { | |||||
loadFaqDetail(); | |||||
}); | |||||
// SEO优化 | |||||
useHead({ | |||||
title: computed(() => faq.value ? `${faq.value.question} - FAQ - Hanye` : 'FAQ详情 - Hanye'), | |||||
meta: [ | |||||
{ | |||||
name: 'description', | |||||
content: computed(() => faq.value?.answer?.substring(0, 150) + '...' || '查看详细的FAQ回答和相关问题。') | |||||
} | |||||
] | |||||
}); | |||||
</script> | |||||
<style> | |||||
.prose p { | |||||
margin-top: 1.25em; | |||||
margin-bottom: 1.25em; | |||||
} | |||||
</style> |
<template> | |||||
<div class="py-8"> | |||||
<div class="container-custom"> | |||||
<h1 class="text-3xl font-bold mb-8">{{ $t('faq.title') }}</h1> | |||||
<div class="mb-8"> | |||||
<div class="relative"> | |||||
<input | |||||
type="text" | |||||
v-model="searchQuery" | |||||
:placeholder="$t('faq.searchPlaceholder')" | |||||
class="w-full px-4 py-3 border border-gray-300 rounded-md pl-10 focus:outline-none focus:ring-2 focus:ring-blue-500" | |||||
/> | |||||
<div class="absolute left-3 top-3 text-gray-400"> | |||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> | |||||
</svg> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
<ErrorBoundary :error="error"> | |||||
<div v-if="isLoading" class="flex justify-center py-12"> | |||||
<!-- 加载中 --> | |||||
<div class="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"></div> | |||||
</div> | |||||
<div v-else> | |||||
<div v-if="filteredFaqs.length === 0" class="bg-yellow-50 border border-yellow-200 text-yellow-800 p-4 rounded-md"> | |||||
没有找到匹配的问题,请尝试其他关键词。 | |||||
</div> | |||||
<div v-else class="space-y-6"> | |||||
<div v-for="faq in filteredFaqs" :key="faq.id" class="bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow"> | |||||
<div class="p-6"> | |||||
<NuxtLink :to="`/faq/${faq.id}`" class="block"> | |||||
<h2 class="text-xl font-semibold mb-2 text-blue-700 hover:text-blue-800">{{ faq.question }}</h2> | |||||
<p class="text-gray-600 line-clamp-2">{{ faq.answer }}</p> | |||||
<div class="mt-4 flex items-center text-blue-600"> | |||||
<span class="text-sm font-medium">阅读全文</span> | |||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> | |||||
</svg> | |||||
</div> | |||||
</NuxtLink> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
</ErrorBoundary> | |||||
</div> | |||||
</div> | |||||
</template> | |||||
<script setup lang="ts"> | |||||
/** | |||||
* FAQ列表页面 | |||||
* 展示所有常见问题并支持搜索 | |||||
*/ | |||||
import { ref, computed, onMounted } from 'vue'; | |||||
import { useErrorHandler } from '~/composables/useErrorHandler'; | |||||
// FAQ接口定义 | |||||
interface Faq { | |||||
id: number; | |||||
question: string; | |||||
answer: string; | |||||
category: string; | |||||
} | |||||
const { error, isLoading, wrapAsync } = useErrorHandler(); | |||||
const faqs = ref<Faq[]>([]); | |||||
const searchQuery = ref(''); | |||||
/** | |||||
* 根据搜索条件过滤FAQ | |||||
*/ | |||||
const filteredFaqs = computed(() => { | |||||
if (!searchQuery.value.trim()) return faqs.value; | |||||
const query = searchQuery.value.toLowerCase(); | |||||
return faqs.value.filter(faq => | |||||
faq.question.toLowerCase().includes(query) || | |||||
faq.answer.toLowerCase().includes(query) | |||||
); | |||||
}); | |||||
/** | |||||
* 加载FAQ数据 | |||||
*/ | |||||
async function loadFaqs() { | |||||
await wrapAsync(async () => { | |||||
// 模拟API请求延迟 | |||||
await new Promise(resolve => setTimeout(resolve, 500)); | |||||
// 模拟数据,实际项目中应从API获取 | |||||
faqs.value = [ | |||||
{ | |||||
id: 1, | |||||
question: '如何使用产品?', | |||||
answer: '我们的产品设计简单直观,开箱即用。首先,打开包装并检查所有配件是否齐全。然后,按照说明书的步骤进行安装。如有任何问题,可以观看我们网站上的视频教程或联系客服。', | |||||
category: '使用指南' | |||||
}, | |||||
{ | |||||
id: 2, | |||||
question: '产品有哪些保修政策?', | |||||
answer: '我们为所有产品提供1年的标准保修服务,覆盖制造缺陷和材料问题。部分高端产品可享受最长3年的延长保修服务。保修期内,我们提供免费维修或更换服务。请注意,人为损坏、不当使用或自行拆卸不在保修范围内。', | |||||
category: '售后服务' | |||||
}, | |||||
{ | |||||
id: 3, | |||||
question: '如何进行退换货?', | |||||
answer: '购买后30天内,如产品未使用且包装完好,可申请无理由退换货。请保留原始包装和购买凭证,联系我们的客服团队安排退换事宜。退款将在收到退回产品并确认状态后的7个工作日内处理。', | |||||
category: '售后服务' | |||||
}, | |||||
{ | |||||
id: 4, | |||||
question: '产品支持哪些操作系统?', | |||||
answer: '我们的软件产品兼容Windows 10/11、macOS 10.15及以上版本、iOS 14及以上版本和Android 10及以上版本。硬件产品与大多数现代设备兼容,具体请查看产品详情页的技术规格部分。', | |||||
category: '技术支持' | |||||
}, | |||||
{ | |||||
id: 5, | |||||
question: '如何联系客服?', | |||||
answer: '您可以通过多种方式联系我们的客服团队:拨打服务热线400-123-4567(工作日9:00-18:00);发送邮件至support@example.com(24小时内回复);在官网使用在线客服(工作日9:00-20:00);或填写联系表单,我们会尽快与您联系。', | |||||
category: '联系方式' | |||||
}, | |||||
{ | |||||
id: 6, | |||||
question: '是否提供国际配送服务?', | |||||
answer: '是的,我们提供国际配送服务,覆盖大部分国家和地区。国际订单的配送时间通常为7-15个工作日,具体取决于目的地和当地海关情况。国际订单可能产生额外的关税和进口费用,这些费用需由收件人承担。', | |||||
category: '配送信息' | |||||
} | |||||
]; | |||||
return faqs.value; | |||||
}); | |||||
} | |||||
// 页面加载时获取FAQ数据 | |||||
onMounted(() => { | |||||
loadFaqs(); | |||||
}); | |||||
// SEO优化 | |||||
useHead({ | |||||
title: '常见问题 - Hanye', | |||||
meta: [ | |||||
{ name: 'description', content: '浏览我们的常见问题解答,获取产品使用指南、技术支持和售后服务等相关信息。' } | |||||
] | |||||
}); | |||||
</script> | |||||
<style scoped> | |||||
.line-clamp-2 { | |||||
display: -webkit-box; | |||||
-webkit-line-clamp: 2; | |||||
-webkit-box-orient: vertical; | |||||
overflow: hidden; | |||||
} | |||||
</style> |
#!/usr/bin/env node | |||||
/** | |||||
* 图片本地化处理脚本 | |||||
* 在静态站点生成后运行,将API返回的图片下载到本地 | |||||
*/ | |||||
import fs from 'fs' | |||||
import path from 'path' | |||||
import { createHash } from 'crypto' | |||||
import { fileURLToPath } from 'url' | |||||
// 获取当前文件的目录 | |||||
const __filename = fileURLToPath(import.meta.url) | |||||
const __dirname = path.dirname(__filename) | |||||
const PROJECT_ROOT = path.resolve(__dirname, '..') | |||||
/** | |||||
* 下载并本地化图片 | |||||
* @param imageUrl 原始图片URL | |||||
* @param basePath 保存图片的基础路径 | |||||
* @returns 本地化后的图片路径 | |||||
*/ | |||||
async function localizeImage(imageUrl, basePath = 'public/images/remote') { | |||||
// 检查URL是否有效 | |||||
if (!imageUrl || !imageUrl.startsWith('http')) { | |||||
return imageUrl | |||||
} | |||||
try { | |||||
// 创建保存目录 | |||||
const fullBasePath = path.resolve(PROJECT_ROOT, basePath) | |||||
if (!fs.existsSync(fullBasePath)) { | |||||
fs.mkdirSync(fullBasePath, { recursive: true }) | |||||
} | |||||
// 生成唯一文件名(使用URL的MD5哈希) | |||||
const urlHash = createHash('md5').update(imageUrl).digest('hex') | |||||
const fileExt = path.extname(new URL(imageUrl).pathname) || '.jpg' | |||||
const fileName = `${urlHash}${fileExt}` | |||||
const filePath = path.join(fullBasePath, fileName) | |||||
// 如果文件已存在,直接返回路径 | |||||
if (fs.existsSync(filePath)) { | |||||
return `/images/remote/${fileName}` | |||||
} | |||||
// 下载图片 | |||||
const response = await fetch(imageUrl) | |||||
if (!response.ok) { | |||||
throw new Error(`下载图片失败: ${response.status} ${response.statusText}`) | |||||
} | |||||
const buffer = Buffer.from(await response.arrayBuffer()) | |||||
fs.writeFileSync(filePath, buffer) | |||||
console.log(`✅ 下载图片成功: ${imageUrl} -> /images/remote/${fileName}`) | |||||
// 返回本地URL(相对于public目录) | |||||
return `/images/remote/${fileName}` | |||||
} catch (error) { | |||||
console.error(`❌ 本地化图片失败 ${imageUrl}:`, error) | |||||
return imageUrl // 失败时返回原始URL | |||||
} | |||||
} | |||||
/** | |||||
* 递归处理对象中的所有图片URL | |||||
* @param data 需要处理的数据对象或数组 | |||||
* @param imageFields 指定哪些字段包含图片URL | |||||
* @returns 处理后的数据 | |||||
*/ | |||||
async function localizeImages( | |||||
data, | |||||
imageFields = ['image', 'imageUrl', 'thumbnail', 'cover', 'avatar', 'photo', 'src'] | |||||
) { | |||||
if (!data) return data | |||||
// 处理数组 | |||||
if (Array.isArray(data)) { | |||||
return Promise.all(data.map(item => localizeImages(item, imageFields))) | |||||
} | |||||
// 处理对象 | |||||
if (typeof data === 'object') { | |||||
const result = { ...data } | |||||
// 处理所有键 | |||||
for (const [key, value] of Object.entries(result)) { | |||||
// 如果是图片字段且值是字符串,本地化图片 | |||||
if (imageFields.includes(key) && typeof value === 'string') { | |||||
result[key] = await localizeImage(value) | |||||
} | |||||
// 递归处理嵌套对象或数组 | |||||
else if (typeof value === 'object' && value !== null) { | |||||
result[key] = await localizeImages(value, imageFields) | |||||
} | |||||
} | |||||
return result | |||||
} | |||||
return data | |||||
} | |||||
/** | |||||
* 从JSON文件提取图片URL并下载到本地 | |||||
* @param jsonFilePath JSON文件路径 | |||||
* @param imageFields 包含图片URL的字段名称数组 | |||||
*/ | |||||
async function extractAndDownloadImages( | |||||
jsonFilePath, | |||||
imageFields = ['image', 'imageUrl', 'thumbnail', 'cover', 'avatar', 'photo', 'src'] | |||||
) { | |||||
// 检查文件是否存在 | |||||
if (!fs.existsSync(jsonFilePath)) { | |||||
console.error(`文件不存在: ${jsonFilePath}`) | |||||
return | |||||
} | |||||
try { | |||||
// 读取JSON文件 | |||||
const jsonData = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8')) | |||||
// 递归查找所有图片URL | |||||
const imageUrls = new Set() | |||||
// 递归函数查找所有图片URL | |||||
function findImageUrls(obj) { | |||||
if (!obj) return | |||||
if (Array.isArray(obj)) { | |||||
obj.forEach(item => findImageUrls(item)) | |||||
return | |||||
} | |||||
if (typeof obj === 'object') { | |||||
for (const [key, value] of Object.entries(obj)) { | |||||
if (imageFields.includes(key) && typeof value === 'string' && value.startsWith('http')) { | |||||
imageUrls.add(value) | |||||
} else if (typeof value === 'object' && value !== null) { | |||||
findImageUrls(value) | |||||
} | |||||
} | |||||
} | |||||
} | |||||
findImageUrls(jsonData) | |||||
// 下载所有图片 | |||||
console.log(`📋 在 ${jsonFilePath} 中找到 ${imageUrls.size} 个图片URL`) | |||||
let downloadedCount = 0 | |||||
for (const url of imageUrls) { | |||||
try { | |||||
const localUrl = await localizeImage(url) | |||||
if (localUrl !== url) { | |||||
downloadedCount++ | |||||
} | |||||
} catch (error) { | |||||
console.error(`❌ 下载失败 ${url}:`, error) | |||||
} | |||||
} | |||||
console.log(`📊 成功下载 ${downloadedCount}/${imageUrls.size} 个图片`) | |||||
// 将本地化的图片URL写回到JSON文件 | |||||
try { | |||||
const localizedData = await localizeImages(jsonData) | |||||
fs.writeFileSync(jsonFilePath, JSON.stringify(localizedData, null, 2)) | |||||
console.log(`💾 已更新图片URL至本地路径: ${jsonFilePath}`) | |||||
} catch (error) { | |||||
console.error(`❌ 无法更新JSON文件 ${jsonFilePath}:`, error) | |||||
} | |||||
} catch (error) { | |||||
console.error(`❌ 处理文件 ${jsonFilePath} 时出错:`, error) | |||||
} | |||||
} | |||||
/** | |||||
* 处理API响应文件中的图片 | |||||
* @param outputDir 输出目录路径(通常是.output目录) | |||||
*/ | |||||
async function processApiResponseImages(outputDir = '.output') { | |||||
console.log('🚀 开始处理API响应文件中的图片...') | |||||
// 检查输出目录是否存在 | |||||
if (!fs.existsSync(outputDir)) { | |||||
console.error(`❌ 输出目录不存在: ${outputDir}`) | |||||
return | |||||
} | |||||
const serverDir = path.join(outputDir, 'server/api') | |||||
if (!fs.existsSync(serverDir)) { | |||||
console.error(`❌ 服务器API目录不存在: ${serverDir}`) | |||||
return | |||||
} | |||||
console.log(`🔍 扫描API响应文件: ${serverDir}`) | |||||
// 递归函数查找所有JSON文件 | |||||
async function processDirectory(dir) { | |||||
const entries = fs.readdirSync(dir, { withFileTypes: true }) | |||||
for (const entry of entries) { | |||||
const fullPath = path.join(dir, entry.name) | |||||
if (entry.isDirectory()) { | |||||
await processDirectory(fullPath) | |||||
} else if (entry.name.endsWith('.json')) { | |||||
console.log(`📄 处理JSON文件: ${fullPath}`) | |||||
await extractAndDownloadImages(fullPath) | |||||
} | |||||
} | |||||
} | |||||
await processDirectory(serverDir) | |||||
console.log('✅ 所有API响应文件处理完成') | |||||
} | |||||
// 确保脚本能单独运行 | |||||
async function main() { | |||||
console.log('===== 🖼️ 开始图片本地化处理 =====') | |||||
try { | |||||
// 默认的输出目录是.output | |||||
const outputDir = process.argv[2] || '.output' | |||||
await processApiResponseImages(outputDir) | |||||
console.log('===== ✅ 图片本地化处理完成 =====') | |||||
process.exit(0) | |||||
} catch (error) { | |||||
console.error('❌ 图片本地化处理失败:', error) | |||||
process.exit(1) | |||||
} | |||||
} | |||||
main() |
{ | { | ||||
// https://nuxt.com/docs/guide/concepts/typescript | // https://nuxt.com/docs/guide/concepts/typescript | ||||
"extends": "./.nuxt/tsconfig.json" | |||||
"extends": "./.nuxt/tsconfig.json", | |||||
"compilerOptions": { | |||||
"target": "ESNext", | |||||
"module": "ESNext", | |||||
"moduleResolution": "Bundler", | |||||
"strict": true, | |||||
"jsx": "preserve", | |||||
"sourceMap": true, | |||||
"resolveJsonModule": true, | |||||
"isolatedModules": true, | |||||
"esModuleInterop": true, | |||||
"lib": ["ESNext", "DOM"], | |||||
"skipLibCheck": true, | |||||
"types": ["@nuxt/types", "@nuxt/content"] | |||||
} | |||||
} | } |
import fs from 'fs' | |||||
import path from 'path' | |||||
import { localizeImage } from './image-localizer' | |||||
/** | |||||
* 从JSON文件提取图片URL并下载到本地 | |||||
* @param jsonFilePath JSON文件路径 | |||||
* @param imageFields 包含图片URL的字段名称数组 | |||||
*/ | |||||
async function extractAndDownloadImages( | |||||
jsonFilePath: string, | |||||
imageFields = ['image', 'imageUrl', 'thumbnail', 'cover', 'avatar', 'photo', 'src'] | |||||
) { | |||||
// 检查文件是否存在 | |||||
if (!fs.existsSync(jsonFilePath)) { | |||||
console.error(`文件不存在: ${jsonFilePath}`) | |||||
return | |||||
} | |||||
try { | |||||
// 读取JSON文件 | |||||
const jsonData = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8')) | |||||
// 递归查找所有图片URL | |||||
const imageUrls = new Set<string>() | |||||
// 递归函数查找所有图片URL | |||||
function findImageUrls(obj: any) { | |||||
if (!obj) return | |||||
if (Array.isArray(obj)) { | |||||
obj.forEach(item => findImageUrls(item)) | |||||
return | |||||
} | |||||
if (typeof obj === 'object') { | |||||
for (const [key, value] of Object.entries(obj)) { | |||||
if (imageFields.includes(key) && typeof value === 'string' && value.startsWith('http')) { | |||||
imageUrls.add(value) | |||||
} else if (typeof value === 'object' && value !== null) { | |||||
findImageUrls(value) | |||||
} | |||||
} | |||||
} | |||||
} | |||||
findImageUrls(jsonData) | |||||
// 下载所有图片 | |||||
console.log(`在 ${jsonFilePath} 中找到 ${imageUrls.size} 个图片URL`) | |||||
let downloadedCount = 0 | |||||
for (const url of imageUrls) { | |||||
try { | |||||
const localUrl = await localizeImage(url) | |||||
if (localUrl !== url) { | |||||
downloadedCount++ | |||||
console.log(`已下载: ${url} -> ${localUrl}`) | |||||
} | |||||
} catch (error) { | |||||
console.error(`下载失败 ${url}:`, error) | |||||
} | |||||
} | |||||
console.log(`成功下载 ${downloadedCount}/${imageUrls.size} 个图片`) | |||||
// 将本地化的图片URL写回到JSON文件 | |||||
try { | |||||
const localizedData = await localizeImages(jsonData) | |||||
fs.writeFileSync(jsonFilePath, JSON.stringify(localizedData, null, 2)) | |||||
console.log(`已更新图片URL至本地路径: ${jsonFilePath}`) | |||||
} catch (error) { | |||||
console.error(`无法更新JSON文件 ${jsonFilePath}:`, error) | |||||
} | |||||
} catch (error) { | |||||
console.error(`处理文件 ${jsonFilePath} 时出错:`, error) | |||||
} | |||||
} | |||||
/** | |||||
* 递归处理对象中的所有图片URL | |||||
* 此函数是image-localizer.ts中同名函数的复制,以避免在命令行运行时的循环依赖问题 | |||||
*/ | |||||
async function localizeImages( | |||||
data: any, | |||||
imageFields = ['image', 'imageUrl', 'thumbnail', 'cover', 'avatar', 'photo', 'src'] | |||||
): Promise<any> { | |||||
if (!data) return data | |||||
// 处理数组 | |||||
if (Array.isArray(data)) { | |||||
return Promise.all(data.map(item => localizeImages(item, imageFields))) | |||||
} | |||||
// 处理对象 | |||||
if (typeof data === 'object') { | |||||
const result = { ...data } | |||||
// 处理所有键 | |||||
for (const [key, value] of Object.entries(result)) { | |||||
// 如果是图片字段且值是字符串,本地化图片 | |||||
if (imageFields.includes(key) && typeof value === 'string') { | |||||
result[key] = await localizeImage(value) | |||||
} | |||||
// 递归处理嵌套对象或数组 | |||||
else if (typeof value === 'object' && value !== null) { | |||||
result[key] = await localizeImages(value, imageFields) | |||||
} | |||||
} | |||||
return result | |||||
} | |||||
return data | |||||
} | |||||
/** | |||||
* 处理API响应文件中的图片 | |||||
* @param outputDir 输出目录路径(通常是.output目录) | |||||
*/ | |||||
export async function processApiResponseImages(outputDir = '.output') { | |||||
console.log('开始处理API响应文件中的图片...') | |||||
// 检查输出目录是否存在 | |||||
if (!fs.existsSync(outputDir)) { | |||||
console.error(`输出目录不存在: ${outputDir}`) | |||||
return | |||||
} | |||||
const serverDir = path.join(outputDir, 'server/api') | |||||
if (!fs.existsSync(serverDir)) { | |||||
console.error(`服务器API目录不存在: ${serverDir}`) | |||||
return | |||||
} | |||||
console.log(`扫描API响应文件: ${serverDir}`) | |||||
// 递归函数查找所有JSON文件 | |||||
async function processDirectory(dir: string) { | |||||
const entries = fs.readdirSync(dir, { withFileTypes: true }) | |||||
for (const entry of entries) { | |||||
const fullPath = path.join(dir, entry.name) | |||||
if (entry.isDirectory()) { | |||||
await processDirectory(fullPath) | |||||
} else if (entry.name.endsWith('.json')) { | |||||
console.log(`处理JSON文件: ${fullPath}`) | |||||
await extractAndDownloadImages(fullPath) | |||||
} | |||||
} | |||||
} | |||||
await processDirectory(serverDir) | |||||
console.log('所有API响应文件处理完成') | |||||
} | |||||
// 允许通过命令行直接运行 | |||||
// 兼容 ESM 和 CommonJS | |||||
if (typeof require !== 'undefined' && require.main === module) { | |||||
const outputDir = process.argv[2] || '.output' | |||||
processApiResponseImages(outputDir) | |||||
.then(() => console.log('图片本地化处理完成')) | |||||
.catch(error => console.error('图片本地化处理失败:', error)) | |||||
} | |||||
// 导出主函数,供脚本调用 | |||||
export default processApiResponseImages |
import fs from 'fs' | |||||
import path from 'path' | |||||
import { createHash } from 'crypto' | |||||
/** | |||||
* 下载并本地化图片 | |||||
* @param imageUrl 原始图片URL | |||||
* @param basePath 保存图片的基础路径 | |||||
* @returns 本地化后的图片路径 | |||||
*/ | |||||
export async function localizeImage(imageUrl: string, basePath = 'public/images/remote'): Promise<string> { | |||||
// 检查URL是否有效 | |||||
if (!imageUrl || !imageUrl.startsWith('http')) { | |||||
return imageUrl | |||||
} | |||||
try { | |||||
// 创建保存目录 | |||||
const fullBasePath = path.resolve(process.cwd(), basePath) | |||||
if (!fs.existsSync(fullBasePath)) { | |||||
fs.mkdirSync(fullBasePath, { recursive: true }) | |||||
} | |||||
// 生成唯一文件名(使用URL的MD5哈希) | |||||
const urlHash = createHash('md5').update(imageUrl).digest('hex') | |||||
const fileExt = path.extname(new URL(imageUrl).pathname) || '.jpg' | |||||
const fileName = `${urlHash}${fileExt}` | |||||
const filePath = path.join(fullBasePath, fileName) | |||||
// 如果文件已存在,直接返回路径 | |||||
if (fs.existsSync(filePath)) { | |||||
return `/images/remote/${fileName}` | |||||
} | |||||
// 下载图片 | |||||
const response = await fetch(imageUrl) | |||||
if (!response.ok) { | |||||
throw new Error(`下载图片失败: ${response.status} ${response.statusText}`) | |||||
} | |||||
const buffer = Buffer.from(await response.arrayBuffer()) | |||||
fs.writeFileSync(filePath, buffer) | |||||
// 返回本地URL(相对于public目录) | |||||
return `/images/remote/${fileName}` | |||||
} catch (error) { | |||||
console.error(`本地化图片失败 ${imageUrl}:`, error) | |||||
return imageUrl // 失败时返回原始URL | |||||
} | |||||
} | |||||
/** | |||||
* 递归处理对象中的所有图片URL | |||||
* @param data 需要处理的数据对象或数组 | |||||
* @param imageFields 指定哪些字段包含图片URL | |||||
* @returns 处理后的数据 | |||||
*/ | |||||
export async function localizeImages( | |||||
data: any, | |||||
imageFields = ['image', 'imageUrl', 'thumbnail', 'cover', 'avatar', 'photo', 'src'] | |||||
): Promise<any> { | |||||
if (!data) return data | |||||
// 处理数组 | |||||
if (Array.isArray(data)) { | |||||
return Promise.all(data.map(item => localizeImages(item, imageFields))) | |||||
} | |||||
// 处理对象 | |||||
if (typeof data === 'object') { | |||||
const result = { ...data } | |||||
// 处理所有键 | |||||
for (const [key, value] of Object.entries(result)) { | |||||
// 如果是图片字段且值是字符串,本地化图片 | |||||
if (imageFields.includes(key) && typeof value === 'string') { | |||||
result[key] = await localizeImage(value) | |||||
} | |||||
// 递归处理嵌套对象或数组 | |||||
else if (typeof value === 'object' && value !== null) { | |||||
result[key] = await localizeImages(value, imageFields) | |||||
} | |||||
} | |||||
return result | |||||
} | |||||
return data | |||||
} |