txw/txw-mhzc-web/src/pages/index/views/gxnlpt/index.vue

2422 lines
76 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="gxnlpt-page portal-page portal-page-shell portal-landing-figma-page">
<div class="portal-figma-scale-viewport">
<div class="portal-figma-scale-stage" ref="figmaStage">
<div
class="gxnlpt-shell page-content-wrap"
:class="{ 'gxnlpt-shell--stacked': stackedNavLayout }"
>
<div class="gxnlpt-layout" :class="{ 'gxnlpt-layout--stacked': stackedNavLayout }">
<!-- Figma Frame_left 280px窄屏为服务中心式子导航面板 -->
<aside class="gxnlpt-sidebar" :class="{ 'gxnlpt-sidebar--panel': stackedNavLayout }">
<div class="gxnlpt-sidebar-sticky">
<div class="gxnlpt-side-nav-wrap">
<nav class="gxnlpt-side-nav" role="tablist" aria-label="共性能力分类">
<button
v-for="(item, index) in categoryList"
:key="item.id"
type="button"
role="tab"
class="gxnlpt-side-item"
:class="{ 'is-active': activeTabIndex === index && contentView === 'list' }"
:aria-selected="activeTabIndex === index"
@click="onSideNavClick(item.id, index)"
>
<img
class="gxnlpt-side-icon"
:src="getSideIconUrl(item.icon, activeTabIndex === index && contentView === 'list')"
alt=""
/>
<span class="gxnlpt-side-label">{{ item.title }}</span>
</button>
</nav>
</div>
<div class="gxnlpt-side-actions">
<button
type="button"
class="gxnlpt-side-action"
:class="{ 'is-active': contentView === 'submit' }"
@click="openSubmitView"
>
<span>收录</span>
</button>
<button
type="button"
class="gxnlpt-side-action"
:class="{ 'is-active': contentView === 'favorites' }"
@click="openFavoritesView"
>
<span class="gxnlpt-side-action-icon gxnlpt-side-action-icon--star" aria-hidden="true"></span>
<span>我的收藏</span>
</button>
</div>
</div>
<!-- Figma 图二:白底向下铺满,空白在收录/收藏下方 -->
<div class="gxnlpt-sidebar-tail" aria-hidden="true"></div>
</aside>
<!-- Figma Frame 228 flex 896 -->
<main id="anchor-container" class="gxnlpt-content">
<!-- 收录表单Figma 共性能力_收录 -->
<section v-if="contentView === 'submit'" class="gxnlpt-submit" aria-label="收录">
<header class="gxnlpt-submit-head">
<div class="gxnlpt-submit-title-row">
<span class="gxnlpt-submit-title-icon" aria-hidden="true"></span>
<h2 class="gxnlpt-submit-title">收录</h2>
</div>
<p class="gxnlpt-submit-notice">
欢迎各位网友、同仁和专家提交优质网站尽量1个工作日内完成审核。
</p>
</header>
<form class="gxnlpt-submit-form" @submit.prevent="handleSubmitForm">
<div class="gxnlpt-submit-body">
<label class="gxnlpt-field">
<span class="gxnlpt-field-label"><span class="gxnlpt-field-required">*</span>名称</span>
<input
v-model.trim="submitForm.bt1"
type="text"
class="gxnlpt-input"
:class="{ 'is-error': submitErrors.bt1 }"
placeholder="请输入名称"
@blur="touchSubmitField('bt1')"
/>
<span v-if="submitErrors.bt1" class="gxnlpt-field-error">{{ submitErrors.bt1 }}</span>
</label>
<label class="gxnlpt-field">
<span class="gxnlpt-field-label"><span class="gxnlpt-field-required">*</span>链接</span>
<input
v-model.trim="submitForm.lj"
type="text"
class="gxnlpt-input"
:class="{ 'is-error': submitErrors.lj }"
placeholder="请输入链接http:// 或 https://"
@blur="touchSubmitField('lj')"
/>
<span v-if="submitErrors.lj" class="gxnlpt-field-error">{{ submitErrors.lj }}</span>
</label>
<label class="gxnlpt-field">
<span class="gxnlpt-field-label"><span class="gxnlpt-field-required">*</span>简介</span>
<textarea
v-model.trim="submitForm.jj"
class="gxnlpt-textarea"
:class="{ 'is-error': submitErrors.jj }"
maxlength="40"
rows="3"
placeholder="请输入简介"
@blur="touchSubmitField('jj')"
></textarea>
<span class="gxnlpt-field-counter">{{ submitForm.jj.length }}/40</span>
<span v-if="submitErrors.jj" class="gxnlpt-field-error">{{ submitErrors.jj }}</span>
</label>
<div class="gxnlpt-submit-row-2">
<label class="gxnlpt-field">
<span class="gxnlpt-field-label"><span class="gxnlpt-field-required">*</span>分类</span>
<select
v-model="submitForm.fl"
class="gxnlpt-select"
:class="{ 'is-error': submitErrors.fl }"
@blur="touchSubmitField('fl')"
>
<option value="">请选择分类</option>
<option
v-for="cat in categoryList"
:key="cat.id"
:value="cat.title"
>{{ cat.title }}</option>
</select>
<span v-if="submitErrors.fl" class="gxnlpt-field-error">{{ submitErrors.fl }}</span>
</label>
<label class="gxnlpt-field">
<span class="gxnlpt-field-label">标签</span>
<input
v-model.trim="submitForm.bq"
type="text"
class="gxnlpt-input"
placeholder="多个标签用逗号分隔"
/>
</label>
</div>
</div>
<button type="submit" class="gxnlpt-submit-btn">提交审核</button>
</form>
</section>
<!-- 我的收藏 -->
<section v-else-if="contentView === 'favorites'" class="gxnlpt-favorites" aria-label="我的收藏">
<header class="gxnlpt-block-head gxnlpt-favorites-head">
<span class="gxnlpt-favorites-star" aria-hidden="true"></span>
<h2 class="gxnlpt-block-title">我的收藏</h2>
</header>
<div v-if="favoritesLoading" class="gxnlpt-state">加载中...</div>
<div v-else-if="!favoriteCards.length" class="gxnlpt-state">暂无收藏</div>
<div v-else class="gxnlpt-card-grid">
<article
v-for="card in favoriteCards"
:key="card.gxUuid || card._demoId"
class="gxnlpt-card"
:class="{ 'gxnlpt-card--expired': card.yxzt === 'N' }"
@click="card.yxzt === 'N' ? null : handleCardClick(card)"
>
<div class="gxnlpt-card-body">
<div class="gxnlpt-card-info">
<div class="gxnlpt-card-name-row">
<h3 class="gxnlpt-card-name" :title="card.bt1">{{ card.bt1 }}</h3>
<span v-if="card.yxzt === 'N'" class="gxnlpt-card-expired-tag">已失效</span>
<button
v-else
type="button"
class="gxnlpt-card-star"
aria-label="取消收藏"
@click.stop="handleCollect(card)"
>
<img :src="starFilled" alt="" />
</button>
</div>
<p class="gxnlpt-card-org" :title="card.qymc">{{ card.qymc }}</p>
</div>
<GxnlptCardTags :tags="card.tagList" />
</div>
</article>
</div>
</section>
<template v-else>
<section
v-for="{ item, index } in listSections"
:key="item.id"
:id="item.id"
class="gxnlpt-block"
:data-section-index="index"
>
<header class="gxnlpt-block-head">
<img class="gxnlpt-block-icon" :src="getIconUrl(item.icon)" alt="" />
<h2 class="gxnlpt-block-title">{{ item.title }}</h2>
</header>
<div v-if="pageLoading && index === 0" class="gxnlpt-state">加载中...</div>
<div v-else-if="!pageLoading && !item.displayList.length" class="gxnlpt-state">暂无相关服务</div>
<template v-else-if="item.displayList.length">
<div class="gxnlpt-card-grid">
<article
v-for="card in item.displayList"
:key="card.gxUuid || card._demoId"
class="gxnlpt-card"
@click="handleCardClick(card)"
>
<div class="gxnlpt-card-body">
<div class="gxnlpt-card-info">
<div class="gxnlpt-card-name-row">
<h3 class="gxnlpt-card-name" :title="card.bt1">{{ card.bt1 }}</h3>
<button
type="button"
class="gxnlpt-card-star"
aria-label="收藏"
@click.stop="handleCollect(card)"
>
<img :src="card.scbz === 'Y' ? starFilled : starOutline" alt="" />
</button>
</div>
<p class="gxnlpt-card-org" :title="card.qymc">{{ card.qymc }}</p>
</div>
<GxnlptCardTags :tags="card.tagList" />
</div>
</article>
</div>
<div
v-if="item.cardList.length > item.previewSize"
class="gxnlpt-more-wrap"
>
<button type="button" class="gxnlpt-more" @click="toggleViewMore(item)">
{{ item.expanded ? '收起' : '查看更多>>' }}
</button>
</div>
</template>
</section>
<!-- 优化需求碳链接专区21 条精选外站链接,按 3 大类组织) -->
<section class="gxnlpt-block gxnlpt-carbon-links" aria-label="碳链接">
<header class="gxnlpt-block-head">
<img class="gxnlpt-block-icon" :src="getIconUrl('tanjl.svg')" alt="" />
<h2 class="gxnlpt-block-title">碳链接</h2>
<span class="gxnlpt-block-subtitle">产品碳足迹 · 企业碳管理平台 · CBAM</span>
</header>
<div
v-for="group in carbonLinkGroups"
:key="group.type"
class="gxnlpt-carbon-group"
>
<h3 class="gxnlpt-carbon-group-title">
<span class="gxnlpt-carbon-group-bar"></span>
<span>{{ group.type }}</span>
<span class="gxnlpt-carbon-group-count">{{ group.list.length }}</span>
</h3>
<ul class="gxnlpt-carbon-list">
<li
v-for="(item, idx) in group.list"
:key="`${group.type}-${idx}`"
class="gxnlpt-carbon-item"
>
<a
class="gxnlpt-carbon-link"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
>
<span class="gxnlpt-carbon-name">{{ item.name }}</span>
<span v-if="item.desc" class="gxnlpt-carbon-desc">{{ item.desc }}</span>
</a>
<span class="gxnlpt-carbon-external" aria-hidden="true"></span>
</li>
</ul>
</div>
</section>
</template>
</main>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import GxnlptCardTags from '@/pages/index/views/gxnlpt/components/GxnlptCardTags.vue';
import api from '@/pages/index/api/gxnl/index.js';
// 兜底数据184 条共性能力链接 + 21 条碳链接(共约 200+ 条)
// 来源:可信碳网站优化需求文档;按页面 5 大类自动归类
import gxnlLinksByCategory from '@/pages/index/data/gxnl-links.js';
// 21 条碳链接独立数据(产品碳足迹/企业碳管理平台/CBAM
import carbonLinksData from '@/pages/index/data/carbon-links.js';
import {
isPortalLoggedIn,
isUnauthorizedError,
showLoginGuide,
} from '@/pages/index/utils/auth-guard';
import { BP } from '@/pages/index/utils/breakpoint.js';
import portalFigmaScaleMixin from '@/pages/index/utils/portal-figma-scale-mixin';
import comingSoonMixin from '@/pages/index/utils/coming-soon-mixin';
const starOutline = require('@/pages/index/assets/fwsc/wsc.svg');
const starFilled = require('@/pages/index/assets/fwsc/ysc.svg');
const PREVIEW_SIZE = 6;
/** 与 breakpoints.less @mq-desktop-sm-max 一致 */
const STACKED_NAV_MEDIA = `(max-width: ${BP.desktopSm - 1}px)`;
function readStackedNavLayout() {
if (typeof window === 'undefined') return false;
return window.matchMedia(STACKED_NAV_MEDIA).matches;
}
/** 临时开启:优先展示分类假数据,接口就绪后改为 false */
const FORCE_DEMO_PREVIEW = false;
/**
* 演示:标签溢出省略 UI 测试(需与 FORCE_DEMO_PREVIEW 同时为 true
* 关闭后恢复每卡 23 个短标签的正常展示
*/
/** 关闭后卡片仅展示接口/演示数据中的 23 个短标签,与图二设计稿一致 */
const DEMO_TAG_OVERFLOW_PREVIEW = false;
/** 按分类注入的碳领域长标签(仅溢出 UI 测试用) */
const DEMO_TAG_OVERFLOW_BY_CATEGORY = {
'content-1': [
'温室气体盘查',
'组织碳排放',
'产品碳足迹',
'范围三排放',
'ISO14064-1',
'GHG Protocol',
'排放因子',
'碳数据核算',
'年度碳盘查',
'供应链碳足迹',
'碳中和路径',
],
'content-2': [
'第三方碳核查',
'CCER项目审定',
'碳中和认证',
'自愿减排核证',
'绿色项目认定',
'产品碳足迹认证',
'零碳工厂评价',
'林业碳汇核证',
'碳标识认证',
],
'content-3': [
'全国碳配额',
'CCER自愿减排',
'碳市场交易',
'配额回购',
'履约清缴',
'试点碳市场',
'碳资产托管',
'大宗协议转让',
'碳普惠交易',
'跨境碳信用',
],
};
function mergeDemoOverflowTags(bqjh, categoryId) {
let base = [];
if (bqjh) {
try {
const parsed = JSON.parse(bqjh);
base = Array.isArray(parsed) ? parsed : [];
} catch (e) {
base = String(bqjh)
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean);
}
}
const extra = DEMO_TAG_OVERFLOW_BY_CATEGORY[categoryId] || [];
return JSON.stringify([...new Set([...base, ...extra])]);
}
/** content-1 前 3 张、content-3 前 2 张注入长标签,其余保持短标签便于对比 */
function shouldInjectOverflowDemoTags(categoryId, index) {
if (!DEMO_TAG_OVERFLOW_PREVIEW || !FORCE_DEMO_PREVIEW) return false;
if (categoryId === 'content-1' && index < 3) return true;
if (categoryId === 'content-3' && index < 2) return true;
return false;
}
/**
* 历史说明:原先这里有 ORIGINAL_DEMO_BY_CATEGORY5×8=40 条“概念性占位”数据),
* 它们没有 lj 字段,依赖数据库/接口不可用时显示,但用户点击会触发“敬请期待”,
* 体感为“点击无反应”,现已全部清理。
* 当前兜底数据源改为 gxnlLinksByCategorygxnl-links.js
* 含 184 条共性能力链接 + 21 条碳链接,全部有真实外链。
*/
// 页面 5 大类 id → 数据分类标题 映射
const CATEGORY_ID_TO_TITLE = {
'content-1': '碳核算平台',
'content-2': '碳认证机构',
'content-3': '碳交易平台',
'content-4': '碳金融服务',
'content-5': '碳技术咨询',
};
/**
* 真实接口数据失败时,页面会回退到本地的“兜底数据”。
* 数据源已清理ORIGINAL_DEMO_BY_CATEGORY —— 早期 40 条“概念性占位”数据,
* 没有 lj 字段,点击会走“敬请期待”分支造成“点击无反应”体感,已全部清理。
* 数据源gxnlLinksByCategory —— 来自《可信碳网站优化需求文档》整理的 184 条真实外链 + 21 条碳链接。
*/
const DEMO_BY_CATEGORY = (() => {
const merged = {};
Object.entries(CATEGORY_ID_TO_TITLE).forEach(([id, title]) => {
const extra = (gxnlLinksByCategory && gxnlLinksByCategory[title]) || [];
merged[id] = extra.map((item) => ({
bt1: item.bt1,
qymc: item.qymc,
bqjh: item.bqjh,
lj: item.lj,
scbz: item.scbz || 'N',
}));
});
return merged;
})();
/** 各分类标签池(来自演示数据,供接口数据补标签) */
const CATEGORY_TAG_POOL = Object.keys(DEMO_BY_CATEGORY).reduce((pools, id) => {
const set = new Set();
(DEMO_BY_CATEGORY[id] || []).forEach((item) => {
try {
JSON.parse(item.bqjh).forEach((tag) => set.add(tag));
} catch (e) {
/* ignore */
}
});
pools[id] = [...set];
return pools;
}, {});
/** 按标题/简介语义匹配标签(与演示卡片风格一致) */
const CATEGORY_TAG_RULES = {
'content-1': [
{ test: /盘查|温室气体|组织碳|MRV/, tags: ['碳盘查', '组织碳', 'ISO14064'] },
{ test: /足迹|LCA|PAS|14067/, tags: ['产品碳足迹', 'LCA', 'PAS2050'] },
{ test: /CBAM|边境|欧盟|关税/, tags: ['CBAM', '欧盟碳关税', '出口合规'] },
{ test: /园区|能碳|协同/, tags: ['园区碳管理', '能碳协同'] },
{ test: /供应链|范围三|范围 3|填报/, tags: ['范围三', '供应链', '数据填报'] },
{ test: /资产|年度/, tags: ['碳资产', '年度盘查', '全国'] },
{ test: /监测|核算平台|在线/, tags: ['在线监测', '核算', '平台'] },
{ test: /重点行业|指南/, tags: ['重点行业', '核算指南'] },
],
'content-2': [
{ test: /核查|第三方/, tags: ['碳核查', '第三方核查', '重点排放单位'] },
{ test: /CCER|造林|碳汇/, tags: ['CCER', '项目审定', '造林碳汇'] },
{ test: /绿色.*认定|绿色项目/, tags: ['绿色认定', '全国'] },
{ test: /碳中和|抵消/, tags: ['碳中和', '认证', '抵消'] },
{ test: /自愿减排|验证/, tags: ['自愿减排', '验证', 'MRV'] },
{ test: /零碳工厂|工厂评价/, tags: ['零碳工厂', '评价认证'] },
{ test: /足迹.*认证|产品碳足迹/, tags: ['产品碳足迹', '认证'] },
{ test: /供应链.*认证|绿色认证/, tags: ['供应链', '绿色认证'] },
],
'content-3': [
{ test: /配额|全国碳/, tags: ['碳配额', '全国碳市场', '交易'] },
{ test: /CCER|自愿减排|挂牌/, tags: ['CCER', '挂牌', '自愿减排'] },
{ test: /回购/, tags: ['回购', '流动性', '碳金融'] },
{ test: /试点|转让/, tags: ['试点市场', '配额转让'] },
{ test: /托管|履约/, tags: ['托管', '碳资产', '履约'] },
{ test: /VCS|国际|跨境/, tags: ['VCS', '国际碳信用', '跨境'] },
{ test: /大宗|协议/, tags: ['大宗交易', '配额'] },
{ test: /碳普惠|普惠/, tags: ['碳普惠', '交易'] },
],
'content-4': [
{ test: /信贷|绿色信贷/, tags: ['绿色信贷', '融资', '转型金融'] },
{ test: /质押|贷款/, tags: ['质押', '碳配额', '贷款'] },
{ test: /保险|收益权/, tags: ['碳保险', '收益权', '风险'] },
{ test: /基金|ESG|投资/, tags: ['碳基金', 'ESG', '投资'] },
{ test: /债券|发行/, tags: ['绿色债券', '发行', '顾问'] },
{ test: /回购融资/, tags: ['回购融资', '配额', '全国'] },
{ test: /转型金融/, tags: ['转型金融', '贷款'] },
{ test: /减排.*工具|支持工具/, tags: ['碳减排', '支持工具'] },
],
'content-5': [
{ test: /达峰|路径/, tags: ['碳达峰', '路径规划', '咨询'] },
{ test: /减排.*技术|技术评估/, tags: ['减排技术', '评估', '电力'] },
{ test: /管理体系|ISO|培训/, tags: ['管理体系', 'ISO14067', '培训'] },
{ test: /信息披露|CDP|ESG报告/, tags: ['信息披露', 'CDP', 'ESG报告'] },
{ test: /林业碳汇|项目开发/, tags: ['林业碳汇', '项目开发', 'CCER'] },
{ test: /零碳园区|综合能源/, tags: ['零碳园区', '规划', '综合能源'] },
{ test: /政策|解读/, tags: ['政策解读', '碳市场'] },
{ test: /ESG|议题/, tags: ['ESG', '碳议题', '咨询'] },
],
};
function hashText(str) {
let h = 0;
const s = String(str || '');
for (let i = 0; i < s.length; i += 1) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
function scoreTagAgainstText(tag, text) {
if (text.includes(tag)) return tag.length + 20;
let score = 0;
for (let len = Math.min(tag.length, 4); len >= 2; len -= 1) {
for (let i = 0; i <= tag.length - len; i += 1) {
const sub = tag.slice(i, i + len);
if (text.includes(sub)) score += len;
}
}
return score;
}
/** 接口数据缺标签时,根据标题/简介推断 23 个展示标签 */
function inferTagsFromContent(card, category) {
const text = `${card.bt1 || ''}${card.fwnr || ''}${card.qymc || ''}`;
const rules = CATEGORY_TAG_RULES[category.id] || [];
for (let i = 0; i < rules.length; i += 1) {
if (rules[i].test.test(text)) {
return rules[i].tags.slice(0, 3);
}
}
const pool = CATEGORY_TAG_POOL[category.id] || [];
const scored = pool
.map((tag) => ({ tag, score: scoreTagAgainstText(tag, text) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
if (scored.length >= 2) {
return scored.slice(0, 3).map((item) => item.tag);
}
const kwTags = (category.keywords || []).filter((kw) => text.includes(kw)).slice(0, 2);
if (kwTags.length) {
const extra = pool.find((tag) => !kwTags.includes(tag));
return [...kwTags, extra].filter(Boolean).slice(0, 3);
}
const seed = hashText(card.bt1 || text);
const picked = [];
for (let i = 0; i < pool.length && picked.length < 3; i += 1) {
const tag = pool[(seed + i) % pool.length];
if (tag && !picked.includes(tag)) picked.push(tag);
}
return picked;
}
const SIDE_ICON_GRAY = {
'thspt.svg': require('../../assets/gxnlpt/icons/01-carbon-accounting.svg'),
'trzjg.svg': require('../../assets/gxnlpt/icons/02-carbon-certification.svg'),
'tjypt.svg': require('../../assets/gxnlpt/icons/03-carbon-trading.svg'),
'tjrfw.svg': require('../../assets/gxnlpt/icons/04-carbon-finance.svg'),
'tjszx.svg': require('../../assets/gxnlpt/icons/05-carbon-consulting.svg'),
};
const SIDE_ICON_WHITE = {
'thspt.svg': require('../../assets/gxnlpt/side-white/01-carbon-accounting.png'),
'trzjg.svg': require('../../assets/gxnlpt/side-white/02-carbon-certification.png'),
'tjypt.svg': require('../../assets/gxnlpt/side-white/03-carbon-trading.png'),
'tjrfw.svg': require('../../assets/gxnlpt/side-white/04-carbon-finance.png'),
'tjszx.svg': require('../../assets/gxnlpt/side-white/05-carbon-consulting.png'),
};
const CATEGORY_META = [
{ id: 'content-1', title: '碳核算平台', flDm: '01', icon: 'thspt.svg', keywords: ['核算', '足迹', 'LCA', 'CBAM', '碳管理', '碳盘查', '标准化'] },
{ id: 'content-2', title: '碳认证机构', flDm: '02', icon: 'trzjg.svg', keywords: ['认证', '核查', '审定', '验证'] },
{ id: 'content-3', title: '碳交易平台', flDm: '03', icon: 'tjypt.svg', keywords: ['交易', '交易所', '配额', 'CCER'] },
{ id: 'content-4', title: '碳金融服务', flDm: '04', icon: 'tjrfw.svg', keywords: ['金融', '融资', '信贷', '保险', '基金', '资产'] },
{ id: 'content-5', title: '碳技术咨询', flDm: '05', icon: 'tjszx.svg', keywords: ['咨询', '技术', '研究', '规划', '方案'] },
];
function buildCategoryList() {
return CATEGORY_META.map((meta) => ({
...meta,
cardList: [],
displayList: [],
total: 0,
previewSize: PREVIEW_SIZE,
expanded: false,
}));
}
const FL_DM_MAP = {
'碳核算平台': '01',
'碳认证机构': '02',
'碳交易平台': '03',
'碳金融服务': '04',
'碳技术咨询': '05',
};
function flTitleToDm(title) {
return FL_DM_MAP[title] || '';
}
const SUBMIT_FORM_EMPTY = () => ({
bt1: '',
lj: '',
jj: '',
fl: '',
bq: '',
});
export default {
name: 'GxnlptIndex',
mixins: [portalFigmaScaleMixin, comingSoonMixin],
components: { GxnlptCardTags },
data() {
return {
starOutline,
starFilled,
contentView: 'list',
activeTabIndex: 0,
suppressSectionObserver: false,
scrollRootEl: null,
pageLoading: false,
useDemoData: false,
categoryList: buildCategoryList(),
submitForm: SUBMIT_FORM_EMPTY(),
submitTouched: {},
submitErrors: {},
submitAttempted: false,
stackedNavLayout: readStackedNavLayout(),
_stackedNavMq: null,
favoriteList: [],
favoritesLoading: false,
};
},
computed: {
isStackedNavMode() {
return this.stackedNavLayout;
},
listSections() {
if (this.isStackedNavMode) {
const item = this.categoryList[this.activeTabIndex];
if (!item) return [];
return [{ item, index: this.activeTabIndex }];
}
return this.categoryList.map((item, index) => ({ item, index }));
},
/* 碳链接:按 3 大类(产品碳足迹 / 企业碳管理平台 / CBAM分组 */
carbonLinkGroups() {
const order = ['产品碳足迹', '企业碳管理平台', 'CBAM'];
const groups = order.map((type) => ({
type,
list: (carbonLinksData || []).filter((x) => x.type === type),
}));
return groups.filter((g) => g.list.length > 0);
},
favoriteCards() {
if (this.favoriteList && this.favoriteList.length) {
return this.favoriteList;
}
const list = [];
this.categoryList.forEach((cat) => {
cat.cardList.forEach((card) => {
if (card.scbz === 'Y') {
list.push(card);
}
});
});
return list;
},
},
created() {
this.stackedNavLayout = readStackedNavLayout();
},
mounted() {
this.bindStackedNavMedia();
this.bootstrap();
this.$nextTick(() => this.initSidebarSticky());
},
activated() {
this.syncStackedNavLayout();
if (this.contentView === 'list' && !this.stackedNavLayout) {
this.$nextTick(() => {
this.initScrollSpy();
this.initSidebarSticky();
});
}
},
beforeDestroy() {
this.unbindStackedNavMedia();
this.clearScrollUnlock();
this.destroyScrollSpy();
this.destroySidebarSticky();
},
methods: {
/** 收录 / 我的收藏 / 收藏操作前校验登录(弹窗引导,不直接跳登录页) */
ensureLogin(actionText) {
if (isPortalLoggedIn()) {
return true;
}
showLoginGuide({ actionText });
return false;
},
async bootstrap() {
try {
await this.loadAllSections();
} catch (e) {
console.error('共性能力页加载失败', e);
this.applyDemoFallback();
}
this.$nextTick(() => {
const anchor = this.$route.query.anchor || this.$route.params.anchor;
if (anchor) {
const index = this.categoryList.findIndex((item) => item.id === anchor);
const tabIndex = index >= 0 ? index : 0;
if (this.isStackedNavMode) {
this.activeTabIndex = tabIndex;
} else {
this.initScrollSpy();
this.initSidebarSticky();
this.scrollToSection(anchor, tabIndex);
}
return;
}
this.initScrollSpy();
this.initSidebarSticky();
});
},
getIconUrl(iconName) {
return SIDE_ICON_GRAY[iconName] || SIDE_ICON_GRAY['thspt.svg'];
},
getSideIconUrl(iconName, isActive) {
if (isActive && SIDE_ICON_WHITE[iconName]) {
return SIDE_ICON_WHITE[iconName];
}
return this.getIconUrl(iconName);
},
bindStackedNavMedia() {
if (typeof window === 'undefined' || !window.matchMedia) return;
this._stackedNavMq = window.matchMedia(STACKED_NAV_MEDIA);
this._onStackedNavMqChange = () => this.syncStackedNavLayout();
if (typeof this._stackedNavMq.addEventListener === 'function') {
this._stackedNavMq.addEventListener('change', this._onStackedNavMqChange);
} else if (typeof this._stackedNavMq.addListener === 'function') {
this._stackedNavMq.addListener(this._onStackedNavMqChange);
}
this.syncStackedNavLayout();
},
unbindStackedNavMedia() {
if (!this._stackedNavMq || !this._onStackedNavMqChange) return;
if (typeof this._stackedNavMq.removeEventListener === 'function') {
this._stackedNavMq.removeEventListener('change', this._onStackedNavMqChange);
} else if (typeof this._stackedNavMq.removeListener === 'function') {
this._stackedNavMq.removeListener(this._onStackedNavMqChange);
}
this._stackedNavMq = null;
this._onStackedNavMqChange = null;
},
syncStackedNavLayout() {
const next = readStackedNavLayout();
const prev = this.stackedNavLayout;
this.stackedNavLayout = next;
if (prev === next) return;
this.$nextTick(() => {
if (this.contentView !== 'list') return;
if (next) {
this.destroyScrollSpy();
} else {
this.initScrollSpy();
}
});
},
scrollListContentToTop() {
const scrollRoot = this.getScrollRoot();
const contentEl = this.$el && this.$el.querySelector('.gxnlpt-content');
if (scrollRoot && contentEl) {
const rootRect = scrollRoot.getBoundingClientRect();
const contentRect = contentEl.getBoundingClientRect();
const targetTop = scrollRoot.scrollTop + (contentRect.top - rootRect.top);
scrollRoot.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
return;
}
if (scrollRoot) {
scrollRoot.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
window.scrollTo({ top: 0, behavior: 'smooth' });
},
/**
* 解析后端标签:支持 JSON 数组、对象数组、逗号分隔字符串
* 例:'["全国","碳核查"]' | '[{"name":"全国"}]' | '全国,碳核查'
*/
splitTagText(value) {
if (value == null || value === '') return [];
if (Array.isArray(value)) {
return value.flatMap((item) => this.splitTagText(item));
}
if (typeof value === 'object') {
const label =
value.label ??
value.name ??
value.title ??
value.text ??
value.bqmc ??
value.bqMc ??
value.value;
const text = label != null ? String(label).trim() : '';
return text ? [text] : [];
}
const str = String(value).trim();
if (!str) return [];
if (str.startsWith('[') || str.startsWith('{')) {
try {
return this.splitTagText(JSON.parse(str));
} catch (e) {
/* 非 JSON按逗号分隔 */
}
}
return str
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean);
},
buildTagList(item) {
const tags = [];
const append = (list) => {
list.forEach((t) => {
if (t && !tags.includes(t)) tags.push(t);
});
};
// 优先:后端标签合集 / 服务类型(后期可为 JSON
append(this.splitTagText(item.bqjh));
append(this.splitTagText(item.tagList));
append(this.splitTagText(item.bqList));
append(this.splitTagText(item.fwlxjh));
append(this.splitTagText(item.fwlxbq));
if (Array.isArray(item.fwlxbqList) && item.fwlxbqList.length) {
append(item.fwlxbqList);
}
const maxTags = DEMO_TAG_OVERFLOW_PREVIEW && FORCE_DEMO_PREVIEW ? 20 : 12;
return tags.slice(0, maxTags);
},
attachTags(card) {
const tagList = this.buildTagList(card);
card.fwlxbqList = tagList;
card.tagList = tagList;
return card;
},
/** 解析 fwlxjh / bqjh含 JSON并生成 tagList */
normalizeRecord(item) {
const record = { ...item };
record.gxUuid = record.wzUuid;
record.fwnr = record.jj;
const typeTags = this.splitTagText(record.fwlxjh);
const labelTags = this.splitTagText(record.bqjh);
record.fwlxbqList = [...new Set([...typeTags, ...labelTags])];
return this.attachTags(record);
},
ensureCardTags(card, category) {
if (this.useDemoData) {
if (card.tagList && card.tagList.length) return;
card.tagList = [];
card.fwlxbqList = [];
return;
}
if (card.tagList && card.tagList.length) return;
const inferred = inferTagsFromContent(card, category);
if (!inferred.length) {
card.tagList = [];
card.fwlxbqList = [];
return;
}
card.tagList = inferred;
card.fwlxbqList = inferred;
},
matchCategory(record, category) {
// 优先按共性能力分类代码精确匹配gxnlFlDm: 01~05
if (record.gxnlFlDm && category.flDm && record.gxnlFlDm === category.flDm) {
return true;
}
// 兜底:按关键词匹配
const text = `${record.bt1 || ''}${record.jj || ''}${record.fwlxjh || ''}${record.qymc || ''}`;
return category.keywords.some((kw) => text.includes(kw));
},
assignCategoryCards(allRecords) {
this.categoryList.forEach((cat) => {
const list = allRecords
.filter((r) => this.matchCategory(r, cat))
.map((r) => {
const card = this.normalizeRecord(r);
this.ensureCardTags(card, cat);
return card;
});
cat.cardList = list;
cat.total = list.length;
cat.expanded = false;
this.refreshDisplayList(cat);
});
},
applyDemoFallback() {
this.useDemoData = true;
this.categoryList.forEach((cat) => {
const demos = DEMO_BY_CATEGORY[cat.id] || [];
cat.cardList = demos.map((c, i) => {
const demo = shouldInjectOverflowDemoTags(cat.id, i)
? { ...c, bqjh: mergeDemoOverflowTags(c.bqjh, cat.id) }
: c;
return this.attachTags({
...demo,
_demoId: `${cat.id}-${i}`,
gxUuid: '',
});
});
cat.total = cat.cardList.length;
cat.expanded = false;
this.refreshDisplayList(cat);
});
},
refreshDisplayList(category) {
category.displayList = category.expanded
? category.cardList.slice()
: category.cardList.slice(0, category.previewSize);
},
toggleViewMore(category) {
category.expanded = !category.expanded;
this.refreshDisplayList(category);
},
async loadAllSections() {
this.pageLoading = true;
this.useDemoData = false;
if (FORCE_DEMO_PREVIEW) {
this.applyDemoFallback();
this.pageLoading = false;
return;
}
try {
const res = await api.wzxxList({
pageNo: 1,
pageSize: 200,
});
const data = res && res.data;
const records = ((data && data.records) || []).map((r) => this.normalizeRecord(r));
if (!records.length) {
this.applyDemoFallback();
return;
}
this.assignCategoryCards(records);
const hasCards = this.categoryList.some((c) => c.displayList.length);
if (!hasCards) {
this.applyDemoFallback();
}
} catch (e) {
console.error('共性能力列表加载失败', e);
this.applyDemoFallback();
} finally {
this.pageLoading = false;
}
},
getScrollRoot() {
// 门户落地页共用滚动容器为 .content-wrapmain.vue 唯一滚动区),
// 必须优先锁定它,避免被内部 .gxnlpt-side-nav-wrap 等 overflow-y:auto 抢走
const portalRoot = document.querySelector('.content-wrap');
if (portalRoot) return portalRoot;
let el = this.$el.parentElement;
while (el) {
const style = window.getComputedStyle(el);
if (/(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight) {
return el;
}
el = el.parentElement;
}
return null;
},
clearScrollUnlock() {
if (this._scrollUnlockTimer) {
clearTimeout(this._scrollUnlockTimer);
this._scrollUnlockTimer = null;
}
const scrollRoot = this.getScrollRoot();
if (scrollRoot && this._scrollEndHandler) {
scrollRoot.removeEventListener('scrollend', this._scrollEndHandler);
this._scrollEndHandler = null;
}
},
unlockSectionObserver(tabIndex) {
this.suppressSectionObserver = false;
if (typeof tabIndex === 'number' && !Number.isNaN(tabIndex)) {
this.activeTabIndex = tabIndex;
}
},
scheduleScrollUnlock(tabIndex) {
this.clearScrollUnlock();
this.suppressSectionObserver = true;
this.activeTabIndex = tabIndex;
const scrollRoot = this.getScrollRoot();
let unlocked = false;
const done = () => {
if (unlocked) return;
unlocked = true;
this.clearScrollUnlock();
this.unlockSectionObserver(tabIndex);
};
if (scrollRoot) {
this._scrollEndHandler = done;
scrollRoot.addEventListener('scrollend', done, { once: true });
}
this._scrollUnlockTimer = setTimeout(done, 900);
},
onSideNavClick(sectionId, tabIndex) {
if (this.contentView !== 'list') {
this.contentView = 'list';
this.$nextTick(() => {
this.handleCategoryNav(sectionId, tabIndex);
});
return;
}
this.handleCategoryNav(sectionId, tabIndex);
},
handleCategoryNav(sectionId, tabIndex) {
if (typeof tabIndex === 'number' && !Number.isNaN(tabIndex)) {
this.activeTabIndex = tabIndex;
}
if (this.isStackedNavMode) {
this.$nextTick(() => this.scrollListContentToTop());
return;
}
this.initScrollSpy();
this.initSidebarSticky();
this.scrollToSection(sectionId, tabIndex);
},
openSubmitView() {
if (!this.ensureLogin('提交收录')) {
return;
}
this.destroyScrollSpy();
this.destroySidebarSticky();
this.contentView = 'submit';
this.resetSubmitForm();
},
openFavoritesView() {
if (!this.ensureLogin('查看我的收藏')) {
return;
}
this.destroyScrollSpy();
this.destroySidebarSticky();
this.contentView = 'favorites';
this.loadFavorites();
},
async loadFavorites() {
this.favoritesLoading = true;
try {
const res = await api.myFavoriteList({ pageNo: 1, pageSize: 200 });
const data = res && res.data;
const records = ((data && data.records) || []).map((r) => this.normalizeRecord(r));
this.favoriteList = records.map((card) => {
card.scbz = 'Y';
return card;
});
} catch (e) {
if (isUnauthorizedError(e)) {
showLoginGuide({ actionText: '查看我的收藏' });
return;
}
this.$message.warning('我的收藏加载失败');
} finally {
this.favoritesLoading = false;
}
},
resetSubmitForm() {
this.submitForm = SUBMIT_FORM_EMPTY();
this.submitTouched = {};
this.submitErrors = {};
this.submitAttempted = false;
},
touchSubmitField(field) {
this.$set(this.submitTouched, field, true);
this.validateSubmitField(field);
},
validateSubmitField(field) {
const errors = { ...this.submitErrors };
const form = this.submitForm;
const required = {
bt1: '请输入名称',
lj: '请输入链接',
jj: '请输入简介',
};
if (field === 'fl' && !form.fl) {
errors.fl = '请选择分类';
} else if (field === 'fl') {
delete errors.fl;
} else if (required[field] && !form[field]) {
errors[field] = required[field];
} else if (required[field]) {
delete errors[field];
}
if (field === 'lj' && form.lj && !/^https?:\/\//i.test(form.lj)) {
errors.lj = '请输入有效链接(以 http:// 或 https:// 开头)';
}
this.submitErrors = errors;
return !errors[field];
},
validateSubmitForm() {
const fields = ['bt1', 'lj', 'jj', 'fl'];
let valid = true;
fields.forEach((field) => {
this.$set(this.submitTouched, field, true);
if (!this.validateSubmitField(field)) {
valid = false;
}
});
return valid;
},
async handleSubmitForm() {
if (!this.ensureLogin('提交收录')) {
return;
}
this.submitAttempted = true;
if (!this.validateSubmitForm()) {
return;
}
if (this.useDemoData) {
this.$message.success('提交成功,请等待审核');
this.resetSubmitForm();
this.contentView = 'list';
return;
}
try {
await api.submitSlxx({
bt: this.submitForm.bt1,
wzLj: this.submitForm.lj,
jj: this.submitForm.jj,
gxnlFlDm: flTitleToDm(this.submitForm.fl),
bqjh: this.submitForm.bq,
});
this.$message.success('提交成功,请等待审核');
this.resetSubmitForm();
this.contentView = 'list';
await this.loadAllSections();
} catch (e) {
if (isUnauthorizedError(e)) {
showLoginGuide({ actionText: '提交收录' });
return;
}
this.$message.warning('提交失败,请稍后重试');
}
},
/**
* 平滑滚动到指定分类区块。
* 通过 getBoundingClientRect 获取目标元素在视口内的视觉位置,
* 反算至滚动容器布局坐标后 scrollTo不受 scale 变换影响。
*/
scrollToSection(sectionId, tabIndex) {
const el = document.getElementById(sectionId);
const scrollRoot = this.getScrollRoot();
if (!el || !scrollRoot) return;
const scale = this._readScale();
if (scale <= 0) return;
const rootVisualTop = scrollRoot.getBoundingClientRect().top;
const elVisualTop = el.getBoundingClientRect().top;
// 滚动容器内视觉距离 → 布局距离
const visualDist = elVisualTop - rootVisualTop;
const layoutDist = visualDist / scale;
const layoutTarget = scrollRoot.scrollTop + layoutDist;
// nav (64dp) + gap (24dp)
const OFFSET_DP = 88; // 设计稿像素
const target = Math.max(0, layoutTarget - OFFSET_DP);
scrollRoot.scrollTo({ top: target, behavior: 'smooth' });
this.scheduleScrollUnlock(tabIndex);
},
/* ========== IntersectionObserver 驱动的 ScrollSpy ========== */
/**
* 用 IntersectionObserver 监听每个分类区块与滚动容器顶部的交叉比例,
* 选择最接近顶部且露出面积最大的区块为当前激活项。
* 相比手写 scroll 事件更精准、更省性能,且不受 scale 变换影响。
*/
initScrollSpy() {
this.destroyScrollSpy();
if (this.isStackedNavMode || this.contentView !== 'list') return;
const scrollRoot = this.getScrollRoot();
if (!scrollRoot) return;
this.scrollRootEl = scrollRoot;
// 区块交叉记录:{ index, ratio }
this._spyEntries = [];
this._spyRootRect = null;
const obsOptions = {
root: scrollRoot,
// rootMargin 在顶部留出 nav(64dp)+gap(24dp)=88dp 的缓冲区,
// 让区块顶部触及该线时才算"进入视口"。
rootMargin: `-88px 0px 0px 0px`,
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
};
this._spyObserver = new IntersectionObserver((entries) => {
const rootRect = scrollRoot.getBoundingClientRect();
this._spyRootRect = rootRect;
entries.forEach((entry) => {
const index = Number(entry.target.dataset.sectionIndex);
if (Number.isNaN(index)) return;
const existing = this._spyEntries.find((e) => e.index === index);
if (existing) {
existing.ratio = entry.intersectionRatio;
} else {
this._spyEntries.push({ index, ratio: entry.intersectionRatio });
}
});
this._applySpyResult();
}, obsOptions);
// 监听所有 gxnlpt-block 区块
const blocks = this.$el.querySelectorAll('.gxnlpt-block[data-section-index]');
blocks.forEach((block) => this._spyObserver.observe(block));
// 立刻触发一次cover 初始状态
this.$nextTick(() => {
this._spyEntries = [];
const rootRect = scrollRoot.getBoundingClientRect();
blocks.forEach((block, index) => {
const sectionEl = document.getElementById(this.categoryList[index]?.id);
if (!sectionEl) return;
// 手动检测section 顶部是否已在缓冲区线以下
const sectionTop = sectionEl.getBoundingClientRect().top;
const line = rootRect.top + 88;
if (sectionTop <= line) {
this._spyEntries.push({ index, ratio: sectionTop <= rootRect.top ? 1 : 0.5 });
}
});
this._applySpyResult();
});
},
_applySpyResult() {
if (this.suppressSectionObserver) return;
if (!this._spyEntries.length) return;
// 优先取 ratio > 0 且最接近顶部的区块
const visible = this._spyEntries.filter((e) => e.ratio > 0);
if (visible.length) {
// ratio 最高的(最靠近顶部)
const best = visible.reduce((a, b) => (a.ratio >= b.ratio ? a : b));
if (this.activeTabIndex !== best.index) {
this.activeTabIndex = best.index;
}
return;
}
// 没有交叉的:取最后一个"section 顶部已在缓冲区线之上"的
const rootRect = this._spyRootRect || this.getScrollRoot()?.getBoundingClientRect();
if (!rootRect) return;
const line = rootRect.top + 88;
let lastAbove = 0;
this.categoryList.forEach((item, index) => {
const el = document.getElementById(item.id);
if (!el) return;
if (el.getBoundingClientRect().top <= line) {
lastAbove = index;
}
});
if (this.activeTabIndex !== lastAbove) {
this.activeTabIndex = lastAbove;
}
},
destroyScrollSpy() {
if (this._spyObserver) {
this._spyObserver.disconnect();
this._spyObserver = null;
}
this._spyEntries = [];
this._spyRootRect = null;
this.scrollRootEl = null;
},
/* ========== 侧边导航 sticky 模拟(替代 CSS position:sticky ========== */
/**
* 原因:.portal-figma-scale-stage 有 transform:scale()
* 会创建新的 containing block导致 CSS position:sticky 完全失效。
*
* 解决方案:
* - 每帧滚动时,实时测量侧边栏"自然视觉位置"(无 transform 时的 getBoundingClientRect
* - 与导航栏上方 88dp (64dp 导航栏 + 24dp 间距) 比较
* - 若自然位置已被滚出视口上方,用 translateY 推回来
* - translateY 值不超过 sidebar 容器高度,防止侧边栏脱出
*
* 坐标系:
* - getBoundingClientRect → 视觉像素scale 后)
* - translateY → 布局像素scale 容器内部空间)
*/
_readScale() {
if (typeof document === 'undefined') return 1;
const val = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--home-figma-scale'),
);
return val > 0 ? val : (window.innerWidth >= 768 ? window.innerWidth / 1440 : 1);
},
/**
* 测量 sticky 元素当前的自然视觉 top清除 transform 后测量,再恢复)。
* 返回 { naturalVisualTop, stickyHeight, sidebarHeight } 三个视觉像素值。
* 若元素不存在或 scale 无效则返回 null。
*/
_measureStickyNatural() {
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
const sidebarEl = stickyEl?.parentElement;
const scrollRoot = this.getScrollRoot();
const scale = this._readScale();
if (!stickyEl || !sidebarEl || !scrollRoot || scale <= 0) return null;
// 保存当前 transform清除后测量自然位置
const saved = stickyEl.style.transform;
stickyEl.style.transform = '';
const rect = stickyEl.getBoundingClientRect();
const rootRect = scrollRoot.getBoundingClientRect();
const sidebarRect = sidebarEl.getBoundingClientRect();
// 恢复 transform
stickyEl.style.transform = saved;
return {
// 自然视觉 top相对于滚动容器 visual top
naturalVisualTop: rect.top - rootRect.top,
// sticky 元素视觉高度
stickyHeight: rect.height,
// 父容器视觉高度
sidebarHeight: sidebarRect.height,
// 父容器视觉 top相对于滚动容器 visual top
sidebarVisualTop: sidebarRect.top - rootRect.top,
};
},
initSidebarSticky() {
this.destroySidebarSticky();
if (this.isStackedNavMode || this.contentView !== 'list') return;
const scrollRoot = this.getScrollRoot();
if (!scrollRoot) return;
this._sidebarStickyScrollEl = scrollRoot;
const handler = () => {
if (this._sidebarStickyRaf) return;
this._sidebarStickyRaf = requestAnimationFrame(() => {
this._sidebarStickyRaf = null;
this.updateSidebarSticky();
});
};
scrollRoot.addEventListener('scroll', handler, { passive: true });
this._onSidebarStickyScroll = handler;
// resize 时 scale 可能变化,触发重新测量
this._onSidebarResize = () => {
if (this._sidebarResizeTimer) clearTimeout(this._sidebarResizeTimer);
this._sidebarResizeTimer = setTimeout(() => {
this.updateSidebarSticky();
}, 60);
};
window.addEventListener('resize', this._onSidebarResize);
// figmaStage 尺寸变化(异步数据填充导致),也触发重新测量
const stage = this.$refs.figmaStage;
if (stage && typeof ResizeObserver !== 'undefined') {
this._sidebarStageObserver = new ResizeObserver(() => {
if (this._sidebarStageTimer) clearTimeout(this._sidebarStageTimer);
this._sidebarStageTimer = setTimeout(() => {
this.updateSidebarSticky();
}, 80);
});
this._sidebarStageObserver.observe(stage);
}
this.$nextTick(() => this.updateSidebarSticky());
},
destroySidebarSticky() {
if (this._sidebarStickyScrollEl && this._onSidebarStickyScroll) {
this._sidebarStickyScrollEl.removeEventListener('scroll', this._onSidebarStickyScroll);
}
this._sidebarStickyScrollEl = null;
this._onSidebarStickyScroll = null;
if (this._sidebarStickyRaf) {
cancelAnimationFrame(this._sidebarStickyRaf);
this._sidebarStickyRaf = null;
}
if (this._onSidebarResize) {
window.removeEventListener('resize', this._onSidebarResize);
this._onSidebarResize = null;
}
if (this._sidebarResizeTimer) {
clearTimeout(this._sidebarResizeTimer);
this._sidebarResizeTimer = null;
}
if (this._sidebarStageObserver) {
this._sidebarStageObserver.disconnect();
this._sidebarStageObserver = null;
}
if (this._sidebarStageTimer) {
clearTimeout(this._sidebarStageTimer);
this._sidebarStageTimer = null;
}
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
if (stickyEl) stickyEl.style.transform = '';
},
updateSidebarSticky() {
const stickyEl = this.$el?.querySelector('.gxnlpt-sidebar-sticky');
if (!stickyEl) return;
const info = this._measureStickyNatural();
if (!info) return;
const scale = this._readScale();
// CSS position:sticky 的 top:24px → 相对于滚动容器顶部 24dp
// naturalVisualTop 也是相对于滚动容器顶部,所以用 24 直接对比
const DESIRED_DP = 24;
const desiredVisualTop = DESIRED_DP * scale;
const naturalVisualTop = info.naturalVisualTop;
// 自然位置已在目标线之下 → 不需要补偿
if (naturalVisualTop >= desiredVisualTop) {
stickyEl.style.transform = '';
return;
}
// 需要推回去的视觉像素距离
const visualOffset = desiredVisualTop - naturalVisualTop;
// 转为布局像素translateY 在 scale 容器内以布局像素为单位)
const layoutOffset = visualOffset / scale;
// 不能推出父容器底部
// sticky 元素在 sidebar 内部有 padding 偏移,需要核算 sidebar 顶部到 sticky 顶部
// 的真实可用距离,而不能简单地 sidebarHeight - stickyHeight
const sidebarBottom = info.sidebarVisualTop + info.sidebarHeight;
const stickyBottom = info.naturalVisualTop + info.stickyHeight;
// 允许的视觉余量sidebar 底部 - sticky 自然底部
const availableVisual = Math.max(0, sidebarBottom - stickyBottom);
// 由于 naturalVisualTop 已经包含了 padding 偏移,所以 availableVisual
// 就是 sticky 底部到 sidebar 底部的真实视觉距离
const maxLayoutOffset = availableVisual / scale;
const clamped = Math.min(layoutOffset, maxLayoutOffset);
stickyEl.style.transform = clamped > 0
? `translateY(${clamped}px)`
: '';
},
handleCardClick(card) {
// 优先使用后端返回的 wzLj否则用兜底数据的 lj
const target = card.wzLj || card.lj;
if (target) {
window.open(target, '_blank', 'noopener,noreferrer');
return;
}
// 仅当后端记录或兜底数据都缺失 lj 时,才走“敬请期待”兜底
this.showComingSoon(card.bt1 ? `${card.bt1}(详情筹备中)` : '详情筹备中');
},
async handleCollect(card) {
const isAdd = card.scbz !== 'Y';
if (isAdd && !this.ensureLogin('收藏')) {
return;
}
if (this.useDemoData || !card.gxUuid) {
card.scbz = card.scbz === 'Y' ? 'N' : 'Y';
return;
}
try {
const type = card.scbz === 'Y' ? 'remove' : 'add';
await api.toggleGxsc({ wzUuid: card.gxUuid, type });
const next = card.scbz === 'Y' ? 'N' : 'Y';
card.scbz = next;
this.syncFavoriteListAfterToggle(card, next);
} catch (e) {
if (isUnauthorizedError(e)) {
showLoginGuide({ actionText: '收藏' });
return;
}
this.$message.warning('收藏操作失败');
}
},
/** 收藏切换后同步本地 favoriteList避免再次进入「我的收藏」时与服务端不一致 */
syncFavoriteListAfterToggle(card, nextScbz) {
const list = this.favoriteList || [];
const idx = list.findIndex((c) => c.gxUuid && c.gxUuid === card.gxUuid);
if (nextScbz === 'Y') {
if (idx < 0) {
list.unshift({ ...card, scbz: 'Y' });
} else {
list.splice(idx, 1, { ...card, scbz: 'Y' });
}
} else if (idx >= 0) {
list.splice(idx, 1);
}
this.favoriteList = list;
},
},
};
</script>
<style lang="less" scoped>
@import '../../styles/breakpoints.less';
@import '../../styles/home-figma-variables.less';
/* Figma 150605:2210 共性能力 */
.gxnlpt-page {
display: flex;
flex-direction: column;
min-height: 100%;
padding-top: 0;
overflow: visible;
font-family: @home-font-family;
background: @gxnlpt-page-bg;
box-sizing: border-box;
.portal-figma-scale-page();
}
/* gxnlpt 内容远大于一屏5 个分类 + 大量卡片):
- 整页走 Figma 缩放时stage 内的 .gxnlpt-shell (max-width: 1200px) 会被整页 scale 缩放
到视口宽度。stage 自身 layout 高度 = 内容自然高度transform 不影响 layout 高度),
这会远大于 .content-wrap 的固定高度,理论上 .content-wrap 会出现滚动条。
- 关键陷阱home-figma-variables.less 中
html.portal-figma-scale-active.portal-landing-figma-scale-active
.portal-landing-figma-page { height: var(--home-figma-visual-height) !important; ... }
会把 page 高度 clamp 到 --home-figma-visual-height。
而 --home-figma-visual-height 由 syncHomeFigmaStageLayout 在 mounted nextTick 写入:
visualHeight = stage.offsetHeight * scale
在 gxnlpt 上 stage 此时还是初始高度demo 数据/卡片未装填),算出来的值比真实
stage 高度小几百到上千像素,于是 page 永远被 clamp 裁切,
--home-figma-visual-height 也不会被重算(除了 resize
表现F12 关闭视口时 height=616 → 裁切剩 1 屏F12 打开后视口高度变化 →
resize 重算 visualHeight → 变成一个不同的值 → 整页能塞下。
- 解法:让 .gxnlpt-page 不依赖 --home-figma-visual-height 决定高度。直接重写为
height:auto / max-height:none / overflow:visible。 */
html.portal-figma-scale-active.portal-landing-figma-scale-active .gxnlpt-page,
html.portal-figma-scale-active .gxnlpt-page {
/* 不再走 landing page 的 height clamp否则 page 高度被卡死,外层不会滚动 */
height: auto !important;
max-height: none !important;
overflow: visible !important;
.portal-figma-scale-viewport {
/* viewport 自身高度由 stage 自然撑开,不再被 clamp 到 --home-figma-visual-height。
这样 .content-wrap 内的 .gxnlpt-page 能被 5 个分类的内容自然撑高。 */
height: auto !important;
/* 保持 overflow:hidden 也无害stage 是 transform:scale 缩放显示,
layout 高度 = 缩放前高度viewport 高度 = 同一个 layout 高度stage 不会被裁切。 */
}
}
.gxnlpt-shell.page-content-wrap {
display: flex;
flex: 1 0 auto;
flex-direction: column;
padding-top: @gxnlpt-shell-padding-top;
padding-bottom: @gxnlpt-shell-padding-bottom;
}
.gxnlpt-layout {
display: grid;
flex: 1 1 auto;
grid-template-columns: @gxnlpt-sidebar-width minmax(0, 1fr);
gap: @gxnlpt-layout-gap;
align-items: stretch;
min-height: @gxnlpt-content-min-height;
}
@gxnlpt-side-actions-block-h: 114px;
@gxnlpt-side-actions-gap-top: 20px;
/* Figma Frame_left 602px白卡片高度拉伸至与右侧内容列同高
为 sticky 侧边导航提供足够的垂直滚动空间,确保导航始终可见。
空白区域由 gxnlpt-sidebar-tail 填充,符合设计稿"白底向下铺满"的意图。 */
.gxnlpt-sidebar {
display: flex;
flex-direction: column;
align-self: stretch;
width: @gxnlpt-sidebar-width;
min-height: 0;
padding: @gxnlpt-sidebar-padding;
background: #fff;
border-radius: @gxnlpt-sidebar-radius;
box-sizing: border-box;
}
/* 分类 + 收录/收藏:滚动时钉在视口 */
.gxnlpt-sidebar-sticky {
position: sticky;
top: @gxnlpt-sidebar-sticky-top;
z-index: 20;
flex: 0 0 auto;
display: flex;
flex-direction: column;
max-height: calc(
100vh - var(--page-offset-top, @home-nav-height) - @gxnlpt-shell-padding-top -
@gxnlpt-shell-padding-bottom - @gxnlpt-sidebar-sticky-top
);
}
/* 图三:分类过多时在块内滚动 */
.gxnlpt-side-nav-wrap {
flex: 0 1 auto;
min-height: 0;
max-height: calc(
100vh - var(--page-offset-top, @home-nav-height) - @gxnlpt-shell-padding-top -
@gxnlpt-shell-padding-bottom - @gxnlpt-sidebar-sticky-top -
@gxnlpt-side-actions-block-h - @gxnlpt-side-actions-gap-top -
@gxnlpt-sidebar-padding * 2
);
overflow-x: hidden;
overflow-y: auto;
}
.gxnlpt-side-nav {
display: flex;
flex-direction: column;
gap: @gxnlpt-side-nav-gap;
align-items: stretch;
width: 100%;
}
/* 图二:紧挨最后一条分类,仅上边框 + 20px 间距 */
.gxnlpt-side-actions {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: @gxnlpt-side-nav-gap;
margin-top: 0;
padding-top: @gxnlpt-side-actions-gap-top;
border-top: 1px solid @gxnlpt-side-action-border;
}
.gxnlpt-sidebar-tail {
flex: 1 1 0;
min-height: 0;
}
.gxnlpt-side-action {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
min-height: 42px;
margin: 0;
padding: 10px 20px;
font-family: @home-font-family;
font-size: @gxnlpt-side-action-size;
font-weight: 400;
line-height: normal;
color: @gxnlpt-side-action-color;
cursor: pointer;
appearance: none;
border: none;
border-radius: @gxnlpt-side-item-radius-active;
background: @gxnlpt-side-action-bg;
box-sizing: border-box;
outline: none;
transition: opacity 0.2s ease, box-shadow 0.2s ease;
&:hover {
opacity: 0.88;
}
&.is-active {
box-shadow: inset 0 0 0 1px @gxnlpt-side-action-color;
}
&:focus-visible {
box-shadow: 0 0 0 2px fade(@gxnlpt-side-action-color, 35%);
}
}
.gxnlpt-side-action-icon {
flex-shrink: 0;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
&--cloud {
width: 20px;
height: 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M14.5 12.5H6.2C4.4 12.5 3 11.1 3 9.3 3 7.6 4.2 6.2 5.9 6c.3-2 2.2-3.5 4.4-3.5 1.5 0 2.8.7 3.6 1.8.4-.1.8-.2 1.2-.2 2 0 3.6 1.6 3.6 3.6 0 2-1.6 3.6-3.6 3.6h-.6z' stroke='%2300b96b' stroke-width='1.2'/%3E%3C/svg%3E");
}
&--star {
width: 14px;
height: 14px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M7 1.2l1.45 3.1 3.35.5-2.42 2.36.57 3.34L7 9.2l-2.95 1.3.57-3.34-2.42-2.36 3.35-.5L7 1.2z' fill='%23f5a623'/%3E%3C/svg%3E");
}
}
.gxnlpt-side-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: @gxnlpt-side-item-height;
margin: 0;
padding: 10px 20px;
font-family: @home-font-family;
text-align: left;
color: inherit;
cursor: pointer;
appearance: none;
border: none;
border-radius: @gxnlpt-side-item-radius;
background: transparent;
box-sizing: border-box;
outline: none;
transition: background 0.2s ease, color 0.2s ease, border-radius 0.2s ease;
&:focus-visible {
box-shadow: 0 0 0 2px fade(@home-color-primary-green, 35%);
}
&:not(.is-active):hover {
background: @gxnlpt-page-bg;
}
&.is-active {
border-radius: @gxnlpt-side-item-radius-active;
background: linear-gradient(180deg, @home-color-primary-green 0%, @home-color-primary-green-dark 100%);
.gxnlpt-side-label {
font-weight: 600;
color: #fff;
}
}
}
.gxnlpt-side-icon {
display: block;
width: 24px;
height: 24px;
object-fit: contain;
justify-self: center;
flex-shrink: 0;
}
.gxnlpt-side-label {
min-width: 0;
overflow: hidden;
font-size: @gxnlpt-side-label-size;
font-weight: 400;
line-height: normal;
color: #000;
text-overflow: ellipsis;
white-space: nowrap;
}
.gxnlpt-content {
display: flex;
flex-direction: column;
gap: @gxnlpt-section-gap;
min-width: 0;
min-height: @gxnlpt-content-min-height;
padding: @gxnlpt-content-padding;
overflow-x: hidden;
overflow-y: visible;
background: #fff;
border-radius: @gxnlpt-sidebar-radius;
box-sizing: border-box;
}
.gxnlpt-block {
display: flex;
flex-direction: column;
gap: @gxnlpt-block-gap;
align-items: stretch;
width: 100%;
margin: 0;
scroll-margin-top: 24px;
}
.gxnlpt-block-head {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
padding-bottom: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.gxnlpt-block-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.gxnlpt-block-title {
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 22px;
color: @home-color-primary-dark;
}
.gxnlpt-block-subtitle {
margin-left: auto;
font-size: 12px;
font-weight: 400;
line-height: 20px;
color: rgba(0, 59, 26, 0.6);
white-space: nowrap;
}
/* ========== 碳链接专区 ========== */
.gxnlpt-carbon-links {
margin-top: 36px;
}
.gxnlpt-carbon-group {
margin-top: 18px;
}
.gxnlpt-carbon-group-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
color: @home-color-primary-dark;
}
.gxnlpt-carbon-group-bar {
display: inline-block;
width: 4px;
height: 16px;
background: linear-gradient(180deg, @home-color-primary-green 0%, @home-color-primary-green-dark 100%);
border-radius: 2px;
}
.gxnlpt-carbon-group-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 18px;
padding: 0 6px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: @home-color-primary-green-dark;
background: rgba(0, 185, 107, 0.12);
border-radius: 9px;
}
.gxnlpt-carbon-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 10px 12px;
margin: 0;
padding: 0;
list-style: none;
}
.gxnlpt-carbon-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 56px;
padding: 10px 14px;
background: #ffffff;
border: 1px solid rgba(0, 185, 107, 0.2);
border-radius: 6px;
transition: all 0.25s ease;
box-sizing: border-box;
}
.gxnlpt-carbon-item:hover {
border-color: @home-color-primary-green;
background: rgba(0, 185, 107, 0.04);
box-shadow: 0 4px 12px rgba(0, 154, 41, 0.12);
}
.gxnlpt-carbon-link {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
text-decoration: none;
color: @home-color-primary-dark;
}
.gxnlpt-carbon-name {
font-size: 14px;
font-weight: 500;
line-height: 22px;
color: @home-color-primary-dark;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gxnlpt-carbon-desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: rgba(0, 59, 26, 0.6);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gxnlpt-carbon-link:hover .gxnlpt-carbon-name {
color: @home-color-primary-green-dark;
}
.gxnlpt-carbon-external {
flex-shrink: 0;
font-size: 14px;
color: @home-color-primary-green;
opacity: 0.5;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.gxnlpt-carbon-item:hover .gxnlpt-carbon-external {
opacity: 1;
transform: translate(2px, -2px);
}
@media (max-width: 640px) {
.gxnlpt-block-subtitle {
display: none;
}
.gxnlpt-carbon-list {
grid-template-columns: 1fr;
}
}
.gxnlpt-state {
padding: 32px 0;
font-size: 14px;
color: #999;
text-align: center;
}
.gxnlpt-card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: @gxnlpt-card-grid-gap;
align-items: stretch;
}
.gxnlpt-card {
display: flex;
flex-direction: column;
align-items: stretch;
box-sizing: border-box;
min-height: @gxnlpt-card-min-height;
padding: @gxnlpt-card-padding-y @gxnlpt-card-padding-x;
background: #fff;
border: 1px solid fade(#000, 10%);
border-radius: @gxnlpt-card-radius;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, border-radius 0.2s ease;
&:hover {
border-color: @home-color-primary-green;
border-radius: @gxnlpt-card-radius-hover;
box-shadow: @home-shadow-card-green;
}
}
.gxnlpt-card-body {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
overflow: hidden;
}
.gxnlpt-card-body .gxnlpt-card-tags {
flex-shrink: 0;
margin-top: 2px;
}
.gxnlpt-card-info {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 4px;
width: 100%;
min-width: 0;
}
.gxnlpt-card-name-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 22px;
}
.gxnlpt-card-name {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
font-size: 16px;
font-weight: 500;
line-height: 22px;
color: #333;
text-overflow: ellipsis;
white-space: nowrap;
}
.gxnlpt-card-org {
margin: 0;
min-height: 20px;
overflow: hidden;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: #666;
text-overflow: ellipsis;
white-space: nowrap;
}
.gxnlpt-card--expired {
opacity: 0.55;
cursor: default;
pointer-events: none;
}
.gxnlpt-card--expired .gxnlpt-card-name {
color: #bbb;
}
.gxnlpt-card--expired .gxnlpt-card-org {
color: #ccc;
}
.gxnlpt-card--expired:hover {
border-color: fade(#000, 10%);
box-shadow: none;
}
.gxnlpt-card-expired-tag {
flex-shrink: 0;
padding: 1px 6px;
font-size: 11px;
line-height: 18px;
color: #999;
background: #f5f5f5;
border-radius: 3px;
pointer-events: auto;
}
.gxnlpt-card-star {
flex-shrink: 0;
width: 14px;
height: 14px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
img {
display: block;
width: 14px;
height: 14px;
}
}
.gxnlpt-more-wrap {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 100%;
min-height: 30px;
box-sizing: border-box;
}
.gxnlpt-more {
display: inline-block;
margin: 0;
padding: 0;
font-family: @home-font-family;
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: @gxnlpt-more-color;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
appearance: none;
background: transparent;
border: none;
outline: none;
&:hover {
opacity: 0.75;
}
&:focus-visible {
box-shadow: 0 0 0 2px fade(@home-color-primary-dark, 25%);
border-radius: 2px;
}
}
/* 收录表单 */
.gxnlpt-submit {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
min-height: 0;
overflow-y: auto;
}
.gxnlpt-submit-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 24px;
padding-bottom: 20px;
border-bottom: 1px solid fade(#000, 10%);
}
.gxnlpt-submit-title-row {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 22px;
}
.gxnlpt-submit-title-icon {
display: block;
flex-shrink: 0;
width: 20px;
height: 20px;
margin: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M14.5 12.5H6.2C4.4 12.5 3 11.1 3 9.3 3 7.6 4.2 6.2 5.9 6c.3-2 2.2-3.5 4.4-3.5 1.5 0 2.8.7 3.6 1.8.4-.1.8-.2 1.2-.2 2 0 3.6 1.6 3.6 3.6 0 2-1.6 3.6-3.6 3.6h-.6z' stroke='%2300b96b' stroke-width='1.2'/%3E%3C/svg%3E")
center / 20px 20px no-repeat;
}
.gxnlpt-submit-title {
margin: 0;
padding: 0;
font-size: 16px;
font-weight: 600;
line-height: 22px;
color: @home-color-primary-dark;
}
.gxnlpt-field-label {
display: inline-flex;
align-items: center;
font-size: 14px;
line-height: 20px;
color: #333;
}
.gxnlpt-field-required {
margin-right: 4px;
font-size: 14px;
line-height: 20px;
color: @gxnlpt-form-error;
}
.gxnlpt-submit-notice {
flex: 1 1 280px;
max-width: 420px;
margin: 0;
font-size: 12px;
line-height: 18px;
color: @home-color-text-muted;
text-align: right;
}
.gxnlpt-submit-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.gxnlpt-submit-body {
display: flex;
flex-direction: column;
width: 100%;
}
.gxnlpt-submit-row-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 24px;
}
.gxnlpt-field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.gxnlpt-input,
.gxnlpt-textarea,
.gxnlpt-select {
width: 100%;
padding: 10px 12px;
font-family: @home-font-family;
font-size: 14px;
line-height: 20px;
color: #333;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
box-sizing: border-box;
outline: none;
transition: border-color 0.2s ease;
&:focus {
border-color: @home-color-primary-green;
}
&.is-error {
border-color: @gxnlpt-form-error;
}
}
.gxnlpt-textarea {
min-height: 88px;
resize: vertical;
}
.gxnlpt-field-counter {
align-self: flex-end;
font-size: 12px;
line-height: 18px;
color: @home-color-text-muted;
}
.gxnlpt-field-error {
font-size: 12px;
line-height: 18px;
color: @gxnlpt-form-error;
}
.gxnlpt-submit-btn {
width: 100%;
min-height: 48px;
font-family: @home-font-family;
font-size: 16px;
font-weight: 500;
color: #fff;
cursor: pointer;
border: none;
border-radius: 8px;
background: linear-gradient(180deg, @home-color-primary-green 0%, @home-color-primary-green-dark 100%);
}
.gxnlpt-favorites {
display: flex;
flex-direction: column;
gap: @gxnlpt-block-gap;
width: 100%;
min-height: 0;
overflow-y: auto;
}
.gxnlpt-favorites-head {
margin: 0;
}
.gxnlpt-favorites-star {
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M7 1.2l1.45 3.1 3.35.5-2.42 2.36.57 3.34L7 9.2l-2.95 1.3.57-3.34-2.42-2.36 3.35-.5L7 1.2z' fill='%23f5a623'/%3E%3C/svg%3E")
center / contain no-repeat;
}
/* 窄屏:顶部分类子导航 + 下方单块内容(对齐图二 / 服务中心子项) */
.gxnlpt-shell--stacked {
padding-top: 0;
padding-left: 0;
padding-right: 0;
padding-bottom: 24px;
}
.gxnlpt-layout--stacked {
grid-template-columns: 1fr;
gap: 0;
}
.gxnlpt-sidebar--panel {
width: 100%;
min-height: 0;
padding: 12px 16px 16px;
background: @gxnlpt-page-bg;
border-radius: 0;
box-shadow: none;
}
.gxnlpt-sidebar--panel .gxnlpt-side-item:not(.is-active):hover {
background: fade(#fff, 72%);
}
.gxnlpt-layout--stacked .gxnlpt-sidebar-sticky {
position: sticky;
top: 0;
z-index: 30;
max-height: none;
background: @gxnlpt-page-bg;
}
/* JS 未就绪时仍保证 H5 布局与导航可点(与 STACKED_NAV_MEDIA 一致) */
@media screen and (max-width: @mq-desktop-sm-max) {
.gxnlpt-layout {
grid-template-columns: 1fr;
gap: 0;
}
.gxnlpt-sidebar {
width: 100%;
min-height: 0;
background: @gxnlpt-page-bg;
border-radius: 0;
box-shadow: none;
}
.gxnlpt-sidebar-sticky {
position: sticky;
top: 0;
z-index: 30;
background: @gxnlpt-page-bg;
}
.gxnlpt-shell.page-content-wrap {
padding-top: 0;
padding-left: 0;
padding-right: 0;
}
.gxnlpt-side-nav {
flex-direction: column;
flex-wrap: nowrap;
}
.gxnlpt-side-item {
flex: none;
width: 100%;
min-width: 0;
}
.gxnlpt-sidebar-tail {
display: none;
}
}
.gxnlpt-layout--stacked .gxnlpt-side-nav-wrap {
max-height: none;
overflow: visible;
}
.gxnlpt-layout--stacked .gxnlpt-sidebar-tail {
display: none;
}
.gxnlpt-layout--stacked .gxnlpt-side-nav {
flex-direction: column;
flex-wrap: nowrap;
gap: @gxnlpt-side-nav-gap;
}
.gxnlpt-layout--stacked .gxnlpt-side-item {
flex: none;
width: 100%;
min-width: 0;
}
.gxnlpt-layout--stacked .gxnlpt-side-actions {
flex-direction: row;
gap: 12px;
}
.gxnlpt-layout--stacked .gxnlpt-side-action {
flex: 1 1 0;
min-width: 0;
}
.gxnlpt-shell--stacked .gxnlpt-content {
min-height: 0;
margin-left: 12px;
margin-right: 12px;
margin-top: 12px;
}
@media screen and (max-width: 1279px) {
.gxnlpt-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gxnlpt-submit-row-2 {
grid-template-columns: 1fr;
}
.gxnlpt-submit-notice {
max-width: none;
text-align: left;
}
}
@media screen and (max-width: 767px) {
.gxnlpt-shell--stacked .gxnlpt-content {
margin-left: 12px;
margin-right: 12px;
padding: 16px;
}
.gxnlpt-card-grid {
grid-template-columns: 1fr;
}
}
</style>