CasesView.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. <script setup>
  2. import { Icon } from "@iconify/vue";
  3. import { computed, onBeforeUnmount, onMounted, ref } from "vue";
  4. import PageHero from "../components/PageHero.vue";
  5. import Reveal from "../components/motion/Reveal.vue";
  6. import bannerUrl from "../assets/images/banner.jpg?url";
  7. import bgUrl from "../assets/images/bg.jpg?url";
  8. const activeTab = ref("equipment");
  9. const expanded = ref({
  10. equipment: false,
  11. qhse: false,
  12. injection: false,
  13. });
  14. const prefersReducedMotion = () =>
  15. typeof window !== "undefined" &&
  16. window.matchMedia &&
  17. window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  18. const categories = [
  19. {
  20. key: "equipment",
  21. title: "设备管理",
  22. lead: "覆盖台账、巡检、维保、预测性维护到告警处置的全生命周期闭环,让设备运行更稳定、维护更可控。",
  23. icon: "lucide:cpu",
  24. accent: "rgba(37, 99, 235, 0.9)",
  25. cover: bannerUrl,
  26. cases: [
  27. {
  28. title: "设备台账与参数标准化",
  29. desc: "统一资产主数据、参数口径与编码规则,沉淀可复用的设备数据底座。",
  30. tags: ["台账治理", "主数据", "标准化"],
  31. metrics: ["资产一致率 ≥99%", "新增设备上线 T+1", "编码规则统一"],
  32. },
  33. ],
  34. },
  35. {
  36. key: "qhse",
  37. title: "QHSE",
  38. lead: "将质量、健康、安全、环境融入业务流程:从风险识别到整改闭环,让合规与效率同时提升。",
  39. icon: "lucide:shield-check",
  40. accent: "rgba(14, 116, 144, 0.92)",
  41. cover: bgUrl,
  42. cases: [
  43. {
  44. title: "隐患排查治理与整改闭环",
  45. desc: "隐患登记、分级分责、整改验收一体化,形成闭环追踪与审计留痕。",
  46. tags: ["隐患", "整改", "闭环"],
  47. metrics: ["闭环率 ≥98%", "复发率下降", "审计留痕完整"],
  48. },
  49. ],
  50. },
  51. {
  52. key: "injection",
  53. title: "智能注气",
  54. lead: "围绕注气作业计划、过程监控与效果评估,联动工艺参数与产量数据,实现可控、可追溯、可优化。",
  55. icon: "lucide:wind",
  56. accent: "rgba(2, 132, 199, 0.92)",
  57. cover: bannerUrl,
  58. cases: [
  59. {
  60. title: "注气计划编排与批量下发",
  61. desc: "按井/站点编排注气计划,批量下发到现场执行端,确保节奏一致。",
  62. tags: ["计划", "下发", "协同"],
  63. metrics: ["计划变更可追溯", "下发效率提升", "执行偏差可视化"],
  64. },
  65. ],
  66. },
  67. ];
  68. const tabItems = computed(() =>
  69. categories.map((c) => ({
  70. ...c,
  71. count: c.cases.length,
  72. })),
  73. );
  74. const maxPreview = 6;
  75. const visibleCases = (cat) =>
  76. expanded.value[cat.key] ? cat.cases : cat.cases.slice(0, maxPreview);
  77. const toggleExpand = (key) => {
  78. expanded.value = { ...expanded.value, [key]: !expanded.value[key] };
  79. };
  80. const scrollToCategory = (key) => {
  81. const el = document.getElementById(`case-${key}`);
  82. if (!el) return;
  83. activeTab.value = key;
  84. el.scrollIntoView({
  85. behavior: prefersReducedMotion() ? "auto" : "smooth",
  86. block: "start",
  87. });
  88. };
  89. let io = null;
  90. onMounted(() => {
  91. if (typeof window === "undefined") return;
  92. if (!("IntersectionObserver" in window)) return;
  93. const headerHRaw = getComputedStyle(document.documentElement)
  94. .getPropertyValue("--header-h")
  95. .trim();
  96. const headerH = Number.parseInt(headerHRaw, 10);
  97. const topOffset = Number.isFinite(headerH) ? headerH : 72;
  98. const sections = categories
  99. .map((c) => document.getElementById(`case-${c.key}`))
  100. .filter(Boolean);
  101. io = new IntersectionObserver(
  102. (entries) => {
  103. const visible = entries
  104. .filter((e) => e.isIntersecting)
  105. .sort(
  106. (a, b) =>
  107. (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0),
  108. )[0];
  109. if (!visible) return;
  110. const id = visible.target.getAttribute("id") || "";
  111. const key = id.replace("case-", "");
  112. if (key) activeTab.value = key;
  113. },
  114. {
  115. threshold: 0.22,
  116. rootMargin: `-${topOffset + 10}px 0px -55% 0px`,
  117. },
  118. );
  119. for (const s of sections) io.observe(s);
  120. });
  121. onBeforeUnmount(() => {
  122. if (io) io.disconnect();
  123. });
  124. </script>
  125. <template>
  126. <div class="casesView">
  127. <PageHero
  128. kicker="客户案例"
  129. title="案例分为三大类:设备管理、QHSE、智能注气"
  130. subtitle="每个分类沉淀了多项可复用的落地案例与最佳实践,覆盖从方案设计到运营闭环的关键环节。"
  131. >
  132. <template #actions>
  133. <RouterLink
  134. class="btn btn-primary"
  135. style="border-radius: 0"
  136. to="/contact"
  137. >留言咨询</RouterLink
  138. >
  139. </template>
  140. </PageHero>
  141. <nav class="tabBar" aria-label="案例分类导航">
  142. <button
  143. v-for="t in tabItems"
  144. :key="t.key"
  145. type="button"
  146. class="tabBtn"
  147. :class="{ 'is-active': activeTab === t.key }"
  148. @click="scrollToCategory(t.key)"
  149. >
  150. <Icon :icon="t.icon" class="tabIcon" />
  151. <span class="tabText">{{ t.title }}</span>
  152. <span class="tabCount">{{ t.count }}</span>
  153. </button>
  154. </nav>
  155. <section class="section">
  156. <div class="container">
  157. <div class="stack">
  158. <section
  159. v-for="(cat, catIdx) in categories"
  160. :key="cat.key"
  161. class="caseSection"
  162. :id="`case-${cat.key}`"
  163. :style="{ '--accent': cat.accent }"
  164. >
  165. <Reveal as="header" class="caseHead" :delay="40 + catIdx * 80">
  166. <div class="caseHeadTop">
  167. <span class="caseBadge">分类</span>
  168. <div class="caseHeadTitle">
  169. <Icon :icon="cat.icon" class="caseHeadIcon" />
  170. <h2 class="h2">{{ cat.title }}</h2>
  171. </div>
  172. </div>
  173. <p class="muted caseLead">{{ cat.lead }}</p>
  174. <div class="caseHeadActions">
  175. <RouterLink
  176. class="btn btn-ghost"
  177. style="border-radius: 0"
  178. to="/contact"
  179. >对接同类项目</RouterLink
  180. >
  181. </div>
  182. </Reveal>
  183. <div class="caseGrid">
  184. <Reveal
  185. v-for="(c, idx) in visibleCases(cat)"
  186. :key="c.title"
  187. as="article"
  188. class="card caseCard hover-lift"
  189. :delay="60 + idx * 60"
  190. >
  191. <div class="caseCardTop">
  192. <div class="caseCardLine" aria-hidden="true"></div>
  193. <div class="caseCardTitle">{{ c.title }}</div>
  194. <div class="muted caseCardDesc">{{ c.desc }}</div>
  195. </div>
  196. <div class="caseMeta">
  197. <div class="caseTags" aria-label="标签">
  198. <span v-for="t in c.tags" :key="t" class="tag">{{
  199. t
  200. }}</span>
  201. </div>
  202. <div class="caseMetrics" aria-label="指标">
  203. <span v-for="m in c.metrics" :key="m" class="metric">{{
  204. m
  205. }}</span>
  206. </div>
  207. </div>
  208. </Reveal>
  209. </div>
  210. <div class="caseMore">
  211. <button
  212. v-if="cat.cases.length > maxPreview"
  213. type="button"
  214. class="btn btn-link moreBtn"
  215. @click="toggleExpand(cat.key)"
  216. >
  217. {{
  218. expanded[cat.key]
  219. ? "收起"
  220. : `展开更多(${cat.cases.length - maxPreview})`
  221. }}
  222. </button>
  223. </div>
  224. </section>
  225. </div>
  226. </div>
  227. </section>
  228. </div>
  229. </template>
  230. <style scoped>
  231. .casesView {
  232. --tab-bg: rgba(255, 255, 255, 0.92);
  233. }
  234. .tabBar {
  235. position: sticky;
  236. top: var(--header-h);
  237. z-index: 20;
  238. display: grid;
  239. grid-template-columns: repeat(3, minmax(0, 1fr));
  240. background: #eef5f8;
  241. height: 76px;
  242. margin: 10px 0 18px;
  243. border: 0;
  244. border-radius: 0;
  245. }
  246. .tabBtn {
  247. height: 76px;
  248. border: 0;
  249. background: #eef5f8;
  250. padding: 0 18px;
  251. display: inline-flex;
  252. align-items: center;
  253. justify-content: center;
  254. gap: 10px;
  255. color: var(--slate-700);
  256. cursor: pointer;
  257. transition:
  258. transform 160ms ease,
  259. box-shadow 160ms ease,
  260. color 160ms ease,
  261. background-color 160ms ease;
  262. }
  263. .tabBtn.is-active {
  264. color: var(--brand-700);
  265. background-color: #fff;
  266. }
  267. .tabBtn:focus-visible {
  268. outline: none;
  269. box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.22);
  270. }
  271. .tabIcon {
  272. width: 18px;
  273. height: 18px;
  274. }
  275. .tabText {
  276. display: inline-block;
  277. white-space: nowrap;
  278. font-weight: 900;
  279. letter-spacing: -0.01em;
  280. padding-bottom: 10px;
  281. background-image: linear-gradient(currentColor, currentColor);
  282. background-repeat: no-repeat;
  283. background-position: 0 100%;
  284. background-size: 0 2px;
  285. transition: background-size 160ms ease;
  286. }
  287. .tabBtn.is-active .tabText {
  288. background-size: 100% 2px;
  289. }
  290. .tabCount {
  291. font-size: 12px;
  292. font-weight: 900;
  293. padding: 4px 8px;
  294. border-radius: 999px;
  295. border: 1px solid rgba(15, 23, 42, 0.12);
  296. background: rgba(255, 255, 255, 0.72);
  297. color: rgba(2, 6, 23, 0.72);
  298. }
  299. .stack {
  300. display: grid;
  301. gap: 26px;
  302. }
  303. .caseSection {
  304. scroll-margin-top: calc(var(--header-h) + 88px);
  305. padding: 18px 0 10px;
  306. }
  307. .caseHead {
  308. padding: 18px 4px 14px;
  309. display: grid;
  310. gap: 10px;
  311. }
  312. .caseHeadTop {
  313. display: flex;
  314. align-items: center;
  315. justify-content: space-between;
  316. gap: 14px;
  317. flex-wrap: wrap;
  318. }
  319. .caseBadge {
  320. font-size: 12px;
  321. font-weight: 900;
  322. letter-spacing: 0.12em;
  323. color: rgba(2, 6, 23, 0.62);
  324. background: rgba(15, 23, 42, 0.04);
  325. border: 1px solid rgba(15, 23, 42, 0.08);
  326. padding: 6px 10px;
  327. border-radius: 999px;
  328. flex: 0 0 auto;
  329. }
  330. .caseHeadTitle {
  331. display: inline-flex;
  332. align-items: center;
  333. gap: 10px;
  334. }
  335. .caseHeadIcon {
  336. width: 20px;
  337. height: 20px;
  338. color: var(--accent);
  339. }
  340. .caseLead {
  341. max-width: 92ch;
  342. }
  343. .caseHeadActions {
  344. margin-top: 2px;
  345. }
  346. .caseGrid {
  347. display: grid;
  348. grid-template-columns: repeat(3, minmax(0, 1fr));
  349. gap: 18px;
  350. margin-top: 10px;
  351. }
  352. .caseCard {
  353. border: 1px solid var(--border);
  354. border-top: 0;
  355. box-shadow: 0 16px 42px rgba(2, 6, 23, 0.08);
  356. background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), #fff);
  357. }
  358. .caseCardTop {
  359. padding: 18px 18px 10px;
  360. }
  361. .caseCardLine {
  362. height: 2px;
  363. width: 100%;
  364. background: linear-gradient(90deg, var(--accent), rgba(2, 6, 23, 0));
  365. margin-bottom: 12px;
  366. }
  367. .caseCardTitle {
  368. font-weight: 900;
  369. letter-spacing: -0.02em;
  370. line-height: 1.25;
  371. }
  372. .caseCardDesc {
  373. margin-top: 8px;
  374. line-height: 1.75;
  375. }
  376. .caseMeta {
  377. padding: 8px 18px 18px;
  378. display: grid;
  379. gap: 12px;
  380. }
  381. .caseTags {
  382. display: flex;
  383. flex-wrap: wrap;
  384. gap: 8px;
  385. }
  386. .tag {
  387. display: inline-flex;
  388. align-items: center;
  389. padding: 6px 10px;
  390. border-radius: 999px;
  391. background: rgba(15, 23, 42, 0.03);
  392. border: 1px solid var(--border);
  393. font-weight: 700;
  394. color: var(--slate-700);
  395. font-size: 12px;
  396. }
  397. .caseMetrics {
  398. display: flex;
  399. flex-wrap: wrap;
  400. gap: 10px;
  401. }
  402. .metric {
  403. display: inline-flex;
  404. align-items: center;
  405. padding: 6px 10px;
  406. background: rgba(15, 23, 42, 0.03);
  407. border: 1px solid var(--border);
  408. border-left: 2px solid var(--accent);
  409. font-weight: 800;
  410. color: rgba(2, 6, 23, 0.78);
  411. font-size: 12px;
  412. }
  413. .caseMore {
  414. margin-top: 10px;
  415. display: flex;
  416. justify-content: center;
  417. }
  418. .moreBtn {
  419. font-weight: 900;
  420. letter-spacing: -0.01em;
  421. }
  422. :deep(.pageHero) {
  423. background-image: url("../assets/images/bg5.jpg");
  424. background-size: cover, cover;
  425. background-position: center, center;
  426. background-repeat: no-repeat, no-repeat;
  427. background-blend-mode: overlay;
  428. color: #111;
  429. border-bottom: none;
  430. padding: 80px 0;
  431. }
  432. :deep(.pageHero__subtitle) {
  433. color: #4f5055;
  434. }
  435. @media (max-width: 960px) {
  436. .caseGrid {
  437. grid-template-columns: repeat(2, minmax(0, 1fr));
  438. }
  439. .tabBar {
  440. grid-template-columns: 1fr;
  441. height: auto;
  442. }
  443. .tabBtn {
  444. justify-content: space-between;
  445. padding: 0 16px;
  446. height: 64px;
  447. }
  448. .tabText {
  449. padding-bottom: 6px;
  450. }
  451. }
  452. @media (max-width: 560px) {
  453. .caseGrid {
  454. grid-template-columns: 1fr;
  455. }
  456. }
  457. </style>