Files
yunzerwebsiteallinone/uniapp/pages/dashboard/dashboard.vue
T
2026-10-07 13:46:32 +08:00

897 lines
22 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.
<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: '/api/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: '/api/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>