This commit is contained in:
2026-08-04 13:21:17 +08:00
commit cfdaceff26
90 changed files with 7939 additions and 0 deletions

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
#VITE_API_BASE_URL=http://127.0.0.1:8787
VITE_API_BASE_URL=https://huicai-api.wsdns.cn

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://huicai-api.wsdns.cn

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

0
database/huicai.sql Normal file
View File

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<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.js"></script>
</body>
</html>

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "huicai",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.16.1",
"element-plus": "^2.14.0",
"vite-plugin-compression": "^0.5.1",
"vue": "^3.5.34",
"vue-router": "4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.12"
}
}

1179
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

142
src/App.vue Normal file
View File

@@ -0,0 +1,142 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { RouterView } from 'vue-router'
import SiteHeader from './components/SiteHeader.vue'
import SiteFooter from './components/SiteFooter.vue'
import { navItems } from './data/siteContent'
import { getArticleCategories, getCases, getPartyCultureCategories, getProductCategories, getServices } from './utils/api'
import { trackPageView, trackVisit } from './utils/tracking'
const route = useRoute()
const productCategories = ref([])
const partyCultureCategories = ref([])
const articleCategories = ref([])
const caseCategories = ref({ industry: [], service: [] })
const consultingItems = ref([])
function buildCategoryLinks(rootId, path, queryBuilder = (item) => ({ category_id: item.id })) {
const root = (productCategories.value || []).find((item) => String(item.id) === String(rootId))
if (!root) return []
const children = root.children?.length ? root.children : [root]
return children.map((item) => ({
id: item.id,
name: item.name,
path,
query: queryBuilder(item),
}))
}
function buildLinksFromRoots(categories, path, queryBuilder = (item) => ({ category_id: item.id })) {
return (categories || []).flatMap((root) => {
const children = root.children?.length ? root.children : [root]
return children.map((item) => ({
id: item.id,
name: item.name,
path,
query: queryBuilder(item),
}))
})
}
function buildServiceDetailLinks(items, path) {
return (items || []).map((item) => ({
id: item.id,
name: item.title || item.name,
path: `${path}/${item.id}`,
query: {},
}))
}
const headerMenus = computed(() => ({
products: {
type: 'flat',
links: buildCategoryLinks('1', '/products', (item) => ({ type: 'product', category_id: item.id })),
},
partyCulture: {
type: 'flat',
links: buildLinksFromRoots(partyCultureCategories.value, '/party-culture', (item) => ({ category_id: item.id })),
},
services: {
type: 'flat',
links: buildCategoryLinks('2', '/services'),
},
consulting: {
type: 'flat',
links: buildServiceDetailLinks(consultingItems.value, '/consulting'),
},
cases: {
type: 'grouped',
groups: [
{
id: 'industry',
name: '行业分类',
path: '/cases',
query: {},
children: (caseCategories.value.industry || []).map((item) => ({
...item,
path: '/cases',
query: { industry_category_id: item.id },
})),
},
{
id: 'service',
name: '服务分类',
path: '/cases',
query: {},
children: (caseCategories.value.service || []).map((item) => ({
...item,
path: '/cases',
query: { service_category_id: item.id },
})),
},
].filter((group) => group.children.length),
},
news: {
type: 'flat',
links: (articleCategories.value || []).map((item) => ({
id: item.id,
name: item.name,
path: '/news',
query: { category_id: item.id },
})),
},
}))
async function loadHeaderCategories() {
const [products, partyCulture, articles, cases, consulting] = await Promise.all([
getProductCategories({ status: 1 }).catch(() => []),
getPartyCultureCategories({ status: 1 }).catch(() => []),
getArticleCategories().catch(() => []),
getCases().catch(() => ({})),
getServices({ type: 'consulting' }).catch(() => []),
])
productCategories.value = products || []
partyCultureCategories.value = partyCulture || []
articleCategories.value = articles || []
caseCategories.value = cases?.categories || { industry: [], service: [] }
consultingItems.value = consulting || []
}
onMounted(() => {
loadHeaderCategories()
trackVisit(route)
trackPageView(route)
})
watch(
() => route.fullPath,
() => {
trackVisit(route)
trackPageView(route)
},
)
</script>
<template>
<div class="page">
<SiteHeader :nav-items="navItems" :nav-menus="headerMenus" />
<RouterView />
<SiteFooter />
</div>
</template>

BIN
src/assets/cgal-active.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
src/assets/cgal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
src/assets/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

BIN
src/assets/header-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
src/assets/hxywjz.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
src/assets/mock/news-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

BIN
src/assets/mock/news-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

BIN
src/assets/mock/news-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

BIN
src/assets/phone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src/assets/qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
src/assets/qyys_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

BIN
src/assets/qyys_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

BIN
src/assets/qyys_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

BIN
src/assets/qyys_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

BIN
src/assets/暂无内容.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,58 @@
<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import phoneIcon from '../assets/phone.png'
import editIcon from '../assets/edit.png'
import qrCode from '../assets/qr.png'
import { useContactInfo } from '../utils/siteConfig'
const contactInfo = useContactInfo()
const baiduMapUrl = computed(() => `https://map.baidu.com/search/${encodeURIComponent(contactInfo.value.address)}`)
</script>
<template>
<footer class="site-footer">
<div class="container footer-main">
<section class="footer-block">
<div class="footer-title">
<img class="footer-icon" :src="phoneIcon" alt="" />
<h3>联系我们</h3>
</div>
<p>官方邮箱{{ contactInfo.email }}</p>
<p>
公司地址
<a class="map-link footer-map-link" :href="baiduMapUrl" target="_blank" rel="noreferrer">
{{ contactInfo.address }}
</a>
</p>
<strong class="footer-phone">{{ contactInfo.phone }}</strong>
</section>
<section class="footer-block">
<div class="footer-title">
<img class="footer-icon" :src="editIcon" alt="" />
<h3>业务咨询</h3>
</div>
<p>您有任何关于商用产品或解决方案的需求可以填写表单内容</p>
<p>我们会第一时间与您联系</p>
<RouterLink class="footer-link" :to="{ name: 'consulting-request' }">立即填写&gt;&gt;</RouterLink>
</section>
<section class="footer-block qr-block">
<img class="qr-code" :src="qrCode" alt="汇采聚云微信公众号二维码" />
<p>汇采聚云微信公众号</p>
</section>
</div>
<div class="footer-bottom">
<div class="container footer-bottom-inner">
<div class="footer-nav">
<RouterLink :to="{ name: 'about' }">关于我们</RouterLink>
<RouterLink :to="{ name: 'contact' }">联系我们</RouterLink>
<RouterLink :to="{ name: 'notice' }">公告声明</RouterLink>
</div>
<p>桂ICP备88888888号-120000-2026 hcjyun.cn.All rights reserved.</p>
</div>
</div>
</footer>
</template>

View File

@@ -0,0 +1,279 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import logo from '../assets/logo.png'
const props = defineProps({
navItems: {
type: Array,
required: true,
},
navMenus: {
type: Object,
required: true,
},
})
const route = useRoute()
const menuOpen = ref(false)
const openCategoryIds = ref({})
function getMenu(key) {
return props.navMenus?.[key] || {}
}
function isGroupedMenu(key) {
return getMenu(key)?.type === 'grouped'
}
function hasDropdown(key) {
const menu = getMenu(key)
if (menu?.type === 'grouped') {
return (menu.groups || []).length > 0
}
if (menu?.type === 'flat') {
return (menu.links || []).length > 0
}
return false
}
function findMatchedRoot(menuKey, groups) {
const currentId = String(route.query.category_id || '')
if (menuKey === 'services' && route.path.startsWith('/services')) {
return groups.find((group) => String(group.id) === '2')
}
if (menuKey === 'consulting' && route.path.startsWith('/consulting')) {
return groups.find((group) => String(group.id) === '3')
}
if (menuKey === 'products' && route.path.startsWith('/products') && route.query.category_id) {
return groups.find((group) => String(group.id) === currentId || (group.children || []).some((child) => String(child.id) === currentId))
}
if (menuKey === 'cases' && route.path.startsWith('/cases')) {
if (route.query.industry_category_id) {
return groups.find((group) => String(group.id) === 'industry')
}
if (route.query.service_category_id) {
return groups.find((group) => String(group.id) === 'service')
}
}
return null
}
function getOpenCategoryId(menuKey) {
return String(openCategoryIds.value?.[menuKey] || '')
}
function getActiveRootCategory(menuKey) {
const groups = getMenu(menuKey)?.groups || []
if (!groups.length) {
return { id: 0, name: '', children: [] }
}
const matchedRoot = findMatchedRoot(menuKey, groups)
if (matchedRoot) {
return matchedRoot
}
return groups.find((group) => String(group.id) === getOpenCategoryId(menuKey)) || groups[0]
}
function getActiveMenuLinks(menuKey) {
const current = getActiveRootCategory(menuKey)
if (!current) return []
if (current.children?.length) return current.children
return [current]
}
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
function openDropdown(menuKey) {
const menu = getMenu(menuKey)
if (menu?.type !== 'grouped') return
const groups = menu.groups || []
if (!groups.length) {
openCategoryIds.value = {
...openCategoryIds.value,
[menuKey]: 0,
}
return
}
const matchedRoot = findMatchedRoot(menuKey, groups)
openCategoryIds.value = {
...openCategoryIds.value,
[menuKey]: matchedRoot?.id || groups[0].id,
}
}
function switchGroup(menuKey, group) {
openCategoryIds.value = {
...openCategoryIds.value,
[menuKey]: String(group.id),
}
}
function isActiveNewsLink(id) {
return route.path.startsWith('/news') && String(route.query.category_id || '') === String(id || '')
}
function isActiveNavItem(item) {
if (!item?.to) return false
if (item.dropdownKey === 'news') {
return route.path.startsWith('/news')
}
if (item.dropdownKey === 'cases') {
return route.path.startsWith('/cases')
}
if (item.dropdownKey === 'products') {
return route.path.startsWith('/products')
}
if (item.dropdownKey === 'partyCulture') {
return route.path.startsWith('/party-culture')
}
if (item.dropdownKey === 'services') {
return route.path.startsWith('/services')
}
if (item.dropdownKey === 'consulting') {
return route.path.startsWith('/consulting')
}
return route.path === item.to
}
function isActiveFlatLink(link) {
if (!link?.path || route.path !== link.path) return false
const query = link.query || {}
return Object.entries(query).every(([key, value]) => String(route.query[key] || '') === String(value || ''))
}
function getMenuTarget(item) {
return {
path: item.path || '/',
query: item.query || {},
}
}
watch(
() => [
route.path,
route.query.category_id,
props.navMenus?.products?.groups?.length,
props.navMenus?.partyCulture?.links?.length,
props.navMenus?.services?.groups?.length,
props.navMenus?.consulting?.groups?.length,
props.navMenus?.cases?.groups?.length,
props.navMenus?.news?.links?.length,
],
() => {
for (const menuKey of ['products', 'services', 'consulting', 'cases']) {
if (!isGroupedMenu(menuKey)) continue
const groups = getMenu(menuKey)?.groups || []
if (!groups.length) continue
const matchedRoot = findMatchedRoot(menuKey, groups)
openCategoryIds.value = {
...openCategoryIds.value,
[menuKey]: matchedRoot?.id || openCategoryIds.value?.[menuKey] || groups[0].id,
}
}
},
{ immediate: true },
)
</script>
<template>
<header class="site-header">
<div class="container header-inner">
<div class="brand">
<RouterLink to="/" @click="closeMenu">
<img class="brand-logo" :src="logo" alt="汇采聚云信息发展有限公司" />
</RouterLink>
</div>
<button class="menu-toggle" type="button" :aria-expanded="menuOpen" @click="toggleMenu">
<span></span>
<span></span>
<span></span>
</button>
<nav :class="['site-nav', { open: menuOpen }]" aria-label="主导航">
<div
v-for="item in navItems"
:key="item.label"
:class="['nav-item', { 'has-dropdown': item.dropdown }]"
>
<component
:is="item.to ? RouterLink : 'a'"
v-bind="item.to ? { to: item.to } : { href: '#' }"
:class="['nav-link', { dropdown: item.dropdown, active: isActiveNavItem(item) }]"
@mouseenter="item.dropdown && hasDropdown(item.dropdownKey) ? openDropdown(item.dropdownKey) : null"
@click="closeMenu"
>
{{ item.label }}
</component>
<div v-if="item.dropdown && hasDropdown(item.dropdownKey)" class="nav-dropdown" aria-hidden="true">
<div :class="['dropdown-inner', { 'dropdown-inner-flat': getMenu(item.dropdownKey)?.type === 'flat' }]">
<template v-if="getMenu(item.dropdownKey)?.type === 'grouped'">
<div class="dropdown-groups">
<RouterLink
v-for="group in getMenu(item.dropdownKey).groups"
:key="group.id"
:to="getMenuTarget(group)"
:class="['dropdown-group-link', { active: String(getActiveRootCategory(item.dropdownKey)?.id || '') === String(group.id) }]"
@mouseenter="switchGroup(item.dropdownKey, group)"
@click="closeMenu"
>
<span>{{ group.name }}</span>
<i v-if="String(getActiveRootCategory(item.dropdownKey)?.id || '') === String(group.id)" class="dropdown-arrow"></i>
</RouterLink>
</div>
<div class="dropdown-links">
<RouterLink
v-for="link in getActiveMenuLinks(item.dropdownKey)"
:key="link.id"
:to="getMenuTarget(link)"
:class="['dropdown-detail-link', { accent: false }]"
@click="closeMenu"
>
{{ link.name }}
</RouterLink>
</div>
</template>
<div v-else class="dropdown-links dropdown-links-flat">
<RouterLink
v-for="link in getMenu(item.dropdownKey).links"
:key="link.id"
:to="{ path: link.path, query: link.query }"
:class="['dropdown-detail-link', { accent: item.dropdownKey === 'news' ? isActiveNewsLink(link.id) : isActiveFlatLink(link) }]"
@click="closeMenu"
>
{{ link.name }}
</RouterLink>
</div>
</div>
</div>
</div>
<div class="nav-item">
<RouterLink
to="/consulting/request"
:class="['nav-link', { active: route.path === '/consulting/request' }]"
@click="closeMenu"
>
联系我们
</RouterLink>
</div>
</nav>
<div v-if="menuOpen" class="mobile-menu-mask" @click="closeMenu"></div>
</div>
</header>
</template>

