938 lines
28 KiB
Vue
938 lines
28 KiB
Vue
<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>
|