2422 lines
76 KiB
Vue
2422 lines
76 KiB
Vue
<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)
|
||
* 关闭后恢复每卡 2~3 个短标签的正常展示
|
||
*/
|
||
/** 关闭后卡片仅展示接口/演示数据中的 2~3 个短标签,与图二设计稿一致 */
|
||
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_CATEGORY(5×8=40 条“概念性占位”数据),
|
||
* 它们没有 lj 字段,依赖数据库/接口不可用时显示,但用户点击会触发“敬请期待”,
|
||
* 体感为“点击无反应”,现已全部清理。
|
||
* 当前兜底数据源改为 gxnlLinksByCategory(gxnl-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;
|
||
}
|
||
|
||
/** 接口数据缺标签时,根据标题/简介推断 2~3 个展示标签 */
|
||
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-wrap(main.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>
|