Init commit.

This commit is contained in:
sleepwithoutbz
2025-11-10 00:45:30 +08:00
commit de451f2aab
60 changed files with 15847 additions and 0 deletions

8
.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
test-results/
playwright-report/
public/
.vscode/
@types/

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# hfchoice
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Run End-to-End Tests with [Playwright](https://playwright.dev)
```sh
# Install browsers for the first run
npx playwright install
# When testing on CI, must build the project first
npm run build
# Runs the end-to-end tests
npm run test:e2e
# Runs the tests only on Chromium
npm run test:e2e -- --project=chromium
# Runs the tests of a specific file
npm run test:e2e -- tests/example.spec.ts
# Runs the tests in debug mode
npm run test:e2e -- --debug
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

4
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["./**/*"]
}

8
e2e/vue.spec.ts Normal file
View File

@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test';
// See here how to get started:
// https://playwright.dev/docs/intro
test('visits the app root url', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toHaveText('You did it!');
})

1
env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

36
eslint.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
...pluginOxlint.configs['flat/recommended'],
skipFormatting,
)

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>合肥巧士健康科技</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

7244
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "hfchoice",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"format": "prettier --write src/"
},
"dependencies": {
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@playwright/test": "^1.54.1",
"@prettier/plugin-oxc": "^0.0.4",
"@tsconfig/node22": "^22.0.2",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.16.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"@vitest/eslint-plugin": "^1.3.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.31.0",
"eslint-plugin-oxlint": "~1.8.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-vue": "~10.3.0",
"jiti": "^2.4.2",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.8.0",
"prettier": "3.6.2",
"typescript": "~5.8.0",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-vue-devtools": "^8.0.0",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.4"
}
}

55
package.jsonbak Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "hfchoice",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"format": "prettier --write src/"
},
"dependencies": {
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@playwright/test": "^1.54.1",
"@prettier/plugin-oxc": "^0.0.4",
"@tsconfig/node22": "^22.0.2",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.16.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"@vitest/eslint-plugin": "^1.3.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.31.0",
"eslint-plugin-oxlint": "~1.8.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-vue": "~10.3.0",
"jiti": "^2.4.2",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.8.0",
"prettier": "3.6.2",
"typescript": "~5.8.0",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-vue-devtools": "^8.0.0",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.4"
}
}

110
playwright.config.ts Normal file
View File

@@ -0,0 +1,110 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})

4958
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

11
shims-vue-i18n.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
// src/shims-vue-i18n.d.ts
import { I18n } from 'vue-i18n'
// Augment the ComponentCustomProperties interface in 'vue'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
// Defines the $t property and its type
$t: I18n['global']['t']
}
}

10
src/App.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
import NavBar from './components/NavBar.vue'
import FooterComponent from './components/FooterComponent.vue'
</script>
<template>
<NavBar></NavBar>
<router-view></router-view>
<FooterComponent> </FooterComponent>
</template>

11
src/__tests__/App.spec.ts Normal file
View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
describe('App', () => {
it('mounts renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('You did it!')
})
})

Binary file not shown.

BIN
src/assets/water/cjb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src/assets/water/cjq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
src/assets/water/cyq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
src/assets/water/cyq1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
src/assets/water/cyq2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/water/cyq3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src/assets/water/cyq4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
src/assets/water/cyqen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

BIN
src/assets/water/cyqzh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,190 @@
<template>
<div class="hero-section">
<div class="hero-bg" :style="{ backgroundImage: 'url(' + backgroundImage + ')' }"></div>
<div class="hero-content">
<h2 class="hero-title">{{ title }}</h2>
<p class="hero-description">{{ description }}</p>
<button v-if="buttonshow" class="hero-btn" @click="goWaterlife">
>
{{ $t('learnMore') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
function goWaterlife() {
router.push({
path: props.targetPath,
})
}
const props = defineProps({
targetPath: {
type: String,
required: false,
},
buttonshow: {
type: Boolean,
default: false,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
backgroundImage: {
type: String,
default: '',
},
})
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* 背景图标题组件样式 */
.hero-section {
position: relative;
width: 100%;
height: 500px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
border-radius: 12px;
margin: 30px 0;
}
.hero-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* background-image: url('https://images.unsplash.com/photo-1550751827-4bd374c3f58b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1470&q=80'); */
/* background-size: contain; */
background-size: cover;
background-position: center;
/* 背景图透明度50% */
/* opacity: 0.5; */
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
text-align: center;
color: #2c3e50;
max-width: 80%;
padding: 40px;
border-radius: 12px;
/* backdrop-filter: blur(5px); */
}
.hero-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 20px;
color: #2c3e50;
position: relative;
/* padding-bottom: 15px; */
}
.hero-title:after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 3px;
border-radius: 3px;
}
.hero-description {
font-size: 1.3rem;
line-height: 1.8;
margin-bottom: 30px;
color: #444;
font-style: italic;
}
.hero-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 30px;
/* background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); */
background: rgb(108, 186, 254);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(106, 17, 203, 0.2);
}
.hero-btn:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(106, 17, 203, 0.3);
}
.hero-btn i {
margin-right: 10px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.hero-section {
height: 400px;
}
.hero-content {
padding: 30px 20px;
margin: 0 15px;
}
.hero-title {
font-size: 2rem;
}
.hero-description {
font-size: 1.1rem;
}
}
@media (max-width: 480px) {
.hero-section {
/* 手机视图下的高度 */
height: 500px;
}
.hero-title {
font-size: 1.8rem;
}
.hero-description {
font-size: 1rem;
}
.hero-btn {
padding: 10px 20px;
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<p class="styled-text-container">
<span class="first-part">{{ firstPart }}</span>
<span class="second-part">{{ secondPart }}</span>
</p>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
// 使用 defineProps 接收来自父组件的数据
defineProps({
firstPart: {
type: String,
required: true,
},
secondPart: {
type: String,
required: true,
},
})
</script>
<style scoped>
.styled-text-container {
background: #f8f9ff;
border-radius: 10px;
padding: 20px;
transition: all 0.3s ease;
border-left: 4px solid #6a11cb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.styled-text-container:hover {
background: #eef2ff;
transform: translateY(-3px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.first-part {
font-weight: 700;
font-size: 1.2rem;
color: #2c3e50;
display: block;
margin-bottom: 8px;
position: relative;
padding-left: 15px;
}
.first-part:before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
border-radius: 50%;
}
.second-part {
font-weight: 500;
color: #555;
line-height: 1.6;
padding-left: 15px;
border-left: 2px solid #2575fc;
margin-left: 3px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.component-grid {
grid-template-columns: 1fr;
}
.styled-text-container {
padding: 15px;
}
.first-part {
font-size: 1.1rem;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<footer class="footer-contact">
<div class="footer-content">
<div class="footer-section">
<h3 class="footer-title">{{ $t('contactname') }}</h3>
<li>
<span>{{ $t('company.wholeName') }} </span>
</li>
<li>E-mail: ykl1979@163.com</li>
<li>
{{ $t('contact.phone') }}
</li>
</div>
<div class="footer-section">
<h3 class="footer-title">{{ $t('address') }}</h3>
<li>
{{ $t('contact.address') }}
</li>
</div>
</div>
<div class="footer-bottom">
<p>{{ $t('copyright') }} © 2025 {{ $t('company.wholeName') }} | 皖ICP备2020019089号-5</p>
</div>
</footer>
</template>
<script setup lang="ts"></script>
<style scoped>
li {
font-size: 1.3rem;
}
.footer-contact {
/* background: linear-gradient(135deg, #8d53cc 0%, #74a3f4 100%); */
color: rgb(0, 0, 0);
padding: 50px 0 20px;
border-radius: 12px 12px 0 0;
box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.1);
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
}
.footer-section {
padding: 20px;
}
/* footer-section下的li标签样式 */
.footer-section li {
/* padding: 20px; */
font-size: 1rem;
/* font-weight: 700; */
}
.footer-title {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 20px;
position: relative;
padding-bottom: 10px;
display: flex;
align-items: center;
}
.footer-title:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 40px;
height: 3px;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
border-radius: 3px;
}
.footer-title i {
margin-right: 10px;
color: #6a11cb;
}
.footer-bottom {
max-width: 1200px;
margin: 30px auto 0;
padding: 20px;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.9rem;
color: #4f4f4f;
}
/* 响应式设计 */
@media (max-width: 768px) {
.footer-content {
grid-template-columns: 1fr;
}
.footer-section {
padding: 15px;
}
}
</style>

284
src/components/NavBar.vue Normal file
View File

@@ -0,0 +1,284 @@
<template>
<div class="home-view">
<header class="header">
<div class="logo-section">
<img src="../../public/images/logo.png" alt="公司Logo" class="logo" />
<span class="company-name">{{ $t('company.name') }}</span>
<!-- 菜单按钮只在小屏显示 -->
<button class="mobile-menu-btn" @click="toggleMenu"></button>
</div>
<nav :class="['nav-bar', { open: isMenuOpen }]">
<!-- <nav class="nav-bar"> -->
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
@click="closeMenu"
class="nav-item"
>
{{ $t(item.name) }}
</router-link>
</nav>
<div class="language-switcher">
<router-link to="/store" class="nav-item">{{ $t('pages.store.name') }}</router-link>
<select v-model="$i18n.locale" class="lang-selector">
<option value="zh">中文</option>
<option value="en">English</option>
</select>
</div>
</header>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const menuItems = ref([
{ name: 'pages.mainpage.name', path: '/' },
// { name: t('pages.waterlife.name'), path: '/waterlife' },
{ name: 'pages.waterlife_flosser.name', path: '/waterlife_flosser' },
{ name: 'pages.waterlife_cup.name', path: '/waterlife_cup' },
{ name: 'pages.waterlife_bottle.name', path: '/waterlife_bottle' },
{ name: 'pages.aged.name', path: '/todo' },
{ name: 'pages.knowledge.name', path: '/knowledge' },
{ name: 'pages.contact_us.name', path: '/todo' },
{ name: 'pages.others.name', path: '/todo' },
])
const isMenuOpen = ref(false)
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
}
const closeMenu = () => {
isMenuOpen.value = false
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background-color: #f5f7fa;
color: #333;
padding: 20px;
line-height: 1.6;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 30px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
margin-bottom: 30px;
}
.logo-section {
display: flex;
align-items: center;
flex-shrink: 0;
}
.logo {
width: 60px;
height: 40px;
margin-right: 20px;
object-fit: contain;
background-color: white;
padding: 5px;
border-radius: 8px;
}
.company-name {
font-size: 1.6rem;
color: rgb(0, 0, 0);
font-weight: 700;
letter-spacing: 1px;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.2);
}
.nav-bar {
display: flex;
gap: 5px;
flex-wrap: wrap;
justify-content: center;
flex-grow: 1;
margin: 0 40px;
}
.nav-item {
padding: 12px 20px;
text-decoration: none;
color: rgb(0, 0, 0);
font-weight: 600;
border-radius: 8px;
transition: all 0.3s ease;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
}
.nav-item:before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 3px;
background: white;
transition: all 0.3s ease;
transform: translateX(-50%);
border-radius: 3px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
}
.nav-item:hover:before {
width: 70%;
}
.nav-item.active {
background: rgba(255, 255, 255, 0.25);
}
.nav-item.active:before {
width: 70%;
}
.nav-item i {
margin-right: 8px;
font-size: 1.1rem;
}
.language-switcher {
display: flex;
align-items: center;
gap: 2rem;
}
.lang-selector {
padding: 10px 15px;
border: none;
border-radius: 8px;
/* #6a11cb 0% */
background: rgba(108, 186, 254, 0.5);
color: rgb(0, 0, 0);
font-weight: 600;
cursor: pointer;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.lang-selector:hover {
background: rgba(11, 2, 57, 0.3);
}
.lang-selector option {
background: #706bac;
color: rgb(255, 255, 255);
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
color: rgb(0, 0, 0);
font-size: 1.5rem;
cursor: pointer;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.nav-bar {
margin: 0 20px;
}
.nav-item {
padding: 10px 15px;
font-size: 0.95rem;
}
}
@media (max-width: 768px) {
.header {
flex-wrap: wrap;
padding: 15px;
}
.logo-section {
margin-bottom: 15px;
width: 100%;
justify-content: center;
}
.nav-bar {
position: absolute;
/* header 高度 */
top: 70px;
/* left: 0; */
right: 0;
/* background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); */
background: rgb(255, 255, 255);
border: 2px solid rgba(0, 0, 0, 0.1);
border-radius: 5px;
flex-direction: column;
display: none;
z-index: 1000;
}
.nav-bar.open {
display: flex;
}
.language-switcher {
margin-left: auto;
}
.mobile-menu-btn {
display: block;
}
.company-name {
font-size: 1.4rem;
}
}
/* 演示内容样式 */
.demo-content {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
margin-top: 30px;
}
.demo-content h2 {
color: #2c3e50;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #f0f0f0;
}
.demo-content p {
line-height: 1.8;
color: #444;
margin-bottom: 15px;
}
</style>

View File

@@ -0,0 +1,393 @@
<template>
<section class="two-column-section">
<!-- 左侧产品名称与产品图片 -->
<div v-if="imageUrl" class="image-container">
<h3 class="image-title">{{ imagetitle }}</h3>
<img :src="imageUrl" :alt="imageAlt" class="section-image" />
</div>
<!-- 右侧产品参数 -->
<div class="product_details">
<RowContent
v-for="(item, index) in detailList"
:key="index"
:first-part="item.key"
:second-part="item.value"
/>
<!-- 右侧视频播放与手册下载 -->
<p v-if="ad" class="text1">{{ ad }}</p>
<p class="text2">
<a class="text2-intro"> {{ info }} </a>
<br />
<button
v-for="videoUrl in videoUrls"
:key="videoUrl"
class="video-btn"
@click="
showVideo = true,
curVideoUrl = videoUrl
"
>
{{ $t(`operation.watch_video_learn_more`) }}
<img src="/images/camera.png" alt="Video" class="video_icon" />
</button>
<button class="manual-btn" @click="downloadManual">
{{ $t(`operation.download_manual`) }}
</button>
</p>
<teleport to="body">
<div v-if="showVideo" class="video-modal-overlay" @click.self="showVideo = false">
<div class="video-modal-content">
<button class="close-btn" @click="showVideo = false">×</button>
<video controls autoplay webkit-playsinline>
<source :src="curVideoUrl" type="video/mp4" />
您的浏览器不支持视频播放
</video>
</div>
</div>
</teleport>
</div>
</section>
</template>
<script setup lang="ts">
import { defineProps, ref } from 'vue'
import RowContent from './RowContent.vue'
import type { PropType } from 'vue'
import type { DetailItem } from '@/types/product'
import { useI18n } from 'vue-i18n'
const curVideoUrl = ref('')
const props = defineProps({
videoUrls: {
type: Array as PropType<string[]>,
required: true,
},
detailList: {
type: Array as PropType<DetailItem[]>,
required: false,
default: () => [],
},
imagetitle: { type: String, required: false },
ad: {
type: String,
required: false,
},
title: {
type: String,
required: true,
},
info: {
type: String,
required: false,
},
content: {
type: String,
required: true,
},
imageUrl: {
type: String,
required: false,
},
imageAlt: {
type: String,
default: 'Section image',
},
reverse: {
type: Boolean,
default: false,
},
})
const showVideo = ref(false)
// 下载中英文手册
const { locale } = useI18n()
const downloadManual = () => {
const link = document.createElement('a')
link.href = '/manuals/' + props.imagetitle + '.pdf'
link.download = (locale.value === 'zh' ? '手册' : 'Manual') + '.pdf'
link.click()
}
</script>
<style scoped>
/* 观看视频按钮样式 */
.video-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
/* background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); */
background: rgba(255, 255, 255, 0);
color: rgb(0, 0, 0);
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 15px;
box-shadow: 0 4px 12px rgba(106, 17, 203, 0.2);
}
.video-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(106, 17, 203, 0.3);
}
.video-modal-overlay {
display: none; /* 默认隐藏 */
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* 视频按钮图标 */
.video_icon {
padding-left: 5px;
width: 20px;
/* height: 20px; */
fill: currentColor;
}
/* 下载手册按钮样式 */
.manual-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 24px;
/* background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); */
background: rgba(255, 255, 255, 0);
color: rgb(0, 0, 0);
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 15px;
margin-left: 15px;
box-shadow: 0 4px 12px rgba(106, 17, 203, 0.2);
}
.manual-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(106, 17, 203, 0.3);
}
.manual-modal-overlay {
display: none; /* 默认隐藏 */
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* 弹窗 */
.video-modal-content {
height: 80%; /* 屏幕一半高度 */
border-radius: 12px;
overflow: hidden;
position: relative;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
}
/* 视频全屏充满弹窗 */
.video-modal-content video {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 关闭按钮 */
.close-btn {
position: absolute;
top: 15px;
right: 15px;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: none;
border-radius: 50%;
font-size: 1.2rem;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
z-index: 10;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
h1 {
text-align: center;
margin: 30px 0;
color: #2c3e50;
font-weight: 700;
position: relative;
padding-bottom: 15px;
}
h1:after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 3px;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
border-radius: 3px;
}
/* 双栏布局组件样式 */
.two-column-section {
width: 100%;
display: flex;
justify-content: space-between;
gap: 3rem;
padding: 30px;
/* background: white; */
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
margin: 20px 0;
transition: all 0.3s ease;
}
.two-column-section:hover {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
transform: translateY(-5px);
}
.product_details {
flex: 0.6;
display: flex;
flex-direction: column;
justify-content: center;
gap: 15px;
}
.image-container {
flex: 0.35;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.image-title {
font-style: italic;
font-size: 1.5em;
color: #2c3e50;
font-weight: 600;
text-align: center;
padding: 10px 20px;
/* background: linear-gradient(135deg, rgba(4, 134, 204, 0.1) 0%, rgba(3, 15, 110, 0.1) 100%); */
background: linear-gradient(90deg, rgb(224, 242, 254, 0.5) 0%, rgba(167, 212, 255, 0.5) 100%);
border-radius: 8px;
}
.section-image {
width: 100%;
max-width: 300px;
height: auto;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.section-image:hover {
transform: scale(1.03);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}
.two-column-section.reverse-layout {
flex-direction: row-reverse;
}
.text1 {
font-size: 1.1em;
color: #4a5568;
line-height: 1.7;
padding: 15px;
background: #f8f9ff;
border-radius: 8px;
border-left: 4px solid #6a11cb;
}
.text2 {
font-size: 1.4em;
/* color: #2c3e50; */
/* font-weight: 600; */
padding: 15px;
max-width: 1000px;
/* background: linear-gradient(135deg, rgb(242, 242, 242) 0%, rgb(108, 185, 254) 100%); */
/* background: rgba(108, 186, 254, 0.5); */
border-radius: 8px;
}
.text2-intro {
display: inline-flex;
flex-direction: column;
font-size: 1.4em;
color: #2c3e50;
font-weight: 600;
padding: 15px;
max-width: 1000px;
/* background: linear-gradient(135deg, rgb(242, 242, 242) 0%, rgb(108, 185, 254) 100%); */
background: rgba(108, 186, 254, 0.5);
border-radius: 8px;
}
/* 响应式设计 */
@media (max-width: 968px) {
.two-column-section {
flex-direction: column;
padding: 20px;
}
.two-column-section.reverse-layout {
flex-direction: column;
}
.product_details,
.image-container {
flex: 1;
width: 100%;
}
.image-container {
order: -1;
margin-bottom: 20px;
}
}
@media (max-width: 768px) {
.two-column-section {
padding: 15px;
}
.text2 {
font-size: 1.2em;
}
.image-title {
font-size: 1.3em;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<p class="styled-text-container">
<span class="first-part">{{ firstPart }}</span>
<span class="second-part">{{ secondPart }}</span>
</p>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
// 使用 defineProps 接收来自父组件的数据
defineProps({
firstPart: {
type: String,
required: true,
},
secondPart: {
type: String,
required: true,
},
})
</script>
<style scoped>
.styled-text-container {
/* 设置整体段落的样式 */
font-size: 16px;
color: #333; /* 默认颜色 */
}
.first-part {
/* 前半部分样式:加粗、更大字号 */
font-weight: bold;
font-size: 18px;
color: #1a1a1a;
margin-right: 2em; /* 添加一些间距 */
}
.second-part {
/* 后半部分样式:普通字体、灰色 */
font-weight: normal;
color: #666;
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="product-detail">
<h2 class="section-title">{{ $t('product-details.title') }}</h2>
<!-- 产品介绍 -->
<div class="info-card">
<div class="card-header" @click="toggleSection('introduction')">
<h3>{{ $t('product-details.intro') }}</h3>
</div>
<div class="card-content" v-if="visibleSections.introduction">
<p style="white-space: pre-line;">{{ productData.introduction }}</p>
</div>
</div>
<!-- 产品特点 -->
<div class="info-card" v-if="productData.features">
<div class="card-header" @click="toggleSection('features')">
<h3>{{ $t('product-details.feature') }}</h3>
</div>
<div class="card-content" v-if="visibleSections.features">
<p style="white-space: pre-line;">{{ productData.features }}</p>
</div>
</div>
<!-- 使用方法 -->
<div class="info-card">
<div class="card-header" @click="toggleSection('usage')">
<h3>{{ $t('product-details.instruction') }}</h3>
</div>
<div class="card-content" v-if="visibleSections.usage">
<!-- <div class="step-item" v-for="(step, index) in usageSteps" :key="index">
<div class="step-number">{{ index + 1 }}</div>
<div class="step-text">{{ step }}</div>
</div> -->
<p style="white-space: pre-line;">{{ productData.usage }}</p>
</div>
</div>
<!-- 注意事项 -->
<div class="info-card">
<div class="card-header" @click="toggleSection('notice')">
<h3>{{ $t('product-details.precautions') }}</h3>
</div>
<div class="card-content" v-if="visibleSections.notice">
<!-- <div class="notice-item" v-for="(note, index) in noticePoints" :key="index">
<i class="fas fa-check-circle"></i>
<span>{{ note }}</span>
</div> -->
<p style="white-space: pre-line;">{{ productData.notice }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ProductInfoData } from '@/types/product'
import { ref } from 'vue'
import { type PropType } from 'vue'
// 接收 props使用 PropType 确保类型正确性
defineProps({
productData: {
type: Object as PropType<ProductInfoData>,
required: true,
},
})
// 使用 ref 跟踪可见部分,键名可以更精确
const visibleSections = ref({
introduction: true,
features: true,
usage: true,
notice: true,
})
function toggleSection(section: keyof ProductInfoData) {
// 添加类型断言,清除报错
const key = section as keyof typeof visibleSections.value
visibleSections.value[key] = !visibleSections.value[key]
}
</script>
<style scoped>
.product-detail {
margin: 30px 0;
width: 90%;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding-left: 5%;
padding-right: 5%;
}
.section-title {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
font-weight: 600;
position: relative;
padding-bottom: 15px;
}
.section-title:after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 3px;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
border-radius: 3px;
}
.info-card {
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
margin-bottom: 25px;
overflow: hidden;
transition: all 0.3s ease;
}
.info-card:hover {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12);
transform: translateY(-3px);
}
.card-header {
display: flex;
align-items: center;
cursor: pointer;
/* background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); */
background: rgb(108, 186, 254);
color: rgb(255, 255, 255);
position: relative;
}
.card-header h3 {
margin-left: 15px;
font-weight: 600;
font-size: 1.2rem;
flex-grow: 1;
}
.toggle-icon {
font-size: 1.1rem;
transition: transform 0.3s ease;
}
.card-content {
padding: 25px;
background: white;
}
.step-item {
display: flex;
margin-bottom: 20px;
align-items: flex-start;
}
.step-number {
width: 30px;
height: 30px;
border-radius: 50%;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
margin-right: 15px;
}
.step-text {
color: #444;
line-height: 1.6;
}
.notice-item {
display: flex;
align-items: center;
margin-bottom: 15px;
padding: 12px 15px;
background: #f8f9ff;
border-radius: 8px;
transition: all 0.3s ease;
}
.notice-item:hover {
background: #eef2ff;
transform: translateX(5px);
}
.notice-item i {
color: #8d45db;
margin-right: 15px;
font-size: 1.2rem;
}
.notice-item span {
color: #444;
line-height: 1.6;
}
.card-content p {
color: #201515;
line-height: 1.8;
text-align: justify;
}
/* 响应式设计 */
@media (max-width: 768px) {
.card-header {
padding: 15px;
}
.card-header h3 {
font-size: 1.1rem;
}
.card-content {
padding: 15px;
}
.step-item {
flex-direction: column;
}
.step-number {
margin-bottom: 10px;
margin-right: 0;
}
}
</style>

248
src/locales/en.json Normal file
View File

@@ -0,0 +1,248 @@
{
"operation": {
"watch_video_learn_more": "Watch video to learn more",
"download_manual": "Download the manual"
},
"company": {
"name": "HefeiChoice Health Technology",
"wholeName": "HefeiChoice Health Technology",
"introduction": "HefeiChoice Health Technology Company was established in 2020 as a technology-oriented small and medium-sized enterprise (SME) specializing in the application of public health technologies, as well as product design, R&D, and produce. In 2022, the company was awarded the titles of \"National High-Tech Enterprise\" and \"Hefei Big Data Enterprise\", and obtained the qualification for \"Disinfection Equipment Production License\".\nOur company currently holds 21 patents including 1 invention, 8 utility models, 4 design patents, 7 software copyrights, and 1 trademark. Additionally, 3 invention patents are under substantive examination.",
"property": "Our company currently holds 21 patents including 1 invention, 8 utility models, 4 design patents, 7 software copyrights, and 1 trademark. Additionally, 3 invention patents are under substantive examination."
},
"areas": {
"flosser": {
"name": "Electric Water Flosser",
"intro": "New Concept Orthodontic essentials"
},
"cup": {
"name": "Hypochlorous Acid Bionic Sterilizing Cup",
"intro": "Sterilizes efficiently Safe and eco-friendly Rinse and remove odor"
},
"bottle": {
"name": "Multi-functional Electrolyzed Water Sterilizer",
"intro": "Make and use on-site Skin friendly Multipurpose and portable"
},
"aged": {
"name": "Elderly Care",
"intro": "In the field of chronic disease management, relying on independently developed IoT-enabled smart pillboxes and chronic disease management systems, the company integrates IoT blood pressure monitors and mobile terminals for blood glucose meters. While managing medication adherence, health data, and prescription evaluation of patients with chronic diseases, the company has also established a unique \"pocket medical record\" and extended its services to the fields of medical consultation and home-based elderly care."
}
},
"pages": {
"mainpage": {
"name": "Main"
},
"contact_us": {
"name": "Contact Us"
},
"knowledge": {
"name": "Sea of Knowledge"
},
"others": {
"name": "Others"
},
"store": {
"name": "Store",
"buy": "Way to purchase",
"WeChat": "WeChat",
"TaoBao": "TaoBao",
"RedNote": "RedNote"
},
"waterlife_flosser": {
"name": "Electric Water Flosser"
},
"waterlife_cup": {
"name": "Mouthwash Cup"
},
"waterlife_bottle": {
"name": "Travel Companion"
},
"waterlife": {
"name": "Water Life"
},
"aged": {
"name": "Elderly Care"
},
"todo": {
"name": "Not Completed Page",
"content": "To Be Developed..."
}
},
"cyq": {
"video-urls": [
"/videos/productvideos/cyq/en.mp4"
],
"name": "Electric Water Flosser",
"notice": "1. Tap water only.\n2. Turn off the device promptly after the water tank is emptied to avoid dry running.",
"usage": "1 Unscrew the water tank and add an appropriate amount of tap water;\n2Press the switch, and the blue light will turn on; after the teeth are flushed, press the switch again, and the device will shut down;\n3If the red indicator light is on, it indicates a low battery, and a timely charge is required; the charger can be a regular phone charger, and after charging, the green indicator light will turn on.",
"features": "Building on traditional cleaning methods, this approach integrates low-pressure electrolyzed water technology to create a unique \"hypochlorous acid-microbubble cavitation\" system. It not only mimick the bactericidal mechanism of white blood cells through hypochlorous acid but also harnesses the \"cavitation effect\" of nanoscale microbubbles, enabling secondary cleaning of oral bacteria and dirt.",
"introduction": "The product uses electrolysis technology to convert chlorine elements in tap water into low-concentration hypochloric acid (5-10 ppm), while simultaneously forming a high-speed liquid with microbubbles of 0-0.5 µm and producing a \"cavitation effect.\" This unique \"hypochloric acid-microbubble cavitation\" method simulates the bactericidal mechanism of neutrophils while simultaneously structural diagram.utilizing the \"cavitation effect\" of nano-sized microbubbles, achieve secondary removal of oral bacteria and dirt.",
"ad": "Hypochlorous Acid + Microbubble Cavitation, Instantly electrolyzes low-concentration hypochlorous acid upon adding water.",
"detail": [
{
"key": "Product Name",
"value": "Electric Water Flosser"
},
{
"key": "Product Model",
"value": "JKJJ-1"
},
{
"key": "Working Voltage",
"value": "26V"
},
{
"key": "Maximum Power",
"value": "78W"
},
{
"key": "Concentration Reaches A Steady State",
"value": "Municipal Tap Water"
},
{
"key": "Tank Volume",
"value": "About 250ml"
},
{
"key": "Product Weight",
"value": "About 230 grams"
},
{
"key": "Hypochlorous Acid Concentration",
"value": "6-10ppm"
},
{
"key": "Applicable Water Source",
"value": "Municipal Tap Water"
}
],
"adshort": "Geek Clean World Bionic Water Flosser",
"info": "Low-concentration hypochloric acid+cavitation effect"
},
"cjb": {
"video-urls": [
"/videos/productvideos/cjb/en.mp4"
],
"name": "Hypochlorous Acid Bionic Sterilizing Cup",
"notice": "(1) Use immediately after preparation.\n(2) Water temperature exceeding 40°C is not recommended.",
"scenarios": "(1)Cleans the oral cavity, suppresses dental caries and plaque, and relieves periodontal conditions.\n(2)Soaks toothbrushes, orthodontic aligners, dentures, and other oral tools to neutralize odors.",
"features": "(1) Mimics white blood cell bactericidal mechanism: generates hypochlorous acid (HOCl)—the most critical bactericidal agent produced by white blood cells—through in vitro synthesis.\n(2) Food-Grade Formulation: all ingredients are derived from food-grade materials, and the product has passed \"oral toxicity-free\" certification.",
"usage": "(1) Add a small packet of special electrolyte and water or purified water for approximately 250ml.\n(2) Press the power switch; the blue indicator light turns on, and the device automatically stops after approximately 90 seconds of operation. The mouthwash solution is now ready.\n(3) If the blue indicator light starts flashing, it indicates that the battery is low, and a timely charge is required. The charger can be a regular phone charger; when fully charged, the red indicator light turns green.",
"introduction": "This product utilizes electrolysis technology with food ingredients to generate low-concentration hypochlorous acid (18-30 ppm). By mimicking the bactericidal mechanism of white blood cells(neutrophils), it effectively eliminates oral pathogens, inhibits dental caries, and alleviates periodontal diseases. Special electrolyte consists of food materials (common salt and PBS) and the patent application number for electrolyte formula is No: 202410555233.1 ",
"detail": [
{
"key": "Product Name",
"value": "Hypochlorous Acid Bionic Sterilizing Cup"
},
{
"key": "Working Voltage",
"value": "9V-12V"
},
{
"key": "Maximum power",
"value": "12W"
},
{
"key": "Working Volume",
"value": "Approx.250ml"
},
{
"key": "Electrolysis Time",
"value": "90 seconds"
},
{
"key": "Hypochlorous Acid Concentration",
"value": "15-25ppm"
},
{
"key": "PH",
"value": "6-6.8"
}
],
"info": "Generates hypochlorous acid through electrolyte electrolysis.",
"adshort": "Sterilizes efficiently, Safe and eco-friendly"
},
"cjq": {
"video-urls": [
"/videos/productvideos/cjq/en.mp4",
"/videos/productvideos/cjq/en2.mp4",
"/videos/productvideos/cjq/en3.mp4"
],
"name": "Multi-functional Electrolyzed Water Sterilizer",
"notice": "1. Prepare the reaction solution on-site and use it immediately. Low-concentration hypochlorous acid is recommended to be used within 2 hour; medium-concentration hypochlorous acid can be stored for 12 hours at room temperature and away from light.\n2. After each use, clean the product promptly and keep it as dry as possible after cleaning.\n3. Do not modify, disassemble, or repair the product by yourself, as this may cause damage to the main unit.\n4. This product is prohibited from being immersed in water.\n5. The generated solution is forbidden to drink, and it is not allowed to be mixed with detergents such as concentrated sulfuric acid and toilet cleaners at the same time.",
"cleaning": "1.After using the device, rinse it with clean water promptly and shake it dry.\n2.A cathode scale will form on the electrodes after long-term use, which may affect the service life of the device. It is recommended to clean the electrodes once a week with special electrolyte.",
"usage": "1. Unscrew the spray head, pour in approximately 25ml tap water, then screw the spray head tightly.\n2. Press and hold the power button for 1 second to start the device; the blue indicator light will turn on and automatically turn off after 1.5 minutes of operation. If the blue indicator light flashes, it indicates low battery, and the device needs to be charged promptly.\nPs(1) Preventive Disinfection: Use only tap water with no additives; simply start the device directly.\n(2) Emergency Disinfection: Before starting, pull out one special electrolyte tube from the bottom cover and pour into the spray bottle containing 25ml of tap water, shake well and then start the device.\n(3) When charging, the green indicator light at the bottom turns on; the indicator light turns off once charging is complete.",
"scenarios": "(1) Preventive Disinfection (Low-concentration)Apply to the surfaces of contact objects such as handrails of public transportation (e.g., buses, subways, shared bikes), elevators, and express parcels.(2) Emergency Disinfection (Medium-concentration) Apply to pathogen-contaminated items, epidemic-related objects, and contaminants from blood or body fluids of patients with infectious diseases.",
"introduction": "This device can directly utilize the chlorine element in tap water and convert it into low-concentration hypochlorous acid (5-10mg/L) through electrolysis; alternatively, it can use special electrolyte to generate medium-concentration hypochlorous acid (approximately 100mg/L) with pH around 5.8.",
"detail": [
{
"key": "Product Name",
"value": "Multi-Functional Electrolyzed Water Sterilizer (Ultimate Travel Companion)"
},
{
"key": "Operating Voltage",
"value": "12V"
},
{
"key": "Input Power Supply",
"value": "5V/2A Adapter"
},
{
"key": "Rated Power",
"value": "1.4W"
},
{
"key": "Battery Capacity",
"value": "400mAh"
},
{
"key": "Electrolysis time",
"value": "1.5 minutes"
},
{
"key": "Applicable Water Source",
"value": "Municipal Tap Water"
},
{
"key": "Capacity",
"value": "Approx.30ml"
},
{
"key": "Hypochlorous Acid Concentration",
"value": "6-10ppm (tap water only); Approx.98ppm (with dedicated electrolyte)"
}
],
"info": "Hypochlorous Acid, Ultimate Travel Companion",
"adshort": "Travel Companion"
},
"contact": {
"phone": "Phone: 18133670714 (Same number for WeChat)",
"email": "E-mail: ykl1979163.com",
"address": "Room 805, Building 5, Anhui Shangrong Big Health Industrial Park, Intersection of Baogong Avenue and Dazhong Road, Longgang Comprehensive Economic Development Zone, Yaohai District, Hefei City, Anhui Province"
},
"articles": [
{
"title": "The Past and Present of Hypochlorous Acid",
"filename": "/articles/en/The Past and Present of Hypochlorous Acid.pdf"
},
{
"title": "Please A Bottle of 1820 “Eau de Labarraque”",
"filename": "/articles/en/Please A Bottle of 1820 “Eau de Labarraque”.pdf"
}
],
"article_type": {
"article": "Local Article",
"reference": "Journal Reference"
},
"product-details": {
"title": "Product Details",
"feature": "Product Feature",
"intro": "Product Introduction",
"instruction": "Usage Instructions",
"precautions": "Precautions"
},
"property": "Company Property",
"learnMore": "Learn More",
"address": "Company Address",
"contactname": "Contact Info",
"copyright": "Copyright"
}

248
src/locales/zh.json Normal file
View File

@@ -0,0 +1,248 @@
{
"operation": {
"watch_video_learn_more": "观看视频了解更多",
"download_manual": "下载手册"
},
"company": {
"name": "合肥巧士健康科技",
"wholeName": "合肥巧士健康科技有限责任公司",
"introduction": "合肥巧士健康科技成立于 2020 年,是一家专注于公共卫生领域技术应用,产品设计 研发与生产的科技型中小企业。公司于 2022 年荣获“国家高新技术企业” “合肥市大数据企业”称号,并取得“消毒器械生产许可”资质。\n公司现拥有知识产权 21 项,其中发明 1 项 实用新型 8 项 外观专利 4项 软著7项 商标 1 项,另有 2 项实用新型与 3 项发明正在实审阶段。",
"property": "公司现拥有知识产权 21 项,其中发明 1 项 实用新型 8 项 外观专利 4项 软著7项 商标 1 项,另有 2 项实用新型与 3 项发明正在实审阶段。"
},
"areas": {
"flosser": {
"name": "巧士次氯酸仿生水牙线",
"intro": "新概念 正畸必备"
},
"cup": {
"name": "巧士次氯酸除菌杯",
"intro": "高效杀菌 安全环保 漱口除臭"
},
"bottle": {
"name": "次氯酸 差旅神器",
"intro": "即制即用 亲肤温和 多场景应用"
},
"aged": {
"name": "慢病养老系列",
"intro": "在慢病管理领域,公司依托自主研发的物联网智能药盒与慢病管理系统,整合物联网血压计、血糖仪移动终端,在实现慢病患者用药行为、健康数据与处方评价管理的同时,建立独特的“口袋病历”,并向就医服务、居家养老领域延伸。"
}
},
"pages": {
"mainpage": {
"name": "首页"
},
"contact_us": {
"name": "联系我们"
},
"knowledge": {
"name": "知识储备"
},
"store": {
"name": "商城",
"buy": "购买方式",
"WeChat": "微信",
"TaoBao": "淘宝",
"RedNote": "小红书"
},
"others": {
"name": "其它"
},
"waterlife_flosser": {
"name": "冲牙器"
},
"waterlife_cup": {
"name": "除菌杯"
},
"waterlife_bottle": {
"name": "小喷瓶"
},
"waterlife": {
"name": "水生活产品系列"
},
"aged": {
"name": "慢病养老系列"
},
"todo": {
"name": "未完成页面",
"content": "待开发..."
}
},
"cyq": {
"video-urls": [
"/videos/productvideos/cyq/zh.mp4"
],
"name": "次氯酸冲牙器",
"notice": "1、水箱泵完后及时关机,避免空转运行。\n2、公司推崇马斯克的“第一性原理”理念,即产品从最基本的事实、规律和原理出发,不依赖传统经验或类比,通过逻辑推理或者演绎,进而得出结论和解决方法的思维方式。",
"usage": "1拧下水箱,加适量自来水;\n2按下开关,蓝灯亮起;冲牙完毕后再次按下开关,设备关机;\n3如红色指示灯亮起,说明电量不足,须及时充电;充电器可为常规手机充电器,充电完成后绿色指示灯亮起。",
"features": "在传统清洁模式基础上,融合低压电解水技术,形成独特的“次氯酸-微气泡空化”方式,既能模拟白细胞次氯酸杀菌机制,又能发挥纳米级微气泡的“空化效应”,实现口腔病菌与污物的二次清洁。",
"introduction": "本产品通过电解技术,将自来水中的氯元素转化为低浓度次氯酸5-10ppm,同时形成0-0.5um 的微气泡高速液体流并产生“空化效应”。这种独特的“次氯酸-微气泡空化”方式,模拟白细胞次氯酸杀菌机制的同时,发挥纳米级微气泡的“空化效应”,实现口腔病菌与污物的二次清除。",
"ad": "次氯酸+微气泡空化反应,加水即刻电解出低浓度次氯酸",
"detail": [
{
"key": "产品名称",
"value": "电动冲牙器"
},
{
"key": "产品型号",
"value": "JKJJ-1"
},
{
"key": "工作电压",
"value": "26V"
},
{
"key": "最大功率",
"value": "78W"
},
{
"key": "浓度达到稳定状态",
"value": "约工作后8秒"
},
{
"key": "水箱容积",
"value": "约250ml"
},
{
"key": "次氯酸浓度",
"value": "5-10ppm"
},
{
"key": "产品重量",
"value": "约230克"
},
{
"key": "适用水源",
"value": "市政自来水"
}
],
"adshort": "极客净界,仿生水牙线",
"info": "低浓度次氯酸+空化效应"
},
"cjb": {
"video-urls": [
"/videos/productvideos/cjb/zh.mp4"
],
"name": "次氯酸仿生除菌杯",
"notice": "1即制即用,次氯酸易分解,不宜久存;\n2水温不建议超过40°C。",
"usage": "1杯中倒入专用电解质,加自来水或纯净水约250ml;\n2按下开关,蓝灯亮起,工作约90秒后自动停止, 漱口液制备完毕。\n3如工作时蓝色指示灯开始闪烁,说明电量不足,须及时充电;充电器可为常规手机充电器,充电时红色指示灯亮起,充满后则绿色指示灯亮起。",
"scenarios": "1清洁口腔,抑制龋齿与菌斑 缓解牙周疾病。\n2浸泡牙刷 牙套 义齿等牙具,消除异味。",
"features": "1模拟白细胞杀菌机制,体外生成白细胞最重要的杀菌因子-次氯酸。\n2配方均源自食品材料,产物已通过“经口无毒”检测。",
"introduction": "本产品通过食品原料的电解技术,生成低浓度次氯酸18-30ppm,模拟白细胞杀菌机制发挥清除口腔病菌 抑制龋齿 缓解牙周疾病的作用。\n1清洁口腔,抑制龋齿与菌斑 缓解牙周疾病。\n2浸泡牙刷 牙套 义齿等牙具,消除异味。电解配方专利申请号2024105552331",
"detail": [
{
"key": "产品名称",
"value": "次氯酸仿生除菌杯"
},
{
"key": "产品型号",
"value": "QS-JWZ-03"
},
{
"key": "工作电压",
"value": "9V-12V"
},
{
"key": "最大功率",
"value": "12W"
},
{
"key": "工作容积",
"value": "250ml"
},
{
"key": "电解时间",
"value": "90秒"
},
{
"key": "次氯酸浓度",
"value": "15-25ppm"
},
{
"key": "PH",
"value": "6-6.8"
}
],
"info": "电解质电解生成次氯酸",
"adshort": "高效除菌,安全环保"
},
"cjq": {
"video-urls": [
"/videos/productvideos/cjq/zh.mp4",
"/videos/productvideos/cjq/zh2.mp4",
"/videos/productvideos/cjq/zh3.mp4"
],
"name": "多功能电解水除菌器",
"notice": "1、反应液现配现用,低浓度次氯酸建议在1小时内使用,中浓度室温、避光条件下可保存24小时。\n2、每次使用后,及时清洗,洗后尽量保持干燥。\n3、请勿自行改装、拆解、维修以免导致主机损坏。如因外力导致主机破损、脱落、无法工作时,请停止使用并且联系客服维修。\n4、本产品禁止放入水中。\n5、生成后的溶液禁止饮用,禁止与浓硫酸、洁厕灵等洗涤剂同时混用。",
"clean": "1、设备使用完毕后,及时清水冲洗、甩干。\n2、 电极长期使用将形成阴极垢,影响设备寿命,建议使用专用清洗剂每周清洗一次(专用清洗剂可与厂家联系)。",
"usage": "1. 拧开喷头,注入约25ml自来水离瓶肩1-2mm处,旋紧喷头。\n2. 长按开机键1秒,启动设备,蓝色指示灯亮起,工作1.5分钟后自动熄灭。若蓝色指示灯闪烁,提示电量不足,需及时充电。\n(1) 预防性消毒 仅用自来水,无添加物,直接启动设备即可。\n(2) 应急性消毒 启动前,从底盖拔出一支专用电解质盐管,用瓶盖量坑量取0.1克,倒入含25ml自来水的喷瓶中,充分摇匀溶解后,再启动设备。\n3. 充电时,底部绿色指示灯亮起,充电结束后指示灯熄灭。",
"introduction": "本设备可直接利用自来水中的氯元素,通过电解将其转化为低浓度次氯酸5-10mg/L或利用专用的电解质溶液,电解生成PH约5.8的中浓度次氯酸约100mg/L。",
"scenarios": "1预防性消毒低浓度次氯酸公交车 地铁 单车等公共交通工具扶把手,或电梯 快递件等接触物表面。2应急性消毒中高浓度次氯酸病菌感染物 涉疫物品 传染病患者血液或体液污染物等。",
"detail": [
{
"key": "产品名称",
"value": "多功能电解水除菌器(差旅神器)"
},
{
"key": "工作电压",
"value": "12V"
},
{
"key": "输入电源",
"value": "5V/2A适配器"
},
{
"key": "电池容量",
"value": "400mAh"
},
{
"key": "电解时间",
"value": "1.5分钟"
},
{
"key": "适用水源",
"value": "市政自来水"
},
{
"key": "容量",
"value": "约30毫升"
},
{
"key": "次氯酸浓度",
"value": "1-2mg/L (仅自来水) 约100mg/L (专用电解质)"
}
],
"info": "差旅神器——现制现用小喷瓶",
"adshort": "差旅神器"
},
"contact": {
"phone": "联系方式\n电话18133670714微信同号",
"email": "E-mail: ykl1979@163.com",
"address": "地址:安徽省合肥市瑶海区龙岗综合经济开发区包公大道与大众路交口安徽尚荣大健康--产业园5栋805室"
},
"articles": [
{
"title": "次氯酸的前世今生",
"filename": "/articles/zh/次氯酸的前世今生.pdf"
},
{
"title": "来瓶1820的“拉巴拉克”Eau de Labarraque",
"filename": "/articles/zh/来瓶1820的“拉巴拉克”Eau de Labarraque.pdf"
}
],
"article_type": {
"article": "本站文章",
"reference": "期刊文献"
},
"product-details": {
"title": "产品详细信息",
"feature": "产品特点",
"intro": "产品介绍",
"instruction": "使用方法",
"precautions": "注意事项"
},
"property": "公司专利",
"learnMore": "了解更多",
"address": "公司地址",
"contactname": "联系方式",
"copyright": "版权所有"
}

25
src/main.ts Normal file
View File

@@ -0,0 +1,25 @@
// import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n' // node_modules\vue-i18n
import zh from './locales/zh.json'
import en from './locales/en.json'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const i18n = createI18n({
locale: 'en', // 默认语言
fallbackLocale: 'zh', // 备用语言
messages: {
zh,
en,
},
})
app.use(i18n) // 将 i18n 实例添加到 Vue 应用中
app.use(createPinia())
app.use(router)
app.mount('#app')

53
src/router/index.ts Normal file
View File

@@ -0,0 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue'),
},
// {
// path: '/waterlife',
// name: 'waterlife',
// component: () => import('../views/WaterLifeProducts.vue'),
// },
{
path: '/waterlife_flosser',
name: 'waterlife_flosser',
component: () => import('../views/WaterLife_flosser.vue'),
},
{
path: '/waterlife_cup',
name: 'waterlife_cup',
component: () => import('../views/WaterLife_cup.vue'),
},
{
path: '/waterlife_bottle',
name: 'waterlife_bottle',
component: () => import('../views/WaterLife_bottle.vue'),
},
{
path: '/knowledge',
name: 'knowledge',
component: () => import('../views/KnowledgeView.vue'),
},
{
path: '/todo',
name: 'Todo',
component: () => import('../views/TodoView.vue'),
},
{
path: '/store',
name: 'Store',
component: () => import('../views/StoreView.vue'),
},
],
scrollBehavior(to, from, savedPosition) {
// 始终滚动到页面顶部
return { top: 0, behavior: 'smooth' }
},
})
export default router

12
src/stores/counter.ts Normal file
View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

18
src/types/product.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
/**
* @file product相关数据结构
*/
// products 数据
export interface DetailItem {
key: string
value: string
}
// 产品用法、介绍展示
export interface ProductInfoData {
notice: string
usage: string
features: string
introduction: string
support: string
}

85
src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,85 @@
<template>
<div class="home-view">
<AboutSection
v-for="section in waterLIfeSections"
:key="section.title"
:reverse="false"
:title="''"
:description="''"
:background-image="section.backgroundImage + locale + '.jpg'"
:buttonshow="false"
:targetPath="''"
/>
<AboutSection
v-for="section in aboutSections"
:key="section.title"
:reverse="section.reverse"
:title="$t(section.title)"
:description="$t(section.description)"
:background-image="section.backgroundImage"
:buttonshow="section.buttonShow"
:targetPath="section.targetPath"
/>
</div>
</template>
<script setup lang="ts">
import AboutSection from '@/components/AboutSection.vue'
import { ref } from 'vue'
import background_1 from '/images/background_1.jpg'
import background_2 from '/images/background_2.jpg'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const waterLIfeSections = ref([
{
title: 'areas.flosser.name',
description: '',
backgroundImage: '/images/banner1_',
},
{
title: 'areas.cup.name',
description: '',
backgroundImage: '/images/banner2_',
},
{
title: 'areas.bottle.name',
description: '',
backgroundImage: '/images/banner3_',
},
])
const aboutSections = ref([
{
title: 'areas.aged.name',
description: 'areas.aged.intro',
backgroundImage: background_1,
reverse: false,
buttonShow: true,
targetPath: '/todo',
},
{
title: 'company.name',
description: 'company.introduction',
backgroundImage: background_2,
reverse: true,
buttonShow: false,
targetPath: '',
},
// {
// title: 'property',
// description: 'company.property',
// backgroundImage: background_2,
// reverse: false,
// buttonShow: false,
// targetPath: '',
// },
])
</script>
<style scoped>
.home-view {
background-color: rgb(255, 255, 255);
}
</style>

188
src/views/KnowledgeView.vue Normal file
View File

@@ -0,0 +1,188 @@
<template>
<div class="home-view">
<div
v-for="article in $tm('articles') as Reference[]"
:key="article.title"
class="article-list"
>
<a class="article-line" :href="article.filename">
<span class="article">{{ article.title }}</span>
<span class="article-link"></span>
<span class="type">[ {{ $t('article_type.article') }} ]</span>
</a>
</div>
<div v-for="ref in refs" :key="ref.title" class="article-list">
<a class="article-line" href="#" @click="downloadRef(ref.filename)">
<span class="article">{{ ref.filename }}</span>
<span class="article-link"></span>
<span class="type">[ {{ $t('article_type.reference') }} ]</span>
</a>
</div>
</div>
</template>
<script setup lang="ts">
interface Reference {
title: string
filename: string
}
const refs: Reference[] = [
{ title: '5ppm HOCL and oral bacteria', filename: '5ppm HOCL and oral bacteria.pdf' },
{
title: '微酸性电解水的性能与口腔应用研究进展',
filename: '微酸性电解水的性能与口腔应用研究进展.pdf',
},
{
title:
'H2O2 function only as intermediates for the production of HOCl rather than directly killing microbes during phagocytosis',
filename:
'H2O2 function only as intermediates for the production of HOCl rather than directly killing microbes during phagocytosis.pdf',
},
{
title: 'Halide Peroxidase in the Host Squid Euprymna scolopes',
filename: 'Halide Peroxidase in the Host Squid Euprymna scolopes.pdf',
},
{ title: 'HOCL and atopic dermatitis1', filename: 'HOCL and atopic dermatitis1.pdf' },
{ title: 'HOCL and atopic dermatitis2', filename: 'HOCL and atopic dermatitis2.pdf' },
{ title: 'HOCL and NaCLO', filename: 'HOCL and NaCLO.pdf' },
{ title: 'HOCL and wound care', filename: 'HOCL and wound care.pdf' },
{
title: 'HOCL as a potential cavity conditioner for caries affected dentin',
filename: 'HOCL as a potential cavity conditioner for caries affected dentin.pdf',
},
{
title: 'HOCL as a Promising Respiratory Antiseptic',
filename: 'HOCL as a Promising Respiratory Antiseptic.pdf',
},
{
title: 'HOCL as mouthwash killing Staphylococcus aureus in periodontal disease patients',
filename: 'HOCL as mouthwash killing Staphylococcus aureus in periodontal disease patients.pdf',
},
{
title: 'HOCL for wound care and scar management',
filename: 'HOCL for wound care and scar management.pdf',
},
{
title: 'HOCL in gut of Drosophila help killing and expelling pathogendefecation',
filename: 'HOCL in gut of Drosophila help killing and expelling pathogendefecation.pdf',
},
{
title: 'HOCL in haemocyte of marine bivalve (fig 2',
filename: 'HOCL in haemocyte of marine bivalve (fig 2.pdf',
},
{
title: 'HOCL in Neutrophils for killing bacteria1',
filename: 'HOCL in Neutrophils for killing bacteria1.pdf',
},
{
title: 'HOCL in Neutrophils for killing bacteria2',
filename: 'HOCL in Neutrophils for killing bacteria2.pdf',
},
{
title: 'HOCL inside the Neutrophil Phagosome1',
filename: 'HOCL inside the Neutrophil Phagosome1.pdf',
},
{
title: 'HOCL inside the Neutrophil Phagosome2',
filename: 'HOCL inside the Neutrophil Phagosome2.pdf',
},
{
title: 'HOCL more efficient than H2O2 in killing bacteria',
filename: 'HOCL more efficient than H2O2 in killing bacteria.pdf',
},
{
title: 'New Clinical Applications of Electrolyzed Water',
filename: 'New Clinical Applications of Electrolyzed Water.pdf',
},
{
title: 'ROS play an essential role in driving biological evolution of The Cambrian Explosion',
filename:
'ROS play an essential role in driving biological evolution of The Cambrian Explosion.pdf',
},
]
// 下载中英文手册
const downloadRef = (filename: string) => {
const link = document.createElement('a')
link.href = '/references/' + filename
link.download = filename
link.click()
}
</script>
<style scoped>
.home-view {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
/* gap: 8px; */
padding-top: 10px;
padding-bottom: 10px;
/* margin: 0 10px 0 10px; */
/* background: linear-gradient(135deg, #f5f7fa 0%, #e4edfb 100%); */
border: 1px solid rgba(102, 146, 191, 0.3);
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.article-list {
max-width: 90%;
min-width: 90%;
margin: 16px 16px;
color: #333;
}
/* 标题链接(包裹标题文本) */
.article-line {
display: flex;
align-items: center;
justify-content: space-between;
text-decoration: none;
color: #333;
font-size: 14px;
position: relative;
}
/* 标题文本:一行溢出省略 */
.article {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: middle;
max-width: 70%;
font-size: 18px;
line-height: 1.2;
color: #222;
}
.article-link {
content: '';
display: block;
flex: 1;
height: 0;
border-bottom: 1px dashed #300808; /* 点状样式,改为 solid / dashed / double 等即可 */
min-width: 4px;
}
.type {
white-space: nowrap;
color: #888;
font-size: 16px;
max-width: 15%;
overflow: hidden;
flex: 0 0 auto; /* 固定宽度,不会被压缩成多行 */
}
.article-line:hover .article {
color: #0070c9;
}
.article-line:hover .type {
color: #0070c9;
}
</style>

77
src/views/StoreView.vue Normal file
View File

@@ -0,0 +1,77 @@
<template>
<div class="purchase-methods">
<h2>{{ $t('pages.store.buy') }}</h2>
<div class="methods-container">
<!-- 微信 -->
<div class="method">
<h3>{{ $t('pages.store.WeChat') }}</h3>
<img :src="WeChat" alt="微信二维码" />
</div>
<!-- 淘宝 -->
<div class="method">
<h3>{{ $t('pages.store.TaoBao') }}</h3>
<img :src="TaoBao" alt="淘宝二维码" />
</div>
<!-- 小红书 -->
<div class="method">
<h3>{{ $t('pages.store.RedNote') }}</h3>
<img :src="RedNote" alt="小红书二维码" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import WeChat from '/images/WeChat.jpg'
import TaoBao from '/images/TaoBao.jpg'
import RedNote from '/images/RedNote.jpg'
</script>
<style scoped>
.purchase-methods {
font-family: 'Arial', sans-serif;
padding: 20px;
text-align: center;
}
.purchase-methods h2 {
font-size: 1.8rem;
margin-bottom: 30px;
color: #333;
}
.methods-container {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
gap: 40px;
max-width: 800px;
margin: 0 auto;
}
.method {
flex: 1;
min-width: 200px;
padding: 15px;
border-radius: 10px;
background-color: #f9f9f9;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.method h3 {
margin-top: 0;
font-size: 1.2rem;
color: #333;
}
.method img {
width: 150px;
height: 150px;
margin: 10px auto;
display: block;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>

26
src/views/TodoView.vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<div class="home-view">
<AboutSection
key="Todo"
:title="$t('pages.todo.name')"
:description="$t('pages.todo.content')"
:background-image="background_4"
/>
</div>
</template>
<!-- <script>
export default {
name: 'HomeView',
}
</script> -->
<script setup lang="ts">
import AboutSection from '@/components/AboutSection.vue'
import background_4 from '/images/background_4.jpg'
</script>
<style scoped>
.home-view {
background-color: rgb(241, 244, 247);
}
</style>

View File

@@ -0,0 +1,272 @@
<!-- TODO: 拆分为独立的三个页面后续产品种类提升可以合并 -->
<template>
<div class="product-display">
<ProductDetail
v-for="product in products"
:key="product.name"
reverse
:title="$t(`${product.name}.name`)"
:content="$t(`${product.name}.info`)"
:imagetitle="$t(`${product.name}.name`)"
:info="$t(`${product.name}.info`)"
:detailList="product.detailList"
:imageUrl="product.imageUrl"
:videoUrl="$t(`${product.name}.video-url`)"
/>
</div>
<div class="product-move">
<div class="product-list">
<div
v-for="product in products"
:key="product.name"
class="product-card"
:class="{ active: selectedProduct === product.name }"
@click="selectProduct(product.name)"
>
<img :src="product.imageUrl" class="product-image" />
</div>
</div>
<div class="detail-section">
<transition name="fade" mode="out-in">
<seekDetail
v-if="selectedProductData"
:key="selectedProductData.name"
:productData="selectedProductData.infoData"
/>
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ProductDetail from '@/components/ProductDetail.vue'
import { useI18n } from 'vue-i18n'
import seekDetail from '@/components/seekDetail.vue'
const { tm } = useI18n()
// import { useI18n } from 'vue-i18n'
// const { t } = useI18n()
const selectedProductData = computed(() => {
return products.value.find((p) => p.name === selectedProduct.value)
})
import cyqImage from '@/assets/water/cyq.png'
import cjqImage from '@/assets/water/cjq.png'
import cjbImage from '@/assets/water/cjb.png'
import type { DetailItem, ProductInfoData } from '@/types/product'
const products = ref([
{
name: 'cyq',
// detailList: tm('cyq.detail') as unknown as DetailItem[],
detailList: computed(() => tm('cyq.detail') as DetailItem[]),
imageUrl: cyqImage,
// infoData: tm('cyq') as unknown as ProductInfoData,
infoData: computed(() => tm('cyq') as ProductInfoData),
},
{
name: 'cjb',
detailList: computed(() => tm('cjb.detail') as DetailItem[]),
imageUrl: cjbImage,
infoData: computed(() => tm('cjb') as ProductInfoData),
},
{
name: 'cjq',
detailList: computed(() => tm('cjq.detail') as DetailItem[]),
imageUrl: cjqImage,
infoData: computed(() => tm('cjq') as ProductInfoData),
},
])
const selectedProduct = ref('')
// 切换产品方法
function selectProduct(productName: string) {
selectedProduct.value = productName
}
</script>
<style scoped>
.main-display {
}
/* 产品展示区域样式 */
.product-display {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 30px;
}
.product-move {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.product-list {
display: flex;
justify-content: center;
gap: 20px;
width: 100%;
}
.product-card {
display: flex;
justify-content: center;
align-items: center;
width: 20%;
height: auto;
background: white;
border-radius: 12px;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
position: relative;
}
.product-card:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(106, 17, 203, 0.1) 0%, rgba(37, 117, 252, 0.1) 100%);
opacity: 0;
transition: opacity 0.2s ease;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.product-card:hover:before {
opacity: 1;
}
.product-card.active {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(106, 17, 203, 0.2);
}
.product-card.active:before {
opacity: 1;
}
.product-image {
width: 80%;
height: auto;
object-fit: contain;
transition: transform 0.3s ease;
}
.product-card:hover .product-image {
transform: scale(1.05);
}
.detail-section {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-list {
gap: 15px;
}
.product-card {
width: 140px;
height: 140px;
}
.product-display {
gap: 30px;
}
}
@media (max-width: 480px) {
.product-list {
gap: 10px;
}
.product-card {
width: 110px;
height: 110px;
}
}
</style>
<!--
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.6s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.product-display {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 50px;
}
.product-move {
display: flex;
flex-direction: column; /* 内部上下排列 */
align-items: center;
gap: 2%;
}
.product-list {
display: flex;
gap: 1em;
}
.product-image {
height: auto;
width: 80%;
}
.product-card {
/* 开启 Flexbox */
display: flex;
justify-content: center;
align-items: center;
}
.product-card:hover,
.product-card.active {
box-shadow: 10px 10px 12px rgba(14, 70, 174, 0.15);
}
.detail-section {
display: flex;
flex-direction: column;
align-items: center;
}
</style> -->

View File

@@ -0,0 +1,178 @@
<!-- 小喷瓶 -->
<template>
<div class="product-display">
<ProductDetail
:key="product.name"
reverse
:title="$t(`${product.name}.name`)"
:content="$t(`${product.name}.info`)"
:imagetitle="$t(`${product.name}.name`)"
:info="$t(`${product.name}.info`)"
:detailList="product.detailList"
:imageUrl="product.imageUrl"
:videoUrls="tm(`${product.name}.video-urls`) as string[]"
/>
</div>
<div class="product-move">
<div class="product-list">
<div :key="product.name" class="product-card">
<img :src="product.imageUrl" class="product-image" />
</div>
</div>
<div class="detail-section">
<transition name="fade" mode="out-in">
<seekDetail :key="product.name" :productData="product.infoData" />
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ProductDetail from '@/components/ProductDetail.vue'
import { useI18n } from 'vue-i18n'
import seekDetail from '@/components/seekDetail.vue'
import cjqImage from '@/assets/water/cjq.png'
import type { DetailItem, ProductInfoData } from '@/types/product'
// import { useI18n } from 'vue-i18n'
// const { t } = useI18n()
const { tm } = useI18n()
const product = ref({
name: 'cjq',
detailList: computed(() => tm('cjq.detail') as DetailItem[]),
imageUrl: cjqImage,
infoData: computed(() => tm('cjq') as ProductInfoData),
})
</script>
<style scoped>
/* 产品展示区域样式 */
.product-display {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 30px;
}
.product-move {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.product-list {
display: flex;
justify-content: center;
gap: 20px;
width: 100%;
}
.product-card {
display: flex;
justify-content: center;
align-items: center;
width: 20%;
height: auto;
background: white;
border-radius: 12px;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
position: relative;
}
.product-card:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(106, 17, 203, 0.1) 0%, rgba(37, 117, 252, 0.1) 100%);
opacity: 0;
transition: opacity 0.2s ease;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.product-card:hover:before {
opacity: 1;
}
.product-card.active {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(106, 17, 203, 0.2);
}
.product-card.active:before {
opacity: 1;
}
.product-image {
width: 80%;
height: auto;
object-fit: contain;
transition: transform 0.3s ease;
}
.product-card:hover .product-image {
transform: scale(1.05);
}
.detail-section {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-list {
gap: 15px;
}
.product-card {
width: 140px;
height: 140px;
}
.product-display {
gap: 30px;
}
}
@media (max-width: 480px) {
.product-list {
gap: 10px;
}
.product-card {
width: 110px;
height: 110px;
}
}
</style>

178
src/views/WaterLife_cup.vue Normal file
View File

@@ -0,0 +1,178 @@
<!-- 除菌杯 -->
<template>
<div class="product-display">
<ProductDetail
:key="product.name"
reverse
:title="$t(`${product.name}.name`)"
:content="$t(`${product.name}.info`)"
:imagetitle="$t(`${product.name}.name`)"
:info="$t(`${product.name}.info`)"
:detailList="product.detailList"
:imageUrl="product.imageUrl"
:videoUrls="tm(`${product.name}.video-urls`) as string[]"
/>
</div>
<div class="product-move">
<div class="product-list">
<div :key="product.name" class="product-card">
<img :src="product.imageUrl" class="product-image" />
</div>
</div>
<div class="detail-section">
<transition name="fade" mode="out-in">
<seekDetail :key="product.name" :productData="product.infoData" />
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ProductDetail from '@/components/ProductDetail.vue'
import { useI18n } from 'vue-i18n'
import seekDetail from '@/components/seekDetail.vue'
import cjbImage from '@/assets/water/cjb.png'
import type { DetailItem, ProductInfoData } from '@/types/product'
// import { useI18n } from 'vue-i18n'
// const { t } = useI18n()
const { tm } = useI18n()
const product = ref({
name: 'cjb',
detailList: computed(() => tm('cjb.detail') as DetailItem[]),
imageUrl: cjbImage,
infoData: computed(() => tm('cjb') as ProductInfoData),
})
</script>
<style scoped>
/* 产品展示区域样式 */
.product-display {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 30px;
}
.product-move {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.product-list {
display: flex;
justify-content: center;
gap: 20px;
width: 100%;
}
.product-card {
display: flex;
justify-content: center;
align-items: center;
width: 20%;
height: auto;
background: white;
border-radius: 12px;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
position: relative;
}
.product-card:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(106, 17, 203, 0.1) 0%, rgba(37, 117, 252, 0.1) 100%);
opacity: 0;
transition: opacity 0.2s ease;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.product-card:hover:before {
opacity: 1;
}
.product-card.active {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(106, 17, 203, 0.2);
}
.product-card.active:before {
opacity: 1;
}
.product-image {
width: 80%;
height: auto;
object-fit: contain;
transition: transform 0.3s ease;
}
.product-card:hover .product-image {
transform: scale(1.05);
}
.detail-section {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-list {
gap: 15px;
}
.product-card {
width: 140px;
height: 140px;
}
.product-display {
gap: 30px;
}
}
@media (max-width: 480px) {
.product-list {
gap: 10px;
}
.product-card {
width: 110px;
height: 110px;
}
}
</style>

View File

@@ -0,0 +1,180 @@
<!-- 冲牙器 -->
<template>
<div class="product-display">
<ProductDetail
:key="product.name"
reverse
:title="$t(`${product.name}.name`)"
:content="$t(`${product.name}.info`)"
:imagetitle="$t(`${product.name}.name`)"
:info="$t(`${product.name}.info`)"
:detailList="product.detailList"
:imageUrl="product.imageUrl"
:videoUrls="tm(`${product.name}.video-urls`) as string[]"
/>
</div>
<div class="product-move">
<div class="product-list">
<div :key="product.name" class="product-card">
<img :src="product.imageUrl" class="product-image" />
</div>
</div>
<div class="detail-section">
<transition name="fade" mode="out-in">
<seekDetail :key="product.name" :productData="product.infoData" />
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ProductDetail from '@/components/ProductDetail.vue'
import { useI18n } from 'vue-i18n'
import seekDetail from '@/components/seekDetail.vue'
import cyqImage from '@/assets/water/cyq.png'
import type { DetailItem, ProductInfoData } from '@/types/product'
// import { useI18n } from 'vue-i18n'
// const { t } = useI18n()
const { tm } = useI18n()
const product = ref({
name: 'cyq',
// detailList: tm('cyq.detail') as unknown as DetailItem[],
detailList: computed(() => tm('cyq.detail') as DetailItem[]),
imageUrl: cyqImage,
// infoData: tm('cyq') as unknown as ProductInfoData,
infoData: computed(() => tm('cyq') as ProductInfoData),
})
</script>
<style scoped>
/* 产品展示区域样式 */
.product-display {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 30px;
}
.product-move {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.product-list {
display: flex;
justify-content: center;
gap: 20px;
width: 100%;
}
.product-card {
display: flex;
justify-content: center;
align-items: center;
width: 20%;
height: auto;
background: white;
border-radius: 12px;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
position: relative;
}
.product-card:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(106, 17, 203, 0.1) 0%, rgba(37, 117, 252, 0.1) 100%);
opacity: 0;
transition: opacity 0.2s ease;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.product-card:hover:before {
opacity: 1;
}
.product-card.active {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(106, 17, 203, 0.2);
}
.product-card.active:before {
opacity: 1;
}
.product-image {
width: 80%;
height: auto;
object-fit: contain;
transition: transform 0.3s ease;
}
.product-card:hover .product-image {
transform: scale(1.05);
}
.detail-section {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-list {
gap: 15px;
}
.product-card {
width: 140px;
height: 140px;
}
.product-display {
gap: 30px;
}
}
@media (max-width: 480px) {
.product-list {
gap: 10px;
}
.product-card {
width: 110px;
height: 110px;
}
}
</style>

12
tsconfig.app.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

19
tsconfig.node.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

11
tsconfig.vitest.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.app.json",
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"lib": [],
"types": ["node", "jsdom"]
}
}

20
vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)