Files
qitongxue-wx/src/components/home/PaneWorkbench.vue
2026-10-09 16:49:09 +08:00

938 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 首页「工作台」pane。
*
* 结构(承前 refresher 下沉 + N5):**根元素即 scroll-view**,持有自己的下拉刷新,
* 只暴露 `refresh()` 供首页在 onShow 时按域刷新。刷新域 = 工具表 + 工作台 + **额度(/quota/my)**
* ——工作台需要展示额度,这是三 pane 里唯一会打 /quota/my 的域。
*/
import { computed, ref, watch } from 'vue'
import { apiAppConfig } from '@/api/app'
import { apiToolsUsed } from '@/api/user'
import CardTile from '@/components/card/CardTile.vue'
import EmptyState from '@/components/EmptyState.vue'
import PaneRefresher from '@/components/home/PaneRefresher.vue'
import VipCard from '@/components/vip/VipCard.vue'
import type { ProjectItem } from '@/api/project'
import { apiProjectMine } from '@/api/project'
import { CARD_TONES } from '@/config/card-styles'
import { FEATURE_ENTRIES_HOME, type FeatureEntry } from '@/config/features'
import { TOOL_REGISTRY } from '@/config/tools'
import { useCardStyleStore } from '@/stores/cardStyle'
import { useMessageStore } from '@/stores/message'
import { useQuotaStore } from '@/stores/quota'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { REFRESH_BG, REFRESH_THRESHOLD, usePaneRefresh } from '@/utils/pane-refresh'
import { toolIllustration } from '@/utils/palette'
import { heroPadTop } from '@/utils/safearea'
import { showUnlockGuide } from '@/utils/unlock-guide'
const props = defineProps<{ active: boolean }>()
const emit = defineEmits<{ library: [] }>()
const toolsStore = useToolsStore()
const userStore = useUserStore()
const quotaStore = useQuotaStore()
const cardStyle = useCardStyleStore()
const messageStore = useMessageStore()
const { refreshing, bubbleScale, onPulling, onRestore, run } = usePaneRefresh()
/** 头区顶部留白:MP 避开胶囊按钮 */
const heroPad = heroPadTop()
/** 首屏数据是否就绪(失败也置 true,展示空态而非永久 loading) */
const ready = ref(false)
let loadedOnce = false
/** 工作台域刷新:工具表 + 工作台 + 额度(额度为工作台独有)+ 功能卡背景图(fire-and-forget) */
async function load() {
try {
await userStore.ensureLogin()
await toolsStore.fetchAll()
await quotaStore.fetchMine().catch(() => {})
// 站内信未读数(首页消息入口徽标):登录后才拉,失败静默
if (userStore.logged)
messageStore.fetchUnread()
}
catch {
/* 静默:展示空态 */
}
finally {
ready.value = true
}
// 功能卡背景图:后台系统配置下发(/app/config,api/app.ts 内有进程缓存),
// 失败静默走色系渐变兜底,不打扰首页
apiAppConfig().then((d) => {
covers.value = d.feature_covers || {}
}).catch(() => {})
// 进行中项目快捷入口(fire-and-forget,失败静默)
pickActiveProject()
}
/** 进行中的开发项目(首页快捷入口):取「我的项目」里第一个进行中的 */
const activeProject = ref<ProjectItem | null>(null)
function pickActiveProject() {
if (!userStore.logged) {
activeProject.value = null
return
}
apiProjectMine()
.then((res) => {
activeProject.value = (res.list || []).find(p => p.status === 1) || null
})
.catch(() => {
/* 静默:入口下次刷新再出 */
})
}
/** 供首页 onShow 调用 */
function refresh() {
return run(load)
}
watch(() => props.active, (v) => {
if (v && !loadedOnce) {
loadedOnce = true
load()
}
}, { immediate: true })
defineExpose({ refresh })
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6)
return '夜深了'
if (h < 12)
return '早上好'
if (h < 14)
return '中午好'
if (h < 18)
return '下午好'
return '晚上好'
})
const workbench = computed(() => toolsStore.workbenchTools())
/** 热门推荐:热推工具中不在工作台的 */
const hotTools = computed(() =>
toolsStore.tools.filter(t => t.is_hot && !toolsStore.workbenchKeys.includes(t.tool_key)).slice(0, 6),
)
function toolPath(key: string): string {
return TOOL_REGISTRY.find(t => t.key === key)?.path || ''
}
function openTool(path: string, toolKey: string, locked: boolean) {
if (locked) {
showUnlockGuide()
return
}
// 使用统计(fire-and-forget)
apiToolsUsed(toolKey).catch(() => {})
uni.navigateTo({ url: path })
}
function goEdit() {
uni.navigateTo({ url: '/pages/workbench/edit' })
}
/** 消息中心(站内信):首页消息入口 */
function goMessages() {
uni.navigateTo({ url: '/pkg-msg/index' })
}
/** 进行中项目详情 */
function goProject(id: number) {
uni.navigateTo({ url: `/pkg-service/project-detail?id=${id}` })
}
/** 徽标数字:超过 99 显示 99+ */
const messageBadge = computed(() => (messageStore.unread > 99 ? '99+' : messageStore.unread > 0 ? String(messageStore.unread) : ''))
/**
* 功能入口(笔记 / 年会查座)跳转:入口来源 `config/features.ts`,**不走** TOOL_REGISTRY
* (后者会被后端 tools 表判 locked 而静默锁死,见 features.ts 顶部注释)。
* 首页 = 本工作台 pane,故此处渲染即同时满足「首页」与「功能」两处可见(P1-2)。
*/
function goFeature(path: string) {
uni.navigateTo({ url: path })
}
// ===== 便捷功能自适应布局(迭代-2026-09-30)=====
// ≤2 张:双列 CardTile(原排版);=3 张:「1 大 2 小」拼图(图2 参考样式,主题化);
// ≥4 张:横滑图片卡(图3 参考样式)。背景图来自后台「系统配置 → 首页功能卡」
//(settings home.feature.covers,经 /app/config 下发绝对 URL),未配置用色系渐变兜底。
const covers = ref<Record<string, string>>({})
const featsMode = computed(() =>
FEATURE_ENTRIES_HOME.length >= 4 ? 'scroll' : FEATURE_ENTRIES_HOME.length === 3 ? 'mosaic' : 'grid',
)
/** 功能卡背景图(绝对 URL;空 = 未配置) */
function coverOf(fe: FeatureEntry): string {
return covers.value[fe.key] || ''
}
/** 未配置背景图时的色系渐变兜底(配色铁律:只动质感不动色相) */
function fallbackStyle(fe: FeatureEntry): string {
const tone = CARD_TONES.find(t => t.id === fe.tone) || CARD_TONES[0]
return `background: linear-gradient(140deg, ${tone.gradFrom}, ${tone.gradTo})`
}
const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
const HOT_STICKERS = ['sparkle', 'bolt', 'cloud', 'star', 'rocket', 'rainbow'] as const
/** 色系轮转表(P3) */
const TONES = ['gold', 'blue', 'mint', 'rose', 'violet'] as const
/** 工具卡色系哈希:无显式配置时保证宫格色彩多样 */
function toolTone(key: string) {
let h = 0
for (let i = 0; i < key.length; i++)
h = (h * 31 + key.charCodeAt(i)) >>> 0
return TONES[h % TONES.length]
}
/** 工具卡样式:版式/皮肤取「区块 + 该工具」逐入口解析;色系默认按 key 哈希,显式配置则以其为准 */
function toolCard(key: string, section: string) {
const targetKey = `tool.${key}`
const s = cardStyle.styleOf(targetKey, section)
const hasTone = !!cardStyle.explicitOf(targetKey, section).tone
return { template: s.template, variant: s.variant, tone: hasTone ? s.tone : toolTone(key) }
}
/** 功能入口样式(feature.<key>):版式/皮肤/色系按「区块 + 该入口」解析;
色系默认取入口自带 tone(还原改造前逐入口配色),用户显式配置则以其为准 */
function featureCard(key: string, section: string) {
const s = cardStyle.styleOf(`feature.${key}`, section)
const hasTone = !!cardStyle.explicitOf(`feature.${key}`, section).tone
const fallback = FEATURE_ENTRIES_HOME.find(f => f.key === key)?.tone
return { ...s, tone: hasTone ? s.tone : (fallback || s.tone) }
}
/** 通栏主卡:按「区块 + 该工具」逐入口解析(无显式时用区块默认,不做色系哈希,保持主卡观感) */
function leadCard(key: string) {
return cardStyle.styleOf(`tool.${key}`, 'workbench.lead')
}
</script>
<template>
<scroll-view
scroll-y
class="pane"
:show-scrollbar="false"
:refresher-enabled="true"
refresher-default-style="none"
:refresher-threshold="REFRESH_THRESHOLD"
:refresher-background="REFRESH_BG"
:refresher-triggered="refreshing"
@refresherpulling="onPulling"
@refresherrestore="onRestore"
@refresherrefresh="refresh"
>
<template #refresher>
<PaneRefresher :on="refreshing" :scale="bubbleScale" />
</template>
<!-- 渐变头区(橙色波浪) -->
<view class="tb-hero" :style="{ paddingTop: heroPad }">
<view class="hero__meta">
<text class="hero__greet">
{{ greeting }}
</text>
<view v-if="userStore.level" class="hero__level">
{{ userStore.level.name }}
</view>
<!-- 消息入口:铃铛 + 未读徽标(站内信) -->
<view class="hero__msg press" hover-class="press-active" @tap="goMessages">
<wd-icon name="notification" size="34rpx" color="#ffffff" />
<view v-if="messageBadge" class="hero__msg-badge">
{{ messageBadge }}
</view>
</view>
</view>
<text class="hero__title">
工具百宝箱
</text>
<view class="tb-search press" hover-class="press-active" @tap="emit('library')">
<wd-icon name="search" size="34rpx" color="#B3907C" />
<text class="tb-search__text">
搜索工具,如「压缩」「抠图」
</text>
</view>
</view>
<!-- 上浮内容 -->
<view class="tb-body">
<!-- 会员卡:首页会员 / 次数包转化入口(R1-1 上移到「我的工作台」之上) -->
<view class="tb-rise" style="animation-delay: 0ms">
<VipCard />
</view>
<!-- 进行中的开发项目:快捷入口(封面图 + 进度徽标),仅在有进行中项目时出现 -->
<view
v-if="activeProject"
class="proj tb-card tb-elev-2 press tb-rise"
style="animation-delay: 60ms"
hover-class="press-active"
@tap="goProject(activeProject.id)"
>
<view class="proj__cover">
<image
v-if="activeProject.cover_url"
class="proj__img"
:src="activeProject.cover_url"
mode="aspectFill"
/>
<view v-else class="proj__img proj__img--ph">
<wd-icon name="app" size="52rpx" color="#E8A87C" />
</view>
<view class="proj__badge">
{{ activeProject.stage_done }}/{{ activeProject.stage_total }}
</view>
</view>
<view class="proj__body">
<view class="proj__top">
<text class="proj__title">
{{ activeProject.title }}
</text>
<view class="proj__status">
进行中
</view>
</view>
<view class="proj__progress">
<view class="proj__track">
<view
class="proj__fill"
:style="{ width: `${activeProject.stage_total ? Math.round((activeProject.stage_done / activeProject.stage_total) * 100) : 0}%` }"
/>
</view>
</view>
<view class="proj__meta">
<text class="proj__stage">
阶段进度 {{ activeProject.stage_done }}/{{ activeProject.stage_total }}
</text>
<text v-if="activeProject.due_note" class="proj__due">
交付周期 {{ activeProject.due_note }}
</text>
</view>
</view>
</view>
<!-- 便捷功能(笔记 / 年会查座 / 软件开发):入口独立于工具库楼层(P1-2),来源 config/features.ts。
无玻璃外壳(大图卡下玻璃底显得脏,用户点名):标题行直接压页面底色,图卡自带投影。
布局随卡片数量自适应:≤2 双列 CardTile / =3 拼图 / ≥4 横滑(背景图后台可配)。 -->
<view class="sec sec--flat sec--gap tb-rise" style="animation-delay: 90ms">
<view class="sec__head">
<text class="tb-section-title">
便捷功能
</text>
</view>
<!-- ≤2 张:双列 CardTile(原排版) -->
<view v-if="featsMode === 'grid'" class="feats">
<view
v-for="fe in FEATURE_ENTRIES_HOME"
:key="fe.key"
class="cttap press"
hover-class="press-active"
@tap="goFeature(fe.path)"
>
<CardTile
v-bind="featureCard(fe.key, 'workbench.features')"
:icon="fe.icon"
:title="fe.name"
:desc="fe.desc"
:illustration="fe.illustration"
size="md"
/>
</view>
</view>
<!-- =3 张:「1 大 2 小」拼图(主题化图2) -->
<view v-else-if="featsMode === 'mosaic'" class="fm">
<view
class="fm__main press"
hover-class="press-active"
@tap="goFeature(FEATURE_ENTRIES_HOME[0].path)"
>
<image
v-if="coverOf(FEATURE_ENTRIES_HOME[0])"
class="fm__bg"
:src="coverOf(FEATURE_ENTRIES_HOME[0])"
mode="aspectFill"
/>
<view v-else class="fm__bg" :style="fallbackStyle(FEATURE_ENTRIES_HOME[0])" />
<view class="fm__shade" />
<view class="fm__body">
<text class="fm__title">
{{ FEATURE_ENTRIES_HOME[0].name }}
</text>
<view class="fm__pill">
<text class="fm__pill-text">
{{ FEATURE_ENTRIES_HOME[0].desc }}
</text>
<wd-icon name="arrow-right" size="20rpx" color="#E8531A" />
</view>
</view>
</view>
<view class="fm__side">
<view
v-for="fe in FEATURE_ENTRIES_HOME.slice(1)"
:key="fe.key"
class="fm__sub press"
hover-class="press-active"
@tap="goFeature(fe.path)"
>
<image v-if="coverOf(fe)" class="fm__bg" :src="coverOf(fe)" mode="aspectFill" />
<view v-else class="fm__bg" :style="fallbackStyle(fe)" />
<view class="fm__shade" />
<view class="fm__body">
<text class="fm__title fm__title--sm">
{{ fe.name }}
</text>
<view class="fm__pill">
<text class="fm__pill-text">
{{ fe.desc }}
</text>
<wd-icon name="arrow-right" size="18rpx" color="#E8531A" />
</view>
</view>
</view>
</view>
</view>
<!-- ≥4 张:横滑图片卡(主题化图3) -->
<scroll-view v-else scroll-x class="fs" :show-scrollbar="false">
<view class="fs__row">
<view
v-for="fe in FEATURE_ENTRIES_HOME"
:key="fe.key"
class="fs__card press"
hover-class="press-active"
@tap="goFeature(fe.path)"
>
<view class="fs__imgwrap">
<image v-if="coverOf(fe)" class="fs__img" :src="coverOf(fe)" mode="aspectFill" />
<view v-else class="fs__img" :style="fallbackStyle(fe)" />
</view>
<text class="fs__title">
{{ fe.name }}
</text>
<text class="fs__desc">
{{ fe.desc }}
</text>
</view>
</view>
</scroll-view>
</view>
<!-- 我的工作台 -->
<view class="tb-card sec sec--gap tb-rise" style="animation-delay: 170ms">
<view class="sec__head">
<text class="tb-section-title">
我的工作台
</text>
<view class="tb-section-more press" hover-class="press-active" @tap="goEdit">
<text>编辑</text>
<wd-icon name="edit-outline" size="26rpx" color="#FF6B35" />
</view>
</view>
<view v-if="!ready" class="sec__hint">
正在加载…
</view>
<EmptyState
v-else-if="!workbench.length"
title="工作台还是空的"
sub="把常用的工具放进来,用的时候一步直达"
action-text="去添加工具"
main="rocket"
@action="goEdit"
/>
<template v-else>
<!-- 通栏主卡(第 1 个固定工具) -->
<view
v-if="workbench[0]"
class="cttap tb-rise press"
:style="{ animationDelay: '110ms' }"
hover-class="press-active"
@tap="openTool(workbench[0].path, workbench[0].key, workbench[0].locked)"
>
<CardTile
v-bind="leadCard(workbench[0].key)"
:icon="workbench[0].icon"
:title="workbench[0].name"
:desc="workbench[0].desc"
:locked="workbench[0].locked"
illustration="rocket"
size="lg"
/>
</view>
<!-- 副卡(第 2–3 个固定工具) -->
<view v-if="workbench.length > 1" class="wb-sub-row">
<view
v-for="(t, i) in workbench.slice(1, 3)"
:key="t.key"
class="cttap tb-rise press"
:style="{ animationDelay: `${(i + 3) * 40}ms` }"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<CardTile
v-bind="toolCard(t.key, 'workbench.sub')"
:icon="t.icon"
:title="t.name"
:locked="t.locked"
:illustration="SUB_STICKERS[i % SUB_STICKERS.length]"
size="md"
/>
</view>
</view>
<!-- 其余固定工具:宫格 -->
<view v-if="workbench.length > 3" class="wb-grid">
<view
v-for="(t, i) in workbench.slice(3)"
:key="t.key"
class="cttap tb-rise press"
:style="{ animationDelay: `${Math.min(i + 5, 9) * 35}ms` }"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<CardTile v-bind="toolCard(t.key, 'workbench.grid')" :icon="t.icon" :title="t.name" :locked="t.locked" :illustration="toolIllustration(t.key)" size="sm" />
</view>
</view>
</template>
</view>
<!-- 发现工具 -->
<view v-if="hotTools.length" class="tb-card sec sec--gap tb-rise" style="animation-delay: 340ms">
<view class="sec__head">
<text class="tb-section-title">
发现工具
</text>
<view class="tb-section-more press" hover-class="press-active" @tap="emit('library')">
<text>全部工具</text>
<wd-icon name="arrow-right" size="26rpx" color="#FF6B35" />
</view>
</view>
<view class="hots">
<view
v-for="(t, i) in hotTools"
:key="t.tool_key"
class="cttap tb-rise press"
:style="{ animationDelay: `${Math.min(i, 5) * 30 + 370}ms` }"
hover-class="press-active"
@tap="openTool(toolPath(t.tool_key), t.tool_key, t.locked)"
>
<CardTile
v-bind="toolCard(t.tool_key, 'workbench.hot')"
:icon="t.icon"
:title="t.name"
:desc="t.description"
:locked="t.locked"
:illustration="HOT_STICKERS[i % HOT_STICKERS.length]"
size="md"
/>
</view>
</view>
</view>
<view class="foot" />
</view>
</scroll-view>
</template>
<style scoped>
.hero__meta {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 4rpx;
}
.hero__greet {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.08em;
}
.hero__level {
background: rgba(255, 255, 255, 0.22);
border: 2rpx solid rgba(255, 255, 255, 0.45);
color: #ffffff;
font-size: 20rpx;
padding: 4rpx 18rpx;
border-radius: 999rpx;
}
/* 消息入口:白色玻璃圆钮 + 未读徽标 */
.hero__msg {
position: relative;
margin-left: auto;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.22);
border: 1rpx solid rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.hero__msg-badge {
position: absolute;
top: -8rpx;
right: -10rpx;
min-width: 30rpx;
height: 30rpx;
padding: 0 8rpx;
border-radius: 999rpx;
background: #ff4d1f;
border: 2rpx solid #ffffff;
color: #ffffff;
font-size: 18rpx;
font-weight: 700;
line-height: 26rpx;
text-align: center;
box-sizing: border-box;
}
.hero__title {
display: block;
margin-top: 12rpx;
font-size: 54rpx;
font-weight: 800;
letter-spacing: -0.02em;
color: #ffffff;
text-shadow: 0 6rpx 20rpx rgba(151, 60, 8, 0.3);
}
.tb-search {
margin-top: 30rpx;
}
/* 进行中项目快捷入口 */
.proj {
display: flex;
gap: 22rpx;
padding: 22rpx;
margin-top: 28rpx;
}
.proj__cover {
position: relative;
flex-shrink: 0;
width: 190rpx;
height: 150rpx;
border-radius: 20rpx;
overflow: hidden;
background: #fff3e9;
border: 1rpx solid #ffe9dd;
}
.proj__img {
width: 100%;
height: 100%;
/* 同上:图片自带圆角,不依赖容器裁切 */
border-radius: 20rpx;
}
.proj__img--ph {
display: flex;
align-items: center;
justify-content: center;
}
.proj__badge {
position: absolute;
top: 0;
right: 0;
padding: 4rpx 16rpx;
border-radius: 0 20rpx 0 18rpx;
background: linear-gradient(140deg, #ff7a3d, #fb4e14);
color: #ffffff;
font-size: 19rpx;
font-weight: 800;
box-shadow: 0 4rpx 10rpx rgba(251, 78, 20, 0.3);
}
.proj__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12rpx;
}
.proj__top {
display: flex;
align-items: center;
gap: 14rpx;
}
.proj__title {
flex: 1;
min-width: 0;
font-size: 29rpx;
font-weight: 800;
color: #3d2a1e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.proj__status {
flex-shrink: 0;
padding: 4rpx 16rpx;
border-radius: 999rpx;
background: #fff1e8;
border: 1rpx solid #ffd4b8;
color: #e8531a;
font-size: 19rpx;
font-weight: 700;
}
.proj__progress {
display: flex;
}
.proj__track {
flex: 1;
height: 12rpx;
border-radius: 999rpx;
background: #fff1e8;
border: 1rpx solid #ffe3d3;
overflow: hidden;
}
.proj__fill {
height: 100%;
border-radius: 999rpx;
background: linear-gradient(90deg, #ff9f4d, #fb4e14);
}
.proj__meta {
display: flex;
align-items: center;
gap: 18rpx;
}
.proj__stage {
font-size: 21rpx;
font-weight: 700;
color: #fb4e14;
}
.proj__due {
font-size: 20rpx;
color: #9a7b65;
}
.sec {
padding: 30rpx 28rpx 32rpx;
}
.sec--gap {
margin-top: 28rpx;
}
/* frameless 便捷功能(用户点名:玻璃壳在大图卡下显脏):去壳后贴齐页面栅格 */
.sec--flat {
padding: 8rpx 0 0;
}
.sec__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 26rpx;
}
.sec__hint {
text-align: center;
color: #9a7b65;
font-size: 26rpx;
padding: 50rpx 0;
}
/* 通栏主卡 / 副卡 / 宫格 / 发现工具的视觉全部由 CardTile 组件库承担(P3),
此处仅保留网格容器与点击热区编排。
⚠️ 容器栅格属于**本文件**的职责:迁卡片视觉(P3)时不得连带删除下列网格规则,
否则容器退化为块级布局(每张卡占满整行),观感与上方两列副卡断裂。 */
/* 副卡:两列网格(贴纸/渐变由 CardTile 决定) */
.wb-sub-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-top: 20rpx;
}
/* 其余固定工具:四列宫格(回滚缺失的栅格,否则每张卡占满整行 → 观感断裂)。
R5:由 grid 改为 flex 换行 + flex-grow ——
· 满行(4 张)时每张恰好 1/4,与原来一致;
· 末行不足 4 张时,剩余卡片按**剩余位置等分**铺满(占 2 个位置就得到 2 个位置的宽度),
不再出现「一行只有两张、右侧大片空位」。
basis 里 `- 1rpx` 是防 rpx 取整把本该 4 张的一行挤成 3 张;多出的空间由 flex-grow 吸收。
🔴 R8(2026-10-09 用户报「宫格排版不对、空太大」实测修复):`.wb-grid` 只有 1 张卡时,
flex-grow 会把它拉成**整行通栏 328px**,而卡片是 `size="sm"` 窄卡(内边距 8rpx、图标 68rpx),
实测内容只占 50px、右侧空 270px —— 一张 sm 窄卡拉成通栏长条,视觉上就是「大空盒子」。
修法:`max-width` 封顶 = 2 个位置宽。语义上「一张卡最多占半行」,与 R6「占两个位置就有两个位置的宽度」
完全兼容(2 张时 flex-grow 给的就是半行宽,正好等于 max-width,不受影响);
且**只封宽度、不改 flex 机制** ⇒ 4 张满行、末行 2 张等分的既有行为零回归。
(工具库 `.floor-grid` 是 grid repeat(4,1fr),天然按位不拉伸,无需同样处理——已实测确认正常。) */
.wb-grid {
display: flex;
flex-wrap: wrap;
gap: 18rpx;
margin-top: 22rpx;
}
.wb-grid .cttap {
flex: 1 1 calc((100% - 54rpx) / 4 - 1rpx);
min-width: 0;
max-width: calc((100% - 18rpx) / 2 - 1rpx);
}
/* 便捷功能:两列(≤2 张时的原排版) */
.feats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-top: 20rpx;
}
/* 便捷功能:=3 张的「1 大 2 小」拼图(参考图2,主题化) */
.fm {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 16rpx;
height: 380rpx;
margin-top: 20rpx;
}
.fm__main {
position: relative;
overflow: hidden;
border-radius: 26rpx;
background: #fff3e9;
box-shadow: 0 10rpx 24rpx rgba(151, 60, 8, 0.16);
}
.fm__side {
display: flex;
flex-direction: column;
gap: 16rpx;
min-width: 0;
}
.fm__sub {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 26rpx;
background: #fff3e9;
box-shadow: 0 8rpx 18rpx rgba(151, 60, 8, 0.12);
}
.fm__bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* 与容器圆角同值:MP 端 overflow:hidden 裁切 image 子元素不可靠,
必须让图片/遮罩自己带圆角,否则「图片圆角、容器方角」穿帮(用户点名) */
border-radius: 26rpx;
}
.fm__shade {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 26rpx;
background: linear-gradient(180deg, rgba(40, 20, 6, 0.02) 34%, rgba(40, 20, 6, 0.56) 100%);
}
.fm__body {
position: absolute;
left: 20rpx;
right: 20rpx;
bottom: 16rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.fm__title {
color: #ffffff;
font-size: 32rpx;
font-weight: 800;
letter-spacing: 0.01em;
text-shadow: 0 2rpx 10rpx rgba(60, 25, 5, 0.45);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm__title--sm {
font-size: 27rpx;
}
.fm__pill {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4rpx;
background: rgba(255, 255, 255, 0.94);
border-radius: 999rpx;
padding: 6rpx 16rpx;
box-shadow: 0 4rpx 12rpx rgba(60, 25, 5, 0.18);
}
.fm__pill-text {
color: #e8531a;
font-size: 20rpx;
font-weight: 700;
max-width: 240rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 便捷功能:≥4 张的横滑图片卡(参考图3,主题化) */
.fs {
width: 100%;
margin-top: 20rpx;
}
.fs__row {
display: inline-flex;
gap: 16rpx;
padding: 4rpx 2rpx 10rpx;
}
.fs__card {
display: inline-flex;
flex-direction: column;
width: 300rpx;
background: rgba(255, 255, 255, 0.94);
border: 1rpx solid #ffe9dd;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 8rpx 18rpx rgba(151, 60, 8, 0.10);
}
.fs__imgwrap {
height: 220rpx;
background: #fff3e9;
}
.fs__img {
width: 100%;
height: 100%;
display: block;
}
.fs__title {
margin: 14rpx 16rpx 0;
font-size: 26rpx;
font-weight: 800;
color: #3d2a1e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fs__desc {
margin: 4rpx 16rpx 16rpx;
font-size: 21rpx;
color: #9a7b65;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 发现工具:两列 */
.hots {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
/* 底部留白:避开悬浮 TabBar 与 Home Indicator */
.foot {
height: calc(220rpx + env(safe-area-inset-bottom, 0px));
}
</style>