Compare commits
235 Commits
a3defa4bd2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 66adf7f545 | |||
| f87b977c87 | |||
| 601c4abe53 | |||
| b71ee98129 | |||
| 7611d4f079 | |||
| 76fb3ddcb7 | |||
| aa26731731 | |||
| f2533767d2 | |||
| f1116491b6 | |||
| 537a6b0bd6 | |||
| bcc08a53ea | |||
| fa1a22b286 | |||
| 8c720b7ac3 | |||
| 33c0b9cc43 | |||
| fcaf595b73 | |||
| ef20832761 | |||
| 03a692afb5 | |||
| 7b21def74f | |||
| 783d482e0a | |||
| 62ec215340 | |||
| e02f975217 | |||
| 5530776035 | |||
| 63cdff9c41 | |||
| f1398a5545 | |||
| 36d24a4740 | |||
| c9b5b1fad9 | |||
| 246a0b9d4f | |||
| c3ac7c0985 | |||
| 63c3e2e364 | |||
| 1245df497b | |||
| f081a3b33a | |||
| 3c6bff4d31 | |||
| 5011448902 | |||
| 53f3e99d90 | |||
| 815df40745 | |||
| c27337a145 | |||
| 3fb721f278 | |||
| a4dc28fc97 | |||
| 55a35b5498 | |||
| f4ec82a150 | |||
| c82fea48b8 | |||
| 39a23fb382 | |||
| 871b203a5e | |||
| b746247ccc | |||
| 97069815dc | |||
| 5194c72695 | |||
| dbe75ee080 | |||
| 4471851c62 | |||
| 15a75f965b | |||
| 5a4cef52be | |||
| 30ffc7e557 | |||
| c4b9ed7bae | |||
| fc6c922ac3 | |||
| 54389b32ac | |||
| d8002a8265 | |||
| 51042c395a | |||
| 6cf964e8f7 | |||
| 456f2c69f2 | |||
| 6f005c1404 | |||
| 1ab6d92226 | |||
| 67629ed518 | |||
| fe8a0e7656 | |||
| a82872c1c1 | |||
| 02ecb18147 | |||
| 05c970d1e7 | |||
| 496548afa4 | |||
| 6f08701847 | |||
| 17bb8adee3 | |||
| b2b631ed46 | |||
| ec9a097ef5 | |||
| 3fb28c3f00 | |||
| d7bd034d7d | |||
| 341a9c4066 | |||
| ee1597d2c3 | |||
| 17d10a7d80 | |||
| 54d0e297ea | |||
| 644dfa329c | |||
| 100b79f99b | |||
| f4f7e490b4 | |||
| 6418587738 | |||
| 6983f6568d | |||
| c860621e7a | |||
| 154943815d | |||
| 23f2700c0f | |||
| e215a4d498 | |||
| 50f0779a8e | |||
| 5ee6005ad1 | |||
| a520775a8d | |||
| a5f3895794 | |||
| 58223734a2 | |||
| 9163c7fe9a | |||
| 726c38a75d | |||
| 81caa02d11 | |||
| 8213eec217 | |||
| ac9e7b4436 | |||
| 5ad6133252 | |||
| a07d77dde7 | |||
| 3e7b195002 | |||
| 706b754905 | |||
| 0d77e97ad5 | |||
| ac658e01ae | |||
| a93f508e85 | |||
| 1290189d84 | |||
| 691dd34127 | |||
| 7e7775ccc6 | |||
| b234018f72 | |||
| 7b19f59409 | |||
| 9de6ab8799 | |||
| 86b0c29dcf | |||
| 352be1686a | |||
| 710a0cdc5b | |||
| 4c8dfb5b56 | |||
| 007c8f9ce9 | |||
| 0363a88785 | |||
| 308a080ea4 | |||
| a34cfaff6f | |||
| bd894d6f2e | |||
| b4da838cae | |||
| 660892f9e7 | |||
| 2a021cbaea | |||
| d55fe5cce2 | |||
| 9313700660 | |||
| 9d3276a56a | |||
| 37e89c3eda | |||
| b386d4e60d | |||
| bfdae60910 | |||
| e04b9d57da | |||
| 0265ea4978 | |||
| 1cdc29b1ce | |||
| 58e91ed67c | |||
| 191459ec5c | |||
| 881c0ab61e | |||
| f8c95207c2 | |||
| aba3729335 | |||
| 516ad9fa1c | |||
| 6a3493c7e1 | |||
| a328414b4e | |||
| e81532f920 | |||
| 082fe9fea8 | |||
| 7ba7f4a15a | |||
| 083a2695f3 | |||
| 06ab63b8e5 | |||
| 0950e32fe4 | |||
| 04be130b6d | |||
| 7aee68593c | |||
| 7dbe85bdc7 | |||
| 2b3bf0f4a9 | |||
| daa22f8ff9 | |||
| 2d5c231e81 | |||
| 67a2b24e0b | |||
| edcf0ec99e | |||
| e7450005ab | |||
| 128bdf5a16 | |||
| 9460ad5249 | |||
| b7bb0cfff8 | |||
| 085fd8bad3 | |||
| 5990e000bc | |||
| 0403a83751 | |||
| 531f316026 | |||
| 7a15166281 | |||
| 853046d633 | |||
| 0996e910d9 | |||
| 52048fc2a6 | |||
| fc164beaf3 | |||
| 3b6857637b | |||
| d076088747 | |||
| 06c30a7ea3 | |||
| 37da48c07e | |||
| 51080849eb | |||
| 2b53d47c34 | |||
| 959bcaee7c | |||
| 149d05848e | |||
| e0df7ef063 | |||
| dd7ac909fb | |||
| dbc84d5a21 | |||
| cea67404ed | |||
| 63491fd5f9 | |||
| 03fff90091 | |||
| e88d5f4534 | |||
| 95252cd70a | |||
| 84b99deef6 | |||
| 667413dd12 | |||
| 00c4c80e49 | |||
| 9982481c83 | |||
| 5f78c888a2 | |||
| c4e797500f | |||
| 5920925ded | |||
| 300266d32c | |||
| c6e0ea2a47 | |||
| dc90e1045b | |||
| 8883dc3fcc | |||
| ff143f980a | |||
| 4e7131b291 | |||
| 5ab72111ca | |||
| 73e920cd8d | |||
| 9294cd3199 | |||
| 5be3c45ac5 | |||
| 35bcdc0164 | |||
| 75d4d40d39 | |||
| f2634ca0f4 | |||
| d33e7e1dd9 | |||
| 57f29e4c06 | |||
| a2c6006e37 | |||
| 772c25a41b | |||
| 088eee07bf | |||
| b1ff62a3bb | |||
| e403252dba | |||
| 393dc3885b | |||
| 963690bf53 | |||
| e780997a69 | |||
| 4e88fd9bfb | |||
| f62c4a3987 | |||
| 05938550e6 | |||
| c156d1414c | |||
| faf2eb4b44 | |||
| 8269155ae3 | |||
| e48c7fe238 | |||
| 440a46850a | |||
| bc625239cd | |||
| 94d3f31cbd | |||
| 46e79f0b5c | |||
| f53b86cbb6 | |||
| 0ccd855472 | |||
| 568701a80e | |||
| 9abe6431a6 | |||
| 227b537a0f | |||
| 6c76d81a40 | |||
| 202657e634 | |||
| cb861bc955 | |||
| 1704a7b5c1 | |||
| 98f978484c | |||
| de7c03a7a9 | |||
| e158ec8cf5 | |||
| 33c94fb885 | |||
| e05f248b66 |
58
.dockerignore
Normal file
58
.dockerignore
Normal file
@ -0,0 +1,58 @@
|
||||
# ------------------------
|
||||
# Node / Package Manager
|
||||
# ------------------------
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-store
|
||||
|
||||
# Nuxt build outputs
|
||||
.output
|
||||
.nuxt
|
||||
dist
|
||||
.cache
|
||||
.unimport
|
||||
.h3
|
||||
.nitro
|
||||
**/.nitro
|
||||
|
||||
# Dev tools / OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs
|
||||
!.gitkeep
|
||||
|
||||
# Local env files (runtime env should be provided by Docker/K8s)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Editor / IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Tests
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose.yml
|
||||
|
||||
# CI / Local build artifacts
|
||||
*.tgz
|
||||
*.zip
|
||||
*.tar
|
||||
*.mdx
|
||||
5
.graphqlrc.yaml
Normal file
5
.graphqlrc.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
schema:
|
||||
- 'http://192.168.86.5:8055/graphql':
|
||||
headers:
|
||||
Authorization: 'Bearer ixSWeViHIqwj6_r7NM-uZVR3NNOyBa_W'
|
||||
documents: 'app/graphql/**/*.{graphql,js,ts,jsx,tsx}'
|
||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@ -0,0 +1,51 @@
|
||||
# -------- Base image --------
|
||||
FROM node:22-alpine AS base
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
# -------- Dependencies layer -------
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
# Copy package.json and lockfile
|
||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||
|
||||
# Install dependencies with cache
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install
|
||||
|
||||
# -------- Build layer -------
|
||||
FROM deps AS build
|
||||
# Copy entire project
|
||||
COPY . ./
|
||||
|
||||
ENV NITRO_PRESET=node-server
|
||||
|
||||
# Build the project
|
||||
RUN pnpm run build
|
||||
|
||||
# ------- Runtime layer -------
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# COPY .output folder
|
||||
COPY --from=build /app/.output ./
|
||||
|
||||
|
||||
ARG BUILD_TIME
|
||||
ARG GIT_COMMIT
|
||||
|
||||
RUN echo "{\"buildTime\":\"$BUILD_TIME\",\"gitCommit\":\"$GIT_COMMIT\"}" > /app/version.json
|
||||
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENV PORT=3000
|
||||
ENV NITRO_PRESET=node-server
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["node", "/app/server/index.mjs"]
|
||||
29
README.md
29
README.md
@ -1,6 +1,6 @@
|
||||
# 金申机械制造有限公司官方网站——前端服务
|
||||
|
||||
这是公司(金申机械制造有限公司)官网的前端服务。项目使用Nuxt.js与Element Plus进行开发,后端服务使用Strapi。旨在为客户提供直观的公司简介、产品信息、解决方案、联系方式等。
|
||||
这是公司(金申机械制造有限公司)官网的前端服务。项目使用Nuxt.js与Element Plus进行开发,后端服务使用Directus。旨在为客户提供直观的公司简介、产品信息、解决方案、联系方式等。
|
||||
|
||||
## 站点内容
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
- 联系信息
|
||||
- 关于我们
|
||||
- 公司基本信息
|
||||
- 搜索页
|
||||
- 使用Meilisearch作为搜索引擎进行搜索
|
||||
|
||||
## 安装与设置
|
||||
|
||||
@ -56,8 +58,12 @@ pnpm run dev
|
||||
|
||||
项目用到以下环境变量,请自行在项目中配置
|
||||
|
||||
- 'STRAPI_URL': 后端Strapi服务URL
|
||||
- 'STRAPI_TOKEN': Strapi服务的API Token
|
||||
> [!NOTE]
|
||||
>
|
||||
> - NUXT_PUBLIC_DIRECTUS_URL: 后端Directus服务URL
|
||||
> - NUXT_PUBLIC_DIRECTUS_TOKEN: 后端Directus服务的API Token
|
||||
> - MEILI_HOST: Meilisearch服务地址
|
||||
> - MEILI_SEARCH_KEY: MeilisearchKey
|
||||
|
||||
## 构建与部署
|
||||
|
||||
@ -79,3 +85,20 @@ pnpm run preview
|
||||
|
||||
部署构建后的项目并推送到文件服务器中,具体步骤视服务器配置而定
|
||||
|
||||
## Dockerfile部署
|
||||
|
||||
1. 构建Docker镜像
|
||||
|
||||
在项目根目录执行docker build
|
||||
|
||||
```bash
|
||||
docker build -t jinshen-website .
|
||||
```
|
||||
|
||||
2. 运行docker容器
|
||||
|
||||
```bash
|
||||
docker run --name <container-name> jinshen-website
|
||||
```
|
||||
|
||||
网站默认在3000端口开放
|
||||
|
||||
19
app/app.vue
19
app/app.vue
@ -11,26 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ElConfigProvider } from 'element-plus';
|
||||
|
||||
const { login } = useStrapiAuth();
|
||||
|
||||
const { getElementPlusLocale } = useLocalizations();
|
||||
|
||||
const elementPlusLocale = getElementPlusLocale();
|
||||
|
||||
onMounted(() => {
|
||||
// 检查用户是否已登录
|
||||
const user = useStrapiUser();
|
||||
if (!user.value) {
|
||||
// 如果未登录,重定向到登录页面
|
||||
login({ identifier: 'remilia', password: 'huanshuo51' })
|
||||
.then(() => {
|
||||
console.log('Login successful');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Login failed:', error);
|
||||
});
|
||||
} else {
|
||||
console.log('User is already logged in:', user.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
95
app/assets/css/typography.css
Normal file
95
app/assets/css/typography.css
Normal file
@ -0,0 +1,95 @@
|
||||
.html-typography {
|
||||
padding: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.html-typography h1 {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.html-typography h2 {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.html-typography h3 {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.html-typography p {
|
||||
text-indent: 2em;
|
||||
text-align: justify;
|
||||
margin: 0.5em 0;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.html-typography ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 2em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.html-typography ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 2em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.html-typography hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 表格基础样式 */
|
||||
.html-typography table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--el-border-color);
|
||||
margin: 1em 0;
|
||||
table-layout: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 单元格通用样式 */
|
||||
.html-typography th,
|
||||
.html-typography td {
|
||||
border: 1px solid var(--el-border-color);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 表头样式 */
|
||||
.html-typography th {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 行 hover 效果 */
|
||||
.html-typography tbody tr:hover {
|
||||
background-color: var(--el-fill-color-light-hover);
|
||||
}
|
||||
|
||||
/* 交替行背景 */
|
||||
.html-typography tbody tr:nth-child(odd) {
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
/* 表格标题(如有 caption) */
|
||||
.html-typography table caption {
|
||||
caption-side: top;
|
||||
text-align: left;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<div class="document-list">
|
||||
<el-card
|
||||
v-for="(doc, index) in documents"
|
||||
:key="index"
|
||||
class="document-card"
|
||||
>
|
||||
<div class="document-info">
|
||||
<h3>{{ doc.caption || doc.name }}</h3>
|
||||
<div class="document-content">
|
||||
<span v-if="doc.size" class="document-meta"
|
||||
>大小: {{ formatFileSize(doc.size) }}
|
||||
</span>
|
||||
<span v-if="doc.ext" class="document-meta"
|
||||
>格式: {{ formatFileExtension(doc.ext) }}</span
|
||||
>
|
||||
<el-button
|
||||
class="download-button"
|
||||
type="primary"
|
||||
@click="handleDownload(doc.name, doc.url)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
documents: {
|
||||
type: Array as () => Array<StrapiMedia>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async (fileName: string, fileUrl: string) => {
|
||||
const response = await fetch(fileUrl);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.document-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.download-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@ -1,170 +0,0 @@
|
||||
<template>
|
||||
<header class="header-container">
|
||||
<div class="logo-section">
|
||||
<NuxtLink :to="$localePath('/')" class="logo-link">
|
||||
<el-image
|
||||
class="website-logo"
|
||||
src="/jinshen-logo.png"
|
||||
alt="Jinshen Logo"
|
||||
fit="contain"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="header-menu-section">
|
||||
<!-- 导航菜单 -->
|
||||
<el-menu
|
||||
:default-active="activeName"
|
||||
class="header-menu"
|
||||
mode="horizontal"
|
||||
:ellipsis="false"
|
||||
:persistent="false"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="productions" :route="$localePath('/productions')">
|
||||
<span class="title">{{ $t('navigation.productions') }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="solutions" :route="$localePath('/solutions')">
|
||||
<span class="title">{{ $t('navigation.solutions') }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="support" :route="$localePath('/support')">
|
||||
<span class="title">{{ $t('navigation.support') }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="about" :route="$localePath('/about')">
|
||||
<span class="title">{{ $t('navigation.about-us') }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<!-- 右侧功能区 -->
|
||||
<div class="header-actions">
|
||||
<el-link
|
||||
class="search-link"
|
||||
:underline="false"
|
||||
type="info"
|
||||
@click="navigateTo(localePath('/search'))"
|
||||
>
|
||||
<el-icon class="mdi mdi-magnify action-icon" />
|
||||
</el-link>
|
||||
|
||||
<el-link
|
||||
type="info"
|
||||
:underline="false"
|
||||
href="http://cal.jinshen.cn"
|
||||
target="_blank"
|
||||
>
|
||||
<el-icon class="mdi mdi-calculator action-icon" />
|
||||
</el-link>
|
||||
|
||||
<el-dropdown @command="setLocale">
|
||||
<el-link type="info" :underline="false">
|
||||
<el-icon class="mdi mdi-translate action-icon" />
|
||||
</el-link>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="zh">简体中文</el-dropdown-item>
|
||||
<el-dropdown-item command="en">English</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const { setLocale } = useI18n();
|
||||
|
||||
const activeName = ref<string | undefined>(undefined);
|
||||
|
||||
const refreshMenu = () => {
|
||||
const path = router.currentRoute.value.path;
|
||||
if (path.startsWith('/productions')) {
|
||||
activeName.value = 'productions';
|
||||
} else if (path.startsWith('/solutions')) {
|
||||
activeName.value = 'solutions';
|
||||
} else if (path.startsWith('/support')) {
|
||||
activeName.value = 'support';
|
||||
} else if (path.startsWith('/about')) {
|
||||
activeName.value = 'about';
|
||||
} else {
|
||||
activeName.value = undefined; // 默认不激活任何菜单项
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshMenu();
|
||||
// 监听路由变化以更新激活状态
|
||||
router.afterEach(() => {
|
||||
refreshMenu();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-container {
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
height: 80px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.website-logo {
|
||||
height: 64px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header-menu-section {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-menu {
|
||||
margin-right: 40px;
|
||||
border-bottom: none !important;
|
||||
width: auto;
|
||||
--el-menu-horizontal-height: 100%;
|
||||
}
|
||||
|
||||
.header-menu .el-menu-item {
|
||||
font-size: 16px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.header-menu .el-menu-item.is-active {
|
||||
border-bottom: 2.5px solid var(--el-color-primary-dark-2);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-menu .el-menu-item:hover {
|
||||
border-bottom: 2.5px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<div class="question-list">
|
||||
<el-collapse class="question-collapse" accordion>
|
||||
<el-collapse-item
|
||||
v-for="question in questions"
|
||||
:key="question.documentId"
|
||||
:title="question.title"
|
||||
:name="question.documentId"
|
||||
>
|
||||
<markdown-renderer :content="question.content || ''" />
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
questions: {
|
||||
type: Array as () => Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
documentId: string;
|
||||
}>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.question-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.question-collapse {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.question-collapse :deep(.el-collapse-item) {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.question-collapse :deep(.el-collapse-item__header) {
|
||||
font-size: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: #f5f7fa;
|
||||
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.is-active {
|
||||
background-color: #e1e6eb;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.question-collapse :deep(.el-collapse-item__wrap) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.question-collapse :deep(.el-collapse-item__content) {
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
107
app/components/pages/about/LearnMoreCard.vue
Normal file
107
app/components/pages/about/LearnMoreCard.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<component
|
||||
:is="wrapperTag"
|
||||
v-bind="wrapperProps"
|
||||
class="learn-more-wrapper"
|
||||
@click="handleAsyncClick"
|
||||
>
|
||||
<el-card class="learn-more-card">
|
||||
<el-icon class="icon" size="80">
|
||||
<component :is="icon" />
|
||||
</el-icon>
|
||||
<br />
|
||||
{{ title }}
|
||||
</el-card>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
href: {
|
||||
type: [String, Function],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 包裹器标签
|
||||
const wrapperTag = computed(() => {
|
||||
if (props.to) return resolveComponent('NuxtLink');
|
||||
if (props.href) return 'a';
|
||||
return 'div';
|
||||
});
|
||||
|
||||
// 包裹器属性
|
||||
const wrapperProps = computed(() => {
|
||||
// Nuxt 内部跳转
|
||||
if (props.to) {
|
||||
return { to: props.to };
|
||||
}
|
||||
|
||||
// 外部跳转(字符串)
|
||||
if (typeof props.href === 'string') {
|
||||
return {
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
}
|
||||
|
||||
// 其他情况:href 是异步函数 → 不在这里处理
|
||||
return { href: '#' };
|
||||
});
|
||||
|
||||
async function handleAsyncClick(event: Event) {
|
||||
if (typeof props.href === 'function') {
|
||||
event.preventDefault();
|
||||
const url = await props.href();
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.learn-more-card {
|
||||
width: 20%;
|
||||
min-width: 200px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.learn-more-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.learn-more-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.learn-more-card {
|
||||
width: 80%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
app/components/pages/download/FileCard.vue
Normal file
87
app/components/pages/download/FileCard.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<el-card shadow="hover" class="p-4">
|
||||
<template #header>
|
||||
<div class="header-content">
|
||||
<el-icon class="header-icon"><ElIconDocument /></el-icon>
|
||||
<span class="truncate font-medium">{{ file.filename_download }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<dl class="text-gray-600 space-y-1 mb-6">
|
||||
<div>
|
||||
<dt class="font-semibold inline">{{ $t('document-meta.type') }}:</dt>
|
||||
<dd class="inline">{{ file.type }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-semibold inline">{{ $t('document-meta.size') }}:</dt>
|
||||
<dd class="inline">{{ formatFileSize(file.filesize) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-semibold inline">
|
||||
{{ $t('document-meta.upload-at') }}:
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
{{ new Date(file.uploaded_on).toLocaleDateString() }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<template #footer>
|
||||
<div class="button-group">
|
||||
<el-button type="primary" @click="handleDownload">{{
|
||||
$t('document-action.download')
|
||||
}}</el-button>
|
||||
<el-button v-if="file.previewable" @click="handlePreview">{{
|
||||
$t('document-action.preview')
|
||||
}}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
fileId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: Object as PropType<FileMeta>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const localePath = useLocalePath();
|
||||
const router = useRouter();
|
||||
|
||||
function handleDownload() {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/download/${props.fileId}`;
|
||||
link.download = props.file?.filename_download ?? 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function handlePreview() {
|
||||
router.push(localePath(`/preview/${props.fileId}`));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.header-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
92
app/components/pages/homepage/HomepageCarousel.vue
Normal file
92
app/components/pages/homepage/HomepageCarousel.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<section v-if="!pending" class="carousel-section">
|
||||
<el-carousel
|
||||
class="homepage-carousel"
|
||||
height="auto"
|
||||
:interval="5000"
|
||||
arrow="never"
|
||||
autoplay
|
||||
>
|
||||
<el-carousel-item v-for="(item, index) in carousel" :key="index">
|
||||
<div class="carousel-item">
|
||||
<el-image
|
||||
class="carousel-image"
|
||||
:src="getImageUrl(item)"
|
||||
:alt="`Carousel Image ${index + 1}`"
|
||||
fit="contain"
|
||||
lazy
|
||||
/>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</section>
|
||||
<section v-else>
|
||||
<el-skeleton :rows="5" animated />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
homepageData: {
|
||||
type: Object as PropType<HomepageView>,
|
||||
default: null,
|
||||
},
|
||||
pending: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
|
||||
const { getImageUrl } = useDirectusImage();
|
||||
|
||||
const carousel = computed(() => props.homepageData?.carousel || []);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.carousel-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.homepage-carousel .el-carousel__item {
|
||||
width: 100%;
|
||||
height: 33vw;
|
||||
/* 16:9 Aspect Ratio */
|
||||
}
|
||||
|
||||
.el-carousel__item h3 {
|
||||
display: flex;
|
||||
color: #475669;
|
||||
opacity: 0.8;
|
||||
line-height: 300px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.homepage-carousel .carousel-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.carousel-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carousel-image-caption {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.homepage-carousel .el-carousel__item {
|
||||
height: 50vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
228
app/components/pages/homepage/HomepageProductSection.vue
Normal file
228
app/components/pages/homepage/HomepageProductSection.vue
Normal file
@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<section class="homepage-section">
|
||||
<h2>{{ $t('homepage.recommended-products') }}</h2>
|
||||
<p>
|
||||
{{ $t('homepage.recommended-products-desc') }}
|
||||
</p>
|
||||
<div v-if="!pending">
|
||||
<el-carousel
|
||||
class="recommend-carousel"
|
||||
:height="carouselHeight"
|
||||
arrow="never"
|
||||
indicator-position="outside"
|
||||
:autoplay="false"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="n in pages"
|
||||
ref="carouselItem"
|
||||
:key="n"
|
||||
class="recommend-list"
|
||||
>
|
||||
<div class="recommend-card-group">
|
||||
<el-card
|
||||
v-for="(item, index) in pageProducts(n)"
|
||||
:key="index"
|
||||
class="recommend-card"
|
||||
@click="handleProductCardClick(item.id.toString() || '')"
|
||||
>
|
||||
<template #header>
|
||||
<el-image
|
||||
:src="getImageUrl(item.cover)"
|
||||
:alt="item.name"
|
||||
fit="cover"
|
||||
lazy
|
||||
/>
|
||||
</template>
|
||||
<div class="recommend-card-body">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<span class="recommend-card-title">{{ item.name }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="recommend-card-description text-left opacity-25">
|
||||
{{ item.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
homepageData: {
|
||||
type: Object as PropType<HomepageView>,
|
||||
default: null,
|
||||
},
|
||||
pending: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
|
||||
const carouselHeight = ref<string>('auto');
|
||||
const perPage = ref(3);
|
||||
const carouselItem = ref<HTMLElement | null>(null);
|
||||
|
||||
const { getImageUrl } = useDirectusImage();
|
||||
const { height } = useElementSize(carouselItem);
|
||||
|
||||
const products = computed(() => props.homepageData?.recommendProducts || []);
|
||||
const pages = computed(() =>
|
||||
Math.ceil(products.value.length / perPage.value)
|
||||
);
|
||||
|
||||
const updatePerPage = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width < 768) {
|
||||
perPage.value = 1;
|
||||
} else if (width < 1024) {
|
||||
perPage.value = 2;
|
||||
} else {
|
||||
perPage.value = 3;
|
||||
}
|
||||
};
|
||||
|
||||
const pageProducts = (n: number) => {
|
||||
return products.value.slice((n - 1) * perPage.value, n * perPage.value);
|
||||
};
|
||||
|
||||
const handleProductCardClick = (documentId: string) => {
|
||||
// 使用路由导航到产品详情页
|
||||
if (documentId) {
|
||||
const localePath = useLocalePath();
|
||||
const router = useRouter();
|
||||
router.push(localePath(`/products/${documentId}`));
|
||||
}
|
||||
};
|
||||
|
||||
watch(height, (h) => {
|
||||
if (h > 0) {
|
||||
carouselHeight.value = h + 40 + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
updatePerPage();
|
||||
window.addEventListener('resize', updatePerPage);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePerPage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
section {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
section p {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.homepage-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.recommend-carousel :deep(.el-carousel__button) {
|
||||
/* 指示器按钮样式 */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #475669;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.homepage-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.recommend-carousel :deep(.el-carousel__button) {
|
||||
/* 指示器按钮样式 */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #475669;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.recommend-list {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.recommend-card-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.recommend-card {
|
||||
width: 33%;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recommend-card :deep(.el-card__header) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.recommend-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recommend-card-body {
|
||||
margin: 10px auto;
|
||||
padding: 0px auto;
|
||||
}
|
||||
|
||||
.recommend-card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recommend-card-description {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.recommend-card .el-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.recommend-card {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recommend-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
228
app/components/pages/homepage/HomepageSolutionSection.vue
Normal file
228
app/components/pages/homepage/HomepageSolutionSection.vue
Normal file
@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<section class="homepage-section">
|
||||
<h2>{{ $t('homepage.recommended-solutions') }}</h2>
|
||||
<p>{{ $t('homepage.recommended-solutions-desc') }}</p>
|
||||
<div v-if="!pending">
|
||||
<el-carousel
|
||||
class="recommend-carousel"
|
||||
:height="carouselHeight"
|
||||
arrow="never"
|
||||
indicator-position="outside"
|
||||
:autoplay="false"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="n in pages"
|
||||
ref="carouselItem"
|
||||
:key="n"
|
||||
class="recommend-list"
|
||||
>
|
||||
<div class="recommend-card-group">
|
||||
<el-card
|
||||
v-for="(item, index) in pageSolutions(n)"
|
||||
:key="index"
|
||||
class="recommend-card"
|
||||
@click="handleSolutionCardClick(item.id.toString() || '')"
|
||||
>
|
||||
<template #header>
|
||||
<el-image
|
||||
:src="getImageUrl(item.cover)"
|
||||
:alt="item.title"
|
||||
fit="cover"
|
||||
lazy
|
||||
/>
|
||||
</template>
|
||||
<div class="recommend-card-body">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<span class="recommend-card-title">{{ item.title }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="recommend-card-description text-left opacity-25">
|
||||
{{ item.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
homepageData: {
|
||||
type: Object as PropType<HomepageView>,
|
||||
default: null,
|
||||
},
|
||||
pending: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
|
||||
const carouselHeight = ref<string>('auto');
|
||||
const perPage = ref(3);
|
||||
const carouselItem = ref<HTMLElement | null>(null);
|
||||
|
||||
const { getImageUrl } = useDirectusImage();
|
||||
const { height } = useElementSize(carouselItem);
|
||||
|
||||
const solutions = computed(
|
||||
() => props.homepageData?.recommendSolutions || []
|
||||
);
|
||||
const pages = computed(() =>
|
||||
Math.ceil(solutions.value.length / perPage.value)
|
||||
);
|
||||
|
||||
const updatePerPage = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width < 768) {
|
||||
perPage.value = 1;
|
||||
} else if (width < 1024) {
|
||||
perPage.value = 2;
|
||||
} else {
|
||||
perPage.value = 3;
|
||||
}
|
||||
};
|
||||
|
||||
const pageSolutions = (n: number) => {
|
||||
return solutions.value.slice((n - 1) * perPage.value, n * perPage.value);
|
||||
};
|
||||
|
||||
const handleSolutionCardClick = (documentId: string) => {
|
||||
// 使用路由导航到产品详情页
|
||||
if (documentId) {
|
||||
const localePath = useLocalePath();
|
||||
const router = useRouter();
|
||||
router.push(localePath(`/solutions/${documentId}`));
|
||||
}
|
||||
};
|
||||
|
||||
watch(height, (h) => {
|
||||
if (h > 0) {
|
||||
carouselHeight.value = h + 40 + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
updatePerPage();
|
||||
window.addEventListener('resize', updatePerPage);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePerPage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
section {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
section p {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.homepage-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.recommend-carousel :deep(.el-carousel__button) {
|
||||
/* 指示器按钮样式 */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #475669;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.homepage-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.recommend-carousel :deep(.el-carousel__button) {
|
||||
/* 指示器按钮样式 */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #475669;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.recommend-list {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.recommend-card-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.recommend-card {
|
||||
width: 33%;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recommend-card :deep(.el-card__header) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.recommend-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recommend-card-body {
|
||||
margin: 10px auto;
|
||||
padding: 0px auto;
|
||||
}
|
||||
|
||||
.recommend-card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recommend-card-description {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.recommend-card .el-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.recommend-card {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recommend-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
66
app/components/pages/products/ProductDetail.vue
Normal file
66
app/components/pages/products/ProductDetail.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="product-detail">
|
||||
<el-tabs v-model="activeName" class="product-tabs" stretch>
|
||||
<el-tab-pane :label="$t('product-tab.details')" name="details">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="html-typography" v-html="product?.description || ''" />
|
||||
<!-- <div v-if="!hydrated" v-html="product.description || ''" /> -->
|
||||
<!-- <div v-else> -->
|
||||
<!-- <html-renderer -->
|
||||
<!-- class="html-typography" -->
|
||||
<!-- :html="product.description || ''" -->
|
||||
<!-- /> -->
|
||||
<!-- </div> -->
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('product-tab.specs')" name="specs">
|
||||
<spec-table :data="product.specs" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('product-tab.faq')" name="faq">
|
||||
<question-list :questions="product.faqs" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="$t('product-tab.documents')" name="documents">
|
||||
<document-list :documents="product.documents" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
product: {
|
||||
type: Object as PropType<ProductView>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hydrated = ref(false);
|
||||
|
||||
const activeName = ref('details'); // 默认选中概览标签
|
||||
|
||||
onMounted(() => {
|
||||
hydrated.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-tabs ::v-deep(.el-tabs__nav) {
|
||||
min-width: 30%;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.product-tabs ::v-deep(.el-tabs__content) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.product-detail h2 {
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.product-tabs ::v-deep(.el-tabs__nav) {
|
||||
float: none;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
137
app/components/pages/products/ProductHeader.vue
Normal file
137
app/components/pages/products/ProductHeader.vue
Normal file
@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="product-header">
|
||||
<div class="product-image">
|
||||
<el-image
|
||||
v-if="product.images.length <= 1"
|
||||
:src="getImageUrl(product.images[0].image)"
|
||||
:alt="product.name"
|
||||
fit="contain"
|
||||
/>
|
||||
<el-carousel
|
||||
v-else
|
||||
class="product-carousel"
|
||||
height="auto"
|
||||
:autoplay="false"
|
||||
:loop="false"
|
||||
arrow="always"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="(item, index) in product.images || []"
|
||||
:key="index"
|
||||
class="product-carousel-item"
|
||||
>
|
||||
<div>
|
||||
<el-image
|
||||
:src="getImageUrl(item.image || '')"
|
||||
:alt="product.name"
|
||||
fit="contain"
|
||||
lazy
|
||||
/>
|
||||
<p v-if="item.caption" class="product-image-caption">
|
||||
{{ item.caption }}
|
||||
</p>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
|
||||
<div class="product-info">
|
||||
<h1>{{ product.name }}</h1>
|
||||
<p class="summary">{{ product.summary }}</p>
|
||||
<p v-if="product.status === 'discontinued'" class="discontinued-warning">
|
||||
<el-icon><ElIconWarning /></el-icon>
|
||||
{{ $t('product-discontinued-warning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
product: {
|
||||
type: Object as PropType<ProductView>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { getImageUrl } = useDirectusImage();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.product-carousel-item {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.product-image .el-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-image-caption {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
/* left: 10%; */
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.product-carousel :deep(.el-carousel__button) {
|
||||
/* 指示器按钮样式 */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #475669;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.product-info h1 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: var(--el-color-info);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.discontinued-warning {
|
||||
color: var(--el-color-error);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.product-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-carousel-item {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.product-image .el-image {
|
||||
height: 300px;
|
||||
}
|
||||
.product-info {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.product-info h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -3,15 +3,15 @@
|
||||
<el-collapse v-model="activeName">
|
||||
<el-collapse-item
|
||||
v-for="item in data"
|
||||
:key="item.title"
|
||||
:title="item.title"
|
||||
:name="item.title"
|
||||
:key="item.name"
|
||||
:title="item.name"
|
||||
:name="item.name"
|
||||
>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions :column="1" label-width="30%" border>
|
||||
<el-descriptions-item
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.label"
|
||||
:label="subItem.label"
|
||||
v-for="subItem in item.specs"
|
||||
:key="subItem.key"
|
||||
:label="subItem.key"
|
||||
>
|
||||
{{ subItem.value }}
|
||||
</el-descriptions-item>
|
||||
@ -24,15 +24,15 @@
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as () => ProductionSpecGroup[],
|
||||
type: Object as () => ProductSpecGroupView[],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 默认全部展开
|
||||
const activeName = ref<string[]>(
|
||||
props.data.map((item: ProductionSpecGroup) => {
|
||||
return item.title;
|
||||
props.data.map((item: ProductSpecGroupView) => {
|
||||
return item.name;
|
||||
}) || []
|
||||
);
|
||||
</script>
|
||||
@ -40,6 +40,7 @@
|
||||
<style scoped>
|
||||
.spec-collapse ::v-deep(.el-collapse-item__header) {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
78
app/components/pages/search/SearchHeader.vue
Normal file
78
app/components/pages/search/SearchHeader.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="search-header">
|
||||
<h1 class="page-title">{{ $t('search.title') }}</h1>
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
:placeholder="$t('search-placeholder')"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
@keyup.enter="navigateToQuery(keyword)"
|
||||
@clear="handleClear"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="search-button"
|
||||
@click="navigateToQuery(keyword)"
|
||||
>
|
||||
{{ $t('search.search-button') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
|
||||
const keyword = defineModel<string>({ default: '' });
|
||||
const localePath = useLocalePath();
|
||||
const router = useRouter();
|
||||
|
||||
const navigateToQuery = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
navigateTo({
|
||||
path: localePath('/search'),
|
||||
query: { query: trimmed },
|
||||
});
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
keyword.value = '';
|
||||
router.replace(localePath({ path: '/search' }));
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
height: 50px;
|
||||
width: 100px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
75
app/components/pages/search/SearchResultCard.vue
Normal file
75
app/components/pages/search/SearchResultCard.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<el-card class="result-card">
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<h3 class="result-title">{{ item.title }}</h3>
|
||||
<p v-if="item.summary" class="result-summary">
|
||||
{{ item.summary }}
|
||||
</p>
|
||||
<p v-if="item.sectionType" class="result-type">
|
||||
<span>{{ $t('search.section') }}: </span>
|
||||
<span class="result-type-name">{{ typeLabel }}</span>
|
||||
<span v-if="item.type" class="result-type-name"
|
||||
>({{ item.type }})</span
|
||||
>
|
||||
</p>
|
||||
</el-col>
|
||||
<el-col :span="12" class="image-col">
|
||||
<el-image
|
||||
v-if="item.thumbnail"
|
||||
:src="item.thumbnail"
|
||||
:alt="item.title"
|
||||
style="width: 150px"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
item: SearchItemView;
|
||||
typeLabel: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-card {
|
||||
border-radius: 12px;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
font-size: 0.95rem;
|
||||
color: var(--el-text-color-regular);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.result-type-name {
|
||||
margin-left: 4px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.image-col {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@ -2,20 +2,14 @@
|
||||
<div v-if="hasResults">
|
||||
<div class="search-results">
|
||||
<NuxtLink
|
||||
v-for="(hit, hitIndex) in paginatedHits"
|
||||
:key="`${getHitIdentifier(hit.content, hitIndex)}`"
|
||||
:to="localePath(resolveHitLink(hit.content))"
|
||||
v-for="hit in paginatedHits"
|
||||
:key="`${hit.sectionType}-${hit.id}`"
|
||||
:to="localePath(resolveHitLink(hit))"
|
||||
>
|
||||
<el-card class="result-card">
|
||||
<h3 class="result-title">{{ getHitTitle(hit.content) }}</h3>
|
||||
<p v-if="getHitSummary(hit.content)" class="result-summary">
|
||||
{{ getHitSummary(hit.content) }}
|
||||
</p>
|
||||
<p v-if="hit.type" class="result-type">
|
||||
<span>内容类型: </span>
|
||||
<span class="result-type-name">{{ getIndexLabel(hit.type) }}</span>
|
||||
</p>
|
||||
</el-card>
|
||||
<search-result-card
|
||||
:item="hit"
|
||||
:type-label="getIndexLabel(hit.sectionType)"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
@ -44,13 +38,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface HitItem {
|
||||
content: SearchHit;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
hitItems: HitItem[];
|
||||
searchItems: SearchItemView[];
|
||||
currentPage: number;
|
||||
category?: string;
|
||||
}>();
|
||||
@ -74,12 +63,12 @@
|
||||
const pageSize = ref(5);
|
||||
|
||||
// 搜索相关
|
||||
const hits = props.hitItems;
|
||||
const items = props.searchItems;
|
||||
const filteredHits = computed(() => {
|
||||
if (props.category) {
|
||||
return hits.filter((hit) => hit.type === props.category);
|
||||
return items.filter((item) => item.sectionType === props.category);
|
||||
} else {
|
||||
return hits;
|
||||
return items;
|
||||
}
|
||||
});
|
||||
const paginatedHits = computed(() => {
|
||||
@ -89,9 +78,10 @@
|
||||
});
|
||||
|
||||
const indexLabels = computed<Record<string, string>>(() => ({
|
||||
production: t('search.sections.production'),
|
||||
product: t('search.sections.product'),
|
||||
solution: t('search.sections.solution'),
|
||||
support: t('search.sections.support'),
|
||||
question: t('search.sections.faq'),
|
||||
document: t('search.sections.document'),
|
||||
default: t('search.sections.default'),
|
||||
}));
|
||||
|
||||
@ -106,64 +96,13 @@
|
||||
return filteredHits.value.length > 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取搜索条目的唯一标识符
|
||||
* 尝试根据搜索条目的相关词条获取唯一标识符
|
||||
* 若未找到,则fallback至给定的index
|
||||
* @param hit 搜索条目
|
||||
* @param index 条目索引
|
||||
*/
|
||||
const getHitIdentifier = (hit: SearchHit, index: number) => {
|
||||
const candidate = [hit.objectID, hit.documentId, hit.id, hit.slug].find(
|
||||
(value) =>
|
||||
['string', 'number'].includes(typeof value) && String(value).length > 0
|
||||
);
|
||||
return candidate != null ? String(candidate) : String(index);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取搜索条目的标题
|
||||
* @param hit 搜索条目
|
||||
*/
|
||||
const getHitTitle = (hit: SearchHit) => {
|
||||
const candidate = [
|
||||
hit.title,
|
||||
hit.name,
|
||||
hit.heading,
|
||||
hit.documentTitle,
|
||||
].find((value) => typeof value === 'string' && value.trim().length > 0);
|
||||
return candidate ? String(candidate) : t('search.untitled');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取搜索条目的摘要
|
||||
* @param hit 搜索条目
|
||||
*/
|
||||
const getHitSummary = (hit: SearchHit) => {
|
||||
const candidate = [
|
||||
hit.summary,
|
||||
hit.description,
|
||||
hit.snippet,
|
||||
hit.content,
|
||||
hit.text,
|
||||
].find((value) => typeof value === 'string' && value.trim().length > 0);
|
||||
return candidate ? String(candidate) : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析条目链接
|
||||
* 根据条目类型返回正确的跳转链接
|
||||
* @param hit 搜索条目
|
||||
* @param item 搜索条目
|
||||
*/
|
||||
const resolveHitLink = (hit: SearchHit) => {
|
||||
if (typeof hit.route === 'string' && hit.route.trim().length > 0) {
|
||||
return localePath(hit.route);
|
||||
}
|
||||
|
||||
const slugCandidate = [hit.slug, hit.documentId, hit.id, hit.objectID].find(
|
||||
(value) =>
|
||||
['string', 'number'].includes(typeof value) && String(value).length > 0
|
||||
);
|
||||
const resolveHitLink = (item: SearchItemView) => {
|
||||
const slugCandidate = item.id;
|
||||
|
||||
if (!slugCandidate) {
|
||||
return null;
|
||||
@ -171,14 +110,22 @@
|
||||
|
||||
const slug = String(slugCandidate);
|
||||
|
||||
if (hit.indexUid === 'production') {
|
||||
return localePath({ path: `/productions/${slug}` });
|
||||
if (item.sectionType === 'product') {
|
||||
return localePath({ path: `/products/${slug}` });
|
||||
}
|
||||
|
||||
if (hit.indexUid === 'solution') {
|
||||
if (item.sectionType === 'solution') {
|
||||
return localePath({ path: `/solutions/${slug}` });
|
||||
}
|
||||
|
||||
if (item.sectionType === 'document') {
|
||||
return localePath({ path: `/download/${slug}` });
|
||||
}
|
||||
|
||||
if (item.sectionType === 'question') {
|
||||
return localePath({ path: `/support/faq`, query: { focus: slug } });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
55
app/components/pages/search/SearchTabs.vue
Normal file
55
app/components/pages/search/SearchTabs.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:label="`${tab.label}(${resultCount[tab.name] || 0})`"
|
||||
:name="tab.name"
|
||||
>
|
||||
<SearchResults
|
||||
v-model:current-page="currentPage"
|
||||
:search-items="searchItems"
|
||||
:category="tab.name === 'all' ? undefined : tab.name"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
// resultCount: Record<string, number>;
|
||||
searchItems: SearchItemView[];
|
||||
}>();
|
||||
|
||||
const tabs = [
|
||||
{ name: 'all', label: $t('all') },
|
||||
{ name: 'product', label: $t('search.sections.product') },
|
||||
{ name: 'solution', label: $t('search.sections.solution') },
|
||||
{ name: 'question', label: $t('search.sections.faq') },
|
||||
{ name: 'document', label: $t('search.sections.document') },
|
||||
];
|
||||
|
||||
const resultCount = computed(() => {
|
||||
const map: Record<string, number> = { all: props.searchItems.length };
|
||||
for (const item of props.searchItems) {
|
||||
map[item.sectionType] = (map[item.sectionType] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// 分类控制
|
||||
const activeTab = ref('all');
|
||||
|
||||
// 分页控制
|
||||
const currentPage = ref(1);
|
||||
|
||||
watch(activeTab, () => {
|
||||
currentPage.value = 1; // 重置页码
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
59
app/components/pages/solutions/SolutionDetail.vue
Normal file
59
app/components/pages/solutions/SolutionDetail.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<article class="solution-defail">
|
||||
<header class="solution-header">
|
||||
<h1 class="solution-title">{{ solution.title }}</h1>
|
||||
<p class="solution-summary">{{ solution.summary }}</p>
|
||||
</header>
|
||||
<section class="solution-content">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="html-typography" v-html="solution?.content || ''" />
|
||||
<!-- <div v-if="!hydrated" v-html="solution?.content || ''" /> -->
|
||||
<!-- <div v-else> -->
|
||||
<!-- <html-renderer class="html-typography" :html="solution.content || ''" /> -->
|
||||
<!-- </div> -->
|
||||
</section>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
solution: {
|
||||
type: Object as PropType<SolutionView>,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const hydrated = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
hydrated.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.solution-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.solution-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.solution-summary {
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.solution-content {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
113
app/components/pages/support/DocumentFilter.vue
Normal file
113
app/components/pages/support/DocumentFilter.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="document-category">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12" :xs="12">
|
||||
<span class="select-label">{{
|
||||
$t('product-filter.product-type')
|
||||
}}</span>
|
||||
<el-select
|
||||
v-model="model.selectedProductType"
|
||||
:placeholder="$t('product-filter.select-product-type')"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="type in productTypeOptions"
|
||||
:key="type.id"
|
||||
:label="type.name"
|
||||
:value="type.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" :xs="12">
|
||||
<span class="select-label">{{
|
||||
$t('product-filter.product-model')
|
||||
}}</span>
|
||||
<el-select
|
||||
v-model="model.selectedProduct"
|
||||
:placeholder="$t('product-filter.select-product-model')"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="product in productOptions"
|
||||
:key="product.id"
|
||||
:label="product.name"
|
||||
:value="product.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" :xs="24">
|
||||
<span class="select-label">
|
||||
{{ $t('product-filter.document-type') }}
|
||||
</span>
|
||||
<el-select
|
||||
v-model="model.selectedDocumentType"
|
||||
:placeholder="$t('product-filter.select-document-type')"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="questionType in documentTypeOptions"
|
||||
:key="questionType.id"
|
||||
:label="questionType.name"
|
||||
:value="questionType.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" :xs="24">
|
||||
<span class="select-label">{{ $t('product-filter.keyword') }}</span>
|
||||
<el-input
|
||||
v-model="model.keyword"
|
||||
:placeholder="$t('product-filter.enter-keyword')"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
|
||||
defineProps({
|
||||
productTypeOptions: {
|
||||
type: Array as () => Array<DocumentListProductType>,
|
||||
default: () => [],
|
||||
},
|
||||
productOptions: {
|
||||
type: Array as () => Array<DocumentListProduct>,
|
||||
default: () => [],
|
||||
},
|
||||
documentTypeOptions: {
|
||||
type: Array as () => Array<DocumentTypeView>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const model = defineModel<{
|
||||
selectedProduct: string | null;
|
||||
selectedProductType: string | null;
|
||||
selectedDocumentType: string | null;
|
||||
keyword: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.document-category {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0 0;
|
||||
}
|
||||
|
||||
.select-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper),
|
||||
:deep(.el-input__wrapper) {
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
112
app/components/pages/support/QuestionFilter.vue
Normal file
112
app/components/pages/support/QuestionFilter.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="question-category">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12" :xs="12">
|
||||
<span class="select-label">{{
|
||||
$t('product-filter.product-type')
|
||||
}}</span>
|
||||
<el-select
|
||||
v-model="model.selectedProductType"
|
||||
:placeholder="$t('product-filter.select-product-type')"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="type in productTypeOptions"
|
||||
:key="type.id"
|
||||
:label="type.name"
|
||||
:value="type.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" :xs="12">
|
||||
<span class="select-label">{{
|
||||
$t('product-filter.product-model')
|
||||
}}</span>
|
||||
<el-select
|
||||
v-model="model.selectedProduct"
|
||||
:placeholder="$t('product-filter.select-product-model')"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="product in productOptions"
|
||||
:key="product.id"
|
||||
:label="product.name"
|
||||
:value="product.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" :xs="24">
|
||||
<span class="select-label">
|
||||
{{ $t('product-filter.question-type') }}
|
||||
</span>
|
||||
<el-select
|
||||
v-model="model.selectedQuestionType"
|
||||
:placeholder="$t('product-filter.select-question-type')"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="questionType in questionTypeOptions"
|
||||
:key="questionType.id"
|
||||
:label="questionType.name"
|
||||
:value="questionType.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" :xs="24">
|
||||
<span class="select-label">{{ $t('product-filter.keyword') }}</span>
|
||||
<el-input
|
||||
v-model="model.keyword"
|
||||
:placeholder="$t('product-filter.enter-keyword')"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
|
||||
defineProps({
|
||||
productTypeOptions: {
|
||||
type: Array as () => Array<QuestionListProductType>,
|
||||
default: () => [],
|
||||
},
|
||||
productOptions: {
|
||||
type: Array as () => Array<QuestionListProduct>,
|
||||
default: () => [],
|
||||
},
|
||||
questionTypeOptions: {
|
||||
type: Array as () => Array<QuestionTypeView>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const model = defineModel<{
|
||||
selectedProduct: string | null;
|
||||
selectedProductType: string | null;
|
||||
selectedQuestionType: string | null;
|
||||
keyword: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.question-category {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.select-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper),
|
||||
:deep(.el-input__wrapper) {
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
96
app/components/pages/support/SupportCard.vue
Normal file
96
app/components/pages/support/SupportCard.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<el-card class="support-card">
|
||||
<el-row>
|
||||
<el-col :span="6">
|
||||
<el-icon class="card-icon" size="80">
|
||||
<component :is="iconComponent" />
|
||||
</el-icon>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<div class="card-title">
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<div class="card-content">
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<NuxtLink class="card-link" :to="to">
|
||||
<el-button class="card-button" round>
|
||||
<span>{{ $t('learn-more') }} > </span>
|
||||
</el-button>
|
||||
</NuxtLink>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
iconComponent: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.support-card {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
box-shadow: none;
|
||||
border-radius: none;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
margin-left: 2rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-link {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: var(--el-color-primary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.el-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.el-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.el-col {
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@ -21,10 +21,10 @@
|
||||
|
||||
const activeTab = ref(props.modelValue || '');
|
||||
const options = [
|
||||
{ label: '服务支持', value: '' },
|
||||
{ label: '常见问题', value: 'faq' },
|
||||
{ label: '文档资料', value: 'documents' },
|
||||
{ label: '联系售后', value: 'contact-us' },
|
||||
{ label: $t('navigation.support'), value: '' },
|
||||
{ label: $t('navigation.faq'), value: 'faq' },
|
||||
{ label: $t('navigation.documents'), value: 'documents' },
|
||||
{ label: $t('navigation.contact-info'), value: 'contact-us' },
|
||||
];
|
||||
|
||||
const handleSegmentedChange = (value: string) => {
|
||||
21
app/components/shared/AppBreadcrumb.vue
Normal file
21
app/components/shared/AppBreadcrumb.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="text-md opacity-50"
|
||||
>
|
||||
<NuxtLink v-if="item.to" :to="item.to">{{ item.label }}</NuxtLink>
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array as () => Array<{ label: string; to?: string }>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
98
app/components/shared/DocumentList.vue
Normal file
98
app/components/shared/DocumentList.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="document-list">
|
||||
<el-card
|
||||
v-for="(doc, index) in documents"
|
||||
:key="index"
|
||||
class="document-card"
|
||||
@click="handleClick(doc.fileId)"
|
||||
>
|
||||
<div class="document-info">
|
||||
<div class="document-title">
|
||||
<h3>
|
||||
{{ doc.title }}
|
||||
<span v-if="showCategory && doc.type" class="document-category">
|
||||
|
|
||||
</span>
|
||||
<span v-if="showCategory && doc.type" class="document-category">
|
||||
{{ doc.type.name }}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="document-content">
|
||||
<span v-if="doc.size" class="document-meta"
|
||||
>{{ $t('document-meta.size') }}: {{ formatFileSize(doc.size) }}
|
||||
</span>
|
||||
<span v-if="doc.filename" class="document-meta"
|
||||
>{{ $t('document-meta.format') }}:
|
||||
{{ formatFileExtension(getFileExtension(doc.filename)) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
documents: {
|
||||
type: Array as () => Array<ProductDocumentView>,
|
||||
default: () => [],
|
||||
},
|
||||
showCategory: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
// 获取路由参数
|
||||
if (id) {
|
||||
navigateTo(localePath(`/download/${id}`));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.document-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.document-title {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.document-category {
|
||||
font-size: 0.75rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.download-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
179
app/components/shared/FilePreviewer.vue
Normal file
179
app/components/shared/FilePreviewer.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<section class="h-screen flex flex-col">
|
||||
<!-- 头部工具栏 -->
|
||||
<header
|
||||
v-if="showToolbar && fileMeta"
|
||||
class="p-3 border-b flex items-center justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate font-medium" :title="fileMeta.filename_download">
|
||||
{{ fileMeta.filename_download }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ fileMeta.type }} · {{ formatedSize }} · {{ formatedDate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded border hover:bg-gray-50"
|
||||
type="button"
|
||||
:disabled="!fileMeta"
|
||||
@click="openInNewTab"
|
||||
>
|
||||
{{ $t('document-action.open-in-new-tab') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="!fileMeta"
|
||||
@click="download"
|
||||
>
|
||||
{{ $t('document-action.download') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="pending" class="h-48 grid place-items-center border rounded">
|
||||
{{ $t('loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="errorText"
|
||||
class="h-48 grid place-items-center border rounded text-red-600"
|
||||
>
|
||||
{{ errorText }}
|
||||
</div>
|
||||
<!-- 文件预览 -->
|
||||
<ClientOnly v-else>
|
||||
<div
|
||||
v-if="fileMeta && previewable"
|
||||
class="h-full w-full flex justify-center bg-gray-50"
|
||||
>
|
||||
<!-- 图片 -->
|
||||
<el-image
|
||||
v-if="isImage"
|
||||
fit="contain"
|
||||
class="max-w-full max-h-full select-none"
|
||||
:src="src"
|
||||
:alt="fileMeta.title || fileMeta.filename_download"
|
||||
/>
|
||||
|
||||
<!-- PDF -->
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="src"
|
||||
title="PDF 预览"
|
||||
class="w-full h-full border-0"
|
||||
/>
|
||||
|
||||
<!-- 视频 -->
|
||||
<video
|
||||
v-else-if="isVideo"
|
||||
:src="src"
|
||||
controls
|
||||
class="w-full bg-black"
|
||||
/>
|
||||
|
||||
<!-- 文本(简单方式用 iframe;如需代码高亮可改为拉取文本并渲染 <pre>) -->
|
||||
<iframe
|
||||
v-else-if="isText"
|
||||
:src="src"
|
||||
title="文本预览"
|
||||
class="w-full h-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 预览的文件 ID */
|
||||
fileId?: string;
|
||||
file?: FileMeta;
|
||||
|
||||
/** 是否显示上方工具栏(文件名、大小、按钮) */
|
||||
showToolbar?: boolean;
|
||||
|
||||
/** 下载 API 基础路径(你的后端流接口),用于“下载”按钮 */
|
||||
downloadApiBase?: string;
|
||||
|
||||
/** 追加到 file.url 的查询(如临时 token),形如 { token: 'xxx' } */
|
||||
extraQuery?: Record<string, string | number | boolean>;
|
||||
}>(),
|
||||
{
|
||||
fileId: undefined,
|
||||
file: undefined,
|
||||
showToolbar: true,
|
||||
downloadApiBase: '/api/download',
|
||||
extraQuery: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const { data, pending, error } = await useFetch<FileMeta>(
|
||||
() => (props.file ? null : `/api/file/${props.fileId}`),
|
||||
{ server: true }
|
||||
);
|
||||
|
||||
const errorText = computed(() => error.value?.message ?? null);
|
||||
const fileMeta = computed(() => props.file ?? data.value ?? null);
|
||||
|
||||
logger.debug('FilePreviewer - fileMeta:', fileMeta.value);
|
||||
|
||||
/** 预览源地址:支持在 file.url 上追加额外 query(如临时 token、inline) */
|
||||
const src = computed<string>(() => {
|
||||
if (!fileMeta.value) return '';
|
||||
const url = new URL(fileMeta.value.url, window?.location?.origin);
|
||||
if (props.extraQuery) {
|
||||
Object.entries(props.extraQuery).forEach(([k, v]) =>
|
||||
url.searchParams.set(k, String(v))
|
||||
);
|
||||
}
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
/** 类型判定 */
|
||||
const isImage = computed(
|
||||
() => fileMeta.value?.type.startsWith('image/') === true
|
||||
);
|
||||
const isPdf = computed(() => fileMeta.value?.type === 'application/pdf');
|
||||
const isVideo = computed(
|
||||
() => fileMeta.value?.type.startsWith('video/') === true
|
||||
);
|
||||
const isText = computed(
|
||||
() => fileMeta.value?.type.startsWith('text/') === true
|
||||
);
|
||||
const previewable = computed(() => fileMeta.value?.previewable === true);
|
||||
|
||||
const formatedSize = computed(() => {
|
||||
const size = fileMeta.value?.filesize ?? 0;
|
||||
return formatFileSize(size);
|
||||
});
|
||||
|
||||
const formatedDate = computed(() => {
|
||||
if (!fileMeta.value?.uploaded_on) return '';
|
||||
return new Date(fileMeta.value.uploaded_on).toLocaleDateString();
|
||||
});
|
||||
|
||||
/** 下载动作:走你自己的流式后端,避免直链暴露(便于权限与统计) */
|
||||
function download(): void {
|
||||
if (!fileMeta.value) return;
|
||||
const id = fileMeta.value.id;
|
||||
const a = document.createElement('a');
|
||||
a.href = `${props.downloadApiBase}/${encodeURIComponent(id)}`;
|
||||
a.download = fileMeta.value.filename_download;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
/** 新标签打开(直接访问直链,适合预览失败时的兜底体验) */
|
||||
function openInNewTab(): void {
|
||||
if (!src.value) return;
|
||||
window.open(src.value, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
</script>
|
||||
30
app/components/shared/HtmlRenderer.ts
Normal file
30
app/components/shared/HtmlRenderer.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export default defineComponent({
|
||||
name: 'HtmlRenderer',
|
||||
|
||||
props: {
|
||||
html: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
map: {
|
||||
type: Object as () => HtmlRenderMap,
|
||||
default: () => defaultHtmlRenderMap,
|
||||
},
|
||||
allowUnknown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, { attrs }) {
|
||||
const nodes: VNode[] = useHtmlRenderer(props.html, {
|
||||
map: props.map,
|
||||
allowUnknownTags: props.allowUnknown,
|
||||
});
|
||||
|
||||
logger.debug('nodes: ', nodes);
|
||||
|
||||
// 渲染函数:直接返回 VNode 数组
|
||||
return () => h('div', { ...attrs }, nodes);
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<footer class="jinshen-footer">
|
||||
<div class="footer-container">
|
||||
<div class="footer-container hide-on-mobile">
|
||||
<!-- Logo 和公司信息 -->
|
||||
<div class="footer-section">
|
||||
<div class="footer-logo">
|
||||
@ -20,8 +20,8 @@
|
||||
<NuxtLinkLocale to="/">{{ $t('navigation.home') }}</NuxtLinkLocale>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink :to="$localePath('/productions')">{{
|
||||
$t('navigation.productions')
|
||||
<NuxtLink :to="$localePath('/products')">{{
|
||||
$t('navigation.products')
|
||||
}}</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
@ -84,19 +84,24 @@
|
||||
© {{ currentYear }} {{ $t('company-name') }}.
|
||||
{{ $t('all-rights-reserved') }}
|
||||
</p>
|
||||
<p>备案号: 浙ICP备12003709号-5</p>
|
||||
</div>
|
||||
<div class="footer-links-bottom">
|
||||
<NuxtLink :to="$localePath('/privacy')">{{
|
||||
$t('privacy-policy')
|
||||
}}</NuxtLink>
|
||||
<span class="separator">|</span>
|
||||
<NuxtLink :to="$localePath('/terms')">{{
|
||||
$t('terms-of-service')
|
||||
}}</NuxtLink>
|
||||
<span class="separator">|</span>
|
||||
<NuxtLink :to="$localePath('/sitemap')">{{ $t('sitemap') }}</NuxtLink>
|
||||
<p>
|
||||
备案号:
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank"
|
||||
>浙ICP备12003709号-5</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<!-- <div class="footer-links-bottom"> -->
|
||||
<!-- <NuxtLink :to="$localePath('/privacy')">{{ -->
|
||||
<!-- $t('privacy-policy') -->
|
||||
<!-- }}</NuxtLink> -->
|
||||
<!-- <span class="separator">|</span> -->
|
||||
<!-- <NuxtLink :to="$localePath('/terms')">{{ -->
|
||||
<!-- $t('terms-of-service') -->
|
||||
<!-- }}</NuxtLink> -->
|
||||
<!-- <span class="separator">|</span> -->
|
||||
<!-- <NuxtLink :to="$localePath('/sitemap')">{{ $t('sitemap') }}</NuxtLink> -->
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@ -250,6 +255,10 @@
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.hide-on-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1.5rem 1rem;
|
||||
350
app/components/shared/JinshenHeader.vue
Normal file
350
app/components/shared/JinshenHeader.vue
Normal file
@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<header class="header-container">
|
||||
<!-- Logo -->
|
||||
<div class="logo-section">
|
||||
<NuxtLink :to="$localePath('/')" class="logo-link">
|
||||
<el-image
|
||||
class="website-logo"
|
||||
src="/jinshen-logo.png"
|
||||
alt="Jinshen Logo"
|
||||
fit="contain"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 桌面菜单 -->
|
||||
<div class="header-menu-section">
|
||||
<!-- 导航菜单 -->
|
||||
<el-menu
|
||||
:default-active="activeName"
|
||||
class="header-menu"
|
||||
mode="horizontal"
|
||||
:ellipsis="false"
|
||||
:persistent="false"
|
||||
router
|
||||
>
|
||||
<el-menu-item index="products" :route="$localePath('/products')">
|
||||
<span class="title">{{ $t('navigation.products') }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="solutions" :route="$localePath('/solutions')">
|
||||
<span class="title">{{ $t('navigation.solutions') }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="support" :route="$localePath('/support')">
|
||||
<span class="title">{{ $t('navigation.support') }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="about" :route="$localePath('/about')">
|
||||
<span class="title">{{ $t('navigation.about-us') }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<!-- 右侧功能区 -->
|
||||
<div class="header-actions">
|
||||
<el-link
|
||||
class="search-link"
|
||||
:underline="false"
|
||||
type="info"
|
||||
@click="navigateTo(localePath('/search'))"
|
||||
>
|
||||
<el-icon class="mdi mdi-magnify action-icon" />
|
||||
</el-link>
|
||||
|
||||
<el-link
|
||||
class="hide-on-mobile"
|
||||
type="info"
|
||||
:underline="false"
|
||||
href="http://cal.jinshen.cn"
|
||||
target="_blank"
|
||||
>
|
||||
<el-icon class="mdi mdi-calculator action-icon" />
|
||||
</el-link>
|
||||
|
||||
<el-dropdown class="hide-on-mobile" @command="setLocale">
|
||||
<el-link type="info" :underline="false">
|
||||
<el-icon class="mdi mdi-translate action-icon" />
|
||||
</el-link>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="zh">简体中文</el-dropdown-item>
|
||||
<el-dropdown-item command="en">English</el-dropdown-item>
|
||||
<el-dropdown-item command="es"
|
||||
>Español(Machine Translate)</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item command="ru"
|
||||
>Русский(Machine Translate)</el-dropdown-item
|
||||
>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 汉堡按钮(仅移动端显示) -->
|
||||
<el-link type="info" :underline="false">
|
||||
<el-icon
|
||||
class="mdi mdi-menu mobile-menu-button"
|
||||
@click="mobileMenuVisible = true"
|
||||
/>
|
||||
</el-link>
|
||||
|
||||
<!-- Drawer 抽屉菜单 -->
|
||||
<client-only>
|
||||
<el-drawer
|
||||
v-model="mobileMenuVisible"
|
||||
class="mobile-drawer"
|
||||
direction="rtl"
|
||||
size="70%"
|
||||
>
|
||||
<template #header>
|
||||
<div class="drawer-header">
|
||||
<h1>{{ $t('mobile-menu.title') }}</h1>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<h2>{{ $t('mobile-menu.navigation') }}</h2>
|
||||
<el-menu
|
||||
:default-active="activeName"
|
||||
class="mobile-menu"
|
||||
mode="vertical"
|
||||
router
|
||||
@select="mobileMenuVisible = false"
|
||||
>
|
||||
<el-menu-item index="products" :route="$localePath('/products')">
|
||||
{{ $t('navigation.products') }}
|
||||
</el-menu-item>
|
||||
<el-menu-item
|
||||
index="solutions"
|
||||
:route="$localePath('/solutions')"
|
||||
>
|
||||
{{ $t('navigation.solutions') }}
|
||||
</el-menu-item>
|
||||
<el-menu-item index="support" :route="$localePath('/support')">
|
||||
{{ $t('navigation.support') }}
|
||||
</el-menu-item>
|
||||
<el-menu-item index="about" :route="$localePath('/about')">
|
||||
{{ $t('navigation.about-us') }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<h2>{{ $t('mobile-menu.utilities') }}</h2>
|
||||
<el-menu
|
||||
:default-active="activeName"
|
||||
class="mobile-menu"
|
||||
mode="vertical"
|
||||
@select="mobileMenuVisible = false"
|
||||
>
|
||||
<el-menu-item @click="openExternalLink('http://cal.jinshen.cn')">
|
||||
{{ $t('navigation.calculator') }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-dropdown @command="setLocale">
|
||||
<el-link type="info" :underline="false">
|
||||
<el-icon class="mdi mdi-translate mobile-menu-button" />
|
||||
</el-link>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="zh">简体中文</el-dropdown-item>
|
||||
<el-dropdown-item command="en">English</el-dropdown-item>
|
||||
<el-dropdown-item command="es"
|
||||
>Español(Machine Translate)</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item command="ru"
|
||||
>Русский(Machine Translate)</el-dropdown-item
|
||||
>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</client-only>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const { setLocale } = useI18n();
|
||||
|
||||
const activeName = ref<string | undefined>(undefined);
|
||||
const mobileMenuVisible = ref(false);
|
||||
|
||||
const refreshMenu = () => {
|
||||
const path = router.currentRoute.value.path;
|
||||
if (path.startsWith('/products')) {
|
||||
activeName.value = 'products';
|
||||
} else if (path.startsWith('/solutions')) {
|
||||
activeName.value = 'solutions';
|
||||
} else if (path.startsWith('/support')) {
|
||||
activeName.value = 'support';
|
||||
} else if (path.startsWith('/about')) {
|
||||
activeName.value = 'about';
|
||||
} else {
|
||||
activeName.value = undefined; // 默认不激活任何菜单项
|
||||
}
|
||||
};
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshMenu();
|
||||
// 监听路由变化以更新激活状态
|
||||
router.afterEach(() => {
|
||||
refreshMenu();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 80px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.website-logo {
|
||||
height: 64px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header-menu-section {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-menu {
|
||||
margin-right: 40px;
|
||||
border-bottom: none !important;
|
||||
width: auto;
|
||||
--el-menu-horizontal-height: 100%;
|
||||
}
|
||||
|
||||
.header-menu .el-menu-item {
|
||||
font-size: 16px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.header-menu .el-menu-item.is-active {
|
||||
border-bottom: 2.5px solid var(--el-color-primary-dark-2);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-menu .el-menu-item:hover {
|
||||
border-bottom: 2.5px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.mobile-menu-button {
|
||||
display: none; /* 默认隐藏汉堡按钮 */
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.el-drawer__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-drawer__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mobile-drawer h1 {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mobile-drawer h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
border: none;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mobile-menu .el-menu-item {
|
||||
font-size: 16px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.mobile-menu .el-menu-item.is-active {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.mobile-menu .el-menu-item:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.header-container {
|
||||
height: 70px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.website-logo {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.header-menu-section .header-menu .el-menu-item {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-menu-section {
|
||||
display: none; /* 隐藏横向菜单 */
|
||||
}
|
||||
|
||||
.hide-on-mobile {
|
||||
display: none; /* 隐藏部分图标 */
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -13,22 +13,17 @@
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const contentWithAbsoluteUrls = convertMedia(props.content);
|
||||
|
||||
// 将 Markdown 转换成 HTML
|
||||
const safeHtml = computed(() => renderMarkdown(contentWithAbsoluteUrls));
|
||||
// const safeHtml = computed(() => renderMarkdown(props.content))
|
||||
const safeHtml = computed(() => renderMarkdown(props.content));
|
||||
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
if (!safeHtml.value) return;
|
||||
console.log(safeHtml.value);
|
||||
|
||||
// 查找所有 table
|
||||
const tables = container.value.querySelectorAll('table');
|
||||
console.log(tables);
|
||||
tables.forEach((table) => {
|
||||
// 1. 提取表头
|
||||
const headers = Array.from(table.querySelectorAll('thead th')).map(
|
||||
@ -55,8 +50,6 @@
|
||||
app.mount(mountPoint);
|
||||
});
|
||||
});
|
||||
|
||||
// console.log('Rendered HTML:', safeHtml.value);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
33
app/components/shared/NotFoundResult.vue
Normal file
33
app/components/shared/NotFoundResult.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<el-result icon="warning" :title="title" :sub-title="subTitle">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="onBack">
|
||||
{{ backText || $t('back') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subTitle: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
backText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
onBack: {
|
||||
type: Function as () => unknown,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<el-card class="production-card" @click="handleClick">
|
||||
<el-card class="product-card" @click="handleClick">
|
||||
<template #header>
|
||||
<!-- Image -->
|
||||
<el-image class="production-image" :src="imageUrl" fit="contain" />
|
||||
<el-image class="product-image" :src="imageUrl" fit="contain" />
|
||||
</template>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Name -->
|
||||
<div class="text-center">
|
||||
<span class="production-name">{{ name }}</span>
|
||||
<span class="product-name">{{ name }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="card-description text-left opacity-25">{{ description }}</div>
|
||||
@ -32,25 +32,25 @@
|
||||
// 优先使用 slug,如果没有则使用 id
|
||||
const routeParam = props.slug || props.id;
|
||||
if (routeParam) {
|
||||
navigateTo(localePath(`/productions/${routeParam}`));
|
||||
navigateTo(localePath(`/products/${routeParam}`));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.production-card {
|
||||
.product-card {
|
||||
width: 30%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.production-card:hover {
|
||||
.product-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.production-name {
|
||||
.product-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@ -60,8 +60,9 @@
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.production-card .el-image {
|
||||
height: 150px;
|
||||
.product-card .el-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@ -73,13 +74,13 @@
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.production-card {
|
||||
.product-card {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.production-card {
|
||||
.product-card {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
112
app/components/shared/QuestionList.vue
Normal file
112
app/components/shared/QuestionList.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="question-list">
|
||||
<el-collapse v-model="activeNames" class="question-collapse">
|
||||
<el-collapse-item
|
||||
v-for="question in questions"
|
||||
:id="`q-${question.id}`"
|
||||
:key="question.id"
|
||||
:title="question.title"
|
||||
:name="question.id"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="html-typography" v-html="question.content || ''" />
|
||||
<!-- <div v-if="!hydrated" v-html="question.content" /> -->
|
||||
<!-- <div v-else> -->
|
||||
<!-- <html-renderer -->
|
||||
<!-- class="html-typography" -->
|
||||
<!-- :html="question.content || ''" -->
|
||||
<!-- /> -->
|
||||
<!-- </div> -->
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
questions: {
|
||||
type: Array as PropType<ProductQuestionView[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
const route = useRoute();
|
||||
|
||||
const activeNames = ref<(string | number)[]>([]);
|
||||
|
||||
const hydrated = ref(false);
|
||||
|
||||
// 当路由变化(包括初次挂载)时,检查是否需要聚焦
|
||||
watch(
|
||||
() => route.query.focus,
|
||||
async (focusId) => {
|
||||
if (!focusId) return;
|
||||
if (!import.meta.client) return;
|
||||
|
||||
// 确保渲染完成后再操作 DOM
|
||||
await nextTick();
|
||||
|
||||
const target = props.questions.find((q) => q.id === focusId);
|
||||
if (!target) return;
|
||||
|
||||
// 展开目标项
|
||||
activeNames.value = [target.id];
|
||||
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100)); // 等待动画完成
|
||||
|
||||
// 平滑滚动到对应位置
|
||||
// const el = document.querySelector(`#q-${target.id}`);
|
||||
// el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector(`#q-${target.id}`);
|
||||
el?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}, 200); // 与 el-collapse 的 transition 时间匹配
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
hydrated.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.question-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.question-collapse {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.question-collapse :deep(.el-collapse-item) {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.question-collapse :deep(.el-collapse-item__header) {
|
||||
font-size: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: #f5f7fa;
|
||||
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.is-active {
|
||||
background-color: #e1e6eb;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.question-collapse :deep(.el-collapse-item__wrap) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.question-collapse :deep(.el-collapse-item__content) {
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@ -67,7 +67,8 @@
|
||||
}
|
||||
|
||||
.solution-card .el-image {
|
||||
height: 250px;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
11
app/composables/directus/index.ts
Normal file
11
app/composables/directus/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export * from './useDirectusImage';
|
||||
export * from './useDirectusFiles';
|
||||
export * from './useProductList';
|
||||
export * from './useProduct';
|
||||
export * from './useSolutionList';
|
||||
export * from './useSolution';
|
||||
export * from './useQuestionList';
|
||||
export * from './useDocumentList';
|
||||
export * from './useContactInfo';
|
||||
export * from './useCompanyProfile';
|
||||
export * from './useHomepage';
|
||||
17
app/composables/directus/useCompanyProfile.ts
Normal file
17
app/composables/directus/useCompanyProfile.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useCompanyProfile = () => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`company-profile-${locale}`, async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/cms/companyProfile`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching company profile: ', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
17
app/composables/directus/useContactInfo.ts
Normal file
17
app/composables/directus/useContactInfo.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useContactInfo = () => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`contact-info-${locale}`, async () => {
|
||||
try {
|
||||
const data = await $fetch('/api/cms/contactInfo', {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching contact info: ', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
17
app/composables/directus/useDirectusFiles.ts
Normal file
17
app/composables/directus/useDirectusFiles.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useDirectusFiles = () => {
|
||||
const getFileUrl = (
|
||||
id?: string | null,
|
||||
options?: Record<string, string | number | boolean>
|
||||
): string => {
|
||||
if (!id) return '';
|
||||
|
||||
const params = new URLSearchParams(
|
||||
options as Record<string, string>
|
||||
).toString();
|
||||
return `/api/assets/${id}${params ? `?${params}` : ''}`;
|
||||
};
|
||||
|
||||
return {
|
||||
getFileUrl,
|
||||
};
|
||||
};
|
||||
20
app/composables/directus/useDirectusImage.ts
Normal file
20
app/composables/directus/useDirectusImage.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const useDirectusImage = () => {
|
||||
type DirectusAssetParams = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
fit?: 'cover' | 'contain' | 'inside' | 'outside';
|
||||
quality?: number;
|
||||
format?: 'webp' | 'jpg' | 'png' | 'auto';
|
||||
} & Record<string, string | number | boolean>;
|
||||
|
||||
const getImageUrl = (id?: string | null, options?: DirectusAssetParams) => {
|
||||
if (!id) return '';
|
||||
|
||||
const params = new URLSearchParams(
|
||||
options as Record<string, string>
|
||||
).toString();
|
||||
return `/api/assets/${id}${params ? `?${params}` : ''}`;
|
||||
};
|
||||
|
||||
return { getImageUrl };
|
||||
};
|
||||
17
app/composables/directus/useDocumentList.ts
Normal file
17
app/composables/directus/useDocumentList.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useDocumentList = () => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`document-list-${locale}`, async () => {
|
||||
try {
|
||||
const data = $fetch(`/api/cms/documentList`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching document list:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
17
app/composables/directus/useHomepage.ts
Normal file
17
app/composables/directus/useHomepage.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useHomepage = () => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`homepage-${locale}`, async () => {
|
||||
try {
|
||||
const data = $fetch(`/api/cms/homepage`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching homepage:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
17
app/composables/directus/useProduct.ts
Normal file
17
app/composables/directus/useProduct.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useProduct = (id: string) => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`product-${id}-${locale}`, async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/cms/product/${id}`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching product: ', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
17
app/composables/directus/useProductList.ts
Normal file
17
app/composables/directus/useProductList.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useProductList = () => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`product-list-${locale}`, async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/cms/productList`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching product list: ', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
17
app/composables/directus/useQuestionList.ts
Normal file
17
app/composables/directus/useQuestionList.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useQuestionList = () => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`question-list-${locale}`, async () => {
|
||||
try {
|
||||
const data = $fetch(`/api/cms/questionList`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching question list:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
17
app/composables/directus/useSolution.ts
Normal file
17
app/composables/directus/useSolution.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useSolution = (id: string) => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`solution-${id}-${locale}`, async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/cms/solution/${id}`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching solution: ', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
17
app/composables/directus/useSolutionList.ts
Normal file
17
app/composables/directus/useSolutionList.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const useSolutionList = () => {
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
return useAsyncData(`solution-list-${locale}`, async () => {
|
||||
try {
|
||||
const data = $fetch(`/api/cms/solutionList`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching solution list:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
2
app/composables/index.ts
Normal file
2
app/composables/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './directus';
|
||||
export * from './useLocalizations';
|
||||
80
app/composables/useHtmlRenderer.ts
Normal file
80
app/composables/useHtmlRenderer.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { h, type VNode } from 'vue';
|
||||
import { parseDocument } from 'htmlparser2';
|
||||
import type { Element, Text, Node } from 'domhandler';
|
||||
|
||||
/** 标签映射的类型:key=标签名,value=渲染函数 */
|
||||
export type HtmlRenderMap = Record<
|
||||
string,
|
||||
(node: Element, children: VNode[]) => VNode
|
||||
>;
|
||||
|
||||
export interface HtmlRenderOptions {
|
||||
/** 自定义标签渲染映射 */
|
||||
map?: HtmlRenderMap;
|
||||
|
||||
/** 默认是否将未知标签渲染为原生标签(默认 true) */
|
||||
allowUnknownTags?: boolean;
|
||||
}
|
||||
|
||||
export const useHtmlRenderer = (
|
||||
html: string,
|
||||
options: HtmlRenderOptions = {}
|
||||
) => {
|
||||
const { map = {}, allowUnknownTags = true } = options;
|
||||
|
||||
const doc = parseDocument(html);
|
||||
|
||||
function render(node: Node): VNode | string | null {
|
||||
// 文本节点
|
||||
if (node.type === 'text') {
|
||||
const textNode = node as Text;
|
||||
const content = textNode.data;
|
||||
|
||||
// ❗忽略"纯空白" 文本(换行、空格)
|
||||
if (!content || /^\s+$/.test(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// 标签节点
|
||||
if (node.type === 'tag') {
|
||||
const elem = node as Element;
|
||||
const rawName = elem.name ?? '';
|
||||
const name = rawName.trim().toLowerCase();
|
||||
|
||||
// 标签名有效性校验
|
||||
const isValidTag = /^[a-zA-Z][a-zA-Z0-9-]*$/.test(name);
|
||||
if (!isValidTag) {
|
||||
logger.warn(`Invalid tag name ignored: "${rawName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const children: VNode[] = (elem.children || [])
|
||||
.map((child) => render(child))
|
||||
.filter(Boolean) as VNode[];
|
||||
|
||||
// 自定义映射
|
||||
if (map[name]) {
|
||||
return map[name](elem, children);
|
||||
}
|
||||
|
||||
// 默认将未知标签渲染为原生标签
|
||||
if (allowUnknownTags) {
|
||||
return h(name, elem.attribs || {}, children);
|
||||
}
|
||||
|
||||
// 忽略无法渲染节点
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = doc.children
|
||||
.map((child) => render(child))
|
||||
.filter(Boolean) as VNode[];
|
||||
|
||||
return nodes;
|
||||
};
|
||||
@ -1,40 +1,99 @@
|
||||
import type { StrapiLocale } from '@nuxtjs/strapi';
|
||||
import type { Language as ElementLanguage } from 'element-plus/es/locale';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
import en from 'element-plus/es/locale/lang/en';
|
||||
import es from 'element-plus/es/locale/lang/es';
|
||||
import ru from 'element-plus/es/locale/lang/ru';
|
||||
|
||||
// Strapi本地化映射
|
||||
export const strapiLocales: Record<string, StrapiLocale> = {
|
||||
zh: 'zh-CN',
|
||||
en: 'en',
|
||||
};
|
||||
/**
|
||||
* 应用语言映射结构
|
||||
* 用于统一 Strapi / Directus / Element Plus 的多语言配置
|
||||
*/
|
||||
export interface LocaleMapping {
|
||||
/** 用于Directus translations.languages_code **/
|
||||
directus: string;
|
||||
/** Element Plus语言对象 **/
|
||||
element: ElementLanguage;
|
||||
}
|
||||
|
||||
// Element Plus本地化映射
|
||||
export const elementPlusLocales: Record<string, ElementLanguage> = {
|
||||
zh: zhCn,
|
||||
en: en,
|
||||
};
|
||||
/**
|
||||
* 应用支持的语言映射表。
|
||||
*
|
||||
* 每个键(如 "zh"、"en")对应一套统一的本地化配置,
|
||||
* 方便在 Strapi / Directus / Element Plus 三方系统间保持一致。
|
||||
*/
|
||||
export const localeMap = {
|
||||
zh: {
|
||||
directus: 'zh-CN',
|
||||
element: zhCn,
|
||||
},
|
||||
en: {
|
||||
directus: 'en-US',
|
||||
element: en,
|
||||
},
|
||||
es: {
|
||||
directus: 'es-ES',
|
||||
element: es,
|
||||
},
|
||||
ru: {
|
||||
directus: 'ru-RU',
|
||||
element: ru,
|
||||
},
|
||||
} satisfies Record<string, LocaleMapping>;
|
||||
|
||||
/** 应用支持的语言键类型 **/
|
||||
export type AppLocale = keyof typeof localeMap;
|
||||
|
||||
/** 默认语言, 当找不到匹配语言时回退到默认语言 **/
|
||||
const defaultLocale: AppLocale = 'zh';
|
||||
|
||||
/**
|
||||
* 提供 Strapi、 Directus 与 Element Plus的国际化映射工具
|
||||
*
|
||||
* ---
|
||||
* @example
|
||||
* ``` ts
|
||||
* const { locale, getStrapiLocale, getElementPlusLocale } = useLocalizations()
|
||||
*
|
||||
* const strapiLang = getStrapiLocale() // 当前 Strapi 语言码
|
||||
* const elLocale = getElementPlusLocale('en') // 获取 Element Plus 英文对象
|
||||
* const all = getLocaleMapping('zh') // 获取完整映射结构
|
||||
* ```
|
||||
* ---
|
||||
*
|
||||
* @returns 返回当前语言及各系统的本地化获取方法
|
||||
*/
|
||||
export const useLocalizations = () => {
|
||||
const { locale } = useI18n();
|
||||
const { locale } = useI18n<{ locale: Ref<AppLocale> }>();
|
||||
|
||||
// 获取Strapi本地化代码
|
||||
const getStrapiLocale = (nuxtLocale?: string): StrapiLocale => {
|
||||
const currentLocale = nuxtLocale || locale.value;
|
||||
return strapiLocales[currentLocale] || 'zh-Hans';
|
||||
};
|
||||
|
||||
// 获取Element Plus本地化
|
||||
const getElementPlusLocale = (nuxtLocale?: string) => {
|
||||
const currentLocale = nuxtLocale || locale.value;
|
||||
const elementPlusLocale =
|
||||
elementPlusLocales[currentLocale] || elementPlusLocales['zh'];
|
||||
return elementPlusLocale;
|
||||
/**
|
||||
* 获取对应语言的完整映射结构
|
||||
*
|
||||
* @param nuxtLocale - 可选的语言码参数,若提供则使用该语言码,否则使用当前应用语言
|
||||
* @returns 返回当前语言的完整映射对象
|
||||
*/
|
||||
const getMapping = (nuxtLocale?: AppLocale): LocaleMapping => {
|
||||
const current = nuxtLocale || locale.value;
|
||||
return localeMap[current] || localeMap[defaultLocale];
|
||||
};
|
||||
|
||||
return {
|
||||
/** 当前Nuxt I18n语言(只读) **/
|
||||
locale: readonly(locale),
|
||||
getStrapiLocale,
|
||||
getElementPlusLocale,
|
||||
/** 获取Directus的本地化代码 **/
|
||||
getDirectusLocale: (l?: AppLocale) => getMapping(l).directus,
|
||||
/** 获取Element Plus语言对象 **/
|
||||
getElementPlusLocale: (l?: AppLocale) => getMapping(l).element,
|
||||
/**
|
||||
* 获取完整的语言映射结构(Strapi / Directus / Element Plus)
|
||||
*
|
||||
* @param l: 指定语言,默认为当前locale
|
||||
* @returns 语言映射对象
|
||||
*/
|
||||
getLocaleMapping: getMapping,
|
||||
|
||||
/** 所有可用的Directus语言代码列表(只读) **/
|
||||
availableDirectusLocales: readonly(
|
||||
Object.values(localeMap).map((item) => item.directus)
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
import { MeiliSearch } from 'meilisearch';
|
||||
import type { SearchParams, SearchResponse } from 'meilisearch';
|
||||
|
||||
interface RawSearchSection {
|
||||
indexUid: string;
|
||||
response: SearchResponse<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface SearchHit extends Record<string, unknown> {
|
||||
indexUid: string;
|
||||
objectID?: string | number;
|
||||
}
|
||||
|
||||
export interface SearchSection {
|
||||
indexUid: string;
|
||||
hits: SearchHit[];
|
||||
estimatedTotalHits: number;
|
||||
processingTimeMs: number;
|
||||
}
|
||||
|
||||
const parseIndexes = (indexes: string | string[] | undefined): string[] => {
|
||||
if (!indexes) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(indexes)) {
|
||||
return indexes.map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
return indexes
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const useMeilisearch = () => {
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
|
||||
const indexes = computed(() =>
|
||||
parseIndexes(runtimeConfig.public?.meili?.indexes)
|
||||
);
|
||||
|
||||
let meiliClient: MeiliSearch | null = null;
|
||||
|
||||
const ensureClient = () => {
|
||||
if (meiliClient) return meiliClient;
|
||||
|
||||
const host = runtimeConfig.public?.meili?.host;
|
||||
if (!host) {
|
||||
console.warn('Meilisearch host is not configured.');
|
||||
return null;
|
||||
}
|
||||
const apiKey = runtimeConfig.public?.meili?.searchKey;
|
||||
meiliClient = new MeiliSearch({
|
||||
host,
|
||||
apiKey: apiKey || undefined,
|
||||
});
|
||||
return meiliClient;
|
||||
};
|
||||
|
||||
const search = async (
|
||||
query: string,
|
||||
params: SearchParams = {}
|
||||
): Promise<SearchSection[]> => {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const client = ensureClient();
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeIndexes = indexes.value;
|
||||
if (!activeIndexes.length) {
|
||||
console.warn('No Meilisearch indexes configured.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const requests = activeIndexes.map(async (indexUid) => {
|
||||
const response = await client.index(indexUid).search(trimmedQuery, {
|
||||
limit: params.limit ?? 10,
|
||||
...params,
|
||||
});
|
||||
const safeResponse = JSON.parse(JSON.stringify(response));
|
||||
return {
|
||||
indexUid,
|
||||
response: {
|
||||
hits: safeResponse.hits,
|
||||
estimatedTotalHits:
|
||||
safeResponse.estimatedTotalHits ?? safeResponse.hits.length,
|
||||
processingTimeMs: safeResponse.processingTimeMs ?? 0,
|
||||
query: safeResponse.query,
|
||||
},
|
||||
} satisfies RawSearchSection;
|
||||
});
|
||||
|
||||
console.log((await requests[0])?.response.hits[0]?.locale);
|
||||
|
||||
const settled = await Promise.allSettled(requests);
|
||||
|
||||
settled
|
||||
.filter(
|
||||
(result): result is PromiseRejectedResult =>
|
||||
result.status === 'rejected'
|
||||
)
|
||||
.forEach((result) => {
|
||||
console.error('Meilisearch query failed', result.reason);
|
||||
});
|
||||
|
||||
return settled
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => {
|
||||
const fulfilled = result as PromiseFulfilledResult<RawSearchSection>;
|
||||
return {
|
||||
indexUid: fulfilled.value.indexUid,
|
||||
hits: fulfilled.value.response.hits.map((hit) => ({
|
||||
...hit,
|
||||
indexUid: fulfilled.value.indexUid,
|
||||
})),
|
||||
estimatedTotalHits:
|
||||
fulfilled.value.response.estimatedTotalHits ??
|
||||
fulfilled.value.response.hits.length,
|
||||
processingTimeMs: fulfilled.value.response.processingTimeMs ?? 0,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
indexes,
|
||||
};
|
||||
};
|
||||
47
app/composables/usePageSeo.ts
Normal file
47
app/composables/usePageSeo.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 设置页面级 SEO 元数据(包含 title、description、OG/Twitter 等常用字段)。
|
||||
*
|
||||
* 该函数基于 `useSeoMeta` 封装,可用于 Nuxt 3 / Nuxt 4
|
||||
* 配合 SSR/CSR 动态更新,可安全用于异步数据场景(如 CMS 内容页)。
|
||||
*
|
||||
* @param {Object} meta - 页面 SEO 配置对象
|
||||
* @param {string} meta.title - 页面标题(会同时应用到 title / og:title)
|
||||
* @param {string} [meta.description] - 页面描述(会应用到 description / og:description)
|
||||
* @param {string} [meta.image] - 用于分享卡片的预览图(og:image / twitter:image)
|
||||
*
|
||||
* @example
|
||||
* // 用于普通页面
|
||||
* usePageSeo({
|
||||
* title: '产品中心 - 金申机械',
|
||||
* description: '查看全系列纸管机械产品',
|
||||
* image: '/images/og/products.png'
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // 用于动态内容(如产品详情页)
|
||||
* const product = await fetchProduct()
|
||||
* usePageSeo({
|
||||
* title: product.name,
|
||||
* description: product.summary,
|
||||
* image: product.coverImage
|
||||
* })
|
||||
*
|
||||
* @remarks
|
||||
* - 自动生成以下 meta:`title`, `description`, `og:title`, `og:description`, `og:image`, `twitter:card`
|
||||
* - 默认使用 `summary_large_image` 作为 Twitter 卡片类型
|
||||
* - 推荐与 `useHead()` 配合增加 canonical / alternate hreflang 等额外 SEO 标签
|
||||
*/
|
||||
export function usePageSeo(meta: {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
}) {
|
||||
useSeoMeta({
|
||||
title: meta.title,
|
||||
ogTitle: meta.title,
|
||||
description: meta.description,
|
||||
ogDescription: meta.description,
|
||||
ogImage: meta.image,
|
||||
twitterCard: 'summary_large_image',
|
||||
});
|
||||
}
|
||||
@ -12,6 +12,17 @@
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead(() => {
|
||||
const siteTitle = t('company-name');
|
||||
return {
|
||||
titleTemplate: (title) => (title ? `${title} - ${siteTitle}` : siteTitle),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
display: flex;
|
||||
|
||||
16
app/layouts/preview.vue
Normal file
16
app/layouts/preview.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead(() => {
|
||||
const siteTitle = `${$t('page-title.preview')} - ${t('company-name')}`;
|
||||
return {
|
||||
title: siteTitle,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@ -1,17 +1,10 @@
|
||||
<template>
|
||||
<main p="x4 y10" text="center teal-700 dark:gray-200">
|
||||
<div text4xl>
|
||||
<div i-ep-warning inline-block />
|
||||
</div>
|
||||
<div>{{ $t('not-found') }}</div>
|
||||
<div>
|
||||
<button text-sm btn m="3 t8" @click="router.back()">
|
||||
{{ $t('back') }}
|
||||
</button>
|
||||
</div>
|
||||
<not-found-result
|
||||
:title="$t('page-not-found')"
|
||||
:sub-title="$t('page-not-found-desc')"
|
||||
:back-text="$t('back-to-home')"
|
||||
:on-back="() => $router.push($localePath('/'))"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
@ -1,60 +1,70 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="!pending">
|
||||
<el-breadcrumb class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">
|
||||
{{ $t('navigation.home') }}
|
||||
</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/about')">
|
||||
{{ $t('navigation.about-us') }}
|
||||
</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
|
||||
<div class="content">
|
||||
<markdown-renderer :content="content || ''" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<el-skeleton :loading="pending" :rows="10" animated>
|
||||
<template #default>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="html-typography" v-html="companyProfile?.content || ''" />
|
||||
<!-- <div v-if="!hydrated" v-html="companyProfile.content || ''" /> -->
|
||||
<!-- <div v-else> -->
|
||||
<!-- <html-renderer -->
|
||||
<!-- class="html-typography" -->
|
||||
<!-- :html="companyProfile.content || ''" -->
|
||||
<!-- /> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<el-divider content-position="left">更多信息</el-divider>
|
||||
<div class="button-group">
|
||||
<NuxtLink :to="$localePath('/support/contact-us')">
|
||||
<el-card class="card-button">
|
||||
<el-icon class="icon" size="80">
|
||||
<ElIconService />
|
||||
</el-icon>
|
||||
<br />
|
||||
联系信息
|
||||
</el-card>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="loading">
|
||||
<el-skeleton :rows="5" animated />
|
||||
<el-divider content-position="left">{{
|
||||
$t('learn-more')
|
||||
}}</el-divider>
|
||||
<div class="button-group">
|
||||
<learn-more-card
|
||||
:title="$t('navigation.contact-info')"
|
||||
:icon="ElIconService"
|
||||
:to="$localePath('/support/contact-us')"
|
||||
/>
|
||||
<learn-more-card
|
||||
:title="$t('navigation.address')"
|
||||
:icon="ElIconMapLocation"
|
||||
@click="openMap"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { findOne } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
const localePath = useLocalePath();
|
||||
const hydrated = ref(false);
|
||||
const breadcrumbItems = [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.about-us') },
|
||||
];
|
||||
const { data, pending, error } = useCompanyProfile();
|
||||
|
||||
const { data, pending, error } = useAsyncData('company-profile', () =>
|
||||
findOne<StrapiCompanyProfile>('company-profile', undefined, {
|
||||
locale: strapiLocale,
|
||||
})
|
||||
);
|
||||
const companyProfile = computed(() => data.value ?? null);
|
||||
|
||||
const content = computed(() => data.value?.data.content);
|
||||
const openMap = () => {
|
||||
window.open(localePath('/locate'));
|
||||
};
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => $t('page-title.about-us'));
|
||||
useHead({
|
||||
title: pageTitle,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
hydrated.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -73,14 +83,6 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
:deep(.markdown-body p) {
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
:deep(.markdown-body h2) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.el-divider__text) {
|
||||
color: var(--el-color-info);
|
||||
font-size: 1em;
|
||||
@ -91,25 +93,7 @@
|
||||
justify-content: left;
|
||||
margin-top: 2rem;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
width: 20%;
|
||||
min-width: 200px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.card-button:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 10px;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@ -118,4 +102,16 @@
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.button-group {
|
||||
align-items: center;
|
||||
margin-left: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
81
app/pages/download/[id].vue
Normal file
81
app/pages/download/[id].vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('navigation.downloads') }}</h1>
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<el-skeleton
|
||||
:loading="pending"
|
||||
:rows="6"
|
||||
animated
|
||||
:throttle="{ leading: 500, trailing: 500 }"
|
||||
>
|
||||
<template #default>
|
||||
<file-card :file-id="id" :file="file" />
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.support'), to: localePath('/support') },
|
||||
{ label: $t('navigation.documents'), to: localePath('/support/documents') },
|
||||
{ label: $t('navigation.downloads') },
|
||||
];
|
||||
|
||||
const id = route.params.id as string;
|
||||
|
||||
const {
|
||||
data: file,
|
||||
pending,
|
||||
error,
|
||||
} = await useFetch<FileMeta>(`/api/file/${id}`);
|
||||
|
||||
if (error.value || !file.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '文件未找到',
|
||||
});
|
||||
}
|
||||
|
||||
const pageTitle = $t('page-title.download');
|
||||
usePageSeo({
|
||||
title: file.value.filename_download || pageTitle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,328 +1,24 @@
|
||||
<template>
|
||||
<div class="homepage">
|
||||
<section v-if="!pending" class="carousel-section">
|
||||
<el-carousel
|
||||
class="homepage-carousel"
|
||||
height="auto"
|
||||
:interval="5000"
|
||||
arrow="never"
|
||||
autoplay
|
||||
>
|
||||
<el-carousel-item v-for="(item, index) in carousel" :key="index">
|
||||
<div class="carousel-item">
|
||||
<el-image
|
||||
class="carousel-image"
|
||||
:src="useStrapiMedia(item.url || '')"
|
||||
:alt="item.alternativeText || `Carousel Image ${index + 1}`"
|
||||
fit="contain"
|
||||
lazy
|
||||
/>
|
||||
<p v-if="item.caption" class="carousel-image-caption">
|
||||
{{ item.caption }}
|
||||
</p>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</section>
|
||||
<section v-else>
|
||||
<el-skeleton :rows="5" animated />
|
||||
</section>
|
||||
|
||||
<section class="homepage-section">
|
||||
<h2>推荐产品</h2>
|
||||
<p>
|
||||
探索我们的精选产品,满足您的各种需求。无论是创新技术还是经典设计,我们都为您提供优质选择。
|
||||
</p>
|
||||
<div v-if="!pending">
|
||||
<el-carousel
|
||||
class="recommend-carousel"
|
||||
height="auto"
|
||||
arrow="never"
|
||||
indicator-position="outside"
|
||||
:autoplay="false"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="n in Math.floor(recommend_productions.length / 3) + 1"
|
||||
:key="n"
|
||||
class="recommend-list"
|
||||
>
|
||||
<div class="recommend-card-group">
|
||||
<el-card
|
||||
v-for="(item, index) in recommend_productions.slice(
|
||||
(n - 1) * 3,
|
||||
n * 3
|
||||
)"
|
||||
:key="index"
|
||||
class="recommend-card"
|
||||
@click="handleProductionCardClick(item.documentId || '')"
|
||||
>
|
||||
<template #header>
|
||||
<el-image
|
||||
:src="useStrapiMedia(item.cover?.url || '')"
|
||||
:alt="item.cover?.alternativeText || item.title"
|
||||
fit="cover"
|
||||
lazy
|
||||
/>
|
||||
</template>
|
||||
<div class="recommend-card-body">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<span class="recommend-card-title">{{ item.title }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="recommend-card-description text-left opacity-25">
|
||||
{{ item.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
</section>
|
||||
<section class="homepage-section">
|
||||
<h2>推荐解决方案</h2>
|
||||
<p>了解我们的定制解决方案,帮助您优化业务流程,提高效率。</p>
|
||||
<div v-if="!pending">
|
||||
<el-carousel
|
||||
class="recommend-carousel"
|
||||
height="auto"
|
||||
arrow="never"
|
||||
indicator-position="outside"
|
||||
:autoplay="false"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="n in Math.floor(recommend_solutions.length / 3) + 1"
|
||||
:key="n"
|
||||
class="recommend-list"
|
||||
>
|
||||
<div class="recommend-card-group">
|
||||
<el-card
|
||||
v-for="(item, index) in recommend_solutions.slice(
|
||||
(n - 1) * 3,
|
||||
n * 3
|
||||
)"
|
||||
:key="index"
|
||||
class="recommend-card"
|
||||
@click="handleSolutionCardClick(item.documentId || '')"
|
||||
>
|
||||
<template #header>
|
||||
<el-image
|
||||
:src="useStrapiMedia(item.cover?.url || '')"
|
||||
:alt="item.cover?.alternativeText || item.title"
|
||||
fit="cover"
|
||||
lazy
|
||||
/>
|
||||
</template>
|
||||
<div class="recommend-card-body">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<span class="recommend-card-title">{{ item.title }}</span>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="recommend-card-description text-left opacity-25">
|
||||
{{ item.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
</section>
|
||||
<homepage-carousel :homepage-data="data" :pending="pending" />
|
||||
<homepage-product-section :homepage-data="data" :pending="pending" />
|
||||
<homepage-solution-section :homepage-data="data" :pending="pending" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { findOne } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
const { data, pending, error } = await useHomepage();
|
||||
|
||||
const { data, pending, error } = useAsyncData('homepage', () =>
|
||||
findOne<StrapiHomepage>('homepage', undefined, {
|
||||
populate: {
|
||||
carousel: {
|
||||
populate: '*',
|
||||
},
|
||||
recommend_productions: {
|
||||
populate: {
|
||||
cover: {
|
||||
populate: '*',
|
||||
},
|
||||
},
|
||||
},
|
||||
recommend_solutions: {
|
||||
populate: {
|
||||
cover: {
|
||||
populate: '*',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
locale: strapiLocale,
|
||||
})
|
||||
);
|
||||
|
||||
const carousel = computed(() => data.value?.data.carousel || []);
|
||||
const recommend_productions = computed(
|
||||
() => data.value?.data.recommend_productions || []
|
||||
);
|
||||
const recommend_solutions = computed(
|
||||
() => data.value?.data.recommend_solutions || []
|
||||
);
|
||||
const pageTilte = $t('page-title.homepage');
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
const handleProductionCardClick = (documentId: string) => {
|
||||
// 使用路由导航到产品详情页
|
||||
if (documentId) {
|
||||
const localePath = useLocalePath();
|
||||
const router = useRouter();
|
||||
router.push(localePath(`/productions/${documentId}`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSolutionCardClick = (documentId: string) => {
|
||||
// 使用路由导航到解决方案详情页
|
||||
if (documentId) {
|
||||
const localePath = useLocalePath();
|
||||
const router = useRouter();
|
||||
router.push(localePath(`/solutions/${documentId}`));
|
||||
}
|
||||
};
|
||||
useSeoMeta({
|
||||
title: pageTilte,
|
||||
description: $t('company-description'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
section {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
section p {
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.carousel-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.homepage-carousel .el-carousel__item {
|
||||
width: 100%;
|
||||
height: 33vw;
|
||||
/* 16:9 Aspect Ratio */
|
||||
}
|
||||
|
||||
.el-carousel__item h3 {
|
||||
display: flex;
|
||||
color: #475669;
|
||||
opacity: 0.8;
|
||||
line-height: 300px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.homepage-carousel .carousel-item {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.carousel-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carousel-image-caption {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.homepage-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.recommend-carousel :deep(.el-carousel__button) {
|
||||
/* 指示器按钮样式 */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #475669;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.recommend-list {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.recommend-card-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.recommend-card {
|
||||
width: 33%;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recommend-card :deep(.el-card__header) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.recommend-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recommend-card-body {
|
||||
margin: 10px auto;
|
||||
padding: 0px auto;
|
||||
}
|
||||
|
||||
.recommend-card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recommend-card-description {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.recommend-card .el-image {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
28
app/pages/locate.vue
Normal file
28
app/pages/locate.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h1>{{ $t('redirecting') }}</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
onMounted(async () => {
|
||||
const platform = await getAutoMappedService();
|
||||
|
||||
// // ✔ 移动端能正常跳转
|
||||
window.location.href = platform;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
13
app/pages/preview/[id].vue
Normal file
13
app/pages/preview/[id].vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<file-previewer :file-id="id" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'preview',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const id = computed(() => route.params.id as string);
|
||||
</script>
|
||||
@ -1,269 +0,0 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="!pending">
|
||||
<div v-if="production">
|
||||
<!-- 面包屑导航 -->
|
||||
<el-breadcrumb class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">{{
|
||||
$t('navigation.home')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/productions')">{{
|
||||
$t('navigation.productions')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opactiy-50">{{
|
||||
production.title
|
||||
}}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<!-- 产品详情内容 -->
|
||||
<div class="production-header">
|
||||
<div class="production-image">
|
||||
<el-image
|
||||
v-if="production.production_images.length <= 1"
|
||||
:src="useStrapiMedia(production?.cover?.url || '')"
|
||||
:alt="production.title"
|
||||
fit="contain"
|
||||
/>
|
||||
<el-carousel
|
||||
v-else
|
||||
class="production-carousel"
|
||||
height="500px"
|
||||
:autoplay="false"
|
||||
:loop="false"
|
||||
arrow="always"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="(item, index) in production.production_images || []"
|
||||
:key="index"
|
||||
>
|
||||
<div class="production-carousel-item">
|
||||
<el-image
|
||||
:src="useStrapiMedia(item.url || '')"
|
||||
:alt="item.alternativeText || production.title"
|
||||
fit="contain"
|
||||
lazy
|
||||
/>
|
||||
<p v-if="item.caption" class="production-image-caption">
|
||||
{{ item.caption }}
|
||||
</p>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
|
||||
<div class="production-info">
|
||||
<h1>{{ production.title }}</h1>
|
||||
<p class="summary">{{ production.summary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品详细描述 -->
|
||||
<div class="production-content">
|
||||
<el-tabs v-model="activeName" class="production-tabs" stretch>
|
||||
<el-tab-pane label="产品详情" name="details">
|
||||
<markdown-renderer
|
||||
:content="production.production_details || ''"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="技术规格" name="specs">
|
||||
<spec-table :data="production.production_specs" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="常见问题" name="faq">
|
||||
<question-list :questions="production.questions" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="相关文档" name="documents">
|
||||
<document-list
|
||||
:documents="
|
||||
production.production_documents.map(
|
||||
(item) => item.document
|
||||
) || []
|
||||
"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 未找到产品 -->
|
||||
<div v-else class="not-found">
|
||||
<el-result
|
||||
icon="warning"
|
||||
:title="$t('product-not-found')"
|
||||
:sub-title="$t('product-not-found-desc')"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/productions')">
|
||||
{{ $t('back-to-productions') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="loading">
|
||||
<el-skeleton style="--el-skeleton-circle-size: 400px">
|
||||
<template #template>
|
||||
<el-skeleton-item variant="circle" />
|
||||
</template>
|
||||
</el-skeleton>
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const { findOne } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
|
||||
// 获取路由参数(slug 或 id)
|
||||
const documentId = computed(() => route.params.slug as string);
|
||||
|
||||
const { data, pending, error } = useAsyncData(
|
||||
() => `production-${documentId.value}`,
|
||||
() =>
|
||||
findOne<Production>('productions', documentId.value, {
|
||||
populate: {
|
||||
production_specs: {
|
||||
populate: '*',
|
||||
},
|
||||
production_images: {
|
||||
populate: '*',
|
||||
},
|
||||
cover: {
|
||||
populate: '*',
|
||||
},
|
||||
questions: {
|
||||
populate: '*',
|
||||
},
|
||||
production_documents: {
|
||||
populate: 'document',
|
||||
},
|
||||
},
|
||||
locale: strapiLocale,
|
||||
})
|
||||
);
|
||||
|
||||
const production = computed(() => data.value?.data ?? null);
|
||||
|
||||
const activeName = ref('details'); // 默认选中概览标签
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
// SEO
|
||||
useHead({
|
||||
title: computed(() => production.value?.title || 'Product Detail'),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: computed(() => production.value?.summary || ''),
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
min-height: 80vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.production-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.production-image .el-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.production-image-caption {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
/* left: 10%; */
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.production-carousel :deep(.el-carousel__button) {
|
||||
/* 指示器按钮样式 */
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #475669;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.production-info h1 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: var(--el-color-info);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.production-tabs ::v-deep(.el-tabs__nav) {
|
||||
min-width: 30%;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.production-tabs ::v-deep(.el-tabs__content) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.production-content h2 {
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.production-header {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.production-info h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,177 +0,0 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('our-productions') }}</h1>
|
||||
<el-breadcrumb class="breadcrumb">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">{{
|
||||
$t('navigation.home')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/productions')">{{
|
||||
$t('navigation.productions')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div v-if="!pending" class="page-content">
|
||||
<div class="productions-container">
|
||||
<el-collapse v-model="activeNames" class="production-collapse">
|
||||
<el-collapse-item
|
||||
v-for="(group, type) in groupedProductions"
|
||||
:key="type"
|
||||
:title="type || '未分类'"
|
||||
:name="type || 'no-category'"
|
||||
>
|
||||
<div class="group-list">
|
||||
<production-card
|
||||
v-for="production in group"
|
||||
:key="production.documentId || production.id"
|
||||
:slug="production.documentId"
|
||||
:image-url="useStrapiMedia(production?.cover?.url || '')"
|
||||
:name="production.title"
|
||||
:description="production.summary || ''"
|
||||
/>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { find } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
|
||||
const { data, pending, error } = useAsyncData(
|
||||
'productions',
|
||||
() =>
|
||||
find<Production>('productions', {
|
||||
populate: {
|
||||
cover: {
|
||||
populate: '*',
|
||||
},
|
||||
production_type: {
|
||||
populate: '*',
|
||||
},
|
||||
},
|
||||
filters: {
|
||||
show_in_production_list: {
|
||||
$eq: true,
|
||||
},
|
||||
},
|
||||
locale: strapiLocale,
|
||||
}),
|
||||
{
|
||||
lazy: true,
|
||||
}
|
||||
);
|
||||
|
||||
const activeNames = ref<string[]>([]);
|
||||
|
||||
const productions = computed(() => data.value?.data ?? []);
|
||||
|
||||
// 按类型分组
|
||||
// 兼容 production_type 既可能为对象也可能为字符串
|
||||
const groupedProductions = computed(() => {
|
||||
const groups: Record<string, Production[]> = {};
|
||||
for (const prod of productions.value) {
|
||||
let typeKey = '';
|
||||
if (typeof prod.production_type === 'string') {
|
||||
typeKey = prod.production_type;
|
||||
} else if (
|
||||
prod.production_type &&
|
||||
typeof prod.production_type === 'object' &&
|
||||
'type' in prod.production_type
|
||||
) {
|
||||
typeKey = prod.production_type.type || '';
|
||||
}
|
||||
if (!groups[typeKey]) groups[typeKey] = [];
|
||||
groups[typeKey]?.push(prod);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
watch(groupedProductions, () => {
|
||||
if (groupedProductions.value) {
|
||||
activeNames.value = [
|
||||
...Object.keys(groupedProductions.value),
|
||||
'no-category',
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (groupedProductions.value) {
|
||||
activeNames.value = [
|
||||
...Object.keys(groupedProductions.value),
|
||||
'no-category',
|
||||
];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.productions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.production-group {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.group-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 1rem;
|
||||
gap: 20px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
padding: 30px auto;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
75
app/pages/products/[slug].vue
Normal file
75
app/pages/products/[slug].vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="product">
|
||||
<!-- 面包屑导航 -->
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
<!-- 产品详情内容 -->
|
||||
<product-header :product="product" />
|
||||
<!-- 产品详细描述 -->
|
||||
<product-detail :product="product" />
|
||||
</div>
|
||||
<!-- 未找到产品 -->
|
||||
<div v-else class="not-found">
|
||||
<not-found-result
|
||||
:title="$t('product-not-found')"
|
||||
:sub-title="$t('product-not-found-desc')"
|
||||
:back-text="$t('back-to-products')"
|
||||
:on-back="() => $router.push($localePath('/products'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
// 获取路由参数
|
||||
const id = route.params.slug as string;
|
||||
|
||||
const { data: product, error } = await useProduct(id);
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.products'), to: localePath('/products') },
|
||||
{ label: product.value?.name || '' },
|
||||
]);
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
// SEO
|
||||
usePageSeo({
|
||||
title: product.value?.name || $t('page-title.products'),
|
||||
description: product.value?.summary || '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
min-height: 80vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
220
app/pages/products/index.vue
Normal file
220
app/pages/products/index.vue
Normal file
@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ $t('our-products') }}</h1>
|
||||
<p class="page-subtitle">
|
||||
{{ $t('products-desc') }}{{ $t('find-discontinued-products') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
</div>
|
||||
<div>
|
||||
<el-skeleton
|
||||
animated
|
||||
:loading="pending"
|
||||
:throttle="{ leading: 500, trailing: 500 }"
|
||||
>
|
||||
<template #template>
|
||||
<div v-for="i in 3" :key="i" class="products-container">
|
||||
<el-skeleton-item variant="h1" class="skeleton-collapse" />
|
||||
<div class="skeleton-group-list">
|
||||
<el-skeleton-item
|
||||
v-for="j in 3"
|
||||
:key="j"
|
||||
variant="rect"
|
||||
class="skeleton-card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="products-container">
|
||||
<el-collapse v-model="activeNames" class="product-collapse">
|
||||
<el-collapse-item
|
||||
v-for="[key, value] in Object.entries(groupedProducts)"
|
||||
:key="key"
|
||||
:title="key || '未分类'"
|
||||
:name="key || 'no-category'"
|
||||
>
|
||||
<div class="group-list">
|
||||
<product-card
|
||||
v-for="product in value.data"
|
||||
:key="product.id"
|
||||
:slug="product.id.toString()"
|
||||
:image-url="getImageUrl(product.cover.toString())"
|
||||
:name="product.name"
|
||||
:description="product.summary || ''"
|
||||
/>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const localePath = useLocalePath();
|
||||
const { getImageUrl } = useDirectusImage();
|
||||
|
||||
const { data: products, pending, error } = await useProductList();
|
||||
|
||||
const activeNames = ref<string[]>([]);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.products') },
|
||||
];
|
||||
|
||||
// 按类型分组
|
||||
const groupedProducts = computed(() => {
|
||||
const groups: Record<string, { data: ProductListView[]; sort: number }> =
|
||||
{};
|
||||
for (const prod of products.value) {
|
||||
const typeKey = prod.product_type?.name ?? '';
|
||||
if (!groups[typeKey]) {
|
||||
groups[typeKey] = { data: [], sort: prod.product_type?.sort ?? 999 };
|
||||
}
|
||||
groups[typeKey]?.data.push(prod);
|
||||
}
|
||||
const sortedGroups = Object.fromEntries(
|
||||
Object.entries(groups).sort(([, a], [, b]) => a.sort - b.sort)
|
||||
);
|
||||
return sortedGroups;
|
||||
});
|
||||
|
||||
watch(groupedProducts, () => {
|
||||
if (groupedProducts.value) {
|
||||
activeNames.value = [
|
||||
...Object.keys(groupedProducts.value),
|
||||
'no-category',
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (groupedProducts.value) {
|
||||
activeNames.value = [
|
||||
...Object.keys(groupedProducts.value),
|
||||
'no-category',
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const pageTitle = $t('page-title.products');
|
||||
usePageSeo({
|
||||
title: pageTitle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.products-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.product-group {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.group-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 1rem;
|
||||
gap: 20px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.skeleton-collapse {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-group-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
width: 30%;
|
||||
height: 250px;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.skeleton-card {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.group-list {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.skeleton-group-list {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
padding: 30px auto;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@ -1,217 +1,92 @@
|
||||
<template>
|
||||
<div class="search-page">
|
||||
<div class="search-header">
|
||||
<h1 class="page-title">{{ $t('search.title') }}</h1>
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
:placeholder="$t('search-placeholder')"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
@keyup.enter="navigateToQuery(keyword)"
|
||||
@clear="handleClear"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="search-button"
|
||||
@click="navigateToQuery(keyword)"
|
||||
>
|
||||
{{ $t('search.search-button') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="search-state">
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
<div v-else-if="hasResults" class="search-results">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="`全部(${resultCount['all']})`" name="all">
|
||||
<search-results
|
||||
v-model:current-page="currentPage"
|
||||
:hit-items="hits"
|
||||
<search-header v-model="keyword" />
|
||||
<div class="search-state">
|
||||
<el-skeleton
|
||||
:loading="loading"
|
||||
animated
|
||||
:throttle="{ leading: 500, trailing: 500 }"
|
||||
>
|
||||
<template #template>
|
||||
<el-skeleton-item
|
||||
v-for="i in 10"
|
||||
:key="i"
|
||||
variant="rect"
|
||||
class="skeleton-item"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="`产品(${resultCount['production'] || 0})`"
|
||||
name="production"
|
||||
>
|
||||
<search-results
|
||||
v-model:current-page="currentPage"
|
||||
:hit-items="hits"
|
||||
category="production"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="`解决方案(${resultCount['solution'] || 0})`"
|
||||
name="solution"
|
||||
>
|
||||
<search-results
|
||||
v-model:current-page="currentPage"
|
||||
:hit-items="hits"
|
||||
category="solution"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="`相关问题(${resultCount['question'] || 0})`"
|
||||
name="question"
|
||||
>
|
||||
<search-results
|
||||
v-model:current-page="currentPage"
|
||||
:hit-items="hits"
|
||||
category="question"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="`文档资料(${resultCount['document'] || 0})`"
|
||||
name="document"
|
||||
>
|
||||
<search-results
|
||||
v-model:current-page="currentPage"
|
||||
:hit-items="hits"
|
||||
category="document"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<div v-else class="search-state">
|
||||
<el-empty
|
||||
:description="
|
||||
route.query.query
|
||||
? $t('search.no-results', { query: route.query?.query })
|
||||
: $t('search.no-query')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #default>
|
||||
<search-tabs v-if="hasResults" :search-items="searchItems" />
|
||||
<div v-else>
|
||||
<el-empty
|
||||
:description="
|
||||
route.query.query
|
||||
? $t('search.no-results', { query: route.query?.query })
|
||||
: $t('search.no-query')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
|
||||
// i18n相关
|
||||
const { t } = useI18n();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
const { getDirectusLocale } = useLocalizations();
|
||||
const locale = getDirectusLocale();
|
||||
|
||||
// 路由相关
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
// 搜索相关
|
||||
const { search } = useMeilisearch();
|
||||
const keyword = ref('');
|
||||
const activeRequestId = ref(0);
|
||||
|
||||
if (typeof route.query.query === 'string' && route.query.query.trim()) {
|
||||
keyword.value = route.query.query;
|
||||
}
|
||||
|
||||
const {
|
||||
data: sections,
|
||||
data: searchItems,
|
||||
pending: loading,
|
||||
error,
|
||||
} = await useAsyncData(
|
||||
() => `search-${route.query.query ?? ''}`,
|
||||
async () => {
|
||||
const q = String(route.query.query ?? '').trim();
|
||||
if (!q) return [];
|
||||
return await search(q, { limit: 12 });
|
||||
refresh,
|
||||
} = useAsyncData(`meilisearch-${keyword.value}-${locale}`, async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/search?query=${keyword.value}`, {
|
||||
headers: { 'x-locale': locale },
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching search results: ', error);
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
// 本地化+空Section过滤
|
||||
const filteredSections = computed(() =>
|
||||
sections.value
|
||||
.map((section) => ({
|
||||
...section,
|
||||
hits: section.hits.filter(
|
||||
(hit) =>
|
||||
!hit.locale ||
|
||||
String(hit.locale).toLowerCase() === strapiLocale.toLowerCase()
|
||||
),
|
||||
}))
|
||||
.filter((section) => section.hits.length > 0)
|
||||
);
|
||||
// 展平hits
|
||||
const hits = computed(() =>
|
||||
filteredSections.value.flatMap((item) =>
|
||||
item.hits.map((content) => ({ content, type: item.indexUid }))
|
||||
)
|
||||
);
|
||||
|
||||
// 分类控制
|
||||
const activeTab = ref('all');
|
||||
const resultCount = computed(() => {
|
||||
const map: Record<string, number> = { all: hits.value.length };
|
||||
for (const hit of hits.value) {
|
||||
map[hit.type] = (map[hit.type] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// 分页控制
|
||||
const currentPage = ref(1);
|
||||
|
||||
const hasResults = computed(() =>
|
||||
filteredSections.value.some((section) => section.hits.length > 0)
|
||||
);
|
||||
|
||||
const navigateToQuery = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
navigateTo({
|
||||
path: localePath('/search'),
|
||||
query: { query: trimmed },
|
||||
});
|
||||
};
|
||||
|
||||
const performSearch = async (value: string) => {
|
||||
activeRequestId.value += 1;
|
||||
const requestId = activeRequestId.value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
if (requestId === activeRequestId.value) {
|
||||
sections.value = [];
|
||||
loading.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await search(trimmed, { limit: 12 });
|
||||
if (requestId === activeRequestId.value) {
|
||||
sections.value = results;
|
||||
}
|
||||
console.log('hits:', hits.value);
|
||||
console.log(resultCount.value);
|
||||
} catch (error) {
|
||||
console.error('Failed to perform search', error);
|
||||
if (requestId === activeRequestId.value) {
|
||||
sections.value = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
keyword.value = '';
|
||||
sections.value = [];
|
||||
router.replace(localePath({ path: '/search' }));
|
||||
};
|
||||
const hasResults = computed(() => searchItems.value.length > 0);
|
||||
|
||||
watch(
|
||||
() => route.query.query,
|
||||
(newQuery) => {
|
||||
async (newQuery) => {
|
||||
if (typeof newQuery === 'string' && newQuery.trim()) {
|
||||
keyword.value = newQuery;
|
||||
performSearch(newQuery);
|
||||
} else {
|
||||
loading.value = false;
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => locale,
|
||||
async () => {
|
||||
await refresh();
|
||||
}
|
||||
);
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
@ -233,46 +108,14 @@
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
height: 50px;
|
||||
width: 100px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search-meta {
|
||||
font-size: 0.9rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.search-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 80px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="!pending">
|
||||
<div v-if="solution">
|
||||
<div class="page-header">
|
||||
<el-breadcrumb class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">{{
|
||||
$t('navigation.home')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/solutions')">{{
|
||||
$t('navigation.solutions')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">{{
|
||||
solution.title
|
||||
}}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div class="solution-info">
|
||||
<h1>{{ solution.title }}</h1>
|
||||
<div class="solution-meta">
|
||||
<span class="solution-date">
|
||||
CreatedAt:
|
||||
{{ new Date(solution.createdAt).toLocaleDateString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="summary">{{ solution.summary }}</p>
|
||||
<div class="solution-content">
|
||||
<markdown-renderer :content="solution.content || ''" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="not-found">
|
||||
<el-result
|
||||
icon="warning"
|
||||
:title="$t('solution-not-found')"
|
||||
:sub-title="$t('solution-not-found-desc')"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/productions')">
|
||||
{{ $t('back-to-solutions') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="loading">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const { findOne } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
|
||||
// 获取路由参数(documentId)
|
||||
const documentId = computed(() => route.params.slug as string);
|
||||
|
||||
const { data, pending, error } = useAsyncData(
|
||||
() => `solution-${documentId.value}`,
|
||||
() =>
|
||||
findOne<Solution>('solutions', documentId.value, {
|
||||
populate: '*',
|
||||
locale: strapiLocale,
|
||||
})
|
||||
);
|
||||
|
||||
const solution = computed(() => data.value?.data ?? null);
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 1rem;
|
||||
min-height: 80vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.solution-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.solution-header el-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.page-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.solution-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.solution-content {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
73
app/pages/solutions/[slug].vue
Normal file
73
app/pages/solutions/[slug].vue
Normal file
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="solution">
|
||||
<div class="page-header">
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
</div>
|
||||
<solution-detail :solution="solution" />
|
||||
</div>
|
||||
<div v-else class="not-found">
|
||||
<not-found-result
|
||||
:title="$t('solution-not-found')"
|
||||
:sub-title="$t('solution-not-found-desc')"
|
||||
:back-text="$t('back-to-solutions')"
|
||||
:on-back="() => $router.push($localePath('/solutions'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
// 获取路由参数
|
||||
const id = route.params.slug as string;
|
||||
|
||||
const { data: solution, error } = await useSolution(id);
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.solutions'), to: localePath('/solutions') },
|
||||
{ label: solution.value ? solution.value.title : '' },
|
||||
]);
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
usePageSeo({
|
||||
title: solution.value?.title || $t('page-title.solutions'),
|
||||
description: solution.value?.summary || '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 1rem;
|
||||
min-height: 80vh;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
@ -1,107 +1,112 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('learn-our-solutions') }}</h1>
|
||||
<el-breadcrumb class="breadcrumb">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">{{
|
||||
$t('navigation.home')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/solutions')">{{
|
||||
$t('navigation.solutions')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<div>
|
||||
<h1 class="page-title">{{ $t('learn-our-solutions') }}</h1>
|
||||
<p class="page-subtitle">{{ $t('solutions-desc') }}</p>
|
||||
</div>
|
||||
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
</div>
|
||||
<div v-if="!pending" class="solutions-container">
|
||||
<el-tabs v-model="activeName" class="solutions-tabs">
|
||||
<el-tab-pane :label="$t('all')" name="all">
|
||||
<div class="solution-list">
|
||||
<solution-card
|
||||
v-for="solution in solutions"
|
||||
:key="solution.documentId"
|
||||
:title="solution.title"
|
||||
:summary="solution.summary || ''"
|
||||
:cover-url="useStrapiMedia(solution?.cover?.url || '')"
|
||||
:document-id="solution.documentId"
|
||||
<div class="solutions-container">
|
||||
<el-skeleton
|
||||
:loading="pending"
|
||||
animated
|
||||
:throttle="{
|
||||
leading: 500,
|
||||
trailing: 500,
|
||||
}"
|
||||
>
|
||||
<template #template>
|
||||
<div class="skeleton-group-list">
|
||||
<el-skeleton-item
|
||||
v-for="i in 12"
|
||||
:key="i"
|
||||
variant="rect"
|
||||
class="skeleton-card"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
v-for="(group, type) in groupedSolutions"
|
||||
:key="type"
|
||||
:label="type || '未分类'"
|
||||
:name="type || 'no-category'"
|
||||
>
|
||||
<div class="solution-list">
|
||||
<solution-card
|
||||
v-for="solution in group"
|
||||
:key="solution.documentId"
|
||||
:document-id="solution.documentId"
|
||||
:cover-url="useStrapiMedia(solution?.cover?.url || '')"
|
||||
:title="solution.title"
|
||||
:summary="solution.summary || ''"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-skeleton :rows="6" animated />
|
||||
</template>
|
||||
<template #default>
|
||||
<el-tabs v-model="activeName" class="solutions-tabs">
|
||||
<el-tab-pane :label="$t('all')" name="all">
|
||||
<div class="solution-list">
|
||||
<solution-card
|
||||
v-for="solution in solutions"
|
||||
:key="solution.id"
|
||||
:title="solution.title"
|
||||
:summary="solution.summary || ''"
|
||||
:cover-url="getImageUrl(solution.cover || '')"
|
||||
:document-id="solution.id.toString()"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
v-for="[key, value] in Object.entries(groupedSolutions)"
|
||||
:key="key"
|
||||
:label="key || '未分类'"
|
||||
:name="key || 'no-category'"
|
||||
>
|
||||
<div class="solution-list">
|
||||
<solution-card
|
||||
v-for="solution in value.data"
|
||||
:key="solution.id"
|
||||
:document-id="solution.id.toString()"
|
||||
:cover-url="getImageUrl(solution.cover || '')"
|
||||
:title="solution.title"
|
||||
:summary="solution.summary || ''"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { find } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
const localePath = useLocalePath();
|
||||
const { getImageUrl } = useDirectusImage();
|
||||
|
||||
const { data, pending, error } = useAsyncData('solutions', () =>
|
||||
find<Solution>('solutions', {
|
||||
populate: {
|
||||
cover: {
|
||||
populate: '*',
|
||||
},
|
||||
solution_type: {
|
||||
populate: '*',
|
||||
},
|
||||
},
|
||||
locale: strapiLocale,
|
||||
})
|
||||
);
|
||||
const breadcrumbItems = [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.solutions') },
|
||||
];
|
||||
|
||||
const { data, pending, error } = useSolutionList();
|
||||
|
||||
const solutions = computed(() => data.value ?? []);
|
||||
|
||||
const activeName = ref<string>('all');
|
||||
|
||||
const solutions = computed(() => data.value?.data ?? []);
|
||||
|
||||
// 按类型分组
|
||||
const groupedSolutions = computed(() => {
|
||||
const gourps: Record<string, Solution[]> = {};
|
||||
const groups: Record<string, { data: SolutionListView[]; sort: number }> =
|
||||
{};
|
||||
for (const sol of solutions.value) {
|
||||
let typeKey = '';
|
||||
if (typeof sol.solution_type === 'string') {
|
||||
typeKey = sol.solution_type;
|
||||
} else if (
|
||||
sol.solution_type &&
|
||||
typeof sol.solution_type === 'object' &&
|
||||
'type' in sol.solution_type
|
||||
) {
|
||||
typeKey = sol.solution_type.type || '';
|
||||
const typeKey = sol.solution_type?.name ?? '';
|
||||
if (!groups[typeKey]) {
|
||||
groups[typeKey] = { data: [], sort: sol.solution_type?.sort ?? 999 };
|
||||
}
|
||||
if (!gourps[typeKey]) gourps[typeKey] = [];
|
||||
gourps[typeKey]?.push(sol);
|
||||
groups[typeKey]?.data.push(sol);
|
||||
}
|
||||
return gourps;
|
||||
const sortedGroups = Object.fromEntries(
|
||||
Object.entries(groups).sort(([, a], [, b]) => a.sort - b.sort)
|
||||
);
|
||||
return sortedGroups;
|
||||
});
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
const pageTitle = $t('page-title.solutions');
|
||||
usePageSeo({
|
||||
title: pageTitle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -113,15 +118,20 @@
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-left: auto;
|
||||
}
|
||||
@ -133,4 +143,38 @@
|
||||
margin-bottom: 2rem;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.skeleton-group-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
width: 30%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.skeleton-card {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.skeleton-group-list {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -3,53 +3,58 @@
|
||||
<support-tabs model-value="contact-us" />
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('navigation.contact-info') }}</h1>
|
||||
<el-breadcrumb class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">
|
||||
{{ $t('navigation.home') }}
|
||||
</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/support')">
|
||||
{{ $t('navigation.support') }}
|
||||
</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/support/contact-us')">
|
||||
{{ $t('navigation.contact-info') }}
|
||||
</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
</div>
|
||||
|
||||
<div v-if="!pending" class="page-content">
|
||||
<markdown-renderer :content="content || ''" />
|
||||
</div>
|
||||
<div v-else class="loading">
|
||||
<el-skeleton :rows="5" animated />
|
||||
<div class="page-content">
|
||||
<el-skeleton
|
||||
:rows="10"
|
||||
:loading="pending"
|
||||
animated
|
||||
:throttle="{ leading: 500, trailing: 500 }"
|
||||
>
|
||||
<template #default>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="html-typography" v-html="contactInfo?.content || ''" />
|
||||
<!-- <div v-if="!hydrated" v-html="contactInfo?.content || ''" /> -->
|
||||
<!-- <div v-else> -->
|
||||
<!-- <html-renderer -->
|
||||
<!-- class="html-typography" -->
|
||||
<!-- :html="contactInfo?.content || ''" -->
|
||||
<!-- /> -->
|
||||
<!-- </div> -->
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { findOne } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
const localePath = useLocalePath();
|
||||
const hydrated = ref(false);
|
||||
const breadcrumbItems = [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.support'), to: localePath('/support') },
|
||||
{ label: $t('navigation.contact-info') },
|
||||
];
|
||||
const { data, pending, error } = useContactInfo();
|
||||
|
||||
const { data, pending, error } = useAsyncData('contact-info', () =>
|
||||
findOne<StrapiContactInfo>('contact-info', undefined, {
|
||||
populate: '*',
|
||||
locale: strapiLocale,
|
||||
})
|
||||
);
|
||||
|
||||
const content = computed(() => data.value?.data.content ?? '');
|
||||
const contactInfo = computed(() => data.value ?? null);
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
const pageTitle = $t('page-title.contact-us');
|
||||
usePageSeo({
|
||||
title: pageTitle,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
hydrated.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -82,4 +87,10 @@
|
||||
:deep(.markdown-body ul) {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,114 +1,112 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="pending">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div>
|
||||
<support-tabs model-value="documents" />
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('navigation.documents') }}</h1>
|
||||
<el-breadcrumb class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">{{
|
||||
$t('navigation.home')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/support')">{{
|
||||
$t('navigation.support')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/support/documents')">{{
|
||||
$t('navigation.documents')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="document-category">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<span class="select-label">产品分类</span>
|
||||
<el-select
|
||||
v-model="selectedType"
|
||||
placeholder="选择产品类型"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="type in productionTypeOptions"
|
||||
:key="type.documentId"
|
||||
:label="type.type"
|
||||
:value="type.documentId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<span class="select-label">产品系列</span>
|
||||
<el-select
|
||||
v-model="selectedProduction"
|
||||
placeholder="选择系列产品"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="production in productionOptions"
|
||||
:key="production.documentId"
|
||||
:label="production.title"
|
||||
:value="production.documentId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<span class="select-label">关键词</span>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="输入关键词..."
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<document-list :documents="filteredDocuments" />
|
||||
<document-filter
|
||||
v-model="filters"
|
||||
:product-type-options="productTypeOptions"
|
||||
:product-options="productOptions"
|
||||
:document-type-options="documentTypeOptions"
|
||||
/>
|
||||
|
||||
<el-skeleton
|
||||
:loading="pending"
|
||||
animated
|
||||
:throttle="{
|
||||
leading: 500,
|
||||
trailing: 500,
|
||||
}"
|
||||
>
|
||||
<template #template>
|
||||
<div class="flex flex-col gap-xl">
|
||||
<el-skeleton-item
|
||||
variant="rect"
|
||||
style="width: 100%; height: 100px"
|
||||
/>
|
||||
<el-skeleton-item
|
||||
v-for="i in 10"
|
||||
:key="i"
|
||||
variant="h1"
|
||||
style="height: 60px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<document-list
|
||||
:documents="paginatedDocuments"
|
||||
:show-category="
|
||||
filters.selectedDocumentType === null ||
|
||||
filters.selectedDocumentType === undefined
|
||||
"
|
||||
/>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
class="justify-center pagination-container"
|
||||
layout="prev, pager, next"
|
||||
hide-on-single-page
|
||||
:page-size="documentsPerPage"
|
||||
:total="filteredDocuments.length"
|
||||
/>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
const localePath = useLocalePath();
|
||||
const breadcrumbItems = [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.support'), to: localePath('/support') },
|
||||
{ label: $t('navigation.documents') },
|
||||
];
|
||||
|
||||
const { find } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
const filters = reactive({
|
||||
selectedDocumentType: null as string | null,
|
||||
selectedProductType: null as string | null,
|
||||
selectedProduct: null as string | null,
|
||||
keyword: '',
|
||||
});
|
||||
|
||||
const { data, pending, error } = useAsyncData('documents', () =>
|
||||
find<ProductionDocument>('production-documents', {
|
||||
populate: ['document', 'related_productions.production_type'],
|
||||
locale: strapiLocale,
|
||||
})
|
||||
);
|
||||
const page = ref(1);
|
||||
const documentsPerPage = 10;
|
||||
|
||||
// const documents = computed(
|
||||
// () =>
|
||||
// data.value?.data.map((item) => ({
|
||||
// ...item.document,
|
||||
// })) || []
|
||||
// );
|
||||
const documents = computed(() => data.value?.data ?? []);
|
||||
const { data, pending, error } = useDocumentList();
|
||||
|
||||
const keyword = ref('');
|
||||
const documents = computed(() => data.value ?? []);
|
||||
|
||||
const selectedType = ref<string | null>(null);
|
||||
const selectedProduction = ref<string | null>(null);
|
||||
const documentTypeOptions = computed(() => {
|
||||
const types: DocumentTypeView[] = [];
|
||||
documents.value.forEach((doc: DocumentListView) => {
|
||||
if (!types.some((item) => item.id === doc.type.id)) {
|
||||
if (doc.type.id === '-1') {
|
||||
types.push({
|
||||
id: '-1',
|
||||
name: $t('product-filter.misc'),
|
||||
});
|
||||
} else {
|
||||
types.push(doc.type);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const productionTypeOptions = computed(() => {
|
||||
const types: ProductionType[] = [];
|
||||
documents.value.forEach((document: ProductionDocument) => {
|
||||
document.related_productions?.forEach((production: Production) => {
|
||||
const productionType = production?.production_type;
|
||||
if (!types.some((p) => p.documentId === productionType.documentId)) {
|
||||
types.push(productionType);
|
||||
return types;
|
||||
});
|
||||
|
||||
const productTypeOptions = computed(() => {
|
||||
const types: DocumentListProductType[] = [];
|
||||
documents.value.forEach((doc: DocumentListView) => {
|
||||
doc.products?.forEach((product: DocumentListProduct) => {
|
||||
const productType = product.type;
|
||||
if (!types.some((item) => item.id === productType.id)) {
|
||||
types.push(productType);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -116,62 +114,75 @@
|
||||
return types;
|
||||
});
|
||||
|
||||
const productionOptions = computed(() => {
|
||||
if (!selectedType.value) return [];
|
||||
const productions: Production[] = [];
|
||||
documents.value.forEach((document: ProductionDocument) => {
|
||||
document.related_productions.forEach((production: Production) => {
|
||||
const productOptions = computed(() => {
|
||||
if (!filters.selectedProductType) return [];
|
||||
const products: DocumentListProduct[] = [];
|
||||
|
||||
documents.value.forEach((doc: DocumentListView) => {
|
||||
doc.products?.forEach((product: DocumentListProduct) => {
|
||||
if (
|
||||
production.production_type?.documentId === selectedType.value &&
|
||||
!productions.some((p) => p.documentId === production.documentId)
|
||||
product.type.id === filters.selectedProductType &&
|
||||
!products.some((item) => item.id === product.id)
|
||||
) {
|
||||
productions.push(production);
|
||||
products.push(product);
|
||||
}
|
||||
});
|
||||
});
|
||||
return productions;
|
||||
|
||||
return products;
|
||||
});
|
||||
|
||||
const filteredDocuments = computed(() =>
|
||||
documents.value
|
||||
.filter((document: ProductionDocument) => {
|
||||
const matchProduction = selectedProduction.value
|
||||
? document.related_productions?.some(
|
||||
(production: Production) =>
|
||||
production.documentId === selectedProduction.value
|
||||
const filteredDocuments = computed(() => {
|
||||
const fuzzyMatchedDocuments = fuzzyMatch(documents.value, {
|
||||
keyword: filters.keyword,
|
||||
keys: ['title'],
|
||||
threshold: 0.6,
|
||||
});
|
||||
return fuzzyMatchedDocuments.filter((doc: DocumentListView) => {
|
||||
const matchProduct = filters.selectedProduct
|
||||
? doc.products?.some(
|
||||
(product: DocumentListProduct) =>
|
||||
product.id === filters.selectedProduct
|
||||
)
|
||||
: filters.selectedProductType
|
||||
? doc.products?.some(
|
||||
(product: DocumentListProduct) =>
|
||||
product.type?.id === filters.selectedProductType
|
||||
)
|
||||
: selectedType.value
|
||||
? document.related_productions?.some(
|
||||
(production: Production) =>
|
||||
production.production_type?.documentId === selectedType.value
|
||||
)
|
||||
: true;
|
||||
|
||||
const matchKeyword = keyword.value
|
||||
? document.document.caption &&
|
||||
document.document.caption.includes(keyword.value)
|
||||
: true;
|
||||
|
||||
return matchProduction && matchKeyword;
|
||||
})
|
||||
.map((item) => ({
|
||||
...item.document,
|
||||
}))
|
||||
const matchDocumentType = filters.selectedDocumentType
|
||||
? doc.type.id === filters.selectedDocumentType
|
||||
: true;
|
||||
|
||||
return matchProduct && matchDocumentType;
|
||||
});
|
||||
});
|
||||
|
||||
const paginatedDocuments = computed(() => {
|
||||
return filteredDocuments.value.slice(
|
||||
(page.value - 1) * documentsPerPage,
|
||||
page.value * documentsPerPage
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => filters.selectedProductType,
|
||||
() => {
|
||||
filters.selectedProduct = null;
|
||||
}
|
||||
);
|
||||
|
||||
watch(selectedType, () => {
|
||||
selectedProduction.value = null;
|
||||
});
|
||||
|
||||
watch(documents, (value) => {
|
||||
console.log(value);
|
||||
});
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
const pageTitle = $t('page-title.documents');
|
||||
usePageSeo({
|
||||
title: pageTitle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -196,12 +207,6 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.document-category {
|
||||
padding: 0rem 2rem;
|
||||
gap: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 1rem 2rem 2rem;
|
||||
}
|
||||
@ -216,4 +221,41 @@
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-pagination) {
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-pager {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.el-pager li {
|
||||
font-size: 1rem;
|
||||
/* border: 1px solid #409eff; */
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,170 +1,197 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div v-if="pending" class="flex justify-center items-center h-64">
|
||||
<el-skeleton :rows="6" animated />
|
||||
<support-tabs model-value="faq" />
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('navigation.faq') }}</h1>
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<support-tabs model-value="faq" />
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('navigation.faq') }}</h1>
|
||||
<el-breadcrumb class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">{{
|
||||
$t('navigation.home')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/support')">{{
|
||||
$t('navigation.support')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/support/faq')">{{
|
||||
$t('navigation.faq')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="question-category">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<span class="select-label">产品分类</span>
|
||||
<el-select
|
||||
v-model="selectedType"
|
||||
placeholder="选择产品类型"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="type in productionTypeOptions"
|
||||
:key="type.documentId"
|
||||
:label="type.type"
|
||||
:value="type.documentId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<span class="select-label">产品系列</span>
|
||||
<el-select
|
||||
v-model="selectedProduction"
|
||||
placeholder="选择系列产品"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="production in productionOptions"
|
||||
:key="production.documentId"
|
||||
:label="production.title"
|
||||
:value="production.documentId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<span class="select-label">关键词</span>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="输入关键词..."
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
<div class="page-content">
|
||||
<question-filter
|
||||
v-model="filters"
|
||||
:product-type-options="productTypeOptions"
|
||||
:product-options="productOptions"
|
||||
:question-type-options="questionTypeOptions"
|
||||
/>
|
||||
|
||||
<el-skeleton
|
||||
:loading="pending"
|
||||
animated
|
||||
:throttle="{ leading: 500, trailing: 500 }"
|
||||
>
|
||||
<template #template>
|
||||
<div class="flex flex-col gap-xl">
|
||||
<el-skeleton-item
|
||||
v-for="i in 10"
|
||||
:key="i"
|
||||
variant="h1"
|
||||
style="height: 80px"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<question-list :questions="filteredQuestions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<question-list :questions="paginatedQuestions" />
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
class="justify-center pagination-container"
|
||||
layout="prev, pager, next"
|
||||
hide-on-single-page
|
||||
:page-size="questionsPerPage"
|
||||
:total="filteredQuestions.length"
|
||||
/>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
const { find } = useStrapi();
|
||||
const { getStrapiLocale } = useLocalizations();
|
||||
const strapiLocale = getStrapiLocale();
|
||||
const localePath = useLocalePath();
|
||||
const route = useRoute();
|
||||
|
||||
const { data, pending, error } = useAsyncData('questions', () =>
|
||||
find<Question>('questions', {
|
||||
populate: {
|
||||
related_productions: {
|
||||
populate: ['production_type'],
|
||||
},
|
||||
},
|
||||
locale: strapiLocale,
|
||||
})
|
||||
const filters = reactive({
|
||||
selectedQuestionType: null as string | null,
|
||||
selectedProduct: null as string | null,
|
||||
selectedProductType: null as string | null,
|
||||
keyword: '',
|
||||
});
|
||||
|
||||
const page = ref(1);
|
||||
const questionsPerPage = 10;
|
||||
|
||||
const focusQuestionId = ref<string | null>(
|
||||
route.query.focus as string | null
|
||||
);
|
||||
|
||||
const questions = computed(() => data.value?.data ?? null);
|
||||
const breadcrumbItems = [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.support'), to: localePath('/support') },
|
||||
{ label: $t('navigation.faq') },
|
||||
];
|
||||
|
||||
const keyword = ref('');
|
||||
const { data, pending, error } = useQuestionList();
|
||||
|
||||
const selectedType = ref<string | null>(null);
|
||||
const selectedProduction = ref<string | null>(null);
|
||||
const questions = computed(() => data.value ?? []);
|
||||
|
||||
const productionTypeOptions = computed(() => {
|
||||
const types: ProductionType[] = [];
|
||||
questions.value.forEach((q: Question) => {
|
||||
q.related_productions?.forEach((production: Production) => {
|
||||
const productionType = production?.production_type;
|
||||
if (!types.some((p) => p.documentId === productionType.documentId)) {
|
||||
types.push(productionType);
|
||||
const questionTypeOptions = computed(() => {
|
||||
const types: QuestionTypeView[] = [];
|
||||
questions.value.forEach((q: QuestionListView) => {
|
||||
if (!types.some((t) => t.id === q.type.id)) {
|
||||
if (q.type.id === '-1') {
|
||||
types.push({
|
||||
id: '-1',
|
||||
name: $t('product-filter.misc'),
|
||||
});
|
||||
} else {
|
||||
types.push(q.type);
|
||||
}
|
||||
}
|
||||
});
|
||||
return types;
|
||||
});
|
||||
|
||||
const productTypeOptions = computed(() => {
|
||||
const types: QuestionListProductType[] = [];
|
||||
questions.value.forEach((q: QuestionListView) => {
|
||||
q.products.forEach((product: QuestionListProduct) => {
|
||||
const productType = product.type;
|
||||
if (!types.some((p) => p.id === productType.id)) {
|
||||
types.push(productType);
|
||||
}
|
||||
});
|
||||
});
|
||||
return types;
|
||||
});
|
||||
|
||||
const productionOptions = computed(() => {
|
||||
if (!selectedType.value) return [];
|
||||
const productions: Production[] = [];
|
||||
questions.value.forEach((question: Question) => {
|
||||
question.related_productions.forEach((production: Production) => {
|
||||
const productOptions = computed(() => {
|
||||
if (!filters.selectedProductType) return [];
|
||||
const products: QuestionListProduct[] = [];
|
||||
questions.value.forEach((q: QuestionListView) => {
|
||||
q.products.forEach((product: QuestionListProduct) => {
|
||||
if (
|
||||
production.production_type?.documentId === selectedType.value &&
|
||||
!productions.some((p) => p.documentId === production.documentId)
|
||||
product.type.id === filters.selectedProductType &&
|
||||
!products.some((p) => p.id === product.id)
|
||||
) {
|
||||
productions.push(production);
|
||||
products.push(product);
|
||||
}
|
||||
});
|
||||
});
|
||||
return productions;
|
||||
return products;
|
||||
});
|
||||
|
||||
const filteredQuestions = computed(() => {
|
||||
return questions.value.filter((question: Question) => {
|
||||
const matchProduction = selectedProduction.value
|
||||
? question.related_productions?.some(
|
||||
(production: Production) =>
|
||||
production.documentId === selectedProduction.value
|
||||
const fuzzyMatchedQuestions = fuzzyMatch(questions.value, {
|
||||
keyword: filters.keyword,
|
||||
keys: ['title'],
|
||||
threshold: 0.6,
|
||||
});
|
||||
return fuzzyMatchedQuestions.filter((question: QuestionListView) => {
|
||||
const matchProduct = filters.selectedProduct
|
||||
? question.products?.some(
|
||||
(product: QuestionListProduct) =>
|
||||
product.id === filters.selectedProduct
|
||||
)
|
||||
: selectedType.value
|
||||
? question.related_productions?.some(
|
||||
(production: Production) =>
|
||||
production.production_type?.documentId === selectedType.value
|
||||
: filters.selectedProductType
|
||||
? question.products?.some(
|
||||
(product: QuestionListProduct) =>
|
||||
product.type.id === filters.selectedProductType
|
||||
)
|
||||
: true;
|
||||
|
||||
const matchKeyword = keyword.value
|
||||
? (question.title && question.title.includes(keyword.value)) ||
|
||||
(question.content && question.content.includes(keyword.value))
|
||||
const matchQuestionType = filters.selectedQuestionType
|
||||
? question.type.id === filters.selectedQuestionType
|
||||
: true;
|
||||
|
||||
return matchProduction && matchKeyword;
|
||||
return matchProduct && matchQuestionType;
|
||||
});
|
||||
});
|
||||
|
||||
watch(selectedType, () => {
|
||||
selectedProduction.value = null;
|
||||
const paginatedQuestions = computed(() => {
|
||||
const start = (page.value - 1) * questionsPerPage;
|
||||
const end = page.value * questionsPerPage;
|
||||
return filteredQuestions.value.slice(start, end);
|
||||
});
|
||||
|
||||
watch(data, (newVal) => {
|
||||
console.log('useAsyncData updated:', newVal);
|
||||
});
|
||||
watch(
|
||||
focusQuestionId,
|
||||
async (focusId) => {
|
||||
if (!focusId) return;
|
||||
if (!import.meta.client) return;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const question = filteredQuestions.value.find((q) => q.id === focusId);
|
||||
if (!question) return;
|
||||
|
||||
const targetIndex = filteredQuestions.value.indexOf(question);
|
||||
const targetPage = Math.floor(targetIndex / questionsPerPage) + 1;
|
||||
onMounted(() => {
|
||||
if (page.value !== targetPage) {
|
||||
page.value = targetPage;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => filters.selectedProductType,
|
||||
() => {
|
||||
filters.selectedProduct = null;
|
||||
}
|
||||
);
|
||||
|
||||
watch(error, (value) => {
|
||||
if (value) {
|
||||
console.error('数据获取失败: ', value);
|
||||
logger.error('数据获取失败: ', value);
|
||||
}
|
||||
});
|
||||
|
||||
const pageTitle = $t('page-title.faq');
|
||||
usePageSeo({
|
||||
title: pageTitle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -189,24 +216,43 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.question-category {
|
||||
padding: 0rem 2rem;
|
||||
gap: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 1rem 2rem 2rem;
|
||||
}
|
||||
|
||||
.select-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
.pagination-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
:deep(.el-select__wrapper),
|
||||
:deep(.el-input__wrapper) {
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
@media (max-width: 768px) {
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-pagination) {
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-pager {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.el-pager li {
|
||||
font-size: 1rem;
|
||||
/* border: 1px solid #409eff; */
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,115 +1,63 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<support-tabs />
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('navigation.support') }}</h1>
|
||||
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ $t('navigation.support') }}</h1>
|
||||
<el-breadcrumb class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">{{
|
||||
$t('navigation.home')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/support')">{{
|
||||
$t('navigation.support')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<section style="margin-bottom: 2rem">
|
||||
<p>
|
||||
金申机械制造有限公司致力于为客户提供优质的产品与服务。针对纸管机、分纸机、纸吸管等产品,我们提供全方位的售后服务,确保客户能够安心地使用我们的产品。
|
||||
{{ $t('support-page-desc') }}
|
||||
</p>
|
||||
</section>
|
||||
<div class="card-group">
|
||||
<el-card class="card">
|
||||
<el-row>
|
||||
<el-col :span="6">
|
||||
<el-icon class="card-icon" size="80">
|
||||
<ElIconQuestionFilled />
|
||||
</el-icon>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<div class="card-title">
|
||||
<span>{{ $t('navigation.faq') }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<div class="card-content">
|
||||
<p>我们为用户整理了常见问题的答案,帮助您快速解决疑惑。</p>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<NuxtLink class="card-link" :to="$localePath('/support/faq')">
|
||||
<el-button class="card-button" round>
|
||||
<span>了解更多 > </span>
|
||||
</el-button>
|
||||
</NuxtLink>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-card class="card">
|
||||
<el-row>
|
||||
<el-col :span="6">
|
||||
<el-icon class="card-icon" size="80">
|
||||
<ElIconDocumentChecked />
|
||||
</el-icon>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<div class="card-title">
|
||||
<span>{{ $t('navigation.documents') }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<div class="card-content">
|
||||
<p>我们为用户整理了常见问题的答案,为您快速解决疑惑。</p>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<NuxtLink class="card-link" :to="$localePath('/support/documents')">
|
||||
<el-button class="card-button" round>
|
||||
<span>了解更多 > </span>
|
||||
</el-button>
|
||||
</NuxtLink>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-card class="card">
|
||||
<el-row>
|
||||
<el-col :span="6">
|
||||
<el-icon class="card-icon" size="80">
|
||||
<ElIconService />
|
||||
</el-icon>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<div class="card-title">
|
||||
<span>{{ $t('navigation.contact-info') }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<div class="card-content">
|
||||
<p>通过电话、邮箱联系我们,我们将现场为您服务。</p>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<NuxtLink
|
||||
class="card-link"
|
||||
:to="$localePath('/support/contact-us')"
|
||||
>
|
||||
<el-button class="card-button" round>
|
||||
<span>了解更多 > </span>
|
||||
</el-button>
|
||||
</NuxtLink>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<support-card
|
||||
v-for="(item, index) in supportItems"
|
||||
:key="index"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:to="item.to"
|
||||
:icon-component="item.iconComponent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
const localePath = useLocalePath();
|
||||
const breadcrumbItems = [
|
||||
{ label: $t('navigation.home'), to: localePath('/') },
|
||||
{ label: $t('navigation.support') },
|
||||
];
|
||||
|
||||
const supportItems = [
|
||||
{
|
||||
title: $t('navigation.faq'),
|
||||
description: $t('support-card-desc.faq'),
|
||||
to: localePath('/support/faq'),
|
||||
iconComponent: ElIconQuestionFilled,
|
||||
},
|
||||
{
|
||||
title: $t('navigation.documents'),
|
||||
description: $t('support-card-desc.documents'),
|
||||
to: localePath('/support/documents'),
|
||||
iconComponent: ElIconDocumentChecked,
|
||||
},
|
||||
{
|
||||
title: $t('navigation.contact-info'),
|
||||
description: $t('support-card-desc.contact-info'),
|
||||
to: localePath('/support/contact-us'),
|
||||
iconComponent: ElIconService,
|
||||
},
|
||||
];
|
||||
|
||||
const pageTitle = $t('page-title.support');
|
||||
usePageSeo({
|
||||
title: pageTitle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
@ -119,6 +67,7 @@
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
padding: 2rem 2rem 0rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@ -142,7 +91,8 @@
|
||||
}
|
||||
|
||||
.card-group {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 50px;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 2rem;
|
||||
@ -173,36 +123,23 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: var(--el-color-primary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
margin-top: 2rem;
|
||||
margin-left: 2rem;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.el-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.el-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.el-col {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.grid-content {
|
||||
border-radius: 4px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
13
app/plugins/directus.ts
Normal file
13
app/plugins/directus.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { createDirectus, rest, staticToken, graphql } from '@directus/sdk';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const directus = createDirectus<Schema>(config.public.directus.url)
|
||||
.with(rest())
|
||||
.with(graphql())
|
||||
.with(staticToken(config.public.directus.token || ''));
|
||||
return {
|
||||
provide: { directus },
|
||||
};
|
||||
});
|
||||
@ -1,53 +0,0 @@
|
||||
export interface StrapiEntity {
|
||||
id: number;
|
||||
documentId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface StrapiMedia {
|
||||
id: number;
|
||||
url: string;
|
||||
ext: string;
|
||||
name: string;
|
||||
size: number;
|
||||
alternativeText: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
export interface StrapiImageFormat {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface StrapiImage extends StrapiMedia {
|
||||
width: number;
|
||||
height: number;
|
||||
formats: {
|
||||
small: StrapiImageFormat;
|
||||
medium: StrapiImageFormat;
|
||||
thumbnail: StrapiImageFormat;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StrapiResponse<T> {
|
||||
data: T;
|
||||
meta: {
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type StrapiRelation<T, K extends keyof T = never> = Omit<
|
||||
T,
|
||||
K | keyof StrapiEntity
|
||||
> &
|
||||
StrapiEntity;
|
||||
@ -1,5 +0,0 @@
|
||||
export * from './common';
|
||||
export * from './production';
|
||||
export * from './singleTypes';
|
||||
export * from './solution';
|
||||
export * from './question';
|
||||
@ -1,41 +0,0 @@
|
||||
import type {
|
||||
StrapiEntity,
|
||||
StrapiImage,
|
||||
StrapiMedia,
|
||||
StrapiRelation,
|
||||
} from './common';
|
||||
|
||||
export interface ProductionType extends StrapiEntity {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ProductionSpecItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductionSpecGroup {
|
||||
title: string;
|
||||
items: ProductionSpecItem[];
|
||||
}
|
||||
|
||||
export interface Production extends StrapiEntity {
|
||||
title: string;
|
||||
summary: string;
|
||||
production_type: ProductionType;
|
||||
cover: StrapiImage;
|
||||
production_images: StrapiImage[];
|
||||
production_details: string;
|
||||
production_specs: ProductionSpecGroup[];
|
||||
production_documents: StrapiRelation<
|
||||
ProductionDocument,
|
||||
'related_productions'
|
||||
>[];
|
||||
questions: StrapiRelation<Question, 'related_productions'>[];
|
||||
show_in_production_list: boolean;
|
||||
}
|
||||
|
||||
export interface ProductionDocument extends StrapiEntity {
|
||||
document: StrapiMedia;
|
||||
related_productions: StrapiRelation<Production, 'production_documents'>[];
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export interface Question extends StrapiEntity {
|
||||
title: string;
|
||||
content: string;
|
||||
related_productions: StrapiRelation<Production, 'questions'>[];
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
export interface StrapiCompanyProfile extends StrapiEntity {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StrapiContactInfo extends StrapiEntity {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StrapiHomepage extends StrapiEntity {
|
||||
carousel: StrapiImage[];
|
||||
recommend_productions: StrapiRelation<Production>[];
|
||||
recommend_solutions: StrapiRelation<Solution>[];
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import type { StrapiEntity, StrapiImage } from './common';
|
||||
|
||||
export interface SolutionType extends StrapiEntity {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Solution extends StrapiEntity {
|
||||
title: string;
|
||||
summary: string;
|
||||
cover: StrapiImage;
|
||||
solution_type: SolutionType;
|
||||
content: string;
|
||||
}
|
||||
45
app/utils/autoMap.ts
Normal file
45
app/utils/autoMap.ts
Normal file
@ -0,0 +1,45 @@
|
||||
async function testSpeed(url: string, timeout = 1500) {
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
fetch(url, { method: 'HEAD', mode: 'no-cors' }),
|
||||
new Promise((_, reject) => setTimeout(() => reject('timeout'), timeout)),
|
||||
]);
|
||||
|
||||
return performance.now() - start;
|
||||
} catch {
|
||||
return Infinity; // unreachable or timed out
|
||||
}
|
||||
}
|
||||
|
||||
async function selectBestMap() {
|
||||
const testTargets = {
|
||||
// amap: 'https://www.amap.com/favicon.ico',
|
||||
baidu: 'https://map.baidu.com/favicon.ico',
|
||||
google: 'https://maps.google.com/favicon.ico',
|
||||
};
|
||||
|
||||
const results: Record<string, number> = {};
|
||||
|
||||
for (const key in testTargets) {
|
||||
results[key] = await testSpeed(testTargets[key]);
|
||||
}
|
||||
|
||||
logger.debug(results);
|
||||
|
||||
// 根据延迟排序,选择最稳最快的平台
|
||||
return Object.entries(results).sort((a, b) => a[1] - b[1])[0][0];
|
||||
}
|
||||
|
||||
export async function getAutoMappedService(): Promise<string> {
|
||||
const target = {
|
||||
// amap: 'https://surl.amap.com/2dYNorIJ1dgoN',
|
||||
baidu: 'https://j.map.baidu.com/f9/c3x',
|
||||
google: 'https://maps.app.goo.gl/9LqvMwEq7VaRkqnM6',
|
||||
};
|
||||
|
||||
const fastestMap = await selectBestMap();
|
||||
|
||||
return target[fastestMap];
|
||||
}
|
||||
47
app/utils/file.test.ts
Normal file
47
app/utils/file.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { expect, test, describe } from 'vitest';
|
||||
import { formatFileSize, getFileExtension, formatFileExtension } from './file';
|
||||
|
||||
/**
|
||||
* 单元测试: formatFileSize
|
||||
*/
|
||||
describe('formatFileSize', () => {
|
||||
test('format bytes correctly', () => {
|
||||
expect(formatFileSize(500)).toBe('500.00 B');
|
||||
});
|
||||
test('format kilobytes correctly', () => {
|
||||
expect(formatFileSize(2048)).toBe('2.00 KB');
|
||||
});
|
||||
test('format megabytes correctly', () => {
|
||||
expect(formatFileSize(5 * 1024 * 1024)).toBe('5.00 MB');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 单元测试: getFileExtension
|
||||
*/
|
||||
describe('getFileExtension', () => {
|
||||
test('extract extension from filename', () => {
|
||||
expect(getFileExtension('document.pdf')).toBe('pdf');
|
||||
});
|
||||
test('handle filenames without extension', () => {
|
||||
expect(getFileExtension('README')).toBe('');
|
||||
});
|
||||
test('handle multiple dots in filename', () => {
|
||||
expect(getFileExtension('archive.tar.gz')).toBe('gz');
|
||||
});
|
||||
test('handle empty filename', () => {
|
||||
expect(getFileExtension('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 单元测试: formatFileExtension
|
||||
*/
|
||||
describe('formatFileExtension', () => {
|
||||
test('format extension without dot', () => {
|
||||
expect(formatFileExtension('txt')).toBe('TXT');
|
||||
});
|
||||
test('format extension with dot', () => {
|
||||
expect(formatFileExtension('.jpg')).toBe('JPG');
|
||||
});
|
||||
});
|
||||
@ -1,12 +1,21 @@
|
||||
export function formatFileSize(sizeInKB: number): string {
|
||||
if (sizeInKB < 1024) {
|
||||
export function formatFileSize(sizeInBytes: number): string {
|
||||
if (sizeInBytes < 1024) {
|
||||
return `${sizeInBytes.toFixed(2)} B`;
|
||||
} else if (sizeInBytes < 1024 * 1024) {
|
||||
const sizeInKB = sizeInBytes / 1024;
|
||||
return `${sizeInKB.toFixed(2)} KB`;
|
||||
} else {
|
||||
const sizeInMB = sizeInKB / 1024;
|
||||
const sizeInMB = sizeInBytes / 1024 / 1024;
|
||||
return `${sizeInMB.toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExtension(filename: string): string {
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1) return ''; // 找不到拓展名
|
||||
return filename.slice(lastDotIndex + 1);
|
||||
}
|
||||
|
||||
export function formatFileExtension(ext: string): string {
|
||||
return ext.startsWith('.') ? ext.slice(1).toUpperCase() : ext.toUpperCase();
|
||||
}
|
||||
|
||||
108
app/utils/fuzzyFilter.ts
Normal file
108
app/utils/fuzzyFilter.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
interface FuzzyFilterOptions<T> {
|
||||
/** 匹配关键字 */
|
||||
keyword: string;
|
||||
|
||||
/** 搜索字段 */
|
||||
keys: Array<FuzzyKeyOf<T>>;
|
||||
|
||||
/** 模糊程度 (0~1,越低越严格) */
|
||||
threshold?: number;
|
||||
|
||||
/** 最小匹配字符数 */
|
||||
minMatchCharLength?: number;
|
||||
|
||||
/** 当前语言 */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/** 限定 keys 只能选择字符串字段 */
|
||||
type FuzzyKeyOf<T> = {
|
||||
[K in keyof T]: T[K] extends string ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export function fuzzyMatch<T extends object>(
|
||||
source: T[],
|
||||
options: FuzzyFilterOptions<T>
|
||||
): T[] {
|
||||
const { keyword, keys, threshold = 0.35, minMatchCharLength = 1 } = options;
|
||||
|
||||
// --- 文本标准化函数 ---
|
||||
const normalizeText = (text: string): string => {
|
||||
const normalizedText = text.normalize('NFKC').toLowerCase().trim();
|
||||
return normalizedText;
|
||||
};
|
||||
|
||||
/**
|
||||
* 类型安全的对象取值函数
|
||||
*/
|
||||
function getPropertyByPath<T>(
|
||||
obj: T,
|
||||
path: string | string[]
|
||||
): string | undefined {
|
||||
const keys = Array.isArray(path) ? path : path.split('.');
|
||||
let value: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (value && typeof value === 'object' && key in value) {
|
||||
value = (value as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
// --- fallback 模糊匹配算法 ---
|
||||
const fallbackFuzzyMatch = (text: string, pattern: string): boolean => {
|
||||
const normalizedText = normalizeText(text);
|
||||
const normalizedPattern = normalizeText(pattern);
|
||||
let i = 0;
|
||||
for (const char of normalizedPattern) {
|
||||
i = normalizedText.indexOf(char, i);
|
||||
if (i === -1) return false;
|
||||
i++;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const k = keyword.trim();
|
||||
|
||||
if (!k) {
|
||||
return source;
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
// 客户端使用Fuze.js进行模糊匹配
|
||||
const fuse = new Fuse<T>(source, {
|
||||
keys: keys as string[],
|
||||
threshold,
|
||||
minMatchCharLength,
|
||||
ignoreLocation: true,
|
||||
findAllMatches: true,
|
||||
isCaseSensitive: false,
|
||||
getFn: (obj, path) => {
|
||||
const value = getPropertyByPath(obj, path);
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
return normalized;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
const result = fuse.search(k);
|
||||
return result.map((result) => result.item);
|
||||
}
|
||||
|
||||
return source.filter((item) =>
|
||||
keys.some((key) => {
|
||||
const value = item[key];
|
||||
return typeof value === 'string' ? fallbackFuzzyMatch(value, k) : false;
|
||||
})
|
||||
);
|
||||
}
|
||||
30
app/utils/htmlDefaultMap.ts
Normal file
30
app/utils/htmlDefaultMap.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { HtmlRenderMap } from '@/composables/useHtmlRenderer';
|
||||
import { h } from 'vue';
|
||||
import MarkdownTable from '@/components/shared/MarkdownTable.vue';
|
||||
|
||||
export const defaultHtmlRenderMap: HtmlRenderMap = {
|
||||
h1: (_, children) => h('h1', {}, children),
|
||||
|
||||
h2: (_, children) => h('h2', {}, children),
|
||||
|
||||
p: (_, children) => h('p', {}, children),
|
||||
|
||||
// table: (_, children) => h('div', {}, [h('table', {}, children)]),
|
||||
table: (node) => {
|
||||
const { headers, rows } = parseHtmlTable(node);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{},
|
||||
h(MarkdownTable, {
|
||||
headers,
|
||||
rows,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
img: (node) =>
|
||||
h('img', {
|
||||
src: node.attribs?.src,
|
||||
}),
|
||||
};
|
||||
@ -18,17 +18,3 @@ export function renderMarkdown(content: string): string {
|
||||
|
||||
return dirtyHtml;
|
||||
}
|
||||
|
||||
export function convertMedia(content: string): string {
|
||||
// 通过正则表达式替换Markdown中的图片链接
|
||||
//  -> )
|
||||
|
||||
if (!content) return '';
|
||||
|
||||
const contentWithAbsoluteUrls = content.replace(
|
||||
/!\[([^\]]*)\]\((\/uploads\/[^)]+)\)/g,
|
||||
(_, alt, url) => `})`
|
||||
);
|
||||
|
||||
return contentWithAbsoluteUrls;
|
||||
}
|
||||
|
||||
90
app/utils/parseTable.ts
Normal file
90
app/utils/parseTable.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import type { Element, Node } from 'domhandler';
|
||||
|
||||
export interface ParsedTable {
|
||||
headers: string[];
|
||||
rows: Record<string, string>[];
|
||||
}
|
||||
|
||||
const isElement = (n: Node): n is Element => n.type === 'tag';
|
||||
|
||||
export function parseHtmlTable(table: Element): ParsedTable {
|
||||
if (table.name !== 'table') {
|
||||
throw new Error('parseHtmlTable: node is not <table>');
|
||||
}
|
||||
// 获取 table 下的所有 tag 子节点
|
||||
const getChildren = (node: Node): Element[] =>
|
||||
isElement(node) ? node.children.filter(isElement) : [];
|
||||
|
||||
// ---- 找 thead / tbody ----
|
||||
const children = getChildren(table);
|
||||
const thead = children.find((n) => n.name === 'thead');
|
||||
const tbody = children.find((n) => n.name === 'tbody');
|
||||
|
||||
if (!tbody) {
|
||||
return { headers: [], rows: [] };
|
||||
}
|
||||
|
||||
const bodyRows = getChildren(tbody).filter((n) => n.name === 'tr');
|
||||
|
||||
if (bodyRows.length === 0) {
|
||||
return { headers: [], rows: [] };
|
||||
}
|
||||
// ---- 1. 表头 ----
|
||||
let headerCells: Element[] = [];
|
||||
|
||||
if (thead) {
|
||||
// 如果 Directus 有 thead(一般不会)
|
||||
const headerRow = getChildren(thead).find((n) => n.name === 'tr');
|
||||
headerCells = headerRow
|
||||
? getChildren(headerRow).filter((n) => n.name === 'th' || n.name === 'td')
|
||||
: [];
|
||||
} else {
|
||||
// Directus 情况:没有 thead → 用 tbody 第一行作为 header
|
||||
headerCells = getChildren(bodyRows[0]).filter(
|
||||
(n) => n.name === 'th' || n.name === 'td'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(headerCells);
|
||||
|
||||
const headers = headerCells.map((cell, i) => {
|
||||
const text = cell.children
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((t) => t.data.trim())
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
return text || `列${i + 1}`;
|
||||
});
|
||||
|
||||
// ---- 2. 数据行 ----
|
||||
// 如果没有 thead,则跳过第一行(它是 header)
|
||||
const dataRows = thead ? bodyRows : bodyRows.slice(1);
|
||||
|
||||
const rows = dataRows.map((row) => {
|
||||
const cells = getChildren(row).filter(
|
||||
(n) => n.name === 'td' || n.name === 'th'
|
||||
);
|
||||
|
||||
const record: Record<string, string> = {};
|
||||
|
||||
headers.forEach((header, i) => {
|
||||
const cell = cells[i];
|
||||
if (!cell) {
|
||||
record[header] = '';
|
||||
} else {
|
||||
const text = cell.children
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((t) => t.data.trim())
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
record[header] = text;
|
||||
}
|
||||
});
|
||||
|
||||
return record;
|
||||
});
|
||||
|
||||
logger.info(headers, rows);
|
||||
|
||||
return { headers, rows };
|
||||
}
|
||||
63
docker-build.sh
Executable file
63
docker-build.sh
Executable file
@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ========== 脚本配置 ==========
|
||||
#
|
||||
IMAGE_NAME="git.jinshen.cn/remilia/jinshen-website" # 含仓库前缀的镜像名称
|
||||
DOCKERFILE="Dockerfile" # Dockerfile 文件路径
|
||||
BUILD_CONTEXT="." # 构建上下文路径
|
||||
PUSH_IMAGE=true # 是否推送镜像到远程仓库
|
||||
DOCKER_LOGIN=false # 是否需要登录 Docker 仓库
|
||||
REGISTRY_USER="" #Docker 仓库用户名
|
||||
REGISTRY_PASSWORD="" #Docker 仓库密码
|
||||
|
||||
# ==============================
|
||||
|
||||
# 生成时间戳TAG
|
||||
TIMESTAMP_TAG=$(date +'%Y%m%d%H%M%S')
|
||||
BUILD_TIME=$(date +"%Y-%m-%d %H:%M:%S")
|
||||
GIT_COMMIT=$(git rev-parse --short HEAD || echo "unknown")
|
||||
|
||||
echo "📦 构建镜像: ${IMAGE_NAME}"
|
||||
echo "⏱️ 时间戳标签: ${TIMESTAMP_TAG}"
|
||||
echo "🔖 Git 提交: ${GIT_COMMIT}"
|
||||
echo "⭐ 同时构建 latest 标签"
|
||||
|
||||
# docker login(如果启用)
|
||||
if [ "$DOCKER_LOGIN" = true ]; then
|
||||
if [ -z "$REGISTRY_PASSWORD" ]; then
|
||||
echo "❌ 登录失败:启用了 DOCKER_LOGIN,但 REGISTRY_PASSWORD 为空"
|
||||
exit 1
|
||||
fi
|
||||
echo "🔐 正在登录 Docker Registry..."
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$(echo "$IMAGE_NAME" | cut -d'/' -f1)" \
|
||||
--username "$REGISTRY_USER" --password-stdin
|
||||
fi
|
||||
|
||||
# 根据是否推送决定 buildx 参数
|
||||
if [ "$PUSH_IMAGE" = true ]; then
|
||||
BUILDX_MODE="--push"
|
||||
echo "🚀 将构建并推送镜像到仓库"
|
||||
else
|
||||
BUILDX_MODE="--load"
|
||||
echo "🛠️ 仅本地构建镜像(不推送)"
|
||||
fi
|
||||
|
||||
# 构建镜像(带 version metadata)
|
||||
docker buildx build \
|
||||
-t "${IMAGE_NAME}:${TIMESTAMP_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
--build-arg BUILD_TIME="$BUILD_TIME" \
|
||||
--build-arg GIT_COMMIT="$GIT_COMMIT" \
|
||||
-f "$DOCKERFILE" "$BUILD_CONTEXT" \
|
||||
$BUILDX_MODE
|
||||
|
||||
|
||||
echo "🎉 镜像构建完成!"
|
||||
echo "📌 可用镜像:"
|
||||
echo " - ${IMAGE_NAME}:${TIMESTAMP_TAG}"
|
||||
echo " - ${IMAGE_NAME}:latest"
|
||||
|
||||
if [ "$PUSH_IMAGE" = false ]; then
|
||||
echo "⚠️ 镜像未推送(PUSH_IMAGE=false)"
|
||||
fi
|
||||
13
docker-compose.test.yml
Normal file
13
docker-compose.test.yml
Normal file
@ -0,0 +1,13 @@
|
||||
services:
|
||||
website:
|
||||
image: git.jinshen.cn/remilia/jinshen-website
|
||||
container_name: webService
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
NUXT_PUBLIC_DIRECTUS_URL: ${DIRECTUS_URL}
|
||||
NUXT_PUBLIC_DIRECTUS_TOKEN: ${DIRECTUS_TOKEN}
|
||||
NUXT_PUBLIC_MEILI_HOST: ${MEILI_HOST}
|
||||
NUXT_PUBLIC_MEILI_SEARCH_KEY: ${MEILI_MASTER_KEY}
|
||||
ports:
|
||||
- 3200:3000
|
||||
9
entrypoint.sh
Normal file
9
entrypoint.sh
Normal file
@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
echo "Starting Nuxt App..."
|
||||
if [ -f "/app/version.json" ]; then
|
||||
echo "Version info:"
|
||||
cat /app/version.json
|
||||
else
|
||||
echo "⚠️ version.json not found!"
|
||||
fi
|
||||
exec "$@"
|
||||
@ -13,6 +13,7 @@ export default withNuxt(
|
||||
},
|
||||
},
|
||||
],
|
||||
'no-console': 'warn',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@ -10,32 +10,36 @@
|
||||
"result-count": "{count} results",
|
||||
"no-results": "No results found for \"{query}\".",
|
||||
"no-query": "Enter a keyword to start searching.",
|
||||
"section": "Section",
|
||||
"untitled": "Untitled",
|
||||
"sections": {
|
||||
"production": "Products",
|
||||
"product": "Products",
|
||||
"solution": "Solutions",
|
||||
"support": "Support",
|
||||
"faq": "Faqs",
|
||||
"document": "Documents",
|
||||
"default": "Other"
|
||||
}
|
||||
},
|
||||
"company-name": "Jinshen Machinary Manufacturing Co., Ltd.",
|
||||
"company-description": "We specialize in manufacturing a range of paper tube and can equipment, integrating design, manufacturing, sales, and service.",
|
||||
"learn-more": "Learn More",
|
||||
"productions-desc": "We provide high-quality product solutions to meet various business needs.",
|
||||
"solutions-desc": "Providing customized technology solutions for enterprises to accelerate digital transformation.",
|
||||
"products-desc": "We provide the latest products an instant service support to meet various business needs of our customers.",
|
||||
"solutions-desc": "We offer diversified technical solutions for enterprises, supporting various fields such as industry and packaging.",
|
||||
"support-desc": "24/7 professional technical support to ensure stable operation of your business.",
|
||||
"quick-links": "Quick Links",
|
||||
"utilities": "Utilities",
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"productions": "Productions",
|
||||
"products": "Products",
|
||||
"solutions": "Solutions",
|
||||
"support": "Support",
|
||||
"about-us": "About Us",
|
||||
"contact-info": "Contact Info",
|
||||
"downloads": "Downloads",
|
||||
"faq": "FAQ",
|
||||
"documents": "Documents",
|
||||
"calculator": "Calculator"
|
||||
"calculator": "Calculator",
|
||||
"address": "Company Address"
|
||||
},
|
||||
"contact-info": "Contact Us",
|
||||
"telephone": "Telephone",
|
||||
@ -50,13 +54,79 @@
|
||||
"product-details": "Product Details",
|
||||
"product-not-found": "Product Not Found",
|
||||
"product-not-found-desc": "Sorry, the product you are looking for does not exist or has been removed.",
|
||||
"back-to-productions": "Back to Products",
|
||||
"back-to-products": "Back to Products",
|
||||
"solution-not-found": "Solution Not Found",
|
||||
"solution-not-found-desc": "Sorry, the solution you are lokking for does not exist or has been removed.",
|
||||
"back-to-solutions": "Back to Solutions",
|
||||
"page-not-found": "Page Not Found",
|
||||
"page-not-found-desc": "Sorry, the page you are looking for does not exist or has been removed.",
|
||||
"back-to-home": "Back to Home",
|
||||
"no-content-available": "No detailed information available",
|
||||
"loading": "Loading...",
|
||||
"our-productions": "Our Productions",
|
||||
"our-products": "Our Latest Products",
|
||||
"find-discontinued-products": "To find discontinued products, please use the search function.",
|
||||
"product-discontinued-warning": "Product is discontinued and may no longer receive immediate support or updates",
|
||||
"learn-our-solutions": "Learn Our Solutions",
|
||||
"all": "All"
|
||||
"all": "All",
|
||||
"support-page-desc": "Zhejiang Jinshen Machinery Manufacturing Co., Ltd is committed to providing high-quality products and services to our customers. For products such as paper tube machines, slitting machines, and paper straw equipment, we offer comprehensive after-sales support to ensure our customers can use our products with confidence.",
|
||||
"support-card-desc": {
|
||||
"faq": "We have compiled answers to frequently asked questions to help you quickly resolve any concerns.",
|
||||
"documents": "We provide product manuals, technical specifications, and other documentation for easy reference.",
|
||||
"contact-info": "Contact us by phone or email, and we will provide on-site support for you."
|
||||
},
|
||||
|
||||
"product-filter": {
|
||||
"product-type": "Product type",
|
||||
"product-model": "Product model",
|
||||
"keyword": "Keyword",
|
||||
"select-product-type": "Select product type",
|
||||
"select-product-model": "Select product model",
|
||||
"enter-keyword": "Enter keyword",
|
||||
"question-type": "Question Type",
|
||||
"select-question-type": "Select question type",
|
||||
"document-type": "Document Type",
|
||||
"select-document-type": "Select document type",
|
||||
"misc": "Misc"
|
||||
},
|
||||
"document-meta": {
|
||||
"size": "Size",
|
||||
"format": "Format",
|
||||
"type": "Type",
|
||||
"upload-at": "Upload at"
|
||||
},
|
||||
"document-action": {
|
||||
"download": "Download",
|
||||
"open-in-new-tab": "Open in New Tab",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"mobile-menu": {
|
||||
"title": "Menu",
|
||||
"navigation": "Navigation",
|
||||
"utilities": "Utilities"
|
||||
},
|
||||
"homepage": {
|
||||
"recommended-products": "Recommended Products",
|
||||
"recommended-products-desc": "Explore our curated selection of products to meet your diverse needs. Whether it's innovative technology or classic designs, we offer quality choices for you.",
|
||||
"recommended-solutions": "Recommended Solutions",
|
||||
"recommended-solutions-desc": "Learn about our tailored solutions designed to help your business thrive in a competitive market."
|
||||
},
|
||||
"page-title": {
|
||||
"homepage": "Homepage",
|
||||
"products": "Products",
|
||||
"solutions": "Solutions",
|
||||
"support": "Support",
|
||||
"faq": "FAQ",
|
||||
"documents": "Documents",
|
||||
"contact-us": "Contact Us",
|
||||
"download": "Downloads",
|
||||
"preview": "Preview",
|
||||
"about-us": "About Us"
|
||||
},
|
||||
"product-tab": {
|
||||
"details": "Details",
|
||||
"specs": "Specifications",
|
||||
"faq": "FAQ",
|
||||
"documents": "Documents"
|
||||
},
|
||||
"redirecting": "Redirecting..."
|
||||
}
|
||||
|
||||
131
i18n/locales/es.json
Normal file
131
i18n/locales/es.json
Normal file
@ -0,0 +1,131 @@
|
||||
{
|
||||
"back": "Volver",
|
||||
"not-found": "Página no encontrada",
|
||||
"search-placeholder": "Buscar...",
|
||||
"search": {
|
||||
"title": "Búsqueda en el sitio",
|
||||
"head-title": "Búsqueda",
|
||||
"search-button": "Buscar",
|
||||
"results-for": "Resultados de la búsqueda para \"{query}\"",
|
||||
"result-count": "{count} resultados",
|
||||
"no-results": "No se encontraron coincidencias para \"{query}\".",
|
||||
"no-query": "Ingrese una palabra clave para comenzar a buscar.",
|
||||
"section": "sección",
|
||||
"untitled": "sin título",
|
||||
"sections": {
|
||||
"product": "Productos",
|
||||
"solution": "Soluciones",
|
||||
"faq": "FAQ",
|
||||
"document": "Documentaciones",
|
||||
"default": "Otros contenidos"
|
||||
}
|
||||
},
|
||||
"company-name": "Jinshen Machinary Manufacturing Co., Ltd.",
|
||||
"company-description": "Especializado en la producción de una serie de equipos de tubos y latas de papel, integrando diseño, fabricación, ventas y servicio.",
|
||||
"learn-more": "Saber más",
|
||||
"products-desc": "Ofrecemos los últimos productos y un servicio de asistencia instantáneo para satisfacer las diversas necesidades comerciales de nuestros clientes.",
|
||||
"solutions-desc": "Ofrecemos soluciones técnicas diversificadas para empresas, prestando apoyo en diversos campos, como la industria y el embalaje.",
|
||||
"support-desc": "Soporte técnico profesional 24/7, asegurando la estabilidad de su negocio.",
|
||||
"quick-links": "Enlaces rápidos",
|
||||
"utilities": "Herramientas útiles",
|
||||
"navigation": {
|
||||
"home": "Inicio",
|
||||
"products": "Productos",
|
||||
"solutions": "Soluciones",
|
||||
"support": "Soporte",
|
||||
"about-us": "Sobre nosotros",
|
||||
"contact-info": "Información de contacto",
|
||||
"downloads": "Descargas",
|
||||
"faq": "FAQ",
|
||||
"documents": "Documentaciones",
|
||||
"calculator": "Herramienta de cálculo de tubos de papel",
|
||||
"address": "Dirección de la empresa"
|
||||
},
|
||||
"contact-info": "Contáctenos",
|
||||
"telephone": "Teléfono",
|
||||
"email": "Correo electrónico",
|
||||
"address": "Dirección",
|
||||
"company-address": "No. 689 Qiushi Road, Wutong Industrial Zone, Tongxiang City, Zhejiang Province, China",
|
||||
"follow-us": "Síguenos",
|
||||
"all-rights-reserved": "All rights reserved",
|
||||
"privacy-policy": "Política de privacidad",
|
||||
"terms-of-service": "Términos del servicio",
|
||||
"sitemap": "Mapa del sitio",
|
||||
"product-details": "Detalles del producto",
|
||||
"product-not-found": "Producto no encontrado",
|
||||
"product-not-found-desc": "Lo sentimos, el producto que busca no existe o ha sido eliminado.",
|
||||
"back-to-products": "Volver a la lista de productos",
|
||||
"solution-not-found": "Solución no encontrada",
|
||||
"solution-not-found-desc": "Lo sentimos, la solución que busca no existe o ha sido eliminada.",
|
||||
"back-to-solutions": "Volver a la lista de soluciones",
|
||||
"page-not-found": "Página no encontrada",
|
||||
"page-not-found-desc": "Lo sentimos, la página que busca no existe o ha sido eliminada.",
|
||||
"back-to-home": "Volver al inicio",
|
||||
"no-content-available": "No hay información disponible",
|
||||
"loading": "Cargando...",
|
||||
"our-products": "Nuestros últimos productos",
|
||||
"find-discontinued-products": "Para encontrar productos descatalogados, utilice la función de búsqueda.",
|
||||
"product-discontinued-warning": "Este producto ha sido descontinuado y puede que ya no reciba soporte o actualizaciones inmediatas.",
|
||||
"learn-our-solutions": "Aprenda sobre nuestras soluciones",
|
||||
"all": "Todo",
|
||||
"support-page-desc": "Zhejiang Jinshen Machinery Manufacturing Co., Ltd se dedica a proporcionar productos y servicios de alta calidad a los clientes. Para máquinas de tubos de papel, máquinas de corte y pajitas de papel, ofrecemos un servicio postventa integral para garantizar que los clientes puedan usar nuestros productos con confianza.",
|
||||
"support-card-desc": {
|
||||
"faq": "Hemos compilado respuestas a preguntas frecuentes para ayudarle a resolver dudas rápidamente.",
|
||||
"documents": "Proporcionamos manuales de productos, especificaciones técnicas y otros documentos para la comodidad del usuario.",
|
||||
"contact-info": "Contáctenos por teléfono o correo electrónico, y le brindaremos servicio presencial."
|
||||
},
|
||||
"product-filter": {
|
||||
"product-type": "Tipo de producto",
|
||||
"product-model": "Modelo del producto",
|
||||
"keyword": "Palabra clave",
|
||||
"select-product-type": "Seleccione el tipo de producto",
|
||||
"select-product-model": "Seleccione modelo de producto",
|
||||
"enter-keyword": "Ingrese palabra clave",
|
||||
"misc": "Varios",
|
||||
"document-type": "Tipo de documento",
|
||||
"select-document-type": "Seleccionar tipo de documento",
|
||||
"question-type": "Tipo de pregunta",
|
||||
"select-question-type": "Seleccionar tipo de pregunta"
|
||||
},
|
||||
"document-meta": {
|
||||
"size": "Tamaño",
|
||||
"format": "Formato",
|
||||
"type": "Tipo",
|
||||
"upload-at": "Fecha de carga"
|
||||
},
|
||||
"document-action": {
|
||||
"download": "Descargar",
|
||||
"open-in-new-tab": "Abrir en una nueva pestaña",
|
||||
"preview": "Vista previa"
|
||||
},
|
||||
"mobile-menu": {
|
||||
"title": "Menú",
|
||||
"navigation": "Navegación del sitio",
|
||||
"utilities": "Herramientas útiles"
|
||||
},
|
||||
"homepage": {
|
||||
"recommended-products": "Productos recomendados",
|
||||
"recommended-products-desc": "Explore nuestros productos seleccionados para satisfacer sus diversas necesidades. Ya sea tecnología innovadora o diseño clásico, ofrecemos opciones de calidad para usted.",
|
||||
"recommended-solutions": "Soluciones recomendadas",
|
||||
"recommended-solutions-desc": "Aprenda sobre nuestras soluciones personalizadas, que le ayudarán a optimizar sus procesos de negocio y mejorar la eficiencia."
|
||||
},
|
||||
"page-title": {
|
||||
"homepage": "Página principal",
|
||||
"products": "Productos",
|
||||
"solutions": "Soluciones",
|
||||
"support": "Soporte",
|
||||
"faq": "FAQ",
|
||||
"documents": "Documentos",
|
||||
"contact-us": "Contáctenos",
|
||||
"download": "Descarga de documentos",
|
||||
"preview": "Vista previa de documentos",
|
||||
"about-us": "Sobre nosotros"
|
||||
},
|
||||
"product-tab": {
|
||||
"details": "Detalles",
|
||||
"specs": "Especificaciones",
|
||||
"faq": "FAQ",
|
||||
"documents": "Documentos"
|
||||
},
|
||||
"redirecting": "Redirigiendo..."
|
||||
}
|
||||
131
i18n/locales/ru.json
Normal file
131
i18n/locales/ru.json
Normal file
@ -0,0 +1,131 @@
|
||||
{
|
||||
"back": "Назад",
|
||||
"not-found": "Страница не найдена",
|
||||
"search-placeholder": "Поиск...",
|
||||
"search": {
|
||||
"title": "Поиск на сайте",
|
||||
"head-title": "Поиск",
|
||||
"search-button": "Поиск",
|
||||
"results-for": "Результаты поиска для \"{query}\"",
|
||||
"result-count": "Всего {count} результатов",
|
||||
"no-results": "Ничего не найдено по запросу \"{query}\".",
|
||||
"no-query": "Введите ключевые слова для начала поиска.",
|
||||
"section": "раздел",
|
||||
"untitled": "Без названия",
|
||||
"sections": {
|
||||
"product": "Продукты",
|
||||
"solution": "Решения",
|
||||
"faq": "Часто задаваемые вопросы",
|
||||
"document": "Документация",
|
||||
"default": "Другие материалы"
|
||||
}
|
||||
},
|
||||
"company-name": "Jinshen Machinary Manufacturing Co., Ltd.",
|
||||
"company-description": "Профессионально производим оборудование для бумажных трубок и канистр, услуги по проектированию, производству, продаже и сервисному обслуживанию.",
|
||||
"learn-more": "Узнать больше",
|
||||
"products-desc": "Мы предоставляем новейшие продукты и мгновенную сервисную поддержку для удовлетворения различных бизнес-потребностей наших клиентов.",
|
||||
"solutions-desc": "Мы предлагаем разнообразные технические решения для предприятий, поддерживая различные области, такие как промышленность и упаковка.",
|
||||
"support-desc": "Профессиональная техническая поддержка 24/7, обеспечивающая стабильную работу вашего бизнеса.",
|
||||
"quick-links": "Быстрые ссылки",
|
||||
"utilities": "Полезные инструменты",
|
||||
"navigation": {
|
||||
"home": "Главная",
|
||||
"products": "Центр продуктов",
|
||||
"solutions": "Решения",
|
||||
"support": "Поддержка",
|
||||
"about-us": "О нас",
|
||||
"contact-info": "Контактная информация",
|
||||
"downloads": "Файлы для скачивания",
|
||||
"faq": "Часто задаваемые вопросы",
|
||||
"documents": "Документация",
|
||||
"calculator": "Калькулятор бумажных трубок",
|
||||
"address": "Адрес компании"
|
||||
},
|
||||
"contact-info": "Свяжитесь с нами",
|
||||
"telephone": "Телефон",
|
||||
"email": "Электронная почта",
|
||||
"address": "Адрес",
|
||||
"company-address": "No. 689 Qiushi Road, Wutong Industrial Zone, Tongxiang City, Zhejiang Province, China",
|
||||
"follow-us": "Следуйте за нами",
|
||||
"all-rights-reserved": "All rights reserved",
|
||||
"privacy-policy": "Политика конфиденциальности",
|
||||
"terms-of-service": "Условия обслуживания",
|
||||
"sitemap": "Карта сайта",
|
||||
"product-details": "Детали продукта",
|
||||
"product-not-found": "Продукт не найден",
|
||||
"product-not-found-desc": "Извините, продукт, который вы ищете, не существует или был удален.",
|
||||
"back-to-products": "Вернуться к списку продуктов",
|
||||
"solution-not-found": "Решение не найдено",
|
||||
"solution-not-found-desc": "Извините, решение, которое вы ищете, не существует или было удалено.",
|
||||
"back-to-solutions": "Вернуться к списку решений",
|
||||
"page-not-found": "Страница не найдена",
|
||||
"page-not-found-desc": "Извините, страница, которую вы ищете, не существует или была удалена.",
|
||||
"back-to-home": "Вернуться на главную",
|
||||
"no-content-available": "Нет доступной информации",
|
||||
"loading": "Загрузка...",
|
||||
"our-products": "Наши последние продукты",
|
||||
"find-discontinued-products": "Чтобы найти другие продукты, используйте функцию поиска.",
|
||||
"product-discontinued-warning": "Этот продукт снят с производства и может больше не получать мгновенную поддержку или обновления",
|
||||
"learn-our-solutions": "Узнайте о наших решениях",
|
||||
"all": "Все",
|
||||
"support-page-desc": "Zhejiang Jinshen Machinery Manufacturing Co., Ltd Стремится предоставлять клиентам высококачественные продукты и услуги. Мы предлагаем послепродажное обслуживание для таких продуктов, как машины для бумажных трубок и соломинок, обеспечивая уверенность клиентов в использовании нашей продукции.",
|
||||
"support-card-desc": {
|
||||
"faq": "Мы собрали ответы на часто задаваемые вопросы, чтобы помочь вам быстро решить свои проблемы.",
|
||||
"documents": "Предоставляем документацию, такую как руководства по продуктам, технические спецификации, для удобства пользователей.",
|
||||
"contact-info": "Свяжитесь с нами по телефону или электронной почте, и мы оперативно вам поможем."
|
||||
},
|
||||
"product-filter": {
|
||||
"product-type": "Тип продукта",
|
||||
"product-model": "Модель продукта",
|
||||
"keyword": "Ключевое слово",
|
||||
"select-product-type": "Выберите тип продукта",
|
||||
"select-product-model": "Выберите модель продукта",
|
||||
"enter-keyword": "Введите ключевое слово",
|
||||
"misc": "разное",
|
||||
"document-type": "Тип документа",
|
||||
"question-type": "Тип вопроса",
|
||||
"select-document-type": "Выберите тип документа",
|
||||
"select-question-type": "Выберите тип вопроса"
|
||||
},
|
||||
"document-meta": {
|
||||
"size": "Размер",
|
||||
"format": "Формат",
|
||||
"type": "Тип",
|
||||
"upload-at": "Дата загрузки"
|
||||
},
|
||||
"document-action": {
|
||||
"download": "Скачать",
|
||||
"open-in-new-tab": "Открыть в новой вкладке",
|
||||
"preview": "Предварительный просмотр"
|
||||
},
|
||||
"mobile-menu": {
|
||||
"title": "Меню",
|
||||
"navigation": "Навигация по сайту",
|
||||
"utilities": "Полезные инструменты"
|
||||
},
|
||||
"homepage": {
|
||||
"recommended-products": "Рекомендуемые продукты",
|
||||
"recommended-products-desc": "Исследуйте наши тщательно отобранные продукты, которые удовлетворят все ваши потребности. Мы предлагаем как инновационные технологии, так и классические решения.",
|
||||
"recommended-solutions": "Рекомендуемые решения",
|
||||
"recommended-solutions-desc": "Узнайте о наших индивидуальных решениях, чтобы оптимизировать бизнес-процессы и повысить эффективность."
|
||||
},
|
||||
"page-title": {
|
||||
"homepage": "Главная",
|
||||
"products": "Центр продуктов",
|
||||
"solutions": "Решения",
|
||||
"support": "Поддержка",
|
||||
"faq": "Часто задаваемые вопросы",
|
||||
"documents": "Документация",
|
||||
"contact-us": "Свяжитесь с нами",
|
||||
"download": "Скачать докумен",
|
||||
"preview": "Просмотр документа",
|
||||
"about-us": "О нас"
|
||||
},
|
||||
"product-tab": {
|
||||
"details": "Детали продукта",
|
||||
"specs": "Технические спецификации",
|
||||
"faq": "Часто задаваемые вопросы",
|
||||
"documents": "Связанные документы"
|
||||
},
|
||||
"redirecting": "Перенаправление..."
|
||||
}
|
||||
@ -10,32 +10,36 @@
|
||||
"result-count": "共 {count} 条结果",
|
||||
"no-results": "没有找到与 “{query}” 匹配的内容。",
|
||||
"no-query": "请输入关键字开始搜索。",
|
||||
"section": "内容类型",
|
||||
"untitled": "未命名条目",
|
||||
"sections": {
|
||||
"production": "产品",
|
||||
"product": "产品",
|
||||
"solution": "解决方案",
|
||||
"support": "服务支持",
|
||||
"faq": "相关问题",
|
||||
"document": "文档资料",
|
||||
"default": "其他内容"
|
||||
}
|
||||
},
|
||||
"company-name": "金申机械制造有限公司",
|
||||
"company-description": "专业生产一系列纸管、纸罐设备,集设计、制造、销售、服务于一体。",
|
||||
"learn-more": "了解更多",
|
||||
"productions-desc": "我们提供高质量的产品解决方案,满足各种业务需求。",
|
||||
"solutions-desc": "为企业提供定制化的技术解决方案,助力数字化转型。",
|
||||
"products-desc": "我们提供最新的产品与即时的服务支持,满足客户的各类业务需求。",
|
||||
"solutions-desc": "我们为企业提供多样化的技术解决方案,在工业、包装等多种领域提供支持。",
|
||||
"support-desc": "7x24小时专业技术支持,确保您的业务稳定运行。",
|
||||
"quick-links": "快速链接",
|
||||
"utilities": "实用工具",
|
||||
"navigation": {
|
||||
"home": "主页",
|
||||
"productions": "产品中心",
|
||||
"products": "产品中心",
|
||||
"solutions": "解决方案",
|
||||
"support": "服务支持",
|
||||
"about-us": "关于我们",
|
||||
"contact-info": "联系信息",
|
||||
"downloads": "文件下载",
|
||||
"faq": "常见问题",
|
||||
"documents": "文档资料",
|
||||
"calculator": "纸管计算工具"
|
||||
"calculator": "纸管计算工具",
|
||||
"address": "公司地址"
|
||||
},
|
||||
"contact-info": "联系我们",
|
||||
"telephone": "电话",
|
||||
@ -50,13 +54,78 @@
|
||||
"product-details": "产品详情",
|
||||
"product-not-found": "产品未找到",
|
||||
"product-not-found-desc": "抱歉,您访问的产品不存在或已被删除。",
|
||||
"back-to-productions": "返回产品列表",
|
||||
"back-to-products": "返回产品列表",
|
||||
"solution-not-found": "解决方案未找到",
|
||||
"solution-not-found-desc": "抱歉,您访问的解决方案不存在或已被删除",
|
||||
"back-to-solutions": "返回解决方案列表",
|
||||
"page-not-found": "页面未找到",
|
||||
"page-not-found-desc": "抱歉,您访问的页面不存在或已被删除。",
|
||||
"back-to-home": "返回首页",
|
||||
"no-content-available": "暂无详细信息",
|
||||
"loading": "加载中...",
|
||||
"our-productions": "我们的产品",
|
||||
"our-products": "我们的最新产品",
|
||||
"find-discontinued-products": "如需查找其他产品,请使用搜索功能",
|
||||
"product-discontinued-warning": "本产品已停产,可能不再提供即时的支持或更新",
|
||||
"learn-our-solutions": "了解我们的解决方案",
|
||||
"all": "全部"
|
||||
"all": "全部",
|
||||
"support-page-desc": "金申机械制造有限公司致力于为客户提供优质的产品与服务。针对纸管机、分纸机、纸吸管等产品,我们提供全方位的售后服务,确保客户能够安心地使用我们的产品。",
|
||||
"support-card-desc": {
|
||||
"faq": "我们为用户整理了常见问题的答案,帮助您快速解决疑惑。",
|
||||
"documents": "提供产品手册、技术规格等文档资料,方便用户查阅。",
|
||||
"contact-info": "通过电话、邮箱联系我们,我们将现场为您服务。"
|
||||
},
|
||||
"product-filter": {
|
||||
"product-type": "产品类型",
|
||||
"product-model": "产品系列",
|
||||
"keyword": "关键词",
|
||||
"select-product-type": "选择产品类型",
|
||||
"select-product-model": "选择产品系列",
|
||||
"enter-keyword": "输入关键词",
|
||||
"question-type": "问题类型",
|
||||
"select-question-type": "选择问题类型",
|
||||
"document-type": "文档类型",
|
||||
"select-document-type": "选择文档类型",
|
||||
"misc": "其他"
|
||||
},
|
||||
"document-meta": {
|
||||
"size": "大小",
|
||||
"format": "格式",
|
||||
"type": "类型",
|
||||
"upload-at": "上传时间"
|
||||
},
|
||||
"document-action": {
|
||||
"download": "下载",
|
||||
"open-in-new-tab": "在新标签页打开",
|
||||
"preview": "预览"
|
||||
},
|
||||
"mobile-menu": {
|
||||
"title": "菜单",
|
||||
"navigation": "站内导航",
|
||||
"utilities": "实用工具"
|
||||
},
|
||||
"homepage": {
|
||||
"recommended-products": "推荐产品",
|
||||
"recommended-products-desc": "探索我们的精选产品,满足您的各种需求。无论是创新技术还是经典设计,我们都为您提供优质选择。",
|
||||
"recommended-solutions": "推荐解决方案",
|
||||
"recommended-solutions-desc": "了解我们的定制解决方案,帮助您优化业务流程,提高效率。"
|
||||
},
|
||||
"page-title": {
|
||||
"homepage": "首页",
|
||||
"products": "产品中心",
|
||||
"solutions": "解决方案",
|
||||
"support": "服务支持",
|
||||
"faq": "常见问题",
|
||||
"documents": "文档资料",
|
||||
"contact-us": "联系我们",
|
||||
"download": "文档下载",
|
||||
"preview": "文档预览",
|
||||
"about-us": "关于我们"
|
||||
},
|
||||
"product-tab": {
|
||||
"details": "产品详情",
|
||||
"specs": "技术规格",
|
||||
"faq": "常见问题",
|
||||
"documents": "相关文档"
|
||||
},
|
||||
"redirecting": "正在跳转..."
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
import GraphQLLoader from 'vite-plugin-graphql-loader';
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
@ -6,18 +8,39 @@ export default defineNuxtConfig({
|
||||
app: {
|
||||
// head
|
||||
head: {
|
||||
title: '金申机械制造有限公司',
|
||||
titleTemplate: '金申机械制造有限公司',
|
||||
meta: [
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Jinshen Website',
|
||||
content:
|
||||
'浙江金申机械制造有限公司,专业生产一系列纸管、纸罐设备,是一家集设计、制造、销售、服务于一体的企业。公司主要 产品有原纸分切机、数控纸管机、纸管精切机及纸管后加工设备等 三十多个品种,产品在造纸、印刷、包装、纺织及文具等行业得到 广泛应用。公司依靠科技进步,引进高新技术,致力于新产品开发及技术改造,并配备完善的销售网络和售后服务体系,产品销往全国各地及全球上百个国家和地区,真正做到让客户买的放心,用的安心。',
|
||||
},
|
||||
],
|
||||
htmlAttrs: {
|
||||
lang: 'zh',
|
||||
},
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/jinshen-logo.ico' },
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
href: '/jinshen-logo.png',
|
||||
},
|
||||
{
|
||||
rel: 'manifest',
|
||||
href: '/manifest.json',
|
||||
},
|
||||
],
|
||||
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
|
||||
},
|
||||
},
|
||||
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
pathPrefix: false,
|
||||
},
|
||||
],
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
meili: {
|
||||
@ -27,16 +50,15 @@ export default defineNuxtConfig({
|
||||
? typeof process.env.MEILI_SEARCH_INDEXES === 'string'
|
||||
? process.env.MEILI_SEARCH_INDEXES.split(',').map((i) => i.trim())
|
||||
: process.env.MEILI_SEARCH_INDEXES
|
||||
: ['production', 'solution'],
|
||||
: ['products', 'solutions', 'questions', 'product_documents'],
|
||||
},
|
||||
strapi: {
|
||||
url: process.env.STRAPI_URL || 'http://localhost:1337',
|
||||
token: process.env.STRAPI_TOKEN || undefined,
|
||||
prefix: '/api',
|
||||
admin: '/admin',
|
||||
version: 'v5',
|
||||
cookie: {},
|
||||
cookieName: 'strapi_jwt',
|
||||
directus: {
|
||||
url: process.env.DIRECTUS_URL || 'http://localhost:8055',
|
||||
publicUrl:
|
||||
process.env.DIRECTUS_PUBLIC_URL ||
|
||||
process.env.DIRECTUS_URL ||
|
||||
'http://localhost:8055',
|
||||
token: process.env.DIRECTUS_TOKEN || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -48,8 +70,8 @@ export default defineNuxtConfig({
|
||||
typescript: {
|
||||
tsConfig: {
|
||||
compilerOptions: {
|
||||
noUnUsedLocals: false,
|
||||
noUnUsedParameters: false,
|
||||
noUnusedLocals: false,
|
||||
noUnusedParameters: false,
|
||||
strict: false,
|
||||
},
|
||||
},
|
||||
@ -60,6 +82,7 @@ export default defineNuxtConfig({
|
||||
'@unocss/reset/tailwind.css',
|
||||
'~/assets/scss/index.scss',
|
||||
'~/assets/css/fonts.css',
|
||||
'~/assets/css/typography.css',
|
||||
'@mdi/font/css/materialdesignicons.min.css',
|
||||
],
|
||||
|
||||
@ -84,6 +107,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [GraphQLLoader()],
|
||||
},
|
||||
|
||||
devServer: {
|
||||
@ -98,6 +122,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
i18n: {
|
||||
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
|
||||
detectBrowserLanguage: {
|
||||
useCookie: true,
|
||||
cookieKey: 'i18n_redirected',
|
||||
@ -107,6 +132,8 @@ export default defineNuxtConfig({
|
||||
locales: [
|
||||
{ code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
|
||||
{ code: 'zh', language: 'zh-CN', name: '简体中文', file: 'zh.json' },
|
||||
{ code: 'es', language: 'es-ES', name: 'Español', file: 'es.json' },
|
||||
{ code: 'ru', language: 'ru-RU', name: 'Русский', file: 'ru.json' },
|
||||
],
|
||||
defaultLocale: 'zh',
|
||||
strategy: 'prefix_except_default',
|
||||
@ -114,7 +141,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
imports: {
|
||||
dirs: ['types/**'],
|
||||
dirs: ['types/**', 'models/**'],
|
||||
},
|
||||
|
||||
modules: [
|
||||
@ -122,12 +149,11 @@ export default defineNuxtConfig({
|
||||
'@nuxt/fonts',
|
||||
'@nuxt/icon',
|
||||
'@nuxt/image',
|
||||
'@nuxt/test-utils',
|
||||
'@vueuse/nuxt',
|
||||
'@pinia/nuxt',
|
||||
'@unocss/nuxt',
|
||||
'@element-plus/nuxt',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxtjs/strapi',
|
||||
'@nuxt/test-utils/module',
|
||||
],
|
||||
});
|
||||
|
||||
17
package.json
17
package.json
@ -11,25 +11,31 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^20.1.0",
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@nuxt/eslint": "1.8.0",
|
||||
"@nuxt/fonts": "0.11.4",
|
||||
"@nuxt/icon": "2.0.0",
|
||||
"@nuxt/image": "1.11.0",
|
||||
"@nuxt/test-utils": "3.19.2",
|
||||
"@nuxtjs/i18n": "10.0.5",
|
||||
"@nuxtjs/strapi": "2.1.1",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@unocss/nuxt": "^66.4.2",
|
||||
"@vueuse/nuxt": "^13.6.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"dompurify": "^3.2.6",
|
||||
"element-plus": "^2.10.7",
|
||||
"fuse.js": "^7.1.0",
|
||||
"graphql": "^16.12.0",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"meilisearch": "^0.53.0",
|
||||
"nuxt": "^4.0.3",
|
||||
"nuxt-directus": "5.7.0",
|
||||
"sass": "^1.90.0",
|
||||
"sharp": "^0.34.3",
|
||||
"vite-plugin-graphql-loader": "^4.0.4",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
@ -37,9 +43,14 @@
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@element-plus/nuxt": "^1.1.4",
|
||||
"@nuxt/test-utils": "3.19.2",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.35.0",
|
||||
"happy-dom": "^20.0.8",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.6",
|
||||
"prettier": "^3.6.2"
|
||||
"playwright-core": "^1.56.1",
|
||||
"prettier": "^3.6.2",
|
||||
"vitest": "^4.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
625
pnpm-lock.yaml
generated
625
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user