10
src/data/siteContent.js Normal file
View File

@@ -0,0 +1,10 @@
export const navItems = [
{ label: '首页', to: '/' },
{ label: '关于我们', to: '/about' },
{ label: '党建文化', to: '/party-culture', dropdown: true, dropdownKey: 'partyCulture' },
{ label: '产品中心', to: '/products', dropdown: true, dropdownKey: 'products' },
{ label: '运营服务', to: '/services', dropdown: true, dropdownKey: 'services' },
{ label: '数智咨询', to: '/consulting', dropdown: true, dropdownKey: 'consulting' },
{ label: '成功案例', to: '/cases', dropdown: true, dropdownKey: 'cases' },
{ label: '新闻动态', to: '/news', dropdown: true, dropdownKey: 'news' },
]

8
src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import router from './router'
import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
createApp(App).use(router).use(ElementPlus).mount('#app')

130
src/router/index.js Normal file
View File

@@ -0,0 +1,130 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import NewsView from '../views/NewsView.vue'
import NewsDetailView from '../views/NewsDetailView.vue'
import CasesView from '../views/CasesView.vue'
import CaseDetailView from '../views/CaseDetailView.vue'
import ConsultingView from '../views/ConsultingView.vue'
import ConsultingRequestView from '../views/ConsultingRequestView.vue'
import ServiceDetailView from '../views/ServiceDetailView.vue'
import AboutView from '../views/AboutView.vue'
import ContactView from '../views/ContactView.vue'
import NoticeView from '../views/NoticeView.vue'
import ProductsView from '../views/ProductsView.vue'
import ProductDetailView from '../views/ProductDetailView.vue'
import PartyCultureView from '../views/PartyCultureView.vue'
import PartyCultureDetailView from '../views/PartyCultureDetailView.vue'
import ServicesView from '../views/ServicesView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
meta: { title: '首页' },
},
{
path: '/news/:slug',
name: 'news-detail',
component: NewsDetailView,
meta: { title: '新闻详情' },
},
{
path: '/news',
name: 'news',
component: NewsView,
meta: { title: '新闻动态' },
},
{
path: '/cases',
name: 'cases',
component: CasesView,
meta: { title: '成功案例' },
},
{
path: '/cases/:slug',
name: 'case-detail',
component: CaseDetailView,
meta: { title: '案例详情' },
},
{
path: '/consulting',
name: 'consulting',
component: ConsultingView,
meta: { title: '数智咨询' },
},
{
path: '/consulting/:slug',
name: 'consulting-detail',
component: ServiceDetailView,
meta: { title: '咨询详情' },
},
{
path: '/consulting/request',
name: 'consulting-request',
component: ConsultingRequestView,
meta: { title: '咨询需求' },
},
{
path: '/about',
name: 'about',
component: AboutView,
meta: { title: '关于我们' },
},
{
path: '/contact',
name: 'contact',
component: ContactView,
meta: { title: '联系我们' },
},
{
path: '/notice',
name: 'notice',
component: NoticeView,
meta: { title: '公告声明' },
},
{
path: '/products',
name: 'products',
component: ProductsView,
meta: { title: '产品中心' },
},
{
path: '/party-culture',
name: 'party-culture',
component: PartyCultureView,
meta: { title: '党建文化' },
},
{
path: '/party-culture/:slug',
name: 'party-culture-detail',
component: PartyCultureDetailView,
meta: { title: '党建文化详情' },
},
{
path: '/products/:slug',
name: 'product-detail',
component: ProductDetailView,
meta: { title: '产品详情' },
},
{
path: '/services',
name: 'services',
component: ServicesView,
meta: { title: '运营服务' },
},
{
path: '/services/:slug',
name: 'service-detail',
component: ServiceDetailView,
meta: { title: '服务详情' },
},
],
scrollBehavior() {
return { top: 0 }
},
})
export default router

3088
src/style.css Normal file

File diff suppressed because it is too large Load Diff

102
src/utils/api.js Normal file
View File

@@ -0,0 +1,102 @@
import axios from 'axios'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 20000,
})
service.interceptors.response.use((response) => response.data?.content?.data ?? response.data?.content ?? response.data)
function normalizeCategoryChildren(source, rootId) {
const list = Array.isArray(source) ? source : []
const root = list.find((item) => String(item.id) === String(rootId))
if (root) {
return root.children?.length ? root.children : [root]
}
return list
}
export function getSiteHome() {
return service.get('/site/home')
}
export function getArticleCategories() {
return service.get('/site/article-categories')
}
export function getArticles(params = {}) {
return service.get('/site/articles', { params })
}
export function getArticle(id) {
return service.get('/site/article', { params: { id } })
}
export function getContentCategories(params = {}) {
return service.get('/site/content-categories', { params })
}
export function getProductCategories(params = {}) {
return service.get('/site/product-categories', { params })
}
export function getScopedProductCategories(parentId, params = {}) {
return service.get(`/api/product-category/list/${parentId}`, {
params: {
keyword: '',
category_id: '',
parent_id: parentId,
...params,
},
}).then((data) => normalizeCategoryChildren(data, parentId))
}
export function getProducts(params = {}) {
return service.get('/site/products', { params })
}
export function getProduct(id) {
return service.get('/site/product', { params: { id } })
}
export function getPartyCultureCategories(params = {}) {
return service.get('/site/party-culture-categories', { params })
}
export function getPartyCultures(params = {}) {
return service.get('/site/party-cultures', { params })
}
export function getPartyCulture(id) {
return service.get('/site/party-culture', { params: { id } })
}
export function getCases(params = {}) {
return service.get('/site/cases', { params })
}
export function getBanners() {
return service.get('/site/banners')
}
export function getAboutContent() {
return service.get('/site/about')
}
export function getServices(params = {}) {
return service.get('/site/services', { params })
}
export function getService(id) {
return service.get('/site/service', { params: { id } })
}
export function getPageSettings(params = {}) {
return service.get('/site/page-settings', { params })
}
export function getSiteConfig() {
return service.get('/site/config')
}

35
src/utils/pageSettings.js Normal file
View File

@@ -0,0 +1,35 @@
import { computed, onMounted, ref } from 'vue'
import { getPageSettings } from './api'
export const pageDescriptionDefaults = {
about: '汇采聚云信息发展有限公司专注于政府采购数字化建设、平台研发与落地服务。',
partyCulture: '坚持党建引领,凝聚组织力量,把红色文化融入企业治理、团队建设与服务实践。',
products: '围绕政府采购数字化建设场景,汇采聚云构建了覆盖采购全流程管理、平台协同与智能辅助的产品矩阵,支撑制度执行、业务协同与数据决策。',
services: '围绕平台推广、用户培训、日常支撑与应用深化,汇采聚云提供本地化、持续化的运营服务,帮助客户把系统能力真正转化为业务价值。',
consulting: '聚焦政府采购数字化、制度建设与平台落地场景,提供从诊断、规划到实施陪跑的一体化咨询服务。',
cases: '聚焦政府采购数字化建设、平台运营与咨询服务场景,展示汇采聚云在不同行业与单位中的典型落地成果。',
news: '关注汇采聚云在政府采购数字化、平台建设、专题培训与行业交流等方面的最新动态与实践成果。',
consultingRequest: '留下您的单位信息与需求,我们会尽快安排顾问与您联系。',
}
const pageSettings = ref({})
let loadingPromise = null
async function loadPageSettings() {
if (!loadingPromise) {
loadingPromise = getPageSettings()
.then((data) => {
pageSettings.value = data || {}
})
.catch(() => {})
.finally(() => {
loadingPromise = null
})
}
return loadingPromise
}
export function usePageDescription(key) {
onMounted(loadPageSettings)
return computed(() => pageSettings.value?.[key] || pageDescriptionDefaults[key] || '')
}

