260804
2
.env.development
Normal 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
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=https://huicai-api.wsdns.cn
|
||||
24
.gitignore
vendored
Normal 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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
README.md
Normal 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
13
index.html
Normal 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
@@ -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
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 66 KiB |
1
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal 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
@@ -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
|
After Width: | Height: | Size: 129 KiB |
BIN
src/assets/cgal.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
src/assets/edit.png
Normal file
|
After Width: | Height: | Size: 777 B |
BIN
src/assets/header-bg.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/hxywjz.png
Normal file
|
After Width: | Height: | Size: 636 KiB |
BIN
src/assets/image_354242819928488.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
src/assets/mock/news-1.png
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
src/assets/mock/news-2.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
src/assets/mock/news-3.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
src/assets/pdf/Page_10_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
src/assets/pdf/Page_11_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
src/assets/pdf/Page_13_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
src/assets/pdf/Page_14_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
src/assets/pdf/Page_15_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
src/assets/pdf/Page_16_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
src/assets/pdf/Page_17_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
src/assets/pdf/Page_18_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
src/assets/pdf/Page_19_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
src/assets/pdf/Page_20_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
src/assets/pdf/Page_21_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
src/assets/pdf/Page_22_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
src/assets/pdf/Page_23_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
src/assets/pdf/Page_24_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
src/assets/pdf/Page_25_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
src/assets/pdf/Page_26_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
src/assets/pdf/Page_27_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
src/assets/pdf/Page_29_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
src/assets/pdf/Page_30_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
src/assets/pdf/Page_31_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
src/assets/pdf/Page_32_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
src/assets/pdf/Page_33_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
src/assets/pdf/Page_34_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
src/assets/pdf/Page_35_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
src/assets/pdf/Page_37_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
src/assets/pdf/Page_38_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
src/assets/pdf/Page_39_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
src/assets/pdf/Page_5_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
src/assets/pdf/Page_6_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
src/assets/pdf/Page_7_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
src/assets/pdf/Page_8_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
src/assets/pdf/Page_9_docsmall.com.jpg
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
src/assets/phone.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/qr.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
src/assets/qyys_0.png
Normal file
|
After Width: | Height: | Size: 821 B |
BIN
src/assets/qyys_1.png
Normal file
|
After Width: | Height: | Size: 457 B |
BIN
src/assets/qyys_2.png
Normal file
|
After Width: | Height: | Size: 561 B |
BIN
src/assets/qyys_3.png
Normal file
|
After Width: | Height: | Size: 469 B |
BIN
src/assets/暂无内容.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
58
src/components/SiteFooter.vue
Normal 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' }">立即填写>></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>
|
||||
279
src/components/SiteHeader.vue
Normal 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
@@ -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
@@ -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
@@ -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
102
src/utils/api.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
139
src/views/CaseDetailView.vue
Normal 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
@@ -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>
|
||||
134
src/views/ConsultingRequestView.vue
Normal 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>
|
||||
104
src/views/ConsultingView.vue
Normal 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
@@ -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
@@ -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')">详细了解 >></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 & 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>
|
||||
132
src/views/NewsDetailView.vue
Normal 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
@@ -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 & 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
@@ -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>
|
||||
162
src/views/PartyCultureDetailView.vue
Normal 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>
|
||||
259
src/views/PartyCultureView.vue
Normal 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>
|
||||
181
src/views/ProductDetailView.vue
Normal 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
@@ -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>
|
||||
156
src/views/ServiceDetailView.vue
Normal 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
@@ -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
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||