feat:新增收藏校验逻辑
This commit is contained in:
parent
0ebca75894
commit
1888d64850
@ -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` 以下窗口高度
|
||||
- 验证点:
|
||||
- 文案区底部是否存在足够留白
|
||||
- 右侧服务卡片底部是否贴边
|
||||
- 左右栏是否发生重叠
|
||||
- 文案可读性和整体版式是否保持统一
|
||||
- 高分辨率桌面样式是否被误伤
|
||||
|
||||
## 验证结果
|
||||
- 代码诊断通过,未引入新的语法或样式错误。
|
||||
- 项目构建通过。
|
||||
- 低分辨率适配规则仅在目标断点内生效,高分辨率桌面原有版式不受影响。
|
||||
- 三版文案区的底部留白、服务栏下缘空间和整体纵向节奏均已统一收口。
|
||||
|
||||
## 备注
|
||||
- 当前环境无法直接输出真实设计软件源文件,也无法进行真机截图回归;本报告基于代码实现、断点覆盖和构建结果生成。
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
57
txw-mhzc-web/src/pages/index/utils/auth-guard.js
Normal file
57
txw-mhzc-web/src/pages/index/utils/auth-guard.js
Normal 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;
|
||||
}
|
||||
67
txw-mhzc-web/src/pages/index/utils/gxnlpt-scroll-helpers.js
Normal file
67
txw-mhzc-web/src/pages/index/utils/gxnlpt-scroll-helpers.js
Normal 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,
|
||||
};
|
||||
@ -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('收藏操作失败');
|
||||
}
|
||||
},
|
||||
|
||||
85
txw-mhzc-web/tests/unit/gxnlpt-scroll-helpers.test.js
Normal file
85
txw-mhzc-web/tests/unit/gxnlpt-scroll-helpers.test.js
Normal 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
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user