Files
yunzerwebsiteallinone/uniapp/components/sub/SubHome.vue
T

920 lines
21 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" :class="{ embedded }">
<!-- 顶部标识条(独立页模式使用;嵌入 tabs.vue 时由父页提供) -->
<view v-if="!embedded" class="hero" :class="sub.tint" :style="headPad">
<view class="hero-row" @tap="goBackNav">
<FaIcon name="chevron-left" color="#ffffff" :size="18" />
<text class="hero-back">返回导航</text>
</view>
<view class="hero-main">
<view class="hero-icon">
<FaIcon :name="sub.icon" color="#ffffff" :size="26" />
</view>
<view class="hero-meta">
<text class="hero-name">{{ sub.title }}</text>
<text class="hero-desc">{{ sub.desc }}</text>
</view>
</view>
</view>
<view class="body">
<!-- 未开通 -->
<view v-if="!enabled" class="lock-box">
<FaIcon name="lock" color="#f04438" :size="28" />
<text class="lock-title">该子系统未开通</text>
<text class="lock-desc">当前租户套餐未包含「{{ sub.name }}」,请联系管理员开通后使用。</text>
</view>
<block v-else>
<!-- 加载失败 -->
<view v-if="loadError" class="err-box" @tap="load(true)">
<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>
<block v-else>
<!-- 首页卡片流:flex 纵向 + 统一 gap,避免跨组件 margin 不折叠导致间距不均 -->
<view class="home-stack">
<!-- OA 工作台仪表盘上半:公告 + 数据统计(常用功能插在下面) -->
<SubOaDashboard
v-if="isOa && dash"
part="top"
:data="dash"
@view-notices="goNotices"
@view-schedules="goSchedules"
/>
<!-- 概览 / 日程待办 / 最新公告:非 OA 子系统,或 OA 仪表盘加载失败时兜底展示 -->
<block v-if="!(isOa && dash)">
<view class="kpi-card">
<view class="kpi-item">
<text class="kpi-value">{{ leafMenus.length }}</text>
<text class="kpi-label">可用功能</text>
</view>
<view class="kpi-item">
<text class="kpi-value">{{ pending.length }}</text>
<text class="kpi-label">待办</text>
</view>
<view class="kpi-item">
<text class="kpi-value">{{ doneCount }}</text>
<text class="kpi-label">已完成</text>
</view>
<view class="kpi-item">
<text class="kpi-value" :class="{ warn: overdue > 0 }">{{ overdue }}</text>
<text class="kpi-label">已逾期</text>
</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="pending.length === 0" class="empty-box">
<FaIcon name="circle-check" color="#c0c4cc" :size="26" />
<text class="empty-text">暂无待办事项</text>
</view>
<view
v-for="item in pending.slice(0, 3)"
: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>
</view>
</view>
</view>
<!-- 最新公告(仅 OA 子系统) -->
<view v-if="isOa" class="block">
<view class="block-head">
<text class="block-title">最新公告</text>
<view class="block-more" @tap="goNotices">
<text class="block-more-text">全部</text>
<FaIcon name="chevron-right" color="#8a94ad" :size="11" />
</view>
</view>
<view class="list-card">
<view v-if="notices.length === 0" class="empty-box">
<FaIcon name="bullhorn" color="#c0c4cc" :size="26" />
<text class="empty-text">暂无公告</text>
</view>
<view
v-for="item in notices"
:key="item.id"
class="list-item"
@tap="goNoticeDetail(item.id)"
>
<view class="dot" :class="{ 'dot-top': item.is_top }" />
<view class="list-body">
<text class="list-title">{{ item.title }}</text>
<text class="list-time">{{ item.publish_time || item.publisher_name || '' }}</text>
</view>
</view>
</view>
</view>
</block>
<!-- 常用功能:与仪表盘卡片统一的样式,点「全部」切到功能页签 -->
<view class="quick-card">
<view class="quick-head">
<view class="quick-head-left">
<view class="head-icon">
<FaIcon name="star" color="#f59e0b" :size="12" />
</view>
<text class="quick-title">常用功能</text>
</view>
<view class="block-more" @tap="goFunctions">
<text class="block-more-text">全部</text>
<FaIcon name="chevron-right" color="#94a3b8" :size="11" />
</view>
</view>
<view class="grid">
<view
v-for="(item, index) in quickMenus"
:key="item.id"
class="cell"
@tap="openMenu(item, item.title)"
>
<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 || fallbackIcon" color="#ffffff" :size="18" />
</view>
<text class="fn-name">{{ item.title }}</text>
</view>
</view>
<!-- 末位固定「+」:点击选择要加入常用功能的功能 -->
<view class="cell">
<view class="fn-card fn-add" @tap="openPicker">
<view class="add-icon">
<FaIcon name="plus" color="#8a94ad" :size="18" />
</view>
<text class="fn-name">添加</text>
</view>
</view>
</view>
</view>
<!-- OA 工作台仪表盘下半:报销趋势 + 状态分布 -->
<SubOaDashboard
v-if="isOa && dash"
part="bottom"
:data="dash"
@view-notices="goNotices"
@view-schedules="goSchedules"
/>
</view>
</block>
</block>
<view class="bottom-space" />
</view>
<AppTabbar v-if="!embedded" :group="slug" current="home" />
<!-- 常用功能选择器:从当前子系统可用功能中勾选 -->
<view v-if="pickerVisible" class="picker-mask" @tap="pickerVisible = false">
<view class="picker-sheet" @tap.stop>
<view class="picker-head">
<text class="picker-cancel" @tap="pickerVisible = false">取消</text>
<text class="picker-title">选择常用功能</text>
<text class="picker-save" @tap="savePicker">完成</text>
</view>
<text class="picker-tip">最多选 {{ QUICK_MAX }} 个,按点选顺序展示</text>
<scroll-view class="picker-body" scroll-y>
<view v-if="quickCandidates.length === 0" class="picker-empty">
当前子系统暂无可添加的功能
</view>
<view
v-for="(item, index) in quickCandidates"
:key="item.id"
class="picker-item"
@tap="togglePick(item.id)"
>
<view class="tint-icon picker-icon" :class="item.locked ? 'tint-off' : 'tint-' + (index % 8)">
<FaIcon :name="item.icon || fallbackIcon" color="#ffffff" :size="16" />
</view>
<text class="picker-name">{{ item.title }}</text>
<view class="picker-check" :class="{ on: pickedSet.has(item.id) }">
<FaIcon v-if="pickedSet.has(item.id)" name="check" color="#ffffff" :size="12" />
</view>
</view>
</scroll-view>
</view>
</view>
</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 { request } from '@/api/request.js'
import { clearHomeCache } from '@/api/workspace.js'
import { openMenu } from '@/utils/nav.js'
import { useSubsystem } from '@/utils/subsystem.js'
import AppTabbar from '@/components/AppTabbar.vue'
import SubOaDashboard from '@/components/sub/SubOaDashboard.vue'
const props = defineProps({
slug: { type: String, required: true },
embedded: { type: Boolean, default: false }
})
const emit = defineEmits(['switch-tab', 'back'])
const { sub, loading, loadError, enabled, allMenus, leafMenus, quickMenuIds, load } = useSubsystem(props.slug)
const pending = ref([])
const doneCount = ref(0)
const notices = ref([])
// OA 工作台仪表盘数据(仅 isOa 时加载)
const dash = ref(null)
/** 是否 OA 子系统(决定是否展示「最新公告」卡片) */
const isOa = computed(() => String(sub.module || '').toLowerCase() === 'oa')
/** 模块 code → 图标,用于菜单自身没配图标时的兜底 */
const fallbackIcon = computed(() => {
const map = {
crm: 'handshake',
erp: 'boxes-stacked',
oa: 'clipboard-check',
cms: 'globe',
bianjiegongju: 'wrench'
}
return map[String(sub.module || '').toLowerCase()] || 'file-lines'
})
/** 仪表盘类菜单:不属于「常用功能」可选项,也不在宫格展示 */
function isDashboardMenu(m) {
const title = String(m.title || '').trim()
const path = String(m.path || '')
return title === '仪表盘' || /\/dashboard\/?$/.test(path)
}
/** 可作为「常用功能」的候选:可跳转叶子菜单、排除仪表盘 */
const quickCandidates = computed(() =>
allMenus.value
.filter((m) => m.leaf !== false && !isDashboardMenu(m))
.slice()
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
)
/** 宫格最多展示 7 个功能,末位固定留给「+」入口(共 8 格 = 2 行) */
const QUICK_MAX = 7
/** 常用功能:优先用用户自定义顺序,未自定义时回落默认取前 QUICK_MAX 个 */
const quickMenus = computed(() => {
const base = quickCandidates.value
const byId = {}
base.forEach((m) => { byId[m.id] = m })
const ids = (quickMenuIds.value || []).map((v) => Number(v)).filter((v) => byId[v])
if (ids.length) return ids.map((id) => byId[id]).slice(0, QUICK_MAX)
return base.slice(0, QUICK_MAX)
})
// ============ 常用功能选择器(末位「+」) ============
const pickerVisible = ref(false)
const pickerSelected = ref([])
const pickedSet = computed(() => new Set(pickerSelected.value))
function openPicker() {
pickerSelected.value = quickMenus.value.map((m) => m.id)
pickerVisible.value = true
}
function togglePick(id) {
const i = pickerSelected.value.indexOf(id)
if (i >= 0) pickerSelected.value.splice(i, 1)
else {
if (pickerSelected.value.length >= QUICK_MAX) {
uni.showToast({ title: `最多添加 ${QUICK_MAX} 个常用功能`, icon: 'none' })
return
}
pickerSelected.value.push(id)
}
}
async function savePicker() {
try {
await request({
url: '/api/quick-menus',
method: 'POST',
data: { menu_ids: pickerSelected.value }
})
clearHomeCache()
await load(true)
pickerVisible.value = false
uni.showToast({ title: '已保存', icon: 'success' })
} catch (e) {
uni.showToast({ title: (e && e.message) || '保存失败', icon: 'none' })
}
}
// ============ OA 工作台仪表盘 ============
async function loadDashboard() {
if (!isOa.value) return
try {
dash.value = await request({ url: '/api/oa/dashboard', method: 'GET', silent: true })
} catch (e) {
// 仪表盘失败不影响首页其余内容
dash.value = null
}
}
const overdue = computed(() => pending.value.filter((s) => isOverdue(s)).length)
async function loadTodo() {
try {
const [p, d] = await Promise.all([
request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 50 } }),
request({ url: '/api/schedule/list', data: { status: 'done', pageSize: 50 } })
])
pending.value = (p && p.list) || []
doneCount.value = ((d && d.list) || []).length
} catch (e) {
pending.value = []
doneCount.value = 0
}
}
async function loadNotices() {
if (!isOa.value) return
try {
const data = await request({ url: '/api/oa/notice/list', data: { pageSize: 3 } })
notices.value = (data && data.list) || []
} catch (e) {
notices.value = []
}
}
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 goBackNav() {
if (props.embedded) {
emit('back')
return
}
uni.reLaunch({ url: '/pages/nav/nav' })
}
// 「功能」页签在 TAB_ORDER 中的下标为 1
function goFunctions() {
if (props.embedded) {
emit('switch-tab', 1)
return
}
uni.redirectTo({ url: `/pages/${props.slug}/functions/functions` })
}
function goSchedules() {
uni.navigateTo({ url: '/pages/tools/schedule/index' })
}
function goNotices() {
uni.navigateTo({ url: '/pages/oa/notice/index' })
}
function goNoticeDetail(id) {
uni.navigateTo({ url: `/pages/oa/notice/detail?id=${id}` })
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
load()
loadTodo()
loadNotices()
loadDashboard()
})
onShow(() => {
if (isLoggedIn() && !loading.value) {
loadTodo()
loadNotices()
loadDashboard()
}
})
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
@include page-shell;
// 嵌入模式下由父页(tabs.vue)的 scroll-view 统一滚动,这里改为自然高度
&.embedded {
height: auto;
}
}
.hero {
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 20rpx) $page-padding-x 40rpx;
/* #ifdef MP-WEIXIN */
padding-top: calc(env(safe-area-inset-top, 25px) + 84rpx);
/* #endif */
color: #ffffff;
}
.hero-row {
display: flex;
align-items: center;
gap: 4rpx;
opacity: 0.85;
}
.hero-back {
font-size: 24rpx;
color: #ffffff;
}
.hero-main {
display: flex;
align-items: center;
gap: 20rpx;
margin-top: 24rpx;
}
.hero-icon {
width: 92rpx;
height: 92rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.32);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-meta {
flex: 1;
min-width: 0;
}
.hero-name {
display: block;
font-size: 38rpx;
font-weight: 600;
letter-spacing: 1rpx;
}
.hero-desc {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 28rpx $page-padding-x 0;
}
/* 首页卡片流:统一纵向间距(跨组件 margin 不折叠,故用 flex gap) */
.home-stack {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.block-head {
@include block-head;
}
.block-title {
@include section-title;
}
.block-more {
display: flex;
align-items: center;
gap: 4rpx;
}
.block-more-text {
font-size: 24rpx;
color: $color-text-muted;
}
/* ============ 概览卡 ============ */
.kpi-card {
@include card;
display: flex;
padding: 30rpx 0;
}
.kpi-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
position: relative;
& + &::before {
content: '';
position: absolute;
left: 0;
top: 6rpx;
bottom: 6rpx;
width: 1rpx;
background: $color-divider;
}
}
.kpi-value {
font-size: 40rpx;
font-weight: 600;
line-height: 1.1;
color: $color-text;
&.warn {
color: $color-danger;
}
}
.kpi-label {
font-size: 22rpx;
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;
}
}
.list-body {
flex: 1;
min-width: 0;
}
.list-title {
display: block;
font-size: 28rpx;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-meta,
.list-time {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.list-time.warn {
color: $color-danger;
}
.dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: $color-primary;
flex-shrink: 0;
&.warn {
background: $color-danger;
}
&.dot-top {
background: $color-danger;
}
}
.empty-box {
padding: 56rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 14rpx;
}
.empty-text {
font-size: 25rpx;
color: $color-text-muted;
}
/* ============ 常用功能宫格:一行 4 个 ============
cell 占 25% 宽并自带右侧间隙,容器用负 margin 抵消末列间隙,
避免使用 calc 除法——小程序端 wxss 对复杂 calc 支持不稳。 */
/* 与 SubOaDashboard 的 dash-card 保持同一视觉语言 */
.quick-card {
@include card;
padding: 26rpx 24rpx 10rpx;
}
.quick-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.quick-head-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.head-icon {
width: 40rpx;
height: 40rpx;
border-radius: 12rpx;
background: #fff7e6;
display: flex;
align-items: center;
justify-content: center;
}
.quick-title {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
}
.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: transparent;
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;
}
/* 末位「+」添加格:虚线框,区别于普通功能 */
.fn-card.fn-add {
background: transparent;
border: 1rpx dashed #c7cede;
}
.add-icon {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
background: #eef1f6;
display: flex;
align-items: center;
justify-content: center;
}
/* ============ 常用功能选择器 ============ */
.picker-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.45);
z-index: 100;
display: flex;
align-items: flex-end;
@include mask-responsive;
}
.picker-sheet {
width: 100%;
max-height: 72vh;
background: $color-card;
border-radius: 28rpx 28rpx 0 0;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
@include sheet-responsive;
}
.picker-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx $page-padding-x;
border-bottom: 1rpx solid $color-divider;
}
.picker-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.picker-cancel {
font-size: 28rpx;
color: $color-text-muted;
}
.picker-save {
font-size: 28rpx;
font-weight: 600;
color: $color-primary;
}
.picker-tip {
padding: 18rpx $page-padding-x 4rpx;
font-size: 22rpx;
color: $color-text-muted;
}
.picker-body {
flex: 1;
min-height: 0;
max-height: 52vh;
padding: 8rpx $page-padding-x 24rpx;
}
.picker-empty {
padding: 80rpx 0;
text-align: center;
font-size: 26rpx;
color: $color-text-muted;
}
.picker-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid $color-divider;
}
.picker-icon {
@include tint-icon(56rpx, 16rpx);
flex-shrink: 0;
}
.picker-name {
flex: 1;
min-width: 0;
font-size: 28rpx;
color: $color-text;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.picker-check {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #c7cede;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.on {
background: $color-primary;
border-color: $color-primary;
}
}
.err-box {
display: flex;
align-items: center;
gap: 16rpx;
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;
}
.lock-box {
@include card;
padding: 60rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 14rpx;
}
.lock-title {
font-size: 30rpx;
font-weight: 600;
color: $color-danger;
}
.lock-desc {
font-size: 24rpx;
color: $color-text-muted;
text-align: center;
line-height: 1.6;
}
.bottom-space {
height: 32rpx;
}
</style>