feat: 品质提升第二轮 - 标签颜色/数字动画/渐隐/微交互/过渡/sjlbc
⑦ 标签按类别分色 (蓝核算/绿交易/橙认证/紫数据) ⑧ 统计数字进入视口时 count-up 缓动动画 ⑨ sjlbc.vue 表格页同步升级 (骨架屏+EmptyState) ⑩ 移动端 nav-tabs 横向滚动边缘 CSS mask 渐隐 ⑪ 收藏按钮心跳动画 + 操作按钮涟漪反馈 + 标签 hover 放大 ⑫ router-view 路由切换 fade + slide 过渡 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0df6bf575b
commit
50e0ca8c2f
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="stats-summary">
|
||||
<div ref="root" class="stats-summary">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
@ -8,7 +8,7 @@
|
||||
<div class="stats-item__icon" v-if="item.icon" aria-hidden="true">
|
||||
<component :is="item.icon" />
|
||||
</div>
|
||||
<div class="stats-item__value">{{ item.value }}</div>
|
||||
<div class="stats-item__value">{{ displayValues[idx] }}</div>
|
||||
<div class="stats-item__label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -21,6 +21,61 @@ export default {
|
||||
/** [{ label, value, icon? }] */
|
||||
items: { type: Array, default: () => [] },
|
||||
},
|
||||
data() {
|
||||
return { displayValues: [], _animated: false, _observer: null };
|
||||
},
|
||||
watch: {
|
||||
items: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.displayValues = this.items.map((v) => v.value);
|
||||
this._animated = false;
|
||||
this.$nextTick(() => this._observe());
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this._observe();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this._observer) this._observer.disconnect();
|
||||
},
|
||||
methods: {
|
||||
_observe() {
|
||||
if (this._animated) return;
|
||||
const root = this.$refs.root;
|
||||
if (!root || typeof IntersectionObserver === 'undefined') {
|
||||
this._animateAll();
|
||||
return;
|
||||
}
|
||||
if (this._observer) this._observer.disconnect();
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0] && entries[0].isIntersecting) {
|
||||
this._animateAll();
|
||||
this._observer.disconnect();
|
||||
}
|
||||
}, { threshold: 0.2 });
|
||||
this._observer.observe(root);
|
||||
},
|
||||
_animateAll() {
|
||||
if (this._animated) return;
|
||||
this._animated = true;
|
||||
this.items.forEach((item, idx) => {
|
||||
const target = Number(item.value);
|
||||
if (Number.isNaN(target)) return;
|
||||
const start = performance.now();
|
||||
const duration = 800 + idx * 120;
|
||||
const tick = (now) => {
|
||||
const p = Math.min((now - start) / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - p, 3); // ease-out
|
||||
const current = Math.round(eased * target);
|
||||
this.$set(this.displayValues, idx, current.toLocaleString());
|
||||
if (p < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -59,6 +114,7 @@ export default {
|
||||
line-height: 1.2;
|
||||
color: #009a29;
|
||||
letter-spacing: -0.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__label {
|
||||
|
||||
@ -129,6 +129,76 @@
|
||||
&.card--purple { border-left: @market-card-color-stripe-width solid @market-card-color-purple; }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 标签颜色变体(蓝/绿/橙/紫)
|
||||
// -----------------------------------------------------------------------------
|
||||
@market-tag-color-blue: #1565C0;
|
||||
@market-tag-color-green: #009A29;
|
||||
@market-tag-color-orange: #E65100;
|
||||
@market-tag-color-purple: #7B1FA2;
|
||||
|
||||
@market-tag-bg-blue: #E3F2FD;
|
||||
@market-tag-bg-green: #E8F5E9;
|
||||
@market-tag-bg-orange: #FFF3E0;
|
||||
@market-tag-bg-purple: #F3E5F5;
|
||||
|
||||
.market-tag-colors() {
|
||||
&.tag--blue { color: @market-tag-color-blue; background: @market-tag-bg-blue; }
|
||||
&.tag--green { color: @market-tag-color-green; background: @market-tag-bg-green; }
|
||||
&.tag--orange { color: @market-tag-color-orange; background: @market-tag-bg-orange; }
|
||||
&.tag--purple { color: @market-tag-color-purple; background: @market-tag-bg-purple; }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 移动端 nav-tabs 滚动边缘渐隐
|
||||
// -----------------------------------------------------------------------------
|
||||
.market-nav-tabs-scroll-fade() {
|
||||
@media (max-width: 768px) {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
black 4%,
|
||||
black 92%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
black 4%,
|
||||
black 92%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 微交互 keyframes
|
||||
// -----------------------------------------------------------------------------
|
||||
@keyframes market-heart-beat {
|
||||
0% { transform: scale(1); }
|
||||
25% { transform: scale(1.3); }
|
||||
50% { transform: scale(0.95); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes market-ripple-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(0, 185, 107, 0.3); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(0, 185, 107, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(0, 185, 107, 0); }
|
||||
}
|
||||
|
||||
.market-collect-pulse() {
|
||||
&:active img {
|
||||
animation: market-heart-beat 0.4s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.market-action-ripple() {
|
||||
&:active {
|
||||
animation: market-ripple-pulse 0.6s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 列表计数
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@ -53,3 +53,18 @@ export function cardColorClass(tags) {
|
||||
export function jrscCardColorClass(productType) {
|
||||
return productType === 'xd' ? 'card--orange' : 'card--green';
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据单个标签名返回 CSS modifier 类名(用于 tag 颜色区分)
|
||||
* @param {string} tag
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tagColorClass(tag) {
|
||||
if (!tag) return '';
|
||||
for (const [color, keywords] of Object.entries(CATEGORY_COLOR_MAP)) {
|
||||
if (keywords.some((kw) => tag === kw || tag.includes(kw) || kw.includes(tag))) {
|
||||
return `tag--${color}`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
<div class="card-content">
|
||||
<p class="card-desc">{{ card.fwnr }}</p>
|
||||
<div class="card-tags">
|
||||
<span v-for="(tag, i) in card.fwlxbqList" :key="i" class="tag">{{ tag }}</span>
|
||||
<span v-for="(tag, i) in card.fwlxbqList" :key="i" :class="['tag', tagColorClass(tag)]">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -195,7 +195,7 @@ import SkeletonCard from '@/pages/index/components/SkeletonCard.vue';
|
||||
import api from '@/pages/index/api/fwsc/index.js';
|
||||
import { hasLogin } from '@/pages/index/api/login';
|
||||
import { scrollPortalContentToTop } from '@/pages/index/utils/portal-scroll-mode';
|
||||
import { cardColorClass } from '@/pages/index/utils/card-category';
|
||||
import { cardColorClass, tagColorClass } from '@/pages/index/utils/card-category';
|
||||
import portalFigmaScaleMixin from '@/pages/index/utils/portal-figma-scale-mixin';
|
||||
import comingSoonMixin from '@/pages/index/utils/coming-soon-mixin';
|
||||
|
||||
@ -480,6 +480,7 @@ export default {
|
||||
return this.$route.path === path;
|
||||
},
|
||||
cardColorClass,
|
||||
tagColorClass,
|
||||
goToTab({path, disable, url}) {
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
@ -559,6 +560,7 @@ export default {
|
||||
gap: 40px;
|
||||
width: 596px;
|
||||
height: 42px;
|
||||
.market-nav-tabs-scroll-fade();
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
@ -856,6 +858,7 @@ export default {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
.market-collect-pulse();
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
@ -890,9 +893,12 @@ export default {
|
||||
padding: 2px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #00B96B;
|
||||
background: #EEFAE2;
|
||||
color: @market-tag-color;
|
||||
background: @market-tag-bg;
|
||||
border-radius: 4px;
|
||||
transition: transform 0.15s ease;
|
||||
&:hover { transform: scale(1.06); }
|
||||
.market-tag-colors();
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@ -943,6 +949,7 @@ export default {
|
||||
background: rgba(0, 154, 41, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.market-action-ripple();
|
||||
}
|
||||
|
||||
.price-value {
|
||||
|
||||
@ -696,6 +696,7 @@ export default {
|
||||
gap: 40px;
|
||||
width: 596px;
|
||||
height: 42px;
|
||||
.market-nav-tabs-scroll-fade();
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
|
||||
@ -31,7 +31,15 @@
|
||||
<div class="content-wrapper">
|
||||
<!-- 右侧数据列表 -->
|
||||
<div class="data-list">
|
||||
<SkeletonCard v-if="loading" :count="4" />
|
||||
<EmptyState
|
||||
v-else-if="!loading && dataList.length === 0"
|
||||
type="empty"
|
||||
text="暂无数据"
|
||||
hint="该数据库暂未收录数据项"
|
||||
/>
|
||||
<t-table
|
||||
v-else
|
||||
:data="sortedDataList"
|
||||
:pagination="null"
|
||||
row-key="uuid"
|
||||
@ -114,6 +122,8 @@ import BreadcrumbNav from '@/pages/index/components/breadcrumb/index.vue';
|
||||
import gxzxApi from '@/pages/index/api/gxzx/index.js';
|
||||
import { isMobileViewport, watchDeviceClass } from '@/pages/index/utils/breakpoint';
|
||||
import portalFigmaScaleMixin from '@/pages/index/utils/portal-figma-scale-mixin';
|
||||
import EmptyState from '@/pages/index/components/EmptyState.vue';
|
||||
import SkeletonCard from '@/pages/index/components/SkeletonCard.vue';
|
||||
|
||||
export default {
|
||||
name: 'SjlbcPage',
|
||||
@ -121,6 +131,8 @@ export default {
|
||||
components: {
|
||||
NewNav,
|
||||
BreadcrumbNav,
|
||||
EmptyState,
|
||||
SkeletonCard,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -171,6 +183,7 @@ export default {
|
||||
title: '联系方式',
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
isMobile: false,
|
||||
unwatchDevice: null,
|
||||
};
|
||||
@ -243,6 +256,7 @@ export default {
|
||||
});
|
||||
},
|
||||
loadData() {
|
||||
this.loading = true;
|
||||
const params = {
|
||||
pageNo: this.pagination.pageNo,
|
||||
pageSize: this.pagination.pageSize,
|
||||
@ -267,6 +281,8 @@ export default {
|
||||
}));
|
||||
this.pagination.total = Number(res.data.total);
|
||||
}
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
sortBy(field) {
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
<div class="card-content">
|
||||
<p class="card-desc">{{ card.fwnr }}</p>
|
||||
<div class="card-tags">
|
||||
<span v-for="(tag, i) in card.fwlxbqList" :key="i" class="tag">{{ tag }}</span>
|
||||
<span v-for="(tag, i) in card.fwlxbqList" :key="i" :class="['tag', tagColorClass(tag)]">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -179,7 +179,7 @@ import SkeletonCard from '@/pages/index/components/SkeletonCard.vue';
|
||||
import gxzxApi from '@/pages/index/api/gxzx/index.js';
|
||||
import { hasLogin } from '@/pages/index/api/login';
|
||||
import { scrollPortalContentToTop } from '@/pages/index/utils/portal-scroll-mode';
|
||||
import { cardColorClass } from '@/pages/index/utils/card-category';
|
||||
import { cardColorClass, tagColorClass } from '@/pages/index/utils/card-category';
|
||||
import portalFigmaScaleMixin from '@/pages/index/utils/portal-figma-scale-mixin';
|
||||
import comingSoonMixin from '@/pages/index/utils/coming-soon-mixin';
|
||||
|
||||
@ -413,6 +413,7 @@ export default {
|
||||
return this.$route.path === path;
|
||||
},
|
||||
cardColorClass,
|
||||
tagColorClass,
|
||||
goToTab({ path, disable, url }) {
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
@ -487,6 +488,7 @@ export default {
|
||||
gap: 40px;
|
||||
width: 596px;
|
||||
height: 42px;
|
||||
.market-nav-tabs-scroll-fade();
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
@ -793,9 +795,12 @@ export default {
|
||||
padding: 2px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #00B96B;
|
||||
background: #EEFAE2;
|
||||
color: @market-tag-color;
|
||||
background: @market-tag-bg;
|
||||
border-radius: 4px;
|
||||
transition: transform 0.15s ease;
|
||||
&:hover { transform: scale(1.06); }
|
||||
.market-tag-colors();
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
@ -853,6 +858,7 @@ export default {
|
||||
background: rgba(0, 154, 41, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.market-action-ripple();
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
<div class="card-content">
|
||||
<p class="card-desc">{{ card.fwnr }}</p>
|
||||
<div class="card-tags">
|
||||
<span v-for="(tag, i) in card.fwlxbqList" :key="i" class="tag">{{ tag }}</span>
|
||||
<span v-for="(tag, i) in card.fwlxbqList" :key="i" :class="['tag', tagColorClass(tag)]">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -225,7 +225,7 @@ import SkeletonCard from '@/pages/index/components/SkeletonCard.vue';
|
||||
import api from '@/pages/index/api/fwsc/index.js';
|
||||
import { hasLogin } from '@/pages/index/api/login';
|
||||
import { scrollPortalContentToTop } from '@/pages/index/utils/portal-scroll-mode';
|
||||
import { cardColorClass } from '@/pages/index/utils/card-category';
|
||||
import { cardColorClass, tagColorClass } from '@/pages/index/utils/card-category';
|
||||
import portalFigmaScaleMixin from '@/pages/index/utils/portal-figma-scale-mixin';
|
||||
import comingSoonMixin from '@/pages/index/utils/coming-soon-mixin';
|
||||
|
||||
@ -459,6 +459,7 @@ export default {
|
||||
return this.$route.path === path;
|
||||
},
|
||||
cardColorClass,
|
||||
tagColorClass,
|
||||
goToTab({path, disable, url}) {
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
@ -533,6 +534,7 @@ export default {
|
||||
gap: 40px;
|
||||
width: 596px;
|
||||
height: 42px;
|
||||
.market-nav-tabs-scroll-fade();
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
@ -839,9 +841,12 @@ export default {
|
||||
padding: 2px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #00B96B;
|
||||
background: #EEFAE2;
|
||||
color: @market-tag-color;
|
||||
background: @market-tag-bg;
|
||||
border-radius: 4px;
|
||||
transition: transform 0.15s ease;
|
||||
&:hover { transform: scale(1.06); }
|
||||
.market-tag-colors();
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@ -904,6 +909,7 @@ export default {
|
||||
background: rgba(0, 154, 41, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.market-action-ripple();
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
|
||||
@ -10,9 +10,11 @@
|
||||
]"
|
||||
>
|
||||
<div class="portal-route-outlet">
|
||||
<keep-alive :exclude="keepAliveExclude">
|
||||
<router-view @gotoIfreamPage="gotoIfreamPage" @gotoPage="gotoPage" />
|
||||
</keep-alive>
|
||||
<transition name="page-fade" mode="out-in">
|
||||
<keep-alive :exclude="keepAliveExclude">
|
||||
<router-view @gotoIfreamPage="gotoIfreamPage" @gotoPage="gotoPage" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</div>
|
||||
<Footer v-if="showPortalFooter" class="portal-layout-footer" />
|
||||
</div>
|
||||
@ -456,3 +458,19 @@ export default {
|
||||
box-shadow: 0 4px 10px rgba(0, 154, 41, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 页面路由过渡动画 — 非 scoped,作用在 transition 内部元素上 */
|
||||
.page-fade-enter-active,
|
||||
.page-fade-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.page-fade-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.page-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user