39
src/utils/siteConfig.js Normal file
View File

@@ -0,0 +1,39 @@
import { computed, onMounted, ref } from 'vue'
import { getSiteConfig } from './api'
export const contactInfoDefaults = {
email: 'hcjy@163.com',
address: '南宁市良庆区盘歌路4号碧园大厦一单元11层',
phone: '0771-2630838',
}
const siteConfig = ref({
pageDescriptions: {},
contactInfo: { ...contactInfoDefaults },
})
let loadingPromise = null
async function loadSiteConfig() {
if (!loadingPromise) {
loadingPromise = getSiteConfig()
.then((data) => {
siteConfig.value = {
pageDescriptions: data?.pageDescriptions || {},
contactInfo: {
...contactInfoDefaults,
...(data?.contactInfo || {}),
},
}
})
.catch(() => {})
.finally(() => {
loadingPromise = null
})
}
return loadingPromise
}
export function useContactInfo() {
onMounted(loadSiteConfig)
return computed(() => siteConfig.value.contactInfo || contactInfoDefaults)
}

47
src/utils/tracking.js Normal file
View File

@@ -0,0 +1,47 @@
import axios from 'axios'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
})
const VISITOR_KEY = 'huicai_visitor_key'
const VISIT_MARK = 'huicai_visit_mark'
function randomKey() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
}
export function getVisitorKey() {
let key = localStorage.getItem(VISITOR_KEY)
if (!key) {
key = randomKey()
localStorage.setItem(VISITOR_KEY, key)
}
return key
}
function todayMark(path) {
return `${new Date().toISOString().slice(0, 10)}:${path}`
}
export function trackVisit(route) {
const path = route.fullPath || route.path || '/'
const mark = todayMark(path)
if (localStorage.getItem(VISIT_MARK) === mark) return
localStorage.setItem(VISIT_MARK, mark)
service.post('/site/visit', {
visitor_key: getVisitorKey(),
path,
page_title: route.meta?.title || document.title || path,
})
}
export function trackPageView(route) {
service.post('/site/page-view', {
visitor_key: getVisitorKey(),
path: route.fullPath || route.path || '/',
page_title: route.meta?.title || document.title || route.path || '/',
referrer: document.referrer || '',
})
}

78
src/views/AboutView.vue Normal file
View File

