feat:新增收藏校验逻辑

This commit is contained in:
liulong 2026-05-25 20:19:55 +08:00
parent 0ebca75894
commit 1888d64850
7 changed files with 327 additions and 8 deletions

View File

@ -0,0 +1,63 @@
# 企业出海三版文案低分辨率留白修复报告
## 设计源文件
- 页面实现源文件:`src/pages/index/views/qych/index.vue`
- 关联样式变量:`src/pages/index/styles/home-figma-variables.less`
说明:当前仓库未包含独立的 Figma/PSD 设计源文件,本次输出的是已调整完成的前端实现源文件与测试报告。
## 应用场景
- 电池法案:`#section0`
- CBAM碳边境调节机制`#section1`
- 航运燃料:`#section2`
三版文案均定义在 `src/pages/index/views/qych/index.vue`,共用同一套 `text-section / content-item1 / content-item2 / service-section-dcfa` 结构。
## 问题表现
- 在 `720P` 及以下高度场景中,文案区底部留白不足。
- 右侧申请服务卡片下缘空间偏紧,整体显得贴底。
- 左侧滚动文案区和右侧服务栏之间的纵向节奏偏紧,影响可读性和视觉呼吸感。
## 根因分析
- 三版文案区被统一锁定为固定高度:
- `@qych-policy-card-height: 490px`
- `@qych-policy-panel-height: 376px`
- 低分辨率场景下,整体卡片高度、正文滚动区高度、右侧服务栏卡片内边距都没有针对设备高度做单独放大或重分配。
- 底部留白主要依赖固定像素 `padding-bottom`,在 `720P` 一类场景中无法随视口高度自适应。
## 调整方案
- 在 `@media (max-width: 1366px), (max-height: 820px)` 下新增企业出海专题内容区适配。
- 调整项:
- 增大专题上下留白:`--qych-topic-py`
- 放宽模块内部块间距:`.module gap`
- 文案主卡片改为响应式最小高度:`.text-section min-height`
- 标题区上下内边距改为 `clamp(...)`
- 正文区左右和底部内边距改为响应式值
- 左侧滚动文案区底部内边距提升到 `clamp(34px, 5vh, 48px)`
- 右侧服务栏底部内边距和卡片堆叠间距同步提升
- 服务卡片底部内边距提升到 `clamp(28px, 4.4vh, 40px)`
- 针对 CBAM 长文案单独上调正文滚动区高度
## 关键修改文件
- `src/pages/index/views/qych/index.vue`
## 测试范围
- 目标场景:
- `1280 x 720`
- `1366 x 768`
- `1366 x 820` 以下窗口高度
- 验证点:
- 文案区底部是否存在足够留白
- 右侧服务卡片底部是否贴边
- 左右栏是否发生重叠
- 文案可读性和整体版式是否保持统一
- 高分辨率桌面样式是否被误伤
## 验证结果
- 代码诊断通过,未引入新的语法或样式错误。
- 项目构建通过。
- 低分辨率适配规则仅在目标断点内生效,高分辨率桌面原有版式不受影响。
- 三版文案区的底部留白、服务栏下缘空间和整体纵向节奏均已统一收口。
## 备注
- 当前环境无法直接输出真实设计软件源文件,也无法进行真机截图回归;本报告基于代码实现、断点覆盖和构建结果生成。

View File

@ -11,6 +11,7 @@
"build": "cross-env NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build",
"build:site:dev": "vue-cli-service build --mode development",
"build:site:test": "vue-cli-service build --mode test",
"test:unit": "node --test tests/unit/gxnlpt-scroll-helpers.test.js",
"lint": "vue-cli-service lint",
"lint:style": "vue-cli-service lint:style",
"changelog": "conventional-changelog -p custom-config -i CHANGELOG.md -s -r 0",

View File

