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:
liulujian 2026-07-04 22:03:23 +08:00
parent 0df6bf575b
commit 50e0ca8c2f
9 changed files with 212 additions and 17 deletions

View File

@ -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 {

View File

@ -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;
}
}
// -----------------------------------------------------------------------------
// 列表计数
// -----------------------------------------------------------------------------

View File

@ -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 '';
}

View File

@ -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 {

View File

@ -696,6 +696,7 @@ export default {
gap: 40px;
width: 596px;
height: 42px;
.market-nav-tabs-scroll-fade();
}
.nav-right {

View File

@ -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) {

View File

@ -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();
}
//

View File

@ -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();
}
//

View File

@ -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>