@@ -0,0 +1,78 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getAboutContent } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const sections = ref([])
const activeSectionKey = ref('')
const loading = ref(false)
const pageDescription = usePageDescription('about')
const activeSection = computed(() => sections.value.find((item) => item.name === activeSectionKey.value) || sections.value[0] || {})
function normalizeSections(list) {
return (list || []).map((item) => ({
...item,
name: item.name || item.title,
}))
}
onMounted(async () => {
loading.value = true
try {
const res = await getAboutContent()
sections.value = normalizeSections(res || [])
activeSectionKey.value = sections.value[0]?.name || ''
} finally {
loading.value = false
}
})
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>关于我们</em>
</div>
<p class="info-kicker">ABOUT US</p>
<h1>关于我们</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>栏目导航</h2>
<div class="about-menu-list">
<button
v-for="item in sections"
:key="item.id"
type="button"
:class="['about-menu-button', { active: activeSection.name === item.name }]"
@click="activeSectionKey = item.name"
>
{{ item.title }}
</button>
</div>
</aside>
<article class="info-card about-gallery-card">
<div class="about-gallery-head">
<h2>{{ activeSection.title }}</h2>
</div>
<div v-if="loading" class="info-card">
<p>正在加载关于我们内容...</p>
</div>
<div v-else class="rich-content about-rich-content" v-html="activeSection.content || '<p>暂无内容</p>'"></div>
</article>
</section>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,139 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getCases } from '../utils/api'
const route = useRoute()
const article = ref(null)
const casesList = ref([])
const articleIndex = computed(() => {
return casesList.value.findIndex((item) => String(item.id) === String(article.value?.id))
})
const prevArticle = computed(() => {
const index = articleIndex.value
return index > 0 ? casesList.value[index - 1] : null
})
const nextArticle = computed(() => {
const index = articleIndex.value
return index >= 0 && index < casesList.value.length - 1 ? casesList.value[index + 1] : null
})
async function loadCaseDetail() {
const res = await getCases()
casesList.value = res.list || []
article.value = casesList.value.find((item) => String(item.id) === String(route.params.slug)) || casesList.value[0] || null
}
watch(
() => route.params.slug,
async () => {
await loadCaseDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="article" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="{ name: 'cases' }">成功案例</RouterLink>
<span>/</span>
<em>{{ article.serviceCategory?.name || article.industryCategory?.name || '案例详情' }}</em>
</div>
<!-- <p class="article-category">{{ article.serviceCategory?.name || article.industryCategory?.name || '成功案例' }}</p>-->
<h1>{{ article.client_name }}</h1>
<div class="article-meta">
<span>{{ article.industry }}</span>
<span>{{ article.title || article.published_at || '' }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div class="article-cover">
<img :src="article.cover" :alt="article.client_name" />
</div>
<div class="article-summary">
<p>{{ article.service_content }}</p>
</div>
<div class="case-metrics article-case-metrics">
<span>{{ article.industry }}</span>
<span v-if="article.industryCategory?.name">{{ article.industryCategory.name }}</span>
<span v-if="article.serviceCategory?.name">{{ article.serviceCategory.name }}</span>
</div>
<div class="article-content rich-content detail-rich-content" v-html="article.content || '<p>暂无案例详情</p>'">
</div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="{ name: 'cases' }">返回案例列表</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关案例</h3>
<RouterLink
v-for="item in casesList"
:key="item.id"
:to="{ name: 'case-detail', params: { slug: item.id } }"
:class="['related-news-item', { active: item.id === article.id }]"
>
<img :src="item.cover" :alt="item.client_name" />
<div>
<strong>{{ item.client_name }}</strong>
<span>{{ item.serviceCategory?.name || item.industry }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevArticle"
class="article-pager-link"
:to="{ name: 'case-detail', params: { slug: prevArticle.id } }"
>
<label>上一篇</label>
<strong>{{ prevArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一篇</strong>
</div>
<RouterLink
v-if="nextArticle"
class="article-pager-link"
:to="{ name: 'case-detail', params: { slug: nextArticle.id } }"
>
<label>下一篇</label>
<strong>{{ nextArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一篇</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载案例详情...</p></article>
</div>
</section>
</main>
</template>

165
src/views/CasesView.vue Normal file
View File

@@ -0,0 +1,165 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import caseBg from '../assets/cgal.png'
import caseBgActive from '../assets/cgal-active.png'
import { getCases } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const industryTabs = ref([])
const serviceTabs = ref([])
const casesList = ref([])
const activeIndustryId = ref('')
const activeServiceId = ref('')
const pageDescription = usePageDescription('cases')
function isActiveIndustry(id) {
return String(activeIndustryId.value || '') === String(id || '')
}
function isActiveService(id) {
return String(activeServiceId.value || '') === String(id || '')
}
function syncActiveState() {
activeIndustryId.value = String(route.query.industry_category_id || '')
activeServiceId.value = String(route.query.service_category_id || '')
}
async function loadCases() {
const res = await getCases({
industry_category_id: activeIndustryId.value || undefined,
service_category_id: activeServiceId.value || undefined,
})
industryTabs.value = res.categories?.industry || []
serviceTabs.value = res.categories?.service || []
casesList.value = res.list || []
}
async function changeIndustry(id) {
const nextIndustryId = activeIndustryId.value === String(id || '') ? '' : String(id || '')
await router.replace({
path: '/cases',
query: {
...(nextIndustryId ? { industry_category_id: nextIndustryId } : {}),
...(activeServiceId.value ? { service_category_id: activeServiceId.value } : {}),
},
})
}
async function changeService(id) {
const nextServiceId = activeServiceId.value === String(id || '') ? '' : String(id || '')
await router.replace({
path: '/cases',
query: {
...(activeIndustryId.value ? { industry_category_id: activeIndustryId.value } : {}),
...(nextServiceId ? { service_category_id: nextServiceId } : {}),
},
})
}
onMounted(async () => {
syncActiveState()
await loadCases()
})
watch(
() => [route.query.industry_category_id, route.query.service_category_id],
async () => {
syncActiveState()
await loadCases()
},
)
</script>
<template>
<main class="cases-page">
<section class="cases-hero">
<div class="container cases-hero-inner">
<div class="cases-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>成功案例</em>
</div>
<p class="cases-kicker">SUCCESS CASES</p>
<h1>成功案例</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="cases-page-section">
<div class="container">
<div class="tab-row cases-page-tabs">
<button
type="button"
:class="['tab-link tab-button', { active: !activeIndustryId }]"
@click="changeIndustry('')"
>
全部行业
</button>
<button
v-for="tab in industryTabs"
:key="tab.id"
type="button"
:class="['tab-link tab-button', { active: isActiveIndustry(tab.id) }]"
@click="changeIndustry(tab.id)"
>
{{ tab.name }}
</button>
</div>
<div class="tab-row compact cases-page-tabs">
<button
type="button"
:class="['tab-link tab-button', { active: !activeServiceId }]"
@click="changeService('')"
>
全部服务
</button>
<button
v-for="tab in serviceTabs"
:key="tab.id"
type="button"
:class="['tab-link tab-button', { active: isActiveService(tab.id) }]"
@click="changeService(tab.id)"
>
{{ tab.name }}
</button>
</div>
<div v-if="!casesList.length" class="info-card">
<p>暂无成功案例数据</p>
</div>
<div v-else class="cases-page-grid">
<article
v-for="item in casesList"
:key="item.id"
class="case-card case-card-large"
:style="{
backgroundImage: `url(${caseBg})`,
'--case-bg-active': `url(${caseBgActive})`,
}"
>
<p class="case-category">{{ item.serviceCategory?.name || item.industryCategory?.name || '成功案例' }}</p>
<h3>{{ item.client_name }}</h3>
<p class="case-subtitle">{{ item.title || item.industry }}</p>
<p class="case-summary">{{ item.service_content }}</p>
<div class="case-metrics">
<span>{{ item.industry }}</span>
<span v-if="item.serviceCategory?.name">{{ item.serviceCategory.name }}</span>
</div>
<RouterLink
:to="{ name: 'case-detail', params: { slug: item.id } }"
class="ghost-button"
>
了解详情
</RouterLink>
</article>
</div>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,134 @@
<script setup>
import { reactive, ref } from 'vue'
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage, ElOption, ElSelect } from 'element-plus'
import axios from 'axios'
import { usePageDescription } from '../utils/pageSettings'
const consultingOptions = [
'采购数字化规划',
'制度流程梳理',
'平台建设与落地',
'数据治理与分析',
'培训与运营支持',
]
const form = reactive({
company: '',
contact: '',
phone: '',
landline: '',
option: consultingOptions[0],
message: '',
})
const submitting = ref(false)
const pageDescription = usePageDescription('consultingRequest')
async function submitRequest() {
if (!form.company || !form.contact || !form.phone) {
ElMessage.warning('请先完善单位名称、联系人和手机号')
return
}
submitting.value = true
try {
await axios.post(`${import.meta.env.VITE_API_BASE_URL}/consulting/request`, {
company: form.company,
contact: form.contact,
phone: form.phone,
landline: form.landline,
option: form.option,
message: form.message,
source_path: window.location.pathname,
source_title: '咨询需求',
})
ElMessage.success('提交成功')
form.company = ''
form.contact = ''
form.phone = ''
form.landline = ''
form.option = consultingOptions[0]
form.message = ''
} finally {
submitting.value = false
}
}
</script>
<template>
<main class="consulting-page">
<section class="consulting-hero">
<div class="container consulting-hero-inner">
<div class="consulting-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>咨询需求</em>
</div>
<div class="consulting-hero-copy">
<p class="consulting-kicker">CONSULTING REQUEST</p>
<h1>咨询需求</h1>
<p>{{ pageDescription }}</p>
</div>
</div>
</section>
<section class="consulting-section">
<div class="container consulting-request-layout">
<section class="consulting-card form-card">
<div class="form-card-head">
<div>
<h2>提交咨询需求</h2>
<p>{{ pageDescription }}</p>
</div>
<span class="form-badge">1对1响应</span>
</div>
<ElForm class="consulting-form" :model="form" label-position="top">
<div class="consulting-form-grid">
<ElFormItem label="单位名称" class="consulting-form-item">
<ElInput v-model="form.company" placeholder="请输入单位名称" />
</ElFormItem>
<ElFormItem label="联系人" class="consulting-form-item">
<ElInput v-model="form.contact" placeholder="请输入联系人姓名" />
</ElFormItem>
<ElFormItem label="手机号" class="consulting-form-item">
<ElInput v-model="form.phone" placeholder="请输入手机号" />
</ElFormItem>
<ElFormItem label="固定电话" class="consulting-form-item">
<ElInput v-model="form.landline" placeholder="请输入固定电话(选填)" />
</ElFormItem>
<ElFormItem label="咨询方向" class="consulting-form-item">
<ElSelect v-model="form.option" placeholder="请选择咨询方向">
<ElOption
v-for="item in consultingOptions"
:key="item"
:label="item"
:value="item"
/>
</ElSelect>
</ElFormItem>
</div>
<ElFormItem label="需求描述" class="consulting-form-item consulting-textarea-item">
<ElInput
v-model="form.message"
type="textarea"
:rows="6"
placeholder="请简要描述您的业务场景、当前问题或期望达成的目标"
/>
</ElFormItem>
<div class="form-actions">
<ElButton type="danger" class="consulting-submit" size="large" :loading="submitting" @click="submitRequest">提交咨询</ElButton>
<p>提交后将进入后台咨询管理并同步统计</p>
</div>
</ElForm>
</section>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,104 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getServices } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const services = ref([])
const activeServiceId = ref('')
const loading = ref(false)
const pageDescription = usePageDescription('consulting')
const currentService = computed(() => (
services.value.find((item) => String(item.id) === String(activeServiceId.value))
|| services.value[0]
|| null
))
async function loadServicesList() {
loading.value = true
try {
services.value = await getServices({
type: 'consulting',
})
syncActiveService()
} finally {
loading.value = false
}
}
function syncActiveService() {
if (!services.value.length) {
activeServiceId.value = ''
return
}
const matchedService = services.value.find((item) => String(item.id) === String(activeServiceId.value))
activeServiceId.value = matchedService?.id || services.value[0]?.id || ''
}
function isActiveService(id) {
return String(activeServiceId.value || '') === String(id || '')
}
function changeService(id) {
activeServiceId.value = String(id || '')
}
onMounted(async () => {
await loadServicesList()
})
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>数智咨询</em>
</div>
<p class="info-kicker">BUSINESS CONSULTING</p>
<h1>数智咨询</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>咨询列表</h2>
<div v-if="services.length" class="about-menu-list">
<button
v-for="item in services"
:key="item.id"
type="button"
:class="['about-menu-button', { active: isActiveService(item.id) }]"
@click="changeService(item.id)"
>
{{ item.title || item.name }}
</button>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载数智咨询数据...' : '暂无数智咨询数据' }}</p>
</div>
</aside>
<article v-if="currentService" class="info-card about-gallery-card">
<div class="about-gallery-head product-list-head">
<div>
<h2>{{ currentService.title }}</h2>
<p v-if="currentService.name">{{ currentService.name }}</p>
</div>
</div>
<div v-if="currentService.cover" class="info-cover-image">
<img :src="currentService.cover" :alt="currentService.name" />
</div>
<div class="rich-content" v-html="currentService.content || '<p>暂无详细内容</p>'"></div>
</article>
</section>
</div>
</section>
</main>
</template>

65
src/views/ContactView.vue Normal file
View File

@@ -0,0 +1,65 @@
<script setup>
import { computed } from 'vue'
import { useContactInfo } from '../utils/siteConfig'
const contactInfo = useContactInfo()
const baiduMapUrl = computed(() => `https://map.baidu.com/search/${encodeURIComponent(contactInfo.value.address)}`)
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>联系我们</em>
</div>
<p class="info-kicker">CONTACT US</p>
<h1>联系我们</h1>
<p>
如您希望了解产品方案咨询服务或合作方式欢迎通过电话邮件或现场沟通与我们联系我们将尽快安排专人与您对接
</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout two-col">
<article class="info-card">
<h2>联系信息</h2>
<dl class="contact-detail-list">
<div>
<dt>联系电话</dt>
<dd>{{ contactInfo.phone }}</dd>
</div>
<div>
<dt>官方邮箱</dt>
<dd>{{ contactInfo.email }}</dd>
</div>
<div>
<dt>公司地址</dt>
<dd>
<a class="map-link" :href="baiduMapUrl" target="_blank" rel="noreferrer">
{{ contactInfo.address }}
</a>
</dd>
</div>
<div>
<dt>服务时间</dt>
<dd>工作日 09:00 - 18:00</dd>
</div>
</dl>
</article>
<article class="info-card">
<h2>沟通建议</h2>
<ul class="contact-tips">
<li>如需业务咨询可优先通过业务咨询页面填写需求</li>
<li>如涉及项目合作或平台建设建议提前准备单位背景与核心诉求</li>
<li>如需线下交流可提前电话预约我们将安排对应顾问接待</li>
</ul>
</article>
</div>
</section>
</main>
</template>

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

@@ -0,0 +1,520 @@
<script setup>
import {computed, onBeforeUnmount, onMounted, ref, useTemplateRef} from 'vue'
import {RouterLink, useRouter} from 'vue-router'
import businessBg from '../assets/hxywjz.png'
import advantageIcon0 from '../assets/qyys_0.png'
import advantageIcon1 from '../assets/qyys_1.png'
import advantageIcon2 from '../assets/qyys_2.png'
import advantageIcon3 from '../assets/qyys_3.png'
import caseBg from '../assets/cgal.png'
import caseBgActive from '../assets/cgal-active.png'
import emptyContentImage from '../assets/暂无内容.png'
import {getArticleCategories, getArticles, getBanners, getSiteHome, getServices} from '../utils/api'
const defaultAdvantages = [
{
icon: advantageIcon0,
title: '官方独家合作',
description:
'广西唯一官方合作伙伴,全权负责广西全区 134 个区划平台运营、推广与培训。',
},
{
icon: advantageIcon1,
title: '数智产品矩阵完备',
description:
'自主研发汇易采、AI 智能体、调研 / 代理 / 履约验收等 SAAS 平台,覆盖采购全流程,实现链路闭环。',
},
{
icon: advantageIcon2,
title: '资质与技术实力雄厚',
description:
'持有七证一牌、多项软著、管理体系认证与科技成果,团队持高级工程师等证书,技术与合规双保障。',
},
{
icon: advantageIcon3,
title: '本土服务与案例领先',
description:
'落地高校、医院、福彩等标杆案例7×24 小时本地化服务,经验成熟。',
},
]
const defaultAdvantageStats = [
{name: '软件证书', data: '26', unit: '项'},
{name: '服务单位', data: '2', unit: '万'},
{name: '供应商', data: '19', unit: '万'},
]
const homeData = ref({cases: [], articles: [], advantages: [], advantage_stats: []})
const articleCategories = ref([])
const activeNewsCategoryId = ref('')
const newsList = ref([])
const banners = ref([])
const businessSlides = ref([])
const heroCarouselRef = useTemplateRef('heroCarouselRef')
const businessCarouselRef = useTemplateRef('businessCarouselRef')
const newsCarouselRef = useTemplateRef('newsCarouselRef')
const loading = ref(false)
const newsLoading = ref(false)
const router = useRouter()
const isMobile = ref(false)
const activeHeroIndex = ref(0)
let cleanupViewportListener = null
const cases = computed(() => homeData.value.cases.slice(0, 4))
const heroSlides = computed(() => banners.value.length ? banners.value : [])
const businessItems = computed(() => businessSlides.value.length ? businessSlides.value : [])
const advantages = computed(() => {
const list = homeData.value.advantages || []
if (!list.length) {
return defaultAdvantages
}
return list.map((item, index) => ({
icon: item?.cover || defaultAdvantages[index % defaultAdvantages.length]?.icon || advantageIcon0,
title: item?.title || item?.name || defaultAdvantages[index % defaultAdvantages.length]?.title || '企业优势',
description: stripHtml(item?.content || '') || defaultAdvantages[index % defaultAdvantages.length]?.description || '',
}))
})
const advantageStats = computed(() => {
const list = homeData.value.advantage_stats || []
if (!list.length) {
return defaultAdvantageStats
}
return list.map((item, index) => ({
name: item?.name || defaultAdvantageStats[index % defaultAdvantageStats.length]?.name || '',
data: item?.data || defaultAdvantageStats[index % defaultAdvantageStats.length]?.data || '',
unit: item?.unit || defaultAdvantageStats[index % defaultAdvantageStats.length]?.unit || '',
}))
})
const newsGroups = computed(() => {
const size = isMobile.value ? 1 : 3
const groups = []
for (let i = 0; i < newsList.value.length; i += size) {
groups.push(newsList.value.slice(i, i + size))
}
return groups
})
const caseTabs = computed(() => {
const names = Array.from(
new Set(
homeData.value.cases
.map((item) => item.serviceCategory?.name || item.industryCategory?.name || '')
.filter(Boolean),
),
)
return names.length ? names : ['成功案例']
})
const newsTabs = computed(() => {
return articleCategories.value.length
? articleCategories.value
: [
{id: 'company-news', name: '公司新闻'},
{id: 'industry-news', name: '行业资讯'},
]
})
async function loadNewsCategories() {
const categories = await getArticleCategories()
articleCategories.value = categories || []
activeNewsCategoryId.value = articleCategories.value[0]?.id || ''
}
async function loadNewsArticles(categoryId = '') {
newsLoading.value = true
try {
newsList.value = await getArticles(categoryId ? {category_id: categoryId} : {})
} finally {
newsLoading.value = false
}
}
async function changeNewsCategory(id) {
if (id === activeNewsCategoryId.value) return
activeNewsCategoryId.value = id
await loadNewsArticles(id)
}
function openHero(item) {
if (!item?.target_path) return
if (item.target_path.startsWith('/')) {
router.push(item.target_path)
return
}
window.location.href = item.target_path
}
function heroTarget(item) {
return item?.target_path || '#'
}
function heroImage(item) {
return item?.image || businessBg
}
function heroMobileImage(item) {
return item?.mobile_image || item?.image || businessBg
}
function heroIndicatorTitle(item, index) {
return item?.title || `轮播图${index + 1}`
}
function switchHeroSlide(index) {
activeHeroIndex.value = index
heroCarouselRef.value?.setActiveItem(index)
}
function handleHeroChange(index) {
activeHeroIndex.value = index
}
function businessTitle(item) {
return item?.title || item?.name || '核心业务矩阵'
}
function businessCover(item) {
return item?.cover || businessBg
}
function businessMobileCover(item) {
return item?.mobile_cover || item?.cover || businessBg
}
function businessCardStyle(item) {
return {
backgroundImage: `url(${businessCover(item)})`,
}
}
function businessContent(item) {
return item?.content || '<p>暂无内容</p>'
}
function businessTags(item) {
return String(item?.tags || '')
.split(/[,、\n]/)
.map((tag) => tag.trim())
.filter(Boolean)
}
function stripHtml(text) {
return String(text || '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function prevBusinessSlide() {
businessCarouselRef.value?.prev()
}
function nextBusinessSlide() {
businessCarouselRef.value?.next()
}
function prevNewsSlide() {
newsCarouselRef.value?.prev()
}
function nextNewsSlide() {
newsCarouselRef.value?.next()
}
function newsCover(item) {
return item?.cover || item?.news_img || item?.image || emptyContentImage
}
function hasNewsItems() {
return newsList.value.length > 0
}
onMounted(async () => {
const syncViewport = () => {
isMobile.value = window.innerWidth <= 768
}
syncViewport()
window.addEventListener('resize', syncViewport)
cleanupViewportListener = () => window.removeEventListener('resize', syncViewport)
loading.value = true
try {
const [home, bannerList, serviceList] = await Promise.all([
getSiteHome(),
getBanners(),
getServices({type: 'business_matrix'}),
])
homeData.value = home || {cases: [], articles: [], advantages: [], advantage_stats: []}
banners.value = bannerList || []
businessSlides.value = serviceList || []
await loadNewsCategories()
await loadNewsArticles(activeNewsCategoryId.value)
} finally {
loading.value = false
}
})
onBeforeUnmount(() => {
cleanupViewportListener?.()
})
</script>
<template>
<main>
<section class="hero-section">
<div v-if="heroSlides.length" class="hero-media">
<el-carousel
ref="heroCarouselRef"
height="760px"
:autoplay="false"
trigger="click"
indicator-position="none"
arrow="never"
@change="handleHeroChange"
>
<el-carousel-item v-for="item in heroSlides" :key="item.id">
<a class="hero-slide" :href="heroTarget(item)" @click.prevent="openHero(item)">
<picture>
<source :srcset="heroMobileImage(item)" media="(max-width: 768px)">
<img :src="heroImage(item)" :alt="item.title || 'banner'"/>
</picture>
</a>
</el-carousel-item>
</el-carousel>
<div v-if="heroSlides.length > 1" class="hero-indicators" aria-label="首页轮播图切换">
<button
v-for="(item, index) in heroSlides"
:key="item.id || index"
type="button"
:class="['hero-indicator', { active: activeHeroIndex === index }]"
@click="switchHeroSlide(index)"
>
{{ heroIndicatorTitle(item, index) }}
</button>
</div>
</div>
<div v-else class="hero-media hero-media-fallback" :style="{ backgroundImage: `url(${businessBg})` }"></div>
</section>
<section class="section section-gray advantages-section">
<div class="container">
<div class="section-heading center">
<h2 style="color: var(--accent)">企业优势</h2>
<p>CORPORATE ADVANTAGES</p>
</div>
<div class="advantages-layout">
<div class="advantages-intro">
<p class="section-kicker">CORPORATE ADVANTAGES</p>
<h3>汇采聚云</h3>
<p class="intro-text">
汇采聚云是围绕政府采购全过程咨询提供专业数智化服务的科技型企业,专注于政府采购领域的系统开发平台推广和运营数智化采购咨询等服务
</p>
<div class="stats-row">
<div v-for="item in advantageStats" :key="item.name" class="stat-item">
<strong>{{ item.data }}</strong>
<span>{{ item.unit }}</span>
<p>{{ item.name }}</p>
</div>
</div>
<a class="text-link" href="/about" @click.prevent="router.push('/about')">详细了解 &gt;&gt;</a>
</div>
<div class="advantages-grid">
<article
v-for="item in advantages"
:key="item.title"
class="adv-card"
>
<div class="adv-icon-wrap">
<img class="adv-icon" :src="item.icon" :alt="item.title"/>
</div>
<h4>{{ item.title }}</h4>
<el-tooltip
:content="item.description"
placement="top"
effect="light"
popper-class="adv-card-tooltip"
>
<p class="adv-card-desc">{{ item.description }}</p>
</el-tooltip>
</article>
</div>
</div>
</div>
</section>
<section class="section business-section">
<div class="container business-container">
<div class="section-heading center business-heading">
<h2 style="color: var(--accent)">核心业务矩阵</h2>
<p>BUSINESS MATRIX</p>
</div>
<div v-if="businessItems.length" class="business-carousel">
<button class="arrow-button business-arrow left" type="button" aria-label="上一项" @click="prevBusinessSlide">
<span></span>
</button>
<el-carousel
ref="businessCarouselRef"
:height="isMobile ? '420px' : '630px'"
:interval="5000"
:indicator-position="isMobile ? 'outside' : 'none'"
arrow="never"
:autoplay="isMobile"
>
<el-carousel-item
v-for="item in businessItems"
:key="item.id"
class="business-carousel-item"
style="border-radius: 15px; border: 1px solid rgba(255,0,0,0.29)"
>
<article
class="business-card business-card-bg"
:style="businessCardStyle(item)"
>
<picture class="business-card-media">
<source :srcset="businessMobileCover(item)" media="(max-width: 768px)">
<img :src="businessCover(item)" :alt="businessTitle(item)">
</picture>
<div class="business-card-mask"></div>
<div class="business-card-content">
<div class="business-card-text rich-content" v-html="businessContent(item)"></div>
<div v-if="businessTags(item).length" class="business-tag-list">
<span v-for="tag in businessTags(item)" :key="tag" class="business-tag">{{ tag }}</span>
</div>
</div>
</article>
</el-carousel-item>
</el-carousel>
<button class="arrow-button business-arrow right" type="button" aria-label="下一项"
@click="nextBusinessSlide">
<span></span>
</button>
</div>
</div>
</section>
<section class="section section-gray cases-section">
<div class="container">
<div class="section-heading center">
<h2 style="color: var(--accent)">成功案例</h2>
<p>SUCCESS CASES</p>
</div>
<div class="tab-row">
<a v-for="tab in caseTabs" :key="tab" href="#" class="tab-link">
{{ tab }}
</a>
</div>
<div v-if="loading && !cases.length" class="info-card">
<p>正在加载成功案例...</p>
</div>
<div v-else class="cases-grid">
<article
v-for="item in cases"
:key="item.id"
class="case-card"
:style="{
backgroundImage: `url(${caseBg})`,
'--case-bg-active': `url(${caseBgActive})`,
}"
>
<p class="case-category">{{ item.serviceCategory?.name || item.industryCategory?.name || '成功案例' }}</p>
<h3>{{ item.client_name }}</h3>
<p class="case-subtitle">{{ item.title || item.industry }}</p>
<RouterLink
:to="{ name: 'case-detail', params: { slug: item.id } }"
class="ghost-button"
>
了解详情
</RouterLink>
</article>
</div>
<div class="center-action">
<RouterLink class="outline-button more-button" :to="{ name: 'cases' }">更多案例</RouterLink>
</div>
</div>
</section>
<section class="section news-section">
<div class="container">
<div class="section-heading center">
<h2 style="color: var(--accent)">新闻动态</h2>
<p>NEWS &amp; UPDATES</p>
</div>
<div class="tab-row compact">
<button
v-for="tab in newsTabs"
:key="tab.id"
type="button"
:class="['tab-link tab-button', { active: tab.id === activeNewsCategoryId }]"
@click="changeNewsCategory(tab.id)"
>
{{ tab.name }}
</button>
</div>
<div class="news-carousel-wrap">
<button class="arrow-button muted news-arrow left" type="button" aria-label="上一条" @click="prevNewsSlide">
<span></span>
</button>
<div class="news-carousel">
<div v-if="(loading || newsLoading) && !newsList.length" class="info-card">
<p>正在加载新闻动态...</p>
</div>
<div v-else-if="!hasNewsItems()" class="news-empty">
<img :src="emptyContentImage" alt="暂无内容" />
<p>暂无新闻内容</p>
</div>
<el-carousel
v-else
ref="newsCarouselRef"
class="news-slider"
:height="isMobile ? '400px' : '430px'"
arrow="never"
:indicator-position="isMobile ? 'outside' : 'none'"
:autoplay="isMobile"
trigger="click"
:loop="newsGroups.length > 1"
>
<el-carousel-item v-for="(group, index) in newsGroups" :key="index">
<div class="news-grid">
<RouterLink
v-for="item in group"
:key="item.id"
class="news-card"
:to="{ name: 'news-detail', params: { slug: item.id } }"
>
<div class="news-image">
<img :src="newsCover(item)" :alt="item.title"/>
</div>
<h3>{{ item.title }}</h3>
<time>{{ item.published_at || item.created_at }}</time>
</RouterLink>
</div>
</el-carousel-item>
</el-carousel>
</div>
<button class="arrow-button muted news-arrow right" type="button" aria-label="下一条" @click="nextNewsSlide">
<span></span>
</button>
</div>
<div class="center-action news-more-action">
<RouterLink class="outline-button more-button" :to="{ name: 'news' }">更多新闻</RouterLink>
</div>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getArticle, getArticles } from '../utils/api'
const route = useRoute()
const article = ref(null)
const newsList = ref([])
const articleIndex = computed(() => {
return newsList.value.findIndex((item) => String(item.id) === String(article.value?.id))
})
const prevArticle = computed(() => {
const index = articleIndex.value
return index > 0 ? newsList.value[index - 1] : null
})
const nextArticle = computed(() => {
const index = articleIndex.value
return index >= 0 && index < newsList.value.length - 1 ? newsList.value[index + 1] : null
})
async function loadNewsDetail() {
article.value = await getArticle(route.params.slug)
newsList.value = await getArticles(article.value?.category_id ? { category_id: article.value.category_id } : {})
}
watch(
() => route.params.slug,
async () => {
await loadNewsDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="article" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="{ name: 'news' }">新闻动态</RouterLink>
<span>/</span>
<em>{{ article.category?.name || '新闻详情' }}</em>
</div>
<!-- <p class="article-category">{{ article.category?.name || '新闻动态' }}</p>-->
<h1>{{ article.title }}</h1>
<div class="article-meta">
<span>{{ article.author || '汇采聚云' }}</span>
<span>{{ article.published_at || article.created_at }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div class="article-cover">
<img :src="article.cover || article.news_img || article.image" :alt="article.title" />
</div>
<div class="article-summary">
<p>{{ article.summary || '暂无摘要' }}</p>
</div>
<div class="article-content rich-content detail-rich-content" v-html="article.content || '<p>暂无内容</p>'">
</div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="{ name: 'news' }">返回新闻列表</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关新闻</h3>
<RouterLink
v-for="item in newsList"
:key="item.id"
:to="{ name: 'news-detail', params: { slug: item.id } }"
:class="['related-news-item', { active: item.id === article.id }]"
>
<img :src="item.cover || item.news_img || item.image" :alt="item.title" />
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.published_at || item.created_at }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevArticle"
class="article-pager-link"
:to="{ name: 'news-detail', params: { slug: prevArticle.id } }"
>
<label>上一篇</label>
<strong>{{ prevArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一篇</strong>
</div>
<RouterLink
v-if="nextArticle"
class="article-pager-link"
:to="{ name: 'news-detail', params: { slug: nextArticle.id } }"
>
<label>下一篇</label>
<strong>{{ nextArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一篇</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载新闻详情...</p></article>
</div>
</section>
</main>
</template>

119
src/views/NewsView.vue Normal file
View File

@@ -0,0 +1,119 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import emptyContentImage from '../assets/暂无内容.png'
import { getArticleCategories, getArticles } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const tabs = ref([])
const newsList = ref([])
const activeCategoryId = ref('')
const pageDescription = usePageDescription('news')
async function loadCategories() {
const res = await getArticleCategories()
tabs.value = res || []
}
function syncActiveCategory() {
const queryCategoryId = String(route.query.category_id || '')
const matchedTab = tabs.value.find((item) => String(item.id) === queryCategoryId)
activeCategoryId.value = matchedTab?.id || tabs.value[0]?.id || ''
}
async function loadArticles(categoryId = '') {
newsList.value = await getArticles(categoryId ? { category_id: categoryId } : {})
}
async function changeCategory(id) {
await router.replace({
path: '/news',
query: {
category_id: id,
},
})
}
function newsCover(item) {
return item?.cover || item?.news_img || item?.image || emptyContentImage
}
onMounted(async () => {
await loadCategories()
syncActiveCategory()
await loadArticles(activeCategoryId.value)
})
watch(
() => route.query.category_id,
async () => {
if (!tabs.value.length) {
await loadCategories()
}
syncActiveCategory()
await loadArticles(activeCategoryId.value)
},
)
</script>
<template>
<main class="news-page">
<section class="news-page-hero">
<div class="container news-page-hero-inner">
<div class="news-page-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>新闻动态</em>
</div>
<p class="news-page-kicker">NEWS &amp; UPDATES</p>
<h1>新闻动态</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="news-page-section">
<div class="container">
<div class="tab-row compact news-page-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="['tab-link tab-button', { active: tab.id === activeCategoryId }]"
@click="changeCategory(tab.id)"
>
{{ tab.name }}
</button>
</div>
<div v-if="!newsList.length" class="news-page-empty">
<img :src="emptyContentImage" alt="暂无内容" />
<p>暂无新闻内容</p>
</div>
<div v-else class="news-page-grid">
<RouterLink
v-for="item in newsList"
:key="item.id"
class="news-page-card"
:to="{ name: 'news-detail', params: { slug: item.id } }"
>
<div class="news-page-image">
<img :src="newsCover(item)" :alt="item.title" />
</div>
<div class="news-page-body">
<p class="news-page-category">{{ item.category?.name || item.category_name || '新闻动态' }}</p>
<h3>{{ item.title }}</h3>
<p class="news-page-summary">{{ item.summary || '暂无摘要' }}</p>
<div class="news-page-meta">
<span>{{ item.author || '汇采聚云' }}</span>
<time>{{ item.published_at || item.created_at }}</time>
</div>
</div>
</RouterLink>
</div>
</div>
</section>
</main>
</template>

43
src/views/NoticeView.vue Normal file
View File

@@ -0,0 +1,43 @@
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>公告声明</em>
</div>
<p class="info-kicker">NOTICE</p>
<h1>公告声明</h1>
<p>
本页面用于展示汇采聚云官方网站的基础声明信息包括内容说明服务边界与信息更新说明等请在使用本站服务前仔细阅读
</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<article class="info-card">
<h2>内容说明</h2>
<p>
本网站展示的产品信息服务介绍案例内容新闻动态及相关说明旨在为用户了解公司业务范围与服务能力提供参考部分页面为演示内容具体服务信息以双方正式沟通和确认结果为准
</p>
</article>
<article class="info-card">
<h2>服务边界</h2>
<p>
网站中涉及的产品功能咨询服务培训支持及运营推广方案可能因不同单位的业务场景制度要求与实施范围有所调整最终交付内容合作模式与实施路径应以正式方案合同及项目约定为准
</p>
</article>
<article class="info-card">
<h2>信息更新说明</h2>
<p>
我们会持续对网站内容进行维护和更新但不保证所有展示信息在任意时点均保持完全同步如您对具体内容存在疑问欢迎通过联系我们业务咨询页面与我们取得联系
</p>
</article>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,162 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getPartyCulture, getPartyCultures } from '../utils/api'
const route = useRoute()
const culture = ref(null)
const relatedList = ref([])
const cultureIndex = computed(() => {
return relatedList.value.findIndex((item) => String(item.id) === String(culture.value?.id))
})
const prevCulture = computed(() => {
const index = cultureIndex.value
return index > 0 ? relatedList.value[index - 1] : null
})
const nextCulture = computed(() => {
const index = cultureIndex.value
return index >= 0 && index < relatedList.value.length - 1 ? relatedList.value[index + 1] : null
})
const listRoute = computed(() => ({
name: 'party-culture',
query: {
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
...(route.query.page ? { page: route.query.page } : {}),
},
}))
function detailRoute(item) {
const id = item?.id || item
const categoryId = item?.category_id || route.query.category_id
return {
name: 'party-culture-detail',
params: { slug: id },
query: {
...(categoryId ? { category_id: categoryId } : {}),
...(route.query.page ? { page: route.query.page } : {}),
},
}
}
function normalizeList(data) {
if (Array.isArray(data)) return data
return data?.list || []
}
async function loadDetail() {
culture.value = await getPartyCulture(route.params.slug)
const categoryId = culture.value?.category_id || route.query.category_id
const data = await getPartyCultures(categoryId ? { category_id: categoryId } : {}).catch(() => [])
relatedList.value = normalizeList(data)
}
watch(
() => [route.params.slug, route.query.category_id],
async () => {
await loadDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="culture" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="listRoute">党建文化</RouterLink>
<span>/</span>
<em>内容详情</em>
</div>
<p class="article-category">{{ culture.category?.name || '党建文化' }}</p>
<h1>{{ culture.title || culture.name }}</h1>
<div class="article-meta">
<span>{{ culture.name }}</span>
<span>{{ culture.created_at || '' }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div v-if="culture.cover" class="article-cover">
<img :src="culture.cover" :alt="culture.name" />
</div>
<div class="article-summary">
<p>{{ culture.name }}</p>
</div>
<div class="article-content rich-content detail-rich-content" v-html="culture.content || '<p>暂无内容</p>'"></div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="listRoute">返回党建文化</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关内容</h3>
<RouterLink
v-for="item in relatedList"
:key="item.id"
:to="detailRoute(item)"
:class="['related-news-item', { active: item.id === culture.id }]"
>
<img v-if="item.cover" :src="item.cover" :alt="item.name" />
<div v-else class="related-placeholder"></div>
<div>
<strong>{{ item.title || item.name }}</strong>
<span>{{ item.category?.name || '党建文化' }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevCulture"
class="article-pager-link"
:to="detailRoute(prevCulture)"
>
<label>上一篇</label>
<strong>{{ prevCulture.title || prevCulture.name }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一条</strong>
</div>
<RouterLink
v-if="nextCulture"
class="article-pager-link"
:to="detailRoute(nextCulture)"
>
<label>下一篇</label>
<strong>{{ nextCulture.title || nextCulture.name }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一条</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载党建文化详情...</p></article>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,259 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getPartyCultureCategories, getPartyCultures } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const PAGE_SIZE = 5
const rootCategories = ref([])
const cultureList = ref([])
const activeRootCategoryId = ref('')
const activeCategoryId = ref('')
const currentPage = ref(1)
const total = ref(0)
const totalPages = ref(0)
const loading = ref(false)
const pageDescription = usePageDescription('partyCulture')
const activeRootCategory = computed(() => (
rootCategories.value.find((item) => String(item.id) === String(activeRootCategoryId.value))
|| rootCategories.value[0]
|| null
))
const secondCategories = computed(() => {
const current = activeRootCategory.value
if (!current) return []
if (current.children?.length) return current.children
return [current]
})
const activeLeafCategory = computed(() => (
secondCategories.value.find((item) => String(item.id) === String(activeLeafCategoryId.value))
|| secondCategories.value[0]
|| null
))
const activeLeafCategoryId = computed(() => {
const currentRoot = activeRootCategory.value
if (!currentRoot) return ''
if (currentRoot.children?.length) {
return activeCategoryId.value || currentRoot.children[0]?.id || ''
}
return currentRoot.id
})
const pageNumbers = computed(() => {
if (!totalPages.value) return []
const pages = new Set([1, totalPages.value, currentPage.value - 1, currentPage.value, currentPage.value + 1])
return [...pages]
.filter((page) => page >= 1 && page <= totalPages.value)
.sort((a, b) => a - b)
})
function isActiveLeaf(id) {
return String(activeLeafCategoryId.value || '') === String(id || '')
}
async function loadCategories() {
rootCategories.value = await getPartyCultureCategories({ status: 1 }).catch(() => [])
}
function syncActiveState() {
const currentId = String(route.query.category_id || '')
const page = Number(route.query.page || 1)
currentPage.value = Number.isFinite(page) && page > 0 ? page : 1
if (!rootCategories.value.length) {
activeRootCategoryId.value = ''
activeCategoryId.value = ''
return
}
const matchedRoot = rootCategories.value.find((root) => (
String(root.id) === currentId
|| (root.children || []).some((child) => String(child.id) === currentId)
)) || rootCategories.value[0]
activeRootCategoryId.value = matchedRoot?.id || ''
if (!matchedRoot?.children?.length) {
activeCategoryId.value = matchedRoot?.id || ''
return
}
const matchedLeaf = matchedRoot.children.find((child) => String(child.id) === currentId)
activeCategoryId.value = matchedLeaf?.id || matchedRoot.children[0]?.id || ''
}
async function loadCultures() {
loading.value = true
try {
const categoryId = activeLeafCategoryId.value || activeRootCategoryId.value || ''
const res = await getPartyCultures({
...(categoryId ? { category_id: categoryId } : {}),
page: currentPage.value,
per_page: PAGE_SIZE,
})
cultureList.value = res?.list || []
total.value = Number(res?.pagination?.total || 0)
totalPages.value = Number(res?.pagination?.total_pages || 0)
} finally {
loading.value = false
}
}
async function updateRoute(categoryId, page = 1) {
await router.replace({
path: '/party-culture',
query: {
category_id: categoryId,
...(page > 1 ? { page } : {}),
},
})
}
async function changeLeafCategory(id) {
await updateRoute(id, 1)
}
async function changePage(page) {
if (page < 1 || page > totalPages.value || page === currentPage.value) return
await updateRoute(activeLeafCategoryId.value || activeRootCategoryId.value || '', page)
}
function detailRoute(item) {
return {
name: 'party-culture-detail',
params: { slug: item.id },
query: {
...(item.category_id ? { category_id: item.category_id } : {}),
...(currentPage.value > 1 ? { page: currentPage.value } : {}),
},
}
}
onMounted(async () => {
await loadCategories()
syncActiveState()
await loadCultures()
})
watch(
() => [route.query.category_id, route.query.page],
async () => {
if (!rootCategories.value.length) {
await loadCategories()
}
syncActiveState()
await loadCultures()
},
)
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>党建文化</em>
</div>
<p class="info-kicker">PARTY BUILDING</p>
<h1>党建文化</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>分类导航</h2>
<div v-if="secondCategories.length" class="about-menu-list">
<button
v-for="item in secondCategories"
:key="item.id"
type="button"
:class="['about-menu-button', { active: isActiveLeaf(item.id) }]"
@click="changeLeafCategory(item.id)"
>
{{ item.name }}
</button>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载分类...' : '暂无党建文化分类' }}</p>
</div>
</aside>
<article class="info-card about-gallery-card">
<div class="about-gallery-head product-list-head">
<div>
<h2>{{ activeLeafCategory?.name || activeRootCategory?.name || '党建文化' }}</h2>
<p v-if="total"> {{ total }} 条内容</p>
</div>
</div>
<div v-if="cultureList.length" class="product-list-grid">
<RouterLink
v-for="item in cultureList"
:key="item.id"
:to="detailRoute(item)"
class="product-list-card"
>
<div v-if="item.cover" class="product-list-cover">
<img :src="item.cover" :alt="item.name" />
</div>
<div class="product-list-body">
<p class="product-list-category">{{ item.category?.name || activeLeafCategory?.name || '党建文化' }}</p>
<h3>{{ item.title || item.name }}</h3>
<p class="product-list-summary">{{ item.name || '查看党建文化详情' }}</p>
<span class="product-list-link">查看详情</span>
</div>
</RouterLink>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载党建文化数据...' : '暂无党建文化数据' }}</p>
</div>
<div v-if="totalPages > 1" class="product-pagination">
<button
type="button"
class="product-page-button"
:disabled="currentPage <= 1"
@click="changePage(currentPage - 1)"
>
上一页
</button>
<button
v-for="page in pageNumbers"
:key="page"
type="button"
:class="['product-page-button', { active: page === currentPage }]"
@click="changePage(page)"
>
{{ page }}
</button>
<button
type="button"
class="product-page-button"
:disabled="currentPage >= totalPages"
@click="changePage(currentPage + 1)"
>
下一页
</button>
</div>
</article>
</section>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,181 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getProduct, getProductCategories, getProducts } from '../utils/api'
const route = useRoute()
const ROOT_CATEGORY_ID = '1'
const product = ref(null)
const relatedList = ref([])
const pagerList = ref([])
const productIndex = computed(() => {
return pagerList.value.findIndex((item) => String(item.id) === String(product.value?.id))
})
const prevProduct = computed(() => {
const index = productIndex.value
return index > 0 ? pagerList.value[index - 1] : null
})
const nextProduct = computed(() => {
const index = productIndex.value
return index >= 0 && index < pagerList.value.length - 1 ? pagerList.value[index + 1] : null
})
const listRoute = computed(() => ({
name: 'products',
query: {
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
...(route.query.page ? { page: route.query.page } : {}),
type: 'product',
},
}))
function detailRoute(item) {
const id = item?.id || item
const categoryId = item?.category_id || route.query.category_id
return {
name: 'product-detail',
params: { slug: id },
query: {
...(categoryId ? { category_id: categoryId } : {}),
...(route.query.page ? { page: route.query.page } : {}),
type: 'product',
},
}
}
function normalizeProductList(data) {
if (Array.isArray(data)) return data
return data?.list || []
}
function getProductCenterCategoryIds(categories) {
const root = (categories || []).find((item) => String(item.id) === ROOT_CATEGORY_ID)
if (!root) return []
return root.children?.length ? root.children.map((item) => String(item.id)) : [String(root.id)]
}
async function loadProductDetail() {
product.value = await getProduct(route.params.slug)
const categoryId = product.value?.category_id || route.query.category_id
const [relatedProducts, allProducts, categories] = await Promise.all([
getProducts(categoryId ? { category_id: categoryId, type: 'product' } : { type: 'product' }).catch(() => []),
getProducts({ type: 'product' }).catch(() => []),
getProductCategories({ status: 1 }).catch(() => []),
])
const productCenterCategoryIds = getProductCenterCategoryIds(categories)
relatedList.value = normalizeProductList(relatedProducts)
pagerList.value = normalizeProductList(allProducts).filter((item) => {
return !productCenterCategoryIds.length || productCenterCategoryIds.includes(String(item.category_id))
})
}
watch(
() => route.params.slug,
async () => {
await loadProductDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="product" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="listRoute">产品中心</RouterLink>
<span>/</span>
<em>产品详情</em>
</div>
<!-- <p class="article-category">{{ product.category?.name || '产品中心' }}</p>-->
<h1>{{ product.title || product.name }}</h1>
<div class="article-meta">
<span>{{ product.name }}</span>
<span>{{ product.category?.parent?.name || product.category?.name || '' }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div v-if="product.cover" class="article-cover">
<img :src="product.cover" :alt="product.name" />
</div>
<div class="article-summary">
<p>{{ product.name }}</p>
</div>
<div class="article-content rich-content detail-rich-content" v-html="product.content || '<p>暂无内容</p>'"></div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="listRoute">返回产品列表</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关产品</h3>
<RouterLink
v-for="item in relatedList"
:key="item.id"
:to="detailRoute(item)"
:class="['related-news-item', { active: item.id === product.id }]"
>
<img v-if="item.cover" :src="item.cover" :alt="item.name" />
<div v-else class="related-placeholder"></div>
<div>
<strong>{{ item.title || item.name }}</strong>
<span>{{ item.category?.name || '产品中心' }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevProduct"
class="article-pager-link"
:to="detailRoute(prevProduct)"
>
<label>上一篇</label>
<strong>{{ prevProduct.title || prevProduct.name }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一条</strong>
</div>
<RouterLink
v-if="nextProduct"
class="article-pager-link"
:to="detailRoute(nextProduct)"
>
<label>下一篇</label>
<strong>{{ nextProduct.title || nextProduct.name }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一条</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载产品详情...</p></article>
</div>
</section>
</main>
</template>

263
src/views/ProductsView.vue Normal file
View File

@@ -0,0 +1,263 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getProductCategories, getProducts } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const ROOT_CATEGORY_ID = '1'
const PAGE_SIZE = 5
const rootCategories = ref([])
const productList = ref([])
const activeRootCategoryId = ref('')
const activeCategoryId = ref('')
const currentPage = ref(1)
const total = ref(0)
const totalPages = ref(0)
const loading = ref(false)
const pageDescription = usePageDescription('products')
const activeRootCategory = computed(() => (
rootCategories.value.find((item) => String(item.id) === String(activeRootCategoryId.value))
|| rootCategories.value[0]
|| null
))
const secondCategories = computed(() => {
const current = activeRootCategory.value
if (!current) return []
if (current.children?.length) return current.children
return [current]
})
const activeLeafCategory = computed(() => (
secondCategories.value.find((item) => String(item.id) === String(activeLeafCategoryId.value))
|| secondCategories.value[0]
|| null
))
const activeLeafCategoryId = computed(() => {
const currentRoot = activeRootCategory.value
if (!currentRoot) return ''
if (currentRoot.children?.length) {
return activeCategoryId.value || currentRoot.children[0]?.id || ''
}
return currentRoot.id
})
const pageNumbers = computed(() => {
if (!totalPages.value) return []
const pages = new Set([1, totalPages.value, currentPage.value - 1, currentPage.value, currentPage.value + 1])
return [...pages]
.filter((page) => page >= 1 && page <= totalPages.value)
.sort((a, b) => a - b)
})
function isActiveLeaf(id) {
return String(activeLeafCategoryId.value || '') === String(id || '')
}
async function loadCategories() {
const categories = await getProductCategories({ status: 1 }).catch(() => [])
rootCategories.value = (categories || []).filter((item) => String(item.id) === ROOT_CATEGORY_ID)
}
function syncActiveState() {
const currentId = String(route.query.category_id || '')
const page = Number(route.query.page || 1)
currentPage.value = Number.isFinite(page) && page > 0 ? page : 1
if (!rootCategories.value.length) {
activeRootCategoryId.value = ''
activeCategoryId.value = ''
return
}
const matchedRoot = rootCategories.value.find((root) => (
String(root.id) === currentId
|| (root.children || []).some((child) => String(child.id) === currentId)
)) || rootCategories.value[0]
activeRootCategoryId.value = matchedRoot?.id || ''
if (!matchedRoot?.children?.length) {
activeCategoryId.value = matchedRoot?.id || ''
return
}
const matchedLeaf = matchedRoot.children.find((child) => String(child.id) === currentId)
activeCategoryId.value = matchedLeaf?.id || matchedRoot.children[0]?.id || ''
}
async function loadProducts() {
loading.value = true
try {
const categoryId = activeLeafCategoryId.value || activeRootCategoryId.value || ''
const res = await getProducts({
...(categoryId ? { category_id: categoryId } : {}),
page: currentPage.value,
per_page: PAGE_SIZE,
})
productList.value = res?.list || []
total.value = Number(res?.pagination?.total || 0)
totalPages.value = Number(res?.pagination?.total_pages || 0)
} finally {
loading.value = false
}
}
async function updateRoute(categoryId, page = 1) {
await router.replace({
path: '/products',
query: {
type: 'product',
category_id: categoryId,
...(page > 1 ? { page } : {}),
},
})
}
async function changeLeafCategory(id) {
await updateRoute(id, 1)
}
async function changePage(page) {
if (page < 1 || page > totalPages.value || page === currentPage.value) return
await updateRoute(activeLeafCategoryId.value || activeRootCategoryId.value || '', page)
}
function detailRoute(id) {
return {
name: 'product-detail',
params: { slug: id },
query: {
...(activeLeafCategoryId.value ? { category_id: activeLeafCategoryId.value } : {}),
...(currentPage.value > 1 ? { page: currentPage.value } : {}),
type: 'product',
},
}
}
onMounted(async () => {
await loadCategories()
syncActiveState()
await loadProducts()
})
watch(
() => [route.query.category_id, route.query.page],
async () => {
if (!rootCategories.value.length) {
await loadCategories()
}
syncActiveState()
await loadProducts()
},
)
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>产品中心</em>
</div>
<p class="info-kicker">PRODUCT CENTER</p>
<h1>产品中心</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>分类导航</h2>
<div v-if="secondCategories.length" class="about-menu-list">
<button
v-for="item in secondCategories"
:key="item.id"
type="button"
:class="['about-menu-button', { active: isActiveLeaf(item.id) }]"
@click="changeLeafCategory(item.id)"
>
{{ item.name }}
</button>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载分类...' : '暂无产品分类' }}</p>
</div>
</aside>
<article class="info-card about-gallery-card">
<div class="about-gallery-head product-list-head">
<div>
<h2>{{ activeLeafCategory?.name || activeRootCategory?.name || '产品分类' }}</h2>
<p v-if="total"> {{ total }} 条产品</p>
</div>
</div>
<div v-if="productList.length" class="product-list-grid">
<RouterLink
v-for="item in productList"
:key="item.id"
:to="detailRoute(item.id)"
class="product-list-card"
>
<div v-if="item.cover" class="product-list-cover">
<img :src="item.cover" :alt="item.name" />
</div>
<div class="product-list-body">
<p class="product-list-category">{{ item.category?.name || activeLeafCategory?.name || '产品中心' }}</p>
<h3>{{ item.title || item.name }}</h3>
<p class="product-list-summary">{{ item.name }}</p>
<span class="product-list-link">查看详情</span>
</div>
</RouterLink>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载产品数据...' : '暂无产品数据' }}</p>
</div>
<div v-if="totalPages > 1" class="product-pagination">
<button
type="button"
class="product-page-button"
:disabled="currentPage <= 1"
@click="changePage(currentPage - 1)"
>
上一页
</button>
<button
v-for="page in pageNumbers"
:key="page"
type="button"
:class="['product-page-button', { active: page === currentPage }]"
@click="changePage(page)"
>
{{ page }}
</button>
<button
type="button"
class="product-page-button"
:disabled="currentPage >= totalPages"
@click="changePage(currentPage + 1)"
>
下一页
</button>
</div>
</article>
</section>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,156 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { getService, getServices } from '../utils/api'
const route = useRoute()
const article = ref(null)
const relatedList = ref([])
const isConsulting = computed(() => route.path.startsWith('/consulting/'))
const listRouteName = computed(() => (isConsulting.value ? 'consulting' : 'services'))
const listLabel = computed(() => (isConsulting.value ? '数智咨询' : '运营服务'))
const articleIndex = computed(() => {
return relatedList.value.findIndex((item) => String(item.id) === String(article.value?.id))
})
const prevArticle = computed(() => {
const index = articleIndex.value
return index > 0 ? relatedList.value[index - 1] : null
})
const nextArticle = computed(() => {
const index = articleIndex.value
return index >= 0 && index < relatedList.value.length - 1 ? relatedList.value[index + 1] : null
})
function detailRoute(id) {
return {
name: isConsulting.value ? 'consulting-detail' : 'service-detail',
params: { slug: id },
query: {
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
},
}
}
const listRoute = computed(() => ({
name: listRouteName.value,
query: {
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
},
}))
async function loadDetail() {
article.value = await getService(route.params.slug)
relatedList.value = await getServices({
type: article.value?.type || (isConsulting.value ? 'consulting' : 'operations'),
...(route.query.category_id ? { category_id: route.query.category_id } : {}),
})
}
watch(
() => [route.params.slug, route.query.category_id],
async () => {
await loadDetail()
},
{ immediate: true },
)
</script>
<template>
<main v-if="article" class="article-page">
<section class="article-hero">
<div class="container article-hero-inner">
<div class="article-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<RouterLink :to="listRoute">{{ listLabel }}</RouterLink>
<span>/</span>
<em>服务详情</em>
</div>
<p class="article-category">{{ article.type === 'consulting' ? '数智咨询' : '运营服务' }}</p>
<h1>{{ article.title }}</h1>
<div class="article-meta">
<span>{{ article.name }}</span>
<span>{{ article.created_at || '' }}</span>
</div>
</div>
</section>
<section class="article-section">
<div class="container article-layout">
<article class="article-main">
<div v-if="article.cover" class="article-cover">
<img :src="article.cover" :alt="article.name" />
</div>
<div class="article-summary">
<p>{{ article.name }}</p>
</div>
<div class="article-content rich-content detail-rich-content" v-html="article.content || '<p>暂无内容</p>'"></div>
<div class="article-actions">
<RouterLink class="article-back-link" :to="listRoute">返回{{ listLabel }}</RouterLink>
</div>
</article>
<aside class="article-sidebar">
<div class="article-side-card">
<h3>相关内容</h3>
<RouterLink
v-for="item in relatedList"
:key="item.id"
:to="detailRoute(item.id)"
:class="['related-news-item', { active: item.id === article.id }]"
>
<img v-if="item.cover" :src="item.cover" :alt="item.name" />
<div v-else class="related-placeholder"></div>
<div>
<strong>{{ item.title }}</strong>
<span>{{ item.name }}</span>
</div>
</RouterLink>
</div>
</aside>
</div>
<div class="container article-pager">
<RouterLink
v-if="prevArticle"
class="article-pager-link"
:to="detailRoute(prevArticle.id)"
>
<label>上一篇</label>
<strong>{{ prevArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>上一篇</label>
<strong>已经是第一篇</strong>
</div>
<RouterLink
v-if="nextArticle"
class="article-pager-link"
:to="detailRoute(nextArticle.id)"
>
<label>下一篇</label>
<strong>{{ nextArticle.title }}</strong>
</RouterLink>
<div v-else class="article-pager-link disabled">
<label>下一篇</label>
<strong>已经是最后一篇</strong>
</div>
</div>
</section>
</main>
<main v-else class="info-page">
<section class="info-section">
<div class="container info-layout single">
<article class="info-card"><p>正在加载详情...</p></article>
</div>
</section>
</main>
</template>

199
src/views/ServicesView.vue Normal file
View File

@@ -0,0 +1,199 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getProductCategories, getServices } from '../utils/api'
import { usePageDescription } from '../utils/pageSettings'
const route = useRoute()
const router = useRouter()
const ROOT_CATEGORY_ID = '2'
const rootCategories = ref([])
const services = ref([])
const activeRootCategoryId = ref('')
const activeCategoryId = ref('')
const loading = ref(false)
const pageDescription = usePageDescription('services')
const activeRootCategory = computed(() => (
rootCategories.value.find((item) => String(item.id) === String(activeRootCategoryId.value))
|| rootCategories.value[0]
|| null
))
const secondCategories = computed(() => {
const current = activeRootCategory.value
if (!current) return []
if (current.children?.length) return current.children
return [current]
})
const activeLeafCategory = computed(() => (
secondCategories.value.find((item) => String(item.id) === String(activeLeafCategoryId.value))
|| secondCategories.value[0]
|| null
))
const activeLeafCategoryId = computed(() => {
const currentRoot = activeRootCategory.value
if (!currentRoot) return ''
if (currentRoot.children?.length) {
return activeCategoryId.value || currentRoot.children[0]?.id || ''
}
return currentRoot.id
})
function isActiveLeaf(id) {
return String(activeLeafCategoryId.value || '') === String(id || '')
}
async function loadCategories() {
const categories = await getProductCategories({ status: 1 }).catch(() => [])
rootCategories.value = (categories || []).filter((item) => String(item.id) === ROOT_CATEGORY_ID)
}
function syncActiveState() {
const currentId = String(route.query.category_id || '')
if (!rootCategories.value.length) {
activeRootCategoryId.value = ''
activeCategoryId.value = ''
return
}
const matchedRoot = rootCategories.value.find((root) => (
String(root.id) === currentId
|| (root.children || []).some((child) => String(child.id) === currentId)
)) || rootCategories.value[0]
activeRootCategoryId.value = matchedRoot?.id || ''
if (!matchedRoot?.children?.length) {
activeCategoryId.value = matchedRoot?.id || ''
return
}
const matchedLeaf = matchedRoot.children.find((child) => String(child.id) === currentId)
activeCategoryId.value = matchedLeaf?.id || matchedRoot.children[0]?.id || ''
}
async function loadServicesList() {
loading.value = true
try {
services.value = await getServices({
type: 'operations',
...(activeLeafCategoryId.value ? { category_id: activeLeafCategoryId.value } : {}),
})
} finally {
loading.value = false
}
}
async function changeCategory(id) {
await router.replace({
path: '/services',
query: id ? { category_id: id } : {},
})
}
function detailRoute(id) {
return {
name: 'service-detail',
params: { slug: id },
query: {
...(activeLeafCategoryId.value ? { category_id: activeLeafCategoryId.value } : {}),
},
}
}
onMounted(async () => {
await loadCategories()
syncActiveState()
await loadServicesList()
})
watch(
() => route.query.category_id,
async () => {
if (!rootCategories.value.length) {
await loadCategories()
}
syncActiveState()
await loadServicesList()
},
)
</script>
<template>
<main class="info-page">
<section class="info-hero">
<div class="container info-hero-inner">
<div class="info-breadcrumb">
<RouterLink to="/">首页</RouterLink>
<span>/</span>
<em>运营服务</em>
</div>
<p class="info-kicker">OPERATIONS SERVICE</p>
<h1>运营服务</h1>
<p>{{ pageDescription }}</p>
</div>
</section>
<section class="info-section">
<div class="container info-layout single">
<section class="about-tab-layout">
<aside class="info-card about-menu-card">
<h2>分类导航</h2>
<div v-if="secondCategories.length" class="about-menu-list">
<button
v-for="item in secondCategories"
:key="item.id"
type="button"
:class="['about-menu-button', { active: isActiveLeaf(item.id) }]"
@click="changeCategory(item.id)"
>
{{ item.name }}
</button>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载分类...' : '暂无运营服务分类' }}</p>
</div>
</aside>
<article class="info-card about-gallery-card">
<div class="about-gallery-head product-list-head">
<div>
<h2>{{ activeLeafCategory?.name || activeRootCategory?.name || '运营服务' }}</h2>
<p v-if="services.length"> {{ services.length }} 条服务</p>
</div>
</div>
<div v-if="services.length" class="product-list-grid">
<RouterLink
v-for="item in services"
:key="item.id"
:to="detailRoute(item.id)"
class="product-list-card"
>
<div v-if="item.cover" class="product-list-cover">
<img :src="item.cover" :alt="item.name" />
</div>
<div v-else class="product-list-cover"></div>
<div class="product-list-body">
<p class="product-list-category">{{ item.category?.name || activeLeafCategory?.name || '运营服务' }}</p>
<h3>{{ item.title || item.name }}</h3>
<p class="product-list-summary">{{ item.name || '查看服务详情' }}</p>
<span class="product-list-link">查看详情</span>
</div>
</RouterLink>
</div>
<div v-else class="info-empty">
<p>{{ loading ? '正在加载运营服务数据...' : '暂无运营服务数据' }}</p>
</div>
</article>
</section>
</div>
</section>
</main>
</template>

7
vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})