@ -2,6 +2,7 @@ import { request } from '@gtff/tdesign-gt-vue';
import { MessagePlugin } from 'tdesign-vue';
import { mhLogout, getRedirectUri } from '@/pages/index/api/login';
import { LoadingPlugin, DialogPlugin } from '@gt4/common-front';
import { showLoginGuide } from '@/pages/index/utils/auth-guard';
const SingleLoading = {
load: null,
@ -111,9 +112,13 @@ request.interceptors.response.use(
// 获取错误信息
const msg = res.data.msg || '系统未知错误,请反馈给管理员';
if (code === 401) {
// 未认证
// return handleAuthorized();
window.location.href = `/view/mhzc/login`;
showLoginGuide({ actionText: '当前操作' });
return Promise.reject({
__authRequired: true,
code: 401,
msg,
response: res,
});
}
if (code !== 1) {
MessagePlugin.error({
@ -135,8 +140,12 @@ request.interceptors.response.use(
}
// HTTP 状态码 401 未认证,跳转登录页
if (err.response?.status === 401) {
window.location.href = `/view/mhzc/login`;
return Promise.reject(err);
showLoginGuide({ actionText: '当前操作' });
return Promise.reject({
...err,
__authRequired: true,
code: 401,
});
}
// gtff 错误拦截器对 { code, msg } 体误用 a.Response 时会抛 TypeError避免弹出误导性 Toast
if (err instanceof TypeError && /Cannot convert undefined or null to object/.test(err.message)) {
@ -231,8 +240,12 @@ request.interceptors.response.use(
if (err.reqConfig?.loading || err.config?.loading || SingleLoading.load !== null) {
SingleLoading.endLoading(true);
}
window.location.href = `/view/mhzc/login`;
return Promise.reject(err);
showLoginGuide({ actionText: '当前操作' });
return Promise.reject({
...err,
__authRequired: true,
code: 401,
});
}
return Promise.reject(err);
},

View File

@ -0,0 +1,57 @@
import { DialogPlugin } from '@gt4/common-front';
const LOGIN_ROUTE_PATH = '/login';
let activeLoginDialog = null;
function getPortalLoginUrl() {
const routerPrefix = window?.STATIC_ENV_CONFIG?.ROUTER_PREFIX || '/view/mhzc';
return `${String(routerPrefix).replace(/\/$/, '')}${LOGIN_ROUTE_PATH}`;
}
export function isPortalLoggedIn() {
return !!window.sessionStorage.getItem('sfdl');
}
export function isUnauthorizedError(error) {
return (
error === 401 ||
error?.__authRequired === true ||
Number(error?.code) === 401 ||
Number(error?.response?.status) === 401
);
}
export function showLoginGuide(options = {}) {
const {
title = '登录提示',
actionText = '该操作',
confirmText = '去登录/注册',
cancelText = '暂不登录',
} = options;
if (activeLoginDialog) {
return activeLoginDialog;
}
let shouldRedirectToLogin = false;
const dialog = DialogPlugin.confirm({
header: title,
body: `${actionText}需要先登录后才能继续,是否立即前往登录或注册?`,
confirmBtn: confirmText,
cancelBtn: cancelText,
closeBtn: true,
closeOnOverlayClick: false,
onConfirm: () => {
shouldRedirectToLogin = true;
},
onClosed: () => {
activeLoginDialog = null;
if (shouldRedirectToLogin) {
window.location.href = getPortalLoginUrl();
}
},
});
activeLoginDialog = dialog;
return dialog;
}

View File

@ -0,0 +1,67 @@
function buildScrollRequestKey(sectionId, tabIndex) {
return `${sectionId || ''}::${Number.isInteger(tabIndex) ? tabIndex : 'na'}`;
}
function getSectionTargetTop(el, scrollRoot, offsetTop = 24) {
if (!el) return 0;
let total = 0;
let cur = el;
while (cur && cur !== scrollRoot) {
total += Number(cur.offsetTop) || 0;
cur = cur.offsetParent;
}
return Math.max(0, total - offsetTop);
}
function resolveAnchorScrollAction({
currentTop,
targetTop,
pendingRequestKey,
nextRequestKey,
suppressSectionObserver,
nearThreshold = 8,
}) {
if (Math.abs(currentTop - targetTop) <= nearThreshold) {
return 'skip-near-target';
}
if (suppressSectionObserver && pendingRequestKey && pendingRequestKey === nextRequestKey) {
return 'skip-duplicate-pending';
}
return 'scroll';
}
function resolveActiveSectionIndex({
sectionTops,
currentTop,
anchorOffset = 96,
defaultIndex = 0,
}) {
if (!Array.isArray(sectionTops) || !sectionTops.length) {
return defaultIndex;
}
const probeTop = Math.max(0, Number(currentTop) || 0) + Math.max(0, Number(anchorOffset) || 0);
let nextIndex = defaultIndex;
sectionTops.forEach((item, index) => {
if (!item) return;
const top = Math.max(0, Number(item.top) || 0);
if (top <= probeTop) {
nextIndex = Number.isInteger(item.index) ? item.index : index;
}
});
return nextIndex;
}
module.exports = {
buildScrollRequestKey,
getSectionTargetTop,
resolveAnchorScrollAction,
resolveActiveSectionIndex,
};

View File

@ -33,7 +33,6 @@
:class="{ 'is-active': contentView === 'submit' }"
@click="openSubmitView"
>
<span class="gxnlpt-side-action-icon gxnlpt-side-action-icon--cloud" aria-hidden="true"></span>
<span>收录</span>
</button>
<button
@ -292,6 +291,11 @@
<script>
import Footer from '@/pages/index/components/footer/index.vue';
import api from '@/pages/index/api/fwsc/index.js';
import {
isPortalLoggedIn,
isUnauthorizedError,
showLoginGuide,
} from '@/pages/index/utils/auth-guard';
const starOutline = require('@/pages/index/assets/fwsc/wsc.svg');
const starFilled = require('@/pages/index/assets/fwsc/ysc.svg');
@ -443,6 +447,14 @@ export default {
this.destroyScrollSpy();
},
methods: {
/** 收录 / 我的收藏 / 收藏操作前校验登录(弹窗引导,不直接跳登录页) */
ensureLogin(actionText) {
if (isPortalLoggedIn()) {
return true;
}
showLoginGuide({ actionText });
return false;
},
async bootstrap() {
try {
await this.loadAllSections();
@ -678,11 +690,17 @@ export default {
this.scrollToSection(sectionId, tabIndex);
},
openSubmitView() {
if (!this.ensureLogin('提交收录')) {
return;
}
this.destroyScrollSpy();
this.contentView = 'submit';
this.resetSubmitForm();
},
openFavoritesView() {
if (!this.ensureLogin('查看我的收藏')) {
return;
}
this.destroyScrollSpy();
this.contentView = 'favorites';
},
@ -742,6 +760,9 @@ export default {
this.$message.success('验证码已发送');
},
async handleSubmitForm() {
if (!this.ensureLogin('提交收录')) {
return;
}
this.submitAttempted = true;
if (!this.validateSubmitForm()) {
return;
@ -765,6 +786,10 @@ export default {
this.contentView = 'list';
await this.loadAllSections();
} catch (e) {
if (isUnauthorizedError(e)) {
showLoginGuide({ actionText: '提交收录' });
return;
}
this.$message.warning('提交失败,请稍后重试');
}
},
@ -837,6 +862,10 @@ export default {
this.$router.push({ path: '/tfwsc', query: { id: card.gxUuid } });
},
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;
@ -846,6 +875,10 @@ export default {
await api.gxsc({ gxUuid: card.gxUuid, type });
card.scbz = card.scbz === 'Y' ? 'N' : 'Y';
} catch (e) {
if (isUnauthorizedError(e)) {
showLoginGuide({ actionText: '收藏' });
return;
}
this.$message.warning('收藏操作失败');
}
},

View File

@ -0,0 +1,85 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildScrollRequestKey,
getSectionTargetTop,
resolveAnchorScrollAction,
resolveActiveSectionIndex,
} = require('../../src/pages/index/utils/gxnlpt-scroll-helpers.js');
test('buildScrollRequestKey builds stable request keys', () => {
assert.equal(buildScrollRequestKey('content-1', 2), 'content-1::2');
assert.equal(buildScrollRequestKey('content-1'), 'content-1::na');
});
test('getSectionTargetTop uses offsetTop chain instead of viewport coordinates', () => {
const scrollRoot = { offsetTop: 0 };
const mid = { offsetTop: 120, offsetParent: scrollRoot };
const target = { offsetTop: 260, offsetParent: mid };
assert.equal(getSectionTargetTop(target, scrollRoot, 24), 356);
assert.equal(getSectionTargetTop(target, scrollRoot, 0), 380);
});
test('resolveAnchorScrollAction skips duplicate or near-target scrolls', () => {
assert.equal(
resolveAnchorScrollAction({
currentTop: 198,
targetTop: 200,
pendingRequestKey: '',
nextRequestKey: 'content-1::0',
suppressSectionObserver: false,
}),
'skip-near-target'
);
assert.equal(
resolveAnchorScrollAction({
currentTop: 0,
targetTop: 320,
pendingRequestKey: 'content-2::1',
nextRequestKey: 'content-2::1',
suppressSectionObserver: true,
}),
'skip-duplicate-pending'
);
});
test('resolveActiveSectionIndex uses scrollTop-based coordinates for active tab', () => {
const sectionTops = [
{ index: 0, top: 0 },
{ index: 1, top: 420 },
{ index: 2, top: 860 },
];
assert.equal(
resolveActiveSectionIndex({
sectionTops,
currentTop: 0,
anchorOffset: 96,
defaultIndex: 0,
}),
0
);
assert.equal(
resolveActiveSectionIndex({
sectionTops,
currentTop: 360,
anchorOffset: 96,
defaultIndex: 0,
}),
1
);
assert.equal(
resolveActiveSectionIndex({
sectionTops,
currentTop: 820,
anchorOffset: 96,
defaultIndex: 0,
}),
2
);
});