官网开发中 0803

This commit is contained in:
eafonyang
2026-08-03 19:09:03 +08:00
parent eeccdb079c
commit f37b6451ef
36 changed files with 1906 additions and 78 deletions
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { AboutConfig, AboutFeature } from '~/types/site';
import { parseJson } from '~/utils/parseJson';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const { getPageConfig } = useSiteApi();
// SEO:关于我们
await usePageSeo('about', undefined, `${t('app.name')} - ${t('nav.about')}`);
const config = ref<AboutConfig | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
config.value = await getPageConfig<AboutConfig>('about');
} catch {
config.value = null;
} finally {
loading.value = false;
}
});
// ===== 按语言取值 =====
const heroTitle = computed(() => (locale.value === 'en' ? config.value?.hero_title_en : config.value?.hero_title_zh) || (locale.value === 'en' ? 'About Us' : '关于我们'));
const heroSubtitle = computed(() => (locale.value === 'en' ? config.value?.hero_subtitle_en : config.value?.hero_subtitle_zh) || '');
const s1Title = computed(() => (locale.value === 'en' ? config.value?.s1_title_en : config.value?.s1_title_zh) || '');
const s1Content = computed(() => (locale.value === 'en' ? config.value?.s1_content_en : config.value?.s1_content_zh) || '');
const s1Cta = computed(() => (locale.value === 'en' ? config.value?.s1_cta_en : config.value?.s1_cta_zh) || '');
const s2Title = computed(() => (locale.value === 'en' ? config.value?.s2_title_en : config.value?.s2_title_zh) || '');
const s3Title = computed(() => (locale.value === 'en' ? config.value?.s3_title_en : config.value?.s3_title_zh) || '');
const s3Cta = computed(() => (locale.value === 'en' ? config.value?.s3_cta_en : config.value?.s3_cta_zh) || '');
// s2_features:JSON 字符串或数组
const features = computed<AboutFeature[]>(() =>
parseJson<AboutFeature[]>(config.value?.s2_features, [])
);
function featureTitle(f: AboutFeature): string {
return locale.value === 'en' ? f.title_en : f.title_zh;
}
function featureDesc(f: AboutFeature): string {
return locale.value === 'en' ? f.desc_en : f.desc_zh;
}
// 是否需要展示各区块
const hasS1 = computed(() => Boolean(s1Title.value || s1Content.value || config.value?.s1_image_url));
const hasS2 = computed(() => Boolean(s2Title.value && features.value.length));
const hasS3 = computed(() => Boolean(s3Title.value || s3Cta.value));
</script>
<template>
<div>
<PageHero
:title="heroTitle"
:subtitle="heroSubtitle || undefined"
:bg-url="config?.hero_bg_url || undefined"
/>
<div v-if="loading" class="py-80px text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else>
<!-- S1:图文介绍 -->
<section v-if="hasS1" class="section-container py-64px md:py-96px">
<div class="grid md:grid-cols-2 gap-40px md:gap-64px items-center">
<div class="flex flex-col gap-20px order-2 md:order-1">
<h2 v-if="s1Title" class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">{{ s1Title }}</h2>
<!-- 富文本(dashboard 端为 HTML 内容) -->
<div v-if="s1Content" class="text-base md:text-lg text-gray-600 dark:text-gray-300 leading-relaxed" v-html="s1Content" />
<div v-if="s1Cta">
<NuxtLink
:to="localePath(config?.s1_cta_url || '/contact')"
class="inline-block px-28px py-12px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity"
>
{{ s1Cta }}
</NuxtLink>
</div>
</div>
<div v-if="config?.s1_image_url" class="order-1 md:order-2">
<img
:src="config.s1_image_url"
:alt="s1Title"
class="w-full rounded-16px shadow-lg object-cover aspect-[4/3]"
/>
</div>
</div>
</section>
<!-- S2:核心优势 -->
<section v-if="hasS2" class="bg-gray-50 dark:bg-dark-900 border-y border-gray-100 dark:border-dark-700">
<div class="section-container py-64px md:py-96px">
<h2 class="text-center text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-48px">{{ s2Title }}</h2>
<div class="grid gap-24px sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="(f, idx) in features"
:key="idx"
class="p-24px rounded-16px bg-white dark:bg-dark-800 border border-gray-100 dark:border-dark-700 hover:shadow-lg transition-shadow"
>
<h3 class="font-semibold text-gray-900 dark:text-white mb-8px">{{ featureTitle(f) }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">{{ featureDesc(f) }}</p>
</div>
</div>
</div>
</section>
<!-- S3:CTA -->
<section v-if="hasS3" class="section-container py-64px md:py-96px text-center">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-32px">{{ s3Title }}</h2>
<NuxtLink
v-if="s3Cta"
:to="localePath(config?.s3_cta_url || '/contact')"
class="inline-block px-32px py-12px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity"
>
{{ s3Cta }}
</NuxtLink>
</section>
<!-- 空配置提示 -->
<div v-if="!hasS1 && !hasS2 && !hasS3" class="section-container py-80px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
</div>
</div>
</template>
+193
View File
@@ -0,0 +1,193 @@
<script setup lang="ts">
import type { ContactConfig, FormField } from '~/types/site';
import { parseJson } from '~/utils/parseJson';
import { ApiError } from '~/composables/useRequest';
const { t, locale } = useI18n();
const { getContact, submitContact } = useSiteApi();
// SEO:联系页(dashboard 未提供 contact 类型,用默认标题)
useHead({
title: () => `${t('app.name')} - ${t('nav.contact')}`
});
const config = ref<ContactConfig | null>(null);
const loading = ref(true);
// ===== 表单状态 =====
const values = reactive<Record<string, string>>({});
const errors = ref<Record<string, string>>({});
const submitting = ref(false);
const submitted = ref(false);
const notice = ref('');
onMounted(async () => {
try {
config.value = await getContact();
// 初始化字段值
for (const f of config.value?.fields || []) {
values[fieldKey(f)] = '';
}
} catch {
config.value = null;
} finally {
loading.value = false;
}
});
const fields = computed<FormField[]>(() => config.value?.fields || []);
const settings = computed(() => config.value?.settings);
/** 字段在提交数据中的 key:使用当前语言的字段名(与后端必填校验匹配) */
function fieldKey(f: FormField): string {
return locale.value === 'en' ? f.name_en : f.name_zh;
}
function fieldLabel(f: FormField): string {
return locale.value === 'en' ? f.name_en : f.name_zh;
}
function fieldPlaceholder(f: FormField): string {
return locale.value === 'en' ? f.placeholder_en : f.placeholder_zh;
}
function fieldOptions(f: FormField): { label: string; value: string }[] {
return parseJson<{ label: string; value: string }[]>(f.options_json, []);
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[+0-9()\-\s]{6,20}$/;
function validate(): boolean {
const errs: Record<string, string> = {};
for (const f of fields.value) {
const v = (values[fieldKey(f)] || '').trim();
if (f.is_required && !v) {
errs[fieldKey(f)] = t('form.required');
continue;
}
if (!v) continue;
if (f.field_type === 'email' && !EMAIL_RE.test(v)) {
errs[fieldKey(f)] = t('form.invalidEmail');
}
if (f.field_type === 'phone' && !PHONE_RE.test(v)) {
errs[fieldKey(f)] = t('form.invalidPhone');
}
}
errors.value = errs;
return Object.keys(errs).length === 0;
}
async function handleSubmit() {
if (submitting.value) return;
if (!validate()) return;
submitting.value = true;
submitted.value = false;
notice.value = '';
try {
await submitContact({ ...values });
submitted.value = true;
notice.value = locale.value === 'en'
? settings.value?.success_message_en || t('form.success')
: settings.value?.success_message_zh || t('form.success');
// 清空表单
for (const k of Object.keys(values)) values[k] = '';
} catch (e) {
notice.value = e instanceof ApiError
? e.message
: (locale.value === 'en'
? settings.value?.error_message_en || t('form.failed')
: settings.value?.error_message_zh || t('form.failed'));
} finally {
submitting.value = false;
}
}
</script>
<template>
<div>
<PageHero
:title="t('nav.contact')"
:subtitle="t('form.subtitle')"
/>
<div v-if="loading" class="py-80px text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else class="section-container py-64px md:py-80px max-w-720px">
<!-- 提交成功/失败提示 -->
<div
v-if="notice"
class="mb-24px px-20px py-14px rounded-8px text-sm leading-relaxed"
:class="submitted
? 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800'
: 'bg-red-50 text-red-600 border border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800'"
>
{{ notice }}
</div>
<!-- 表单字段:由 CMS 动态配置 -->
<form v-if="fields.length" class="flex flex-col gap-20px" @submit.prevent="handleSubmit">
<div v-for="f in fields" :key="f.id" class="flex flex-col gap-8px">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ fieldLabel(f) }}
<span v-if="f.is_required" class="text-red-500 ml-4px">*</span>
</label>
<!-- 下拉选择 -->
<select
v-if="f.field_type === 'select'"
v-model="values[fieldKey(f)]"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors"
:class="errors[fieldKey(f)]
? 'border-red-400 focus:border-red-400'
: 'border-gray-200 dark:border-dark-600 focus:border-primary'"
>
<option value="">{{ fieldPlaceholder(f) }}</option>
<option v-for="opt in fieldOptions(f)" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- 多行文本 -->
<textarea
v-else-if="f.field_type === 'textarea'"
v-model="values[fieldKey(f)]"
:placeholder="fieldPlaceholder(f)"
rows="5"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors resize-y"
:class="errors[fieldKey(f)]
? 'border-red-400 focus:border-red-400'
: 'border-gray-200 dark:border-dark-600 focus:border-primary'"
></textarea>
<!-- 单行文本 / 邮箱 / 电话 -->
<input
v-else
v-model="values[fieldKey(f)]"
:type="f.field_type === 'email' ? 'email' : f.field_type === 'phone' ? 'tel' : 'text'"
:placeholder="fieldPlaceholder(f)"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors"
:class="errors[fieldKey(f)]
? 'border-red-400 focus:border-red-400'
: 'border-gray-200 dark:border-dark-600 focus:border-primary'"
/>
<p v-if="errors[fieldKey(f)]" class="text-xs text-red-500">{{ errors[fieldKey(f)] }}</p>
</div>
<button
type="submit"
:disabled="submitting"
class="mt-8px px-32px py-12px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed self-start"
>
{{ submitting ? t('common.loading') : t('form.submit') }}
</button>
</form>
<!-- 未配置字段 -->
<div v-else class="py-80px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
</div>
</div>
</template>
+2 -3
View File
@@ -4,9 +4,8 @@ import type { SectionBlock } from '~/types/site';
const { t } = useI18n();
const { getSections } = useSiteApi();
useHead({
title: () => `${t('app.name')} - ${t('nav.home')}`
});
// SEO:首页(page-seo 配置优先,回退默认标题)
await usePageSeo('home', undefined, `${t('app.name')} - ${t('nav.home')}`);
// 拉取首页区块列表(仅可见,按 sort_order 排序)
const sections = ref<SectionBlock[]>([]);
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
import type { NewsArticle, NewsDetail } from '~/types/site';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const route = useRoute();
const { getNewsDetail } = useSiteApi();
const slug = String(route.params.slug || '');
const article = ref<NewsDetail | null>(null);
const loading = ref(true);
const notFound = ref(false);
onMounted(async () => {
try {
const res = await getNewsDetail(slug);
if (!res) {
notFound.value = true;
return;
}
article.value = res;
// SEO:新闻详情页
await usePageSeo('news_detail', res.id, locale.value === 'en' ? res.title_en : res.title_zh);
} catch {
notFound.value = true;
} finally {
loading.value = false;
}
});
const title = computed(() => (locale.value === 'en' ? article.value?.title_en : article.value?.title_zh) || '');
const content = computed(() => (locale.value === 'en' ? article.value?.content_en : article.value?.content_zh) || '');
function formatDate(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString(locale.value === 'en' ? 'en-US' : 'zh-CN') + ' ' + d.toLocaleTimeString(locale.value === 'en' ? 'en-US' : 'zh-CN', { hour: '2-digit', minute: '2-digit' });
}
function recTitle(a: NewsArticle): string {
return locale.value === 'en' ? a.title_en : a.title_zh;
}
</script>
<template>
<div v-if="loading" class="min-h-60vh flex-col-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<!-- 文章不存在 -->
<div v-else-if="notFound || !article" class="min-h-60vh flex-col-center px-16px text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">404</h1>
<p class="mt-12px text-gray-500 dark:text-gray-400">{{ t('common.notFoundDesc') }}</p>
<NuxtLink
:to="localePath('/news')"
class="mt-24px px-24px py-10px rounded-6px bg-primary text-white text-sm hover:opacity-90 transition-opacity"
>
{{ t('nav.news') }}
</NuxtLink>
</div>
<div v-else>
<!-- 文章头部 -->
<section class="section-container py-48px md:py-64px flex flex-col items-center text-center gap-16px">
<nav class="flex items-center gap-8px text-sm text-gray-400">
<NuxtLink :to="localePath('/')" class="hover:text-primary transition-colors">{{ t('nav.home') }}</NuxtLink>
<span>/</span>
<NuxtLink :to="localePath('/news')" class="hover:text-primary transition-colors">{{ t('nav.news') }}</NuxtLink>
</nav>
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white max-w-860px leading-tight">{{ title }}</h1>
<time class="text-sm text-gray-400">{{ formatDate(article.published_at) }}</time>
</section>
<!-- 封面图 -->
<div v-if="article.cover_url" class="section-container">
<img
:src="article.cover_url"
:alt="title"
class="w-full max-h-480px object-cover rounded-16px shadow-md"
/>
</div>
<!-- 正文:CMS 输入支持 HTML -->
<article class="section-container py-48px md:py-64px">
<div
class="prose max-w-860px mx-auto text-base md:text-lg leading-relaxed text-gray-700 dark:text-gray-200 break-words"
v-html="content"
/>
</article>
<!-- 推荐文章 -->
<section v-if="article.recommendations?.length" class="border-t border-gray-100 dark:border-dark-700 bg-gray-50 dark:bg-dark-900">
<div class="section-container py-48px md:py-64px">
<h2 class="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-24px">
{{ t('common.relatedNews') }}
</h2>
<div class="grid gap-16px sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="rec in article.recommendations"
:key="rec.id"
:to="localePath(`/news/${rec.slug}`)"
class="group flex flex-col rounded-12px border border-gray-100 dark:border-dark-700 overflow-hidden bg-white dark:bg-dark-800 hover:shadow-md hover:-translate-y-2px transition-all duration-300"
>
<div class="aspect-[16/8] bg-gray-50 dark:bg-dark-700 overflow-hidden">
<img v-if="rec.cover_url" :src="rec.cover_url" :alt="recTitle(rec)" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
<div class="p-16px flex flex-col gap-6px">
<h3 class="font-medium text-gray-900 dark:text-white group-hover:text-primary transition-colors line-clamp-1">
{{ recTitle(rec) }}
</h3>
<time class="text-xs text-gray-400">{{ formatDate(rec.published_at) }}</time>
</div>
</NuxtLink>
</div>
</div>
</section>
</div>
</template>
<style scoped>
/* 正文 HTML 排版(CMS 输入支持 HTML) */
.prose :deep(h2),
.prose :deep(h3),
.prose :deep(h4) {
margin-top: 1.5em;
margin-bottom: 0.6em;
font-weight: 700;
color: inherit;
}
.prose :deep(h2) {
font-size: 1.5em;
}
.prose :deep(h3) {
font-size: 1.25em;
}
.prose :deep(p) {
margin: 1em 0;
}
.prose :deep(ul),
.prose :deep(ol) {
margin: 1em 0;
padding-left: 1.5em;
list-style: revert;
}
.prose :deep(img) {
max-width: 100%;
height: auto;
border-radius: 12px;
margin: 1.5em auto;
display: block;
}
.prose :deep(a) {
color: #2563eb;
text-decoration: underline;
}
.prose :deep(blockquote) {
border-left: 4px solid #d1d5db;
padding-left: 1em;
color: #6b7280;
margin: 1.5em 0;
}
.prose :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1.5em 0;
}
.prose :deep(th),
.prose :deep(td) {
border: 1px solid #e5e7eb;
padding: 8px 12px;
}
.prose :deep(pre) {
background: #f3f4f6;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 1.5em 0;
}
.prose :deep(code) {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
</style>
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { NewsArticle, NewsListConfig } from '~/types/site';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const { getNews, getPageConfig } = useSiteApi();
// SEO:新闻列表页
await usePageSeo('news', undefined, `${t('app.name')} - ${t('nav.news')}`);
// ===== 页面配置 =====
const config = ref<NewsListConfig | null>(null);
// ===== 文章列表 + 分页 =====
const articles = ref<NewsArticle[]>([]);
const total = ref(0);
const page = ref(1);
const loading = ref(true);
const loadingMore = ref(false);
const noMore = ref(false);
const pageSize = computed(() => config.value?.page_size || 9);
const totalPages = computed(() => Math.max(Math.ceil(total.value / pageSize.value), 1));
async function loadArticles() {
const skip = (page.value - 1) * pageSize.value;
try {
const res = await getNews(skip, pageSize.value);
articles.value = res?.items ?? [];
total.value = res?.total ?? 0;
noMore.value = articles.value.length >= (res?.total ?? 0);
} catch {
articles.value = [];
}
}
async function loadMore() {
if (loadingMore.value || noMore.value) return;
loadingMore.value = true;
try {
const skip = articles.value.length;
const res = await getNews(skip, pageSize.value);
const items = res?.items ?? [];
articles.value = [...articles.value, ...items];
total.value = res?.total ?? 0;
noMore.value = articles.value.length >= total.value;
} catch {
// ignore
} finally {
loadingMore.value = false;
}
}
onMounted(async () => {
try {
const [cfg] = await Promise.all([getPageConfig<NewsListConfig>('news-list')]);
config.value = cfg;
} catch {
config.value = null;
}
await loadArticles();
loading.value = false;
});
function goPage(p: number) {
if (p < 1 || p > totalPages.value || p === page.value) return;
page.value = p;
loadArticles();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
const heroTitle = computed(() => (locale.value === 'en' ? config.value?.hero_title_en : config.value?.hero_title_zh) || (locale.value === 'en' ? 'News' : '新闻中心'));
const heroSubtitle = computed(() => (locale.value === 'en' ? config.value?.hero_subtitle_en : config.value?.hero_subtitle_zh) || '');
function formatDate(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString(locale.value === 'en' ? 'en-US' : 'zh-CN');
}
</script>
<template>
<div>
<PageHero
:title="heroTitle"
:subtitle="heroSubtitle || undefined"
:bg-url="config?.hero_bg_url || undefined"
/>
<div v-if="loading" class="py-80px text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else class="section-container py-64px md:py-80px">
<!-- 文章卡片网格 -->
<div v-if="articles.length" class="grid gap-24px sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="article in articles"
:key="article.id"
:to="localePath(`/news/${article.slug}`)"
class="group flex flex-col rounded-16px border border-gray-100 dark:border-dark-700 overflow-hidden bg-white dark:bg-dark-800 hover:shadow-xl hover:-translate-y-4px transition-all duration-300"
>
<div class="aspect-[16/9] bg-gray-50 dark:bg-dark-700 overflow-hidden">
<img
v-if="article.cover_url"
:src="article.cover_url"
:alt="locale === 'en' ? article.title_en : article.title_zh"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div v-else class="w-full h-full flex-center text-gray-300 dark:text-gray-600 text-4xl">📰</div>
</div>
<div class="p-20px flex flex-col gap-8px flex-1">
<time class="text-xs text-gray-400">{{ formatDate(article.published_at) }}</time>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-primary transition-colors line-clamp-2">
{{ locale === 'en' ? article.title_en : article.title_zh }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{{ locale === 'en' ? article.summary_en : article.summary_zh }}
</p>
</div>
</NuxtLink>
</div>
<!-- 空状态 -->
<div v-else class="py-80px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
<!-- 分页:页码模式 -->
<div
v-if="articles.length && config?.pagination_mode !== 'load_more' && totalPages > 1"
class="mt-48px flex items-center justify-center gap-8px"
>
<button
:disabled="page <= 1"
class="px-12px py-6px rounded-6px border border-gray-200 dark:border-dark-600 text-sm text-gray-600 dark:text-gray-300 disabled:opacity-40 disabled:cursor-not-allowed hover:border-primary transition-colors"
@click="goPage(page - 1)"
>
‹
</button>
<button
v-for="p in totalPages"
:key="p"
class="min-w-32px px-8px py-6px rounded-6px border text-sm transition-colors"
:class="p === page
? 'bg-primary border-primary text-white'
: 'border-gray-200 dark:border-dark-600 text-gray-600 dark:text-gray-300 hover:border-primary'"
@click="goPage(p)"
>
{{ p }}
</button>
<button
:disabled="page >= totalPages"
class="px-12px py-6px rounded-6px border border-gray-200 dark:border-dark-600 text-sm text-gray-600 dark:text-gray-300 disabled:opacity-40 disabled:cursor-not-allowed hover:border-primary transition-colors"
@click="goPage(page + 1)"
>
›
</button>
</div>
<!-- 分页:加载更多模式 -->
<div v-else-if="articles.length && !noMore" class="mt-48px flex justify-center">
<button
class="px-32px py-10px rounded-8px border border-gray-200 dark:border-dark-600 text-sm text-gray-700 dark:text-gray-200 hover:border-primary hover:text-primary transition-colors"
:disabled="loadingMore"
@click="loadMore"
>
{{ loadingMore ? t('common.loading') : t('common.loadMore') }}
</button>
</div>
</div>
</div>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import type { SectionBlock, Product, ProductSeries } from '~/types/site';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const route = useRoute();
const siteStore = useSiteStore();
const { getSections, getProducts } = useSiteApi();
const slug = String(route.params.slug || '');
// ===== 查找产品(优先用 store 数据,直接访问时主动加载) =====
const product = ref<Product | null>(null);
const series = ref<ProductSeries | null>(null);
const sections = ref<SectionBlock[]>([]);
const loading = ref(true);
const notFound = ref(false);
async function loadProduct() {
const { productSeries } = storeToRefs(siteStore);
// store 未初始化或未命中时,主动拉取一次
if (!siteStore.loaded && !siteStore.loading) {
await siteStore.init();
}
for (const s of productSeries.value || []) {
const p = (s.products || []).find((item) => item.slug === slug);
if (p) {
product.value = p;
series.value = s;
break;
}
}
// 兜底:直接调接口
if (!product.value) {
try {
const data = await getProducts();
for (const s of data?.series || []) {
const p = (s.products || []).find((item) => item.slug === slug);
if (p) {
product.value = p;
series.value = s;
break;
}
}
} catch {
// ignore
}
}
}
onMounted(async () => {
try {
await loadProduct();
if (!product.value) {
notFound.value = true;
return;
}
// SEO:产品详情页
await usePageSeo('product_detail', product.value.id, product.value.name_zh);
// 产品详情区块(page_type=product_detail + product_id)
const res = await getSections('product_detail', product.value.id);
sections.value = res?.items ?? [];
} catch {
// ignore
} finally {
loading.value = false;
}
});
// ===== 同系列其他产品 =====
const siblings = computed(() =>
(series.value?.products || []).filter((p) => p.slug !== slug)
);
function productName(p: Product): string {
return locale.value === 'en' ? p.name_en : p.name_zh;
}
</script>
<template>
<div v-if="loading" class="min-h-60vh flex-col-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<!-- 产品不存在 -->
<div v-else-if="notFound || !product" class="min-h-60vh flex-col-center px-16px text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">404</h1>
<p class="mt-12px text-gray-500 dark:text-gray-400">{{ t('common.notFoundDesc') }}</p>
<NuxtLink
:to="localePath('/products')"
class="mt-24px px-24px py-10px rounded-6px bg-primary text-white text-sm hover:opacity-90 transition-opacity"
>
{{ t('nav.products') }}
</NuxtLink>
</div>
<div v-else>
<!-- 产品头部:面包屑 + 名称 + 简介 -->
<section class="section-container py-48px md:py-64px flex flex-col gap-12px">
<nav class="flex items-center gap-8px text-sm text-gray-400">
<NuxtLink :to="localePath('/')" class="hover:text-primary transition-colors">{{ t('nav.home') }}</NuxtLink>
<span>/</span>
<NuxtLink :to="localePath('/products')" class="hover:text-primary transition-colors">{{ t('nav.products') }}</NuxtLink>
<span>/</span>
<span class="text-gray-600 dark:text-gray-300">{{ productName(product) }}</span>
</nav>
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mt-8px">{{ productName(product) }}</h1>
<p class="text-base md:text-lg text-gray-500 dark:text-gray-400 max-w-760px leading-relaxed">
{{ locale === 'en' ? product.intro_en : product.intro_zh }}
</p>
</section>
<!-- 详情区块:由 CMS 的 Section 组合渲染 -->
<SectionRenderer v-for="section in sections" :key="section.id" :section="section" />
<!-- 空区块提示 -->
<div v-if="!sections.length" class="section-container py-64px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
<!-- 同系列其他产品 -->
<section v-if="siblings.length" class="border-t border-gray-100 dark:border-dark-700 bg-gray-50 dark:bg-dark-900">
<div class="section-container py-48px md:py-64px">
<h2 class="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-24px">
{{ t('common.moreInSeries') }}
</h2>
<div class="grid gap-16px sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="p in siblings"
:key="p.id"
:to="localePath(`/products/${p.slug}`)"
class="group flex items-center gap-16px p-16px rounded-12px border border-gray-100 dark:border-dark-700 bg-white dark:bg-dark-800 hover:shadow-md hover:-translate-y-2px transition-all duration-300"
>
<div class="w-64px h-48px rounded-8px bg-gray-50 dark:bg-dark-700 overflow-hidden shrink-0">
<img v-if="p.cover_url" :src="p.cover_url" :alt="productName(p)" class="w-full h-full object-cover" />
</div>
<div class="flex flex-col gap-4px min-w-0">
<span class="font-medium text-gray-900 dark:text-white group-hover:text-primary transition-colors truncate">
{{ productName(p) }}
</span>
<span class="text-xs text-gray-400 truncate">{{ locale === 'en' ? p.intro_en : p.intro_zh }}</span>
</div>
</NuxtLink>
</div>
</div>
</section>
</div>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { ProductListConfig, ProductSeries } from '~/types/site';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const siteStore = useSiteStore();
const { getPageConfig } = useSiteApi();
// SEO:产品列表页
await usePageSeo('products', undefined, `${t('app.name')} - ${t('nav.products')}`);
// 页面配置(hero + 列数),未配置时用默认值
const config = ref<ProductListConfig | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
const [cfg] = await Promise.all([getPageConfig<ProductListConfig>('product-list')]);
config.value = cfg;
} catch {
config.value = null;
} finally {
loading.value = false;
}
});
// 产品系列(store 已在 app 启动时加载)
const { productSeries } = storeToRefs(siteStore);
const maxColumns = computed(() => Math.min(Math.max(config.value?.max_columns || 3, 1), 4));
// 有产品的系列才展示
const visibleSeries = computed(() =>
(productSeries.value || []).filter((s) => (s.products || []).length > 0)
);
const heroTitle = computed(() => (locale.value === 'en' ? config.value?.hero_title_en : config.value?.hero_title_zh) || (locale.value === 'en' ? 'Products' : '产品中心'));
const heroSubtitle = computed(() => (locale.value === 'en' ? config.value?.hero_subtitle_en : config.value?.hero_subtitle_zh) || '');
const heroOverline = computed(() => (locale.value === 'en' ? config.value?.hero_overline_en : config.value?.hero_overline_zh) || '');
function seriesName(s: ProductSeries): string {
return locale.value === 'en' ? s.name_en : s.name_zh;
}
function seriesSubtitle(s: ProductSeries): string {
return locale.value === 'en' ? s.subtitle_en : s.subtitle_zh;
}
</script>
<template>
<div>
<PageHero
:title="heroTitle"
:subtitle="heroSubtitle || undefined"
:overline="heroOverline || undefined"
:bg-url="config?.hero_bg_url || undefined"
/>
<div v-if="loading" class="py-80px text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<!-- 按系列分组的产品卡片 -->
<div v-else-if="visibleSeries.length" class="section-container py-64px md:py-80px flex flex-col gap-80px">
<section v-for="series in visibleSeries" :key="series.id">
<div class="mb-32px flex flex-col gap-8px">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">{{ seriesName(series) }}</h2>
<p v-if="seriesSubtitle(series)" class="text-gray-500 dark:text-gray-400">{{ seriesSubtitle(series) }}</p>
</div>
<div
class="grid gap-24px"
:class="{
'grid-cols-1': maxColumns === 1,
'sm:grid-cols-2': maxColumns === 2,
'sm:grid-cols-2 lg:grid-cols-3': maxColumns === 3,
'sm:grid-cols-2 lg:grid-cols-4': maxColumns === 4
}"
>
<NuxtLink
v-for="product in series.products"
:key="product.id"
:to="localePath(`/products/${product.slug}`)"
class="group flex flex-col rounded-16px border border-gray-100 dark:border-dark-700 overflow-hidden bg-white dark:bg-dark-800 hover:shadow-xl hover:-translate-y-4px transition-all duration-300"
>
<div class="aspect-[16/10] bg-gray-50 dark:bg-dark-700 overflow-hidden">
<img
v-if="product.cover_url"
:src="product.cover_url"
:alt="locale === 'en' ? product.name_en : product.name_zh"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div v-else class="w-full h-full flex-center text-gray-300 dark:text-gray-600 text-4xl">🎧</div>
</div>
<div class="p-20px flex flex-col gap-8px flex-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-primary transition-colors">
{{ locale === 'en' ? product.name_en : product.name_zh }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{{ locale === 'en' ? product.intro_en : product.intro_zh }}
</p>
</div>
</NuxtLink>
</div>
</section>
</div>
<!-- 空状态 -->
<div v-else class="section-container py-80px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
</div>
</template>
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { SupportConfig, FaqCategory, FaqItem, SupportDownload, SupportContactItem } from '~/types/site';
import { parseJson } from '~/utils/parseJson';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const { getPageConfig } = useSiteApi();
// SEO:技术支持
await usePageSeo('support', undefined, `${t('app.name')} - ${t('nav.support')}`);
const config = ref<SupportConfig | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
config.value = await getPageConfig<SupportConfig>('support');
} catch {
config.value = null;
} finally {
loading.value = false;
}
});
// ===== JSON 字段解析 =====
const faqCategories = computed<FaqCategory[]>(() =>
parseJson<FaqCategory[]>(config.value?.s1_faq_categories, [])
);
const downloads = computed<SupportDownload[]>(() =>
parseJson<SupportDownload[]>(config.value?.s2_downloads, [])
);
const contacts = computed<SupportContactItem[]>(() =>
parseJson<SupportContactItem[]>(config.value?.s3_contact, [])
);
// ===== 按语言取值 =====
const heroTitle = computed(() => (locale.value === 'en' ? config.value?.hero_title_en : config.value?.hero_title_zh) || (locale.value === 'en' ? 'Support' : '技术支持'));
const heroSubtitle = computed(() => (locale.value === 'en' ? config.value?.hero_subtitle_en : config.value?.hero_subtitle_zh) || '');
const s4Cta = computed(() => (locale.value === 'en' ? config.value?.s4_cta_en : config.value?.s4_cta_zh) || '');
function catTitle(c: FaqCategory): string {
return locale.value === 'en' ? c.title_en : c.title_zh;
}
function qText(q: FaqItem): string {
return locale.value === 'en' ? q.question_en : q.question_zh;
}
function aText(q: FaqItem): string {
return locale.value === 'en' ? q.answer_en : q.answer_zh;
}
function downloadName(d: SupportDownload): string {
return locale.value === 'en' ? d.name_en : d.name_zh;
}
function contactLabel(c: SupportContactItem): string {
return locale.value === 'en' ? c.label_en : c.label_zh;
}
function contactValue(c: SupportContactItem): string {
return locale.value === 'en' ? c.value_en : c.value_zh;
}
// ===== FAQ 手风琴展开状态(分类 -> 展开的问答 index) =====
const openFaq = ref<Record<number, number>>({});
function toggleFaq(catIdx: number, itemIdx: number) {
if (openFaq.value[catIdx] === itemIdx) {
delete openFaq.value[catIdx];
} else {
openFaq.value[catIdx] = itemIdx;
}
}
const hasFaq = computed(() => faqCategories.value.length > 0);
const hasDownloads = computed(() => downloads.value.length > 0);
const hasContacts = computed(() => contacts.value.length > 0);
</script>
<template>
<div>
<PageHero
:title="heroTitle"
:subtitle="heroSubtitle || undefined"
:bg-url="config?.hero_bg_url || undefined"
/>
<div v-if="loading" class="py-80px text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else class="section-container py-64px md:py-80px flex flex-col gap-64px md:gap-80px">
<!-- S1:常见问题 -->
<section v-if="hasFaq">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-32px">{{ t('support.faq') }}</h2>
<div class="flex flex-col gap-24px">
<div v-for="(cat, catIdx) in faqCategories" :key="catIdx">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-16px">{{ catTitle(cat) }}</h3>
<div class="flex flex-col gap-12px">
<div
v-for="(item, itemIdx) in cat.items"
:key="itemIdx"
class="rounded-12px border border-gray-100 dark:border-dark-700 overflow-hidden bg-white dark:bg-dark-800"
>
<button
class="w-full flex items-center justify-between gap-16px px-20px py-16px text-left hover:bg-gray-50 dark:hover:bg-dark-700 transition-colors"
@click="toggleFaq(catIdx, itemIdx)"
>
<span class="font-medium text-gray-900 dark:text-white">{{ qText(item) }}</span>
<span
class="text-gray-400 transition-transform duration-200 shrink-0"
:class="openFaq[catIdx] === itemIdx ? 'rotate-180' : ''"
>▾</span>
</button>
<div v-if="openFaq[catIdx] === itemIdx" class="px-20px pb-16px text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-line">
{{ aText(item) }}
</div>
</div>
</div>
</div>
</div>
</section>
<!-- S2:资料下载 -->
<section v-if="hasDownloads">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-32px">{{ t('support.downloads') }}</h2>
<div class="grid gap-16px sm:grid-cols-2 lg:grid-cols-3">
<a
v-for="(d, idx) in downloads"
:key="idx"
:href="d.file_url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-16px p-20px rounded-12px border border-gray-100 dark:border-dark-700 bg-white dark:bg-dark-800 hover:border-primary hover:shadow-md transition-all"
>
<span class="text-2xl">📄</span>
<div class="flex flex-col gap-4px min-w-0 flex-1">
<span class="font-medium text-gray-900 dark:text-white truncate">{{ downloadName(d) }}</span>
<span v-if="d.file_size" class="text-xs text-gray-400">{{ d.file_size }}</span>
</div>
<span class="text-primary text-lg shrink-0">↓</span>
</a>
</div>
</section>
<!-- S3:联系方式 -->
<section v-if="hasContacts">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-32px">{{ t('support.contact') }}</h2>
<div class="grid gap-16px sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="(c, idx) in contacts"
:key="idx"
class="p-20px rounded-12px border border-gray-100 dark:border-dark-700 bg-white dark:bg-dark-800"
>
<p class="text-xs text-gray-400 mb-8px">{{ contactLabel(c) }}</p>
<p class="font-medium text-gray-900 dark:text-white break-all">{{ contactValue(c) }}</p>
</div>
</div>
</section>
<!-- S4:CTA -->
<section v-if="s4Cta" class="text-center py-32px">
<NuxtLink
:to="localePath(config?.s4_cta_url || '/contact')"
class="inline-block px-36px py-14px rounded-8px bg-primary text-white font-medium hover:opacity-90 transition-opacity"
>
{{ s4Cta }}
</NuxtLink>
</section>
<!-- 空配置提示 -->
<div v-if="!hasFaq && !hasDownloads && !hasContacts && !s4Cta" class="py-80px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
</div>
</div>
</template>