897 lines
22 KiB
Vue
897 lines
22 KiB
Vue
<template>
|
||
<view class="page">
|
||
<!-- ============ Hero 头部 ============ -->
|
||
<view class="hero" :style="headPad">
|
||
<view class="hero-glow hero-glow-1" />
|
||
<view class="hero-glow hero-glow-2" />
|
||
<view class="hero-top">
|
||
<view class="hero-hello">
|
||
<text class="hero-greet">{{ greeting }}</text>
|
||
<text class="hero-org">{{ orgLine }}</text>
|
||
</view>
|
||
<view class="hero-bell" @tap="goAllApps">
|
||
<FaIcon name="bell" color="#ffffff" :size="20" />
|
||
<text v-if="stats.message > 0" class="bell-badge">{{ badgeText }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="search" @tap="goAllApps">
|
||
<FaIcon name="magnifying-glass" color="#8a94ad" :size="15" />
|
||
<text class="search-text">搜索应用、功能、菜单…</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- ============ 内容区 ============ -->
|
||
<view class="body">
|
||
<!-- KPI 数据条(悬浮压在 Hero 上) -->
|
||
<view class="kpi">
|
||
<view
|
||
v-for="item in kpiList"
|
||
:key="item.label"
|
||
class="kpi-item"
|
||
@tap="item.tap()"
|
||
>
|
||
<text class="kpi-value" :class="{ warn: item.warn }">{{ item.value }}</text>
|
||
<text class="kpi-label">{{ item.label }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 数据加载失败提示(避免把接口故障误显示成「尚未配置」) -->
|
||
<view v-if="loadError" class="err-box" @tap="loadAll">
|
||
<FaIcon name="triangle-exclamation" color="#f04438" :size="18" />
|
||
<view class="err-body">
|
||
<text class="err-title">工作台数据加载失败</text>
|
||
<text class="err-msg">{{ loadError }},点击重试</text>
|
||
</view>
|
||
<FaIcon name="rotate" color="#f04438" :size="16" />
|
||
</view>
|
||
|
||
<!-- 应用:按模块分组,每组一个 card,组头 + 组内一行 4 个功能 -->
|
||
<view class="block">
|
||
<view class="block-head">
|
||
<text class="block-title">应用</text>
|
||
<view class="block-more" @tap="goAllApps">
|
||
<text class="block-more-text">全部功能</text>
|
||
<FaIcon name="chevron-right" color="#8a94ad" :size="11" />
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="!loading && !loadError && moduleGroups.length === 0" class="empty-box">
|
||
<text class="empty-text">尚未配置应用模块</text>
|
||
</view>
|
||
|
||
<block v-else-if="!loadError">
|
||
<view
|
||
v-for="group in moduleGroups"
|
||
:key="group.id"
|
||
class="group-card"
|
||
>
|
||
<view class="group-head" @tap="onModuleTap(group)">
|
||
<view class="group-bar" />
|
||
<text class="group-title">{{ group.name }}</text>
|
||
<text v-if="group.locked" class="group-lock">未开通</text>
|
||
<text class="group-count">{{ group.total }}</text>
|
||
</view>
|
||
<view v-if="group.items.length" class="grid">
|
||
<view
|
||
v-for="(item, index) in group.items"
|
||
:key="item.id"
|
||
class="cell"
|
||
@tap="openMenu(item, item.title)"
|
||
>
|
||
<view class="fn-card" :class="{ locked: group.locked || item.locked }">
|
||
<view
|
||
class="tint-icon fn-icon"
|
||
:class="group.locked || item.locked ? 'tint-off' : 'tint-' + (index % 8)"
|
||
>
|
||
<FaIcon :name="item.icon || group.icon" color="#ffffff" :size="18" />
|
||
</view>
|
||
<text class="fn-name">{{ item.title }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="group-empty">
|
||
<text class="group-empty-text">{{ group.locked ? '未开通该模块' : '该模块下暂无你可见的功能' }}</text>
|
||
</view>
|
||
</view>
|
||
</block>
|
||
</view>
|
||
|
||
<!-- 常用功能:宫格卡(一行 4 个) -->
|
||
<view class="block">
|
||
<view class="block-head">
|
||
<text class="block-title">常用功能</text>
|
||
<view class="block-more" @tap="goAllApps">
|
||
<text class="block-more-text">全部应用</text>
|
||
<FaIcon name="chevron-right" color="#8a94ad" :size="11" />
|
||
</view>
|
||
</view>
|
||
<view v-if="!loading && !loadError && quickApps.length === 0" class="empty-box">
|
||
<text class="empty-text">暂无可用应用,请联系管理员开通</text>
|
||
</view>
|
||
<view v-else-if="!loadError" class="quick-card">
|
||
<view class="grid">
|
||
<view
|
||
v-for="(item, index) in quickApps"
|
||
:key="item.key"
|
||
class="cell"
|
||
@tap="openMenu(item.menu, item.name)"
|
||
>
|
||
<view class="fn-card" :class="{ locked: item.locked }">
|
||
<view
|
||
class="tint-icon fn-icon"
|
||
:class="item.locked ? 'tint-off' : 'tint-' + (index % 8)"
|
||
>
|
||
<FaIcon :name="item.icon" color="#ffffff" :size="18" />
|
||
</view>
|
||
<text class="fn-name">{{ item.name }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 今日待办 -->
|
||
<view class="block">
|
||
<view class="block-head">
|
||
<text class="block-title">今日待办</text>
|
||
<view class="block-more" @tap="goSchedules">
|
||
<text class="block-more-text">全部日程</text>
|
||
<FaIcon name="chevron-right" color="#8a94ad" :size="11" />
|
||
</view>
|
||
</view>
|
||
<view class="list-card">
|
||
<view v-if="pendingList.length === 0" class="empty-box">
|
||
<FaIcon name="circle-check" color="#c0c4cc" :size="26" />
|
||
<text class="empty-text">暂无待办,享受清爽的一天</text>
|
||
</view>
|
||
<view
|
||
v-for="item in pendingList"
|
||
:key="item.id"
|
||
class="list-item"
|
||
@tap="goSchedules"
|
||
>
|
||
<view class="dot" :class="{ warn: isOverdue(item) }" />
|
||
<view class="list-body">
|
||
<text class="list-title">{{ item.content || item.title || '日程' }}</text>
|
||
<text class="list-time" :class="{ warn: isOverdue(item) }">{{ item.schedule_time }}</text>
|
||
</view>
|
||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 最近动态 -->
|
||
<view v-if="activities.length" class="block">
|
||
<view class="block-head">
|
||
<text class="block-title">最近动态</text>
|
||
</view>
|
||
<view class="list-card">
|
||
<view v-for="(item, index) in activities" :key="index" class="list-item">
|
||
<view class="dot" :class="item.type" />
|
||
<view class="list-body">
|
||
<text class="list-title">{{ item.title }}</text>
|
||
<text class="list-time">{{ item.time }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="bottom-space" />
|
||
</view>
|
||
|
||
<AppTabbar current="dashboard" />
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { mpHeadPad } from '@/utils/mpHead.js'
|
||
|
||
const headPad = mpHeadPad()
|
||
import { onShow } from '@dcloudio/uni-app'
|
||
import { isLoggedIn } from '@/utils/auth.js'
|
||
import { fetchHome } from '@/api/workspace.js'
|
||
import { openMenu, resolveNativeRoute } from '@/utils/nav.js'
|
||
import { request } from '@/api/request.js'
|
||
import AppTabbar from '@/components/MainTabbar.vue'
|
||
|
||
/** 公共菜单(无 module 归属)的协作工具图标 */
|
||
const TOOL_ICONS = ['note-sticky', 'calendar-days', 'file-lines', 'clock-rotate-left', 'chart-line']
|
||
|
||
const loading = ref(true)
|
||
const loadError = ref('')
|
||
const user = ref({})
|
||
const menus = ref([])
|
||
const stats = ref({ todo: 0, overdue: 0, note: 0, message: 0, reimburse: 0 })
|
||
const pendingList = ref([])
|
||
const activities = ref([])
|
||
|
||
/* ==================== 派生数据 ==================== */
|
||
|
||
const greeting = computed(() => {
|
||
const h = new Date().getHours()
|
||
const slot = h < 6 ? '夜深了' : h < 11 ? '早上好' : h < 13 ? '中午好' : h < 18 ? '下午好' : '晚上好'
|
||
const name = user.value.name
|
||
return name ? slot + ',' + name : slot
|
||
})
|
||
|
||
/** 统一账号:企业 · 账号 · 角色(账号在全平台各系统通用) */
|
||
const orgLine = computed(() => {
|
||
const parts = []
|
||
if (user.value.tenant) parts.push(user.value.tenant)
|
||
if (user.value.account) parts.push(user.value.account)
|
||
if (user.value.role_name) parts.push(user.value.role_name)
|
||
return parts.length ? parts.join(' · ') : '云泽统一账号'
|
||
})
|
||
|
||
const badgeText = computed(() => (stats.value.message > 99 ? '99+' : String(stats.value.message || '')))
|
||
|
||
/** 叶子菜单(可直接跳转的功能页) */
|
||
const leafMenus = computed(() => menus.value.filter((m) => m.leaf !== false && !m.locked))
|
||
|
||
/**
|
||
* 按「一级分类目录」归组(与子系统功能页 SubFunctions.vue 一致):
|
||
* 菜单层级形如 模块根 → 个人办公 → 通讯录,分组取一级目录名(个人办公 / 人事管理 …);
|
||
* 模块根仅作容器被跳过;无分类归属的顶层功能统一归入「常用功能」。
|
||
* 祖先链需在「全部菜单」里查找(目录不是叶子,不在 leafMenus 中)。
|
||
*/
|
||
const moduleGroups = computed(() => {
|
||
const byId = {}
|
||
menus.value.forEach((m) => {
|
||
byId[m.id] = m
|
||
})
|
||
// 只有叶子菜单才是可点功能;目录只作分组标题
|
||
const leaves = leafMenus.value.slice().sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||
|
||
// 取该菜单所属的「一级分组」节点(自底向上找到链上最顶层节点)
|
||
function groupNodeOf(m) {
|
||
const chain = [m]
|
||
let cur = m
|
||
let guard = 0
|
||
while (byId[cur.pid] && guard++ < 20) {
|
||
cur = byId[cur.pid]
|
||
chain.unshift(cur)
|
||
}
|
||
const top = chain[0]
|
||
if (top.pid === 0) {
|
||
// 模块根在列表内:跳过根,取它下一层(个人办公 / 人事管理 …)
|
||
if (chain.length >= 2) return chain[1]
|
||
// 顶层节点自身:目录容器跳过,叶子归入「常用功能」
|
||
return top.leaf === false ? null : top
|
||
}
|
||
// 模块根不在列表内:链上顶层节点本身就是一级分组
|
||
return top
|
||
}
|
||
|
||
const groupMap = new Map()
|
||
const common = { id: 'common', name: '常用功能', sort: -1, icon: 'star', items: [] }
|
||
|
||
leaves.forEach((m) => {
|
||
const g = groupNodeOf(m)
|
||
if (!g) return
|
||
if (g.id === m.id) {
|
||
// 无分类归属的顶层散装功能
|
||
common.items.push(m)
|
||
return
|
||
}
|
||
if (!groupMap.has(g.id)) {
|
||
groupMap.set(g.id, { id: g.id, name: g.title, sort: g.sort, icon: g.icon, items: [] })
|
||
}
|
||
groupMap.get(g.id).items.push(m)
|
||
})
|
||
|
||
const dirGroups = []
|
||
groupMap.forEach((g) => dirGroups.push(g))
|
||
dirGroups.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||
|
||
const all = []
|
||
if (common.items.length) all.push(common)
|
||
all.push(...dirGroups)
|
||
|
||
// 工作台只展示 2 行(一行 4 个),更多功能留给「全部应用」页
|
||
return all.map((group, gi) => ({
|
||
...group,
|
||
icon: group.icon || 'file-lines',
|
||
tint: 'tint-' + (gi % 8),
|
||
locked: false,
|
||
total: group.items.length,
|
||
items: group.items.slice(0, 8)
|
||
}))
|
||
})
|
||
|
||
/**
|
||
* 协作工具:真正无模块归属的公共菜单(如后续新增的通用功能)。
|
||
* 系统设置类与已归入模块的菜单都不在这里,避免把日志/角色/字典等后台配置
|
||
* 和业务功能混在一块。
|
||
*/
|
||
const commonMenus = computed(() => menus.value.filter((m) => !m.module && m.leaf !== false).slice(0, 6))
|
||
|
||
/** 常用应用:端上原生页面优先,再补各模块首个功能 */
|
||
const quickApps = computed(() => {
|
||
const picked = []
|
||
const seen = new Set()
|
||
|
||
const push = (menu, icon) => {
|
||
if (!menu || seen.has(menu.id) || picked.length >= 8) return
|
||
seen.add(menu.id)
|
||
picked.push({
|
||
key: 'm' + menu.id,
|
||
name: menu.title,
|
||
menu,
|
||
icon: icon || 'circle',
|
||
tint: 'tint-' + (picked.length % 8)
|
||
})
|
||
}
|
||
|
||
leafMenus.value.filter((m) => resolveNativeRoute(m)).forEach((m) => push(m))
|
||
moduleGroups.value.filter((g) => !g.locked).forEach((g) => push(g.items[0], g.icon))
|
||
commonMenus.value.forEach((m, i) => push(m, TOOL_ICONS[i % TOOL_ICONS.length]))
|
||
|
||
return picked
|
||
})
|
||
|
||
const kpiList = computed(() => [
|
||
{ label: '待办日程', value: stats.value.todo || 0, warn: false, tap: goSchedules },
|
||
{ label: '已逾期', value: stats.value.overdue || 0, warn: stats.value.overdue > 0, tap: goSchedules },
|
||
{ label: '报销在途', value: stats.value.reimburse || 0, warn: false, tap: goAllApps },
|
||
{ label: '我的笔记', value: stats.value.note || 0, warn: false, tap: () => openMenu({ title: '记事本' }) }
|
||
])
|
||
|
||
/* ==================== 数据加载 ==================== */
|
||
|
||
async function loadHome() {
|
||
try {
|
||
const data = await fetchHome()
|
||
user.value = data.user || {}
|
||
menus.value = Array.isArray(data.menus) ? data.menus : []
|
||
stats.value = Object.assign({ todo: 0, overdue: 0, note: 0, message: 0, reimburse: 0 }, data.stats || {})
|
||
loadError.value = ''
|
||
} catch (e) {
|
||
// 接口不可用(后端未重启 / 网络异常)时明确暴露原因,
|
||
// 否则会误显示成「尚未配置应用模块」,很难排查
|
||
console.error('[workbench] load home failed:', e)
|
||
loadError.value = (e && e.message) || '接口请求失败'
|
||
}
|
||
}
|
||
|
||
async function loadPending() {
|
||
try {
|
||
const data = await request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 5 } })
|
||
pendingList.value = (data && data.list ? data.list : []).slice(0, 5)
|
||
} catch (e) {
|
||
pendingList.value = []
|
||
}
|
||
}
|
||
|
||
async function loadActivities() {
|
||
try {
|
||
const data = await request({ url: '/app/activity/list', data: { limit: 5 } })
|
||
const list = Array.isArray(data) ? data : (data && data.list) || []
|
||
activities.value = list.slice(0, 5).map((item) => ({
|
||
type: activityTint(item.target_type),
|
||
title: item.title || (String(item.module || '') + ' ' + String(item.action || '')).trim() || '操作记录',
|
||
time: formatTime(item.created_at)
|
||
}))
|
||
} catch (e) {
|
||
activities.value = []
|
||
}
|
||
}
|
||
|
||
async function loadAll() {
|
||
loading.value = true
|
||
await Promise.all([loadHome(), loadPending(), loadActivities()])
|
||
loading.value = false
|
||
}
|
||
|
||
/* ==================== 交互 ==================== */
|
||
|
||
/**
|
||
* 点击应用卡片 → 进入该功能系统。
|
||
* 优先进入模块下的第一个功能页(各模块的仪表盘/首页,即系统入口),
|
||
* 没有功能页时回落到模块自身 path。
|
||
*/
|
||
function onModuleTap(group) {
|
||
if (group.locked) {
|
||
openMenu({ locked: true, title: group.name })
|
||
return
|
||
}
|
||
if (group.items.length) {
|
||
openMenu(group.items[0], group.name)
|
||
return
|
||
}
|
||
goAllApps()
|
||
}
|
||
|
||
function goAllApps() {
|
||
uni.navigateTo({ url: '/pages/features/features' })
|
||
}
|
||
|
||
function goSchedules() {
|
||
uni.navigateTo({ url: '/pages/tools/schedule/index' })
|
||
}
|
||
|
||
/* ==================== 工具 ==================== */
|
||
|
||
function isOverdue(item) {
|
||
const t = String(item.schedule_time || item.created_at || '')
|
||
return t.length >= 10 && t.slice(0, 10) < todayStr()
|
||
}
|
||
|
||
function todayStr() {
|
||
const d = new Date()
|
||
const p = (n) => String(n).padStart(2, '0')
|
||
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
|
||
}
|
||
|
||
function activityTint(type) {
|
||
const map = {
|
||
note: 'ok',
|
||
notebook: 'ok',
|
||
schedule: 'primary',
|
||
erp: 'warn',
|
||
crm: 'purple',
|
||
file: 'danger'
|
||
}
|
||
return map[type] || 'muted'
|
||
}
|
||
|
||
function formatTime(dateStr) {
|
||
if (!dateStr) return ''
|
||
const ts = new Date(String(dateStr).replace(' ', 'T')).getTime()
|
||
if (!ts) return ''
|
||
const diff = Date.now() - ts
|
||
const min = 60 * 1000
|
||
const hour = 60 * min
|
||
const day = 24 * hour
|
||
if (diff < min) return '刚刚'
|
||
if (diff < hour) return Math.floor(diff / min) + ' 分钟前'
|
||
if (diff < day) return Math.floor(diff / hour) + ' 小时前'
|
||
if (diff < 7 * day) return Math.floor(diff / day) + ' 天前'
|
||
const d = new Date(ts)
|
||
return d.getMonth() + 1 + '/' + d.getDate()
|
||
}
|
||
|
||
/* ==================== 生命周期 ==================== */
|
||
|
||
onMounted(() => {
|
||
if (!isLoggedIn()) {
|
||
uni.reLaunch({ url: '/pages/login/login' })
|
||
return
|
||
}
|
||
loadAll()
|
||
})
|
||
|
||
onShow(() => {
|
||
if (isLoggedIn() && !loading.value) loadAll()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import '@/src/styles/page-common.scss';
|
||
|
||
.page {
|
||
@include page-shell;
|
||
}
|
||
|
||
/* ============ Hero ============ */
|
||
.hero {
|
||
position: relative;
|
||
/* 与 .kpi 显式分层:不依赖渲染器对 position:relative 同级元素的隐式绘制顺序,
|
||
否则部分 WebView 下 .kpi 的负边距上移部分会被本层裁掉 */
|
||
z-index: 1;
|
||
flex-shrink: 0;
|
||
padding: calc(var(--status-bar-height, 44px) + 28rpx) $page-padding-x 76rpx;
|
||
/* #ifdef MP-WEIXIN */
|
||
padding-top: calc(env(safe-area-inset-top, 25px) + 92rpx);
|
||
/* #endif */
|
||
background: $hero-gradient;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.hero-glow {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(50rpx);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.hero-glow-1 {
|
||
width: 380rpx;
|
||
height: 380rpx;
|
||
top: -160rpx;
|
||
right: -120rpx;
|
||
background: rgba(255, 255, 255, 0.18);
|
||
}
|
||
|
||
.hero-glow-2 {
|
||
width: 300rpx;
|
||
height: 300rpx;
|
||
bottom: -140rpx;
|
||
left: -100rpx;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
}
|
||
|
||
.hero-top {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.hero-hello {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.hero-greet {
|
||
display: block;
|
||
font-size: 44rpx;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
.hero-org {
|
||
display: block;
|
||
margin-top: 10rpx;
|
||
font-size: 24rpx;
|
||
color: rgba(255, 255, 255, 0.78);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.hero-bell {
|
||
position: relative;
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.16);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bell-badge {
|
||
position: absolute;
|
||
top: 6rpx;
|
||
right: 4rpx;
|
||
min-width: 30rpx;
|
||
height: 30rpx;
|
||
padding: 0 6rpx;
|
||
border-radius: 15rpx;
|
||
background: #f04438;
|
||
color: #ffffff;
|
||
font-size: 18rpx;
|
||
line-height: 30rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.search {
|
||
position: relative;
|
||
margin-top: 32rpx;
|
||
height: 76rpx;
|
||
padding: 0 24rpx;
|
||
border-radius: $radius-full;
|
||
background: #ffffff;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14rpx;
|
||
box-shadow: 0 8rpx 20rpx rgba(17, 35, 79, 0.12);
|
||
}
|
||
|
||
.search-text {
|
||
font-size: 26rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
/* ============ 内容区 ============ */
|
||
.body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
padding: 0 $page-padding-x;
|
||
}
|
||
|
||
/* KPI 悬浮卡 */
|
||
/* KPI 悬浮卡:上移量 = 顶部内边距,保证数字恰好落在 Hero 下边缘之后 */
|
||
.kpi {
|
||
position: relative;
|
||
z-index: 2;
|
||
margin-top: -44rpx;
|
||
display: flex;
|
||
@include card;
|
||
padding: 44rpx 0 28rpx;
|
||
box-shadow: $shadow-float;
|
||
}
|
||
|
||
.kpi-item {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
position: relative;
|
||
|
||
& + &::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 8rpx;
|
||
bottom: 8rpx;
|
||
width: 1rpx;
|
||
background: $color-divider;
|
||
}
|
||
}
|
||
|
||
.kpi-value {
|
||
font-size: 40rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
line-height: 1.2;
|
||
|
||
&.warn {
|
||
color: $color-danger;
|
||
}
|
||
}
|
||
|
||
.kpi-label {
|
||
font-size: 22rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
/* ============ 加载失败提示 ============ */
|
||
.err-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
margin-top: 24rpx;
|
||
padding: 24rpx 22rpx;
|
||
background: #fef3f2;
|
||
border: 1rpx solid #fdd8d4;
|
||
border-radius: $radius-md;
|
||
}
|
||
|
||
.err-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.err-title {
|
||
display: block;
|
||
font-size: 27rpx;
|
||
font-weight: 500;
|
||
color: $color-danger;
|
||
}
|
||
|
||
.err-msg {
|
||
display: block;
|
||
margin-top: 6rpx;
|
||
font-size: 22rpx;
|
||
color: #b42318;
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* ============ 区块通用 ============ */
|
||
.block {
|
||
margin-top: 36rpx;
|
||
}
|
||
.block-head {
|
||
@include block-head;
|
||
}
|
||
|
||
.block-title {
|
||
@include section-title;
|
||
}
|
||
|
||
.block-sub {
|
||
font-size: 24rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.block-more {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.block-more-text {
|
||
font-size: 24rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
.empty-box {
|
||
@include card;
|
||
padding: 56rpx 24rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 25rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
/* ============ 分组卡:外层一个 card,组头 + 组内一行 4 个功能 ============ */
|
||
.group-card {
|
||
@include card;
|
||
padding: 24rpx 20rpx 8rpx;
|
||
|
||
& + & {
|
||
margin-top: 24rpx;
|
||
}
|
||
}
|
||
|
||
.group-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
padding: 0 4rpx 20rpx;
|
||
}
|
||
|
||
.group-bar {
|
||
width: 6rpx;
|
||
height: 28rpx;
|
||
border-radius: $radius-full;
|
||
background: $color-primary;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.group-title {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.group-lock {
|
||
@include chip(#fef3f2, $color-danger);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.group-count {
|
||
@include chip;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.group-empty {
|
||
padding: 20rpx 4rpx 16rpx;
|
||
}
|
||
|
||
.group-empty-text {
|
||
font-size: 24rpx;
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
/* ============ 功能宫格:一行 4 个 ============
|
||
cell 占 25% 宽并自带右侧间隙,容器用负 margin 抵消末列间隙,
|
||
避免使用 calc 除法——小程序端 wxss 对复杂 calc 支持不稳。 */
|
||
.quick-card {
|
||
@include card;
|
||
padding: 24rpx 20rpx 8rpx;
|
||
}
|
||
|
||
.grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
margin-right: -16rpx;
|
||
}
|
||
|
||
.cell {
|
||
width: 25%;
|
||
padding-right: 16rpx;
|
||
margin-bottom: 16rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.fn-card {
|
||
height: 100%;
|
||
padding: 18rpx 6rpx 16rpx;
|
||
border-radius: $radius-md;
|
||
background: $color-bg-page;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
transition: transform 0.15s ease;
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
|
||
.fn-icon {
|
||
@include tint-icon(64rpx, 18rpx);
|
||
}
|
||
|
||
.fn-name {
|
||
max-width: 100%;
|
||
font-size: 22rpx;
|
||
line-height: 1.3;
|
||
color: $color-text;
|
||
text-align: center;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.fn-card.locked .fn-name {
|
||
color: $color-text-muted;
|
||
}
|
||
|
||
/* ============ 列表(待办 / 动态) ============ */
|
||
.list-card {
|
||
@include card;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18rpx;
|
||
padding: 26rpx 24rpx;
|
||
|
||
& + & {
|
||
border-top: 1rpx solid $color-divider;
|
||
}
|
||
|
||
&:active {
|
||
background: $color-bg-page;
|
||
}
|
||
}
|
||
|
||
.dot {
|
||
width: 14rpx;
|
||
height: 14rpx;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
background: $color-text-muted;
|
||
|
||
&.primary { background: $color-primary; }
|
||
&.ok { background: $color-success; }
|
||
&.warn { background: $color-warning; }
|
||
&.danger { background: $color-danger; }
|
||
&.purple { background: #7c3aed; }
|
||
&.muted { background: $color-text-muted; }
|
||
}
|
||
|
||
.list-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.list-title {
|
||
display: block;
|
||
font-size: 27rpx;
|
||
color: $color-text;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.list-time {
|
||
display: block;
|
||
margin-top: 6rpx;
|
||
font-size: 22rpx;
|
||
color: $color-text-muted;
|
||
|
||
&.warn {
|
||
color: $color-danger;
|
||
}
|
||
}
|
||
|
||
.bottom-space {
|
||
height: 40rpx;
|
||
}
|
||
</style>
|