Files
yunzerwebsiteallinone/uniapp/pages/dashboard/dashboard.vue
T
2026-10-03 23:46:55 +08:00

978 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">
<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>
<!-- 应用宫格:选卡片进入对应功能系统 -->
<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>
<view v-else-if="!loadError" class="app-grid">
<view
v-for="group in moduleGroups"
:key="group.code || group.id"
class="app-card"
:class="{ locked: group.locked }"
@tap="onModuleTap(group)"
>
<view class="app-card-top">
<view class="tint-icon app-icon" :class="group.tint">
<FaIcon :name="group.icon" color="#ffffff" :size="24" />
</view>
<text v-if="group.locked" class="app-lock">未开通</text>
</view>
<text class="app-name">{{ group.name }}</text>
<text class="app-desc">{{ group.desc }}</text>
<view class="app-foot">
<text class="app-count">{{ group.total }} 个功能</text>
<FaIcon name="arrow-right" color="#c8cfdf" :size="12" />
</view>
</view>
</view>
</view>
<!-- 常用应用 -->
<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="grid">
<view
v-for="item in quickApps"
:key="item.key"
class="grid-item"
@tap="openMenu(item.menu, item.name)"
>
<view class="tint-icon grid-icon" :class="item.tint">
<FaIcon :name="item.icon" color="#ffffff" :size="22" />
</view>
<text class="grid-name">{{ item.name }}</text>
</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 { 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'
/** 模块 code → 端上图标(后端 icon 存的是 Element Plus 图标名,端上用不了) */
const MODULE_ICONS = {
crm: 'handshake',
erp: 'boxes-stacked',
oa: 'clipboard-check',
cms: 'globe',
ai: 'robot',
system: 'gear',
tool: 'wrench'
}
/** 公共菜单(无 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 modules = 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))
const moduleGroups = computed(() => {
const byCode = {}
leafMenus.value.forEach((m) => {
if (!m.module) return
if (!byCode[m.module]) byCode[m.module] = []
byCode[m.module].push(m)
})
// 已登记的功能模块(type=1)。type=2 是系统设置(角色/日志/字典/域名…),
// 属于后台管理范畴,不适合摆在工作台上,留给「全部应用」页。
const groups = modules.value
.filter((m) => m.type !== 2)
.map((mod, index) => {
const items = byCode[mod.code] || []
const code = String(mod.code || '').toLowerCase()
const locked = mod.enabled === false
return {
id: mod.id,
code: mod.code,
path: mod.path || '',
name: mod.name || '未命名应用',
desc: locked
? '未开通该模块'
: mod.description || (items.length ? items.length + ' 个功能' : '暂无功能'),
icon: MODULE_ICONS[code] || 'cube',
tint: locked ? 'tint-off' : 'tint-' + (index % 8),
locked,
items: items.slice(0, 6),
total: items.length
}
})
// 兜底:模块表里没有对应记录(或已删除)的菜单,避免被静默丢弃
const known = {}
groups.forEach((g) => {
known[String(g.code || '').toLowerCase()] = true
})
const orphan = []
leafMenus.value.forEach((m) => {
const code = String(m.module || '').toLowerCase()
if (code && !known[code] && code !== 'other') orphan.push(m)
})
if (orphan.length) {
groups.push({
id: 'other',
code: 'other',
name: '其他功能',
desc: orphan.length + ' 个功能',
icon: 'file-lines',
tint: 'tint-7',
locked: false,
items: orphan.slice(0, 6),
total: orphan.length
})
}
return groups
})
const enabledCount = computed(() => moduleGroups.value.filter((g) => !g.locked).length)
/**
* 协作工具:真正无模块归属的公共菜单(如后续新增的通用功能)。
* 系统设置类与已归入模块的菜单都不在这里,避免把日志/角色/字典等后台配置
* 和业务功能混在一块。
*/
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 || {}
modules.value = Array.isArray(data.modules) ? data.modules : []
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
}
if (group.path) {
openMenu({ title: group.name, path: group.path, leaf: true }, 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;
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;
}
/* ============ 应用宫格卡片 ============ */
.app-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.app-card {
@include card;
padding: 26rpx 24rpx 20rpx;
display: flex;
flex-direction: column;
transition: transform 0.15s ease, box-shadow 0.2s ease;
&:active {
transform: scale(0.97);
}
&.locked {
.app-name,
.app-desc {
color: $color-text-muted;
}
}
}
.app-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.app-icon {
@include tint-icon(88rpx, 24rpx);
}
.app-lock {
@include chip(#fef3f2, $color-danger);
}
.app-name {
margin-top: 20rpx;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-desc {
margin-top: 6rpx;
font-size: 22rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18rpx;
padding-top: 16rpx;
border-top: 1rpx solid $color-divider;
}
.app-count {
font-size: 22rpx;
color: $color-primary;
}
/* ============ 常用功能宫格 ============ */
.grid {
@include card;
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 28rpx 8rpx 20rpx;
}
.grid-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 14rpx;
padding: 12rpx 0;
&:active {
opacity: 0.7;
}
}
.grid-icon {
@include tint-icon(92rpx, 24rpx);
}
.grid-name {
font-size: 23rpx;
color: $color-text-secondary;
text-align: center;
}
/* ============ 应用中心卡片 ============ */
.mod-card {
@include card;
padding: 24rpx;
margin-bottom: 20rpx;
&.locked {
.mod-name,
.mod-desc {
color: $color-text-muted;
}
}
}
.mod-head {
display: flex;
align-items: center;
gap: 20rpx;
}
.mod-icon {
@include tint-icon(88rpx, 22rpx);
}
.mod-meta {
flex: 1;
min-width: 0;
}
.mod-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.mod-name {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.mod-lock {
@include chip(#fef3f2, $color-danger);
}
.mod-desc {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid $color-divider;
}
.chip {
@include chip;
}
.chip-off {
background: $color-divider;
color: $color-text-muted;
}
.chip-more {
background: $color-primary-bg;
}
/* ============ 协作工具 ============ */
.tool-card {
@include card;
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 24rpx 8rpx;
}
.tool-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
padding: 10rpx 0;
&:active {
opacity: 0.7;
}
}
.tool-icon {
@include icon-box(72rpx);
}
.tool-name {
font-size: 23rpx;
color: $color-text-secondary;
text-align: center;
}
/* ============ 列表(待办 / 动态) ============ */
.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>