主要更新uniapp相关功能
This commit is contained in:
1 parent
aa92ca40a2
commit
5b4dbb2b46
65 files changed
+8532
-1921
No files matched your search
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<template>
|
||||
<view class="app-tabbar">
|
||||
<view class="tabbar-inner">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
v-for="tab in list"
|
||||
:key="tab.name"
|
||||
class="tab-item"
|
||||
:class="{ active: current === tab.name }"
|
||||
@tap="onChange(tab.name)"
|
||||
@tap="onTap(tab.name)"
|
||||
>
|
||||
<FaIcon
|
||||
:name="tab.icon"
|
||||
:color="current === tab.name ? '#3973ff' : '#8a94ad'"
|
||||
:size="20"
|
||||
:color="current === tab.name ? '#2563eb' : '#94a3b8'"
|
||||
:size="21"
|
||||
/>
|
||||
<text class="tab-text">{{ tab.text }}</text>
|
||||
</view>
|
||||
@@ -21,32 +21,52 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 子系统内底部 tabbar。
|
||||
* 底部 tabbar。
|
||||
*
|
||||
* 每个子系统(crm / erp / oa / cms / toolbox)都有独立的页面组与独立的 tabbar,
|
||||
* 通过 group(子系统目录名)解析路由;导航页 nav.vue 不使用本组件。
|
||||
* variant = 'route':独立页模式。每个子系统(crm / erp / oa / cms / toolbox)有独立的页面组,
|
||||
* 通过 group(子系统目录名)解析路由并 redirectTo 跳转;导航页 nav.vue 不使用本组件。
|
||||
* variant = 'switch':页内模式。用于 pages/sub/tabs.vue,切换时只 emit change 由父页控制
|
||||
* 内容区显隐,避免页面栈堆积、保留各页签状态;tabs 可由父页传入。
|
||||
*/
|
||||
const props = defineProps({
|
||||
group: {
|
||||
type: String,
|
||||
required: true
|
||||
default: ''
|
||||
},
|
||||
current: {
|
||||
type: String,
|
||||
default: 'home'
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'route'
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const DEFAULT_TABS = [
|
||||
{ name: 'home', text: '概览', icon: 'gauge' },
|
||||
{ name: 'functions', text: '功能', icon: 'table-cells' },
|
||||
{ name: 'todo', text: '待办', icon: 'circle-check' },
|
||||
{ name: 'mine', text: '我的', icon: 'user' }
|
||||
]
|
||||
|
||||
function onChange(name) {
|
||||
const list = computed(() => (props.tabs && props.tabs.length ? props.tabs : DEFAULT_TABS))
|
||||
|
||||
function onTap(name) {
|
||||
if (name === props.current) return
|
||||
if (props.variant === 'switch') {
|
||||
emit('change', name)
|
||||
return
|
||||
}
|
||||
// 子系统内部页面与 tab 一一对应,用 redirectTo 避免页面栈堆积
|
||||
uni.redirectTo({ url: `/pages/${props.group}/${name}/${name}` })
|
||||
}
|
||||
@@ -59,14 +79,14 @@ function onChange(name) {
|
||||
flex-shrink: 0;
|
||||
background: $color-card;
|
||||
border-top: 1rpx solid $color-border;
|
||||
box-shadow: $shadow-header;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(31, 43, 72, 0.05);
|
||||
}
|
||||
|
||||
.tabbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 10rpx 0 6rpx;
|
||||
padding: 12rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
@@ -74,17 +94,29 @@ function onChange(name) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 8rpx 0;
|
||||
gap: 6rpx;
|
||||
padding: 6rpx 0;
|
||||
transition: transform 0.18s ease;
|
||||
|
||||
&.active .tab-text {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
&.active {
|
||||
.tab-text {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.fa-icon) {
|
||||
transform: translateY(-2rpx);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 22rpx;
|
||||
line-height: 1.2;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
@@ -92,4 +124,4 @@ function onChange(name) {
|
||||
height: constant(safe-area-inset-bottom);
|
||||
height: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -25,10 +25,34 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const iconChar = computed(() => FA_ICONS[props.name] || '')
|
||||
// 菜单图标在数据库里常以完整 Font Awesome 类名字符串存储(如 "fas fa-dashboard"、
|
||||
// "fa-brands fa-cash-app"、"far fa-trash-can"),而 FA_ICONS 的键是去前缀的裸名字。
|
||||
// 这里统一归一化:解析出裸图标名与字重(solid/regular/brands),
|
||||
// 使同一份图标数据在移动端也能正确渲染;已是裸名字(如 "chevron-left")时原样返回。
|
||||
const parsed = computed(() => {
|
||||
const raw = String(props.name || '').trim()
|
||||
if (!raw) return { name: '', brands: false }
|
||||
// 完整类名:按空白拆 token,取以 fa- 开头且非样式字重(fa-solid/regular/brands)的最后一段
|
||||
if (/\bfa-(solid|regular|light|thin|duotone|brands)\b|\bfas\b|\bfar\b|\bfal\b|\bfab\b/.test(raw)) {
|
||||
const tokens = raw.split(/\s+/)
|
||||
let brands = false
|
||||
let icon = ''
|
||||
for (const t of tokens) {
|
||||
if (/^(fa-brands|fab)$/.test(t)) brands = true
|
||||
else if (/^(fa-solid|fas|fa-regular|far|fa-light|fal|fa-thin|fa-duotone)$/.test(t)) continue
|
||||
else if (t.startsWith('fa-')) icon = t.slice(3)
|
||||
else if (t && t !== 'fa') icon = t
|
||||
}
|
||||
return { name: icon, brands }
|
||||
}
|
||||
// 裸名字:可能仍带一个 fa- 前缀,去掉后查表
|
||||
return { name: raw.replace(/^fa-/, ''), brands: props.type === 'brands' }
|
||||
})
|
||||
|
||||
const iconChar = computed(() => FA_ICONS[parsed.value.name] || '')
|
||||
|
||||
const fontClass = computed(() =>
|
||||
props.type === 'brands' ? 'fa-font-brands' : 'fa-font-solid'
|
||||
parsed.value.brands ? 'fa-font-brands' : 'fa-font-solid'
|
||||
)
|
||||
|
||||
const iconStyle = computed(() => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<view class="app-tabbar">
|
||||
<view class="tabbar-inner">
|
||||
<view
|
||||
@@ -10,7 +10,7 @@
|
||||
>
|
||||
<FaIcon
|
||||
:name="tab.icon"
|
||||
:color="current === tab.name ? '#3973ff' : '#8a94ad'"
|
||||
:color="current === tab.name ? '#2563eb' : '#94a3b8'"
|
||||
:size="20"
|
||||
/>
|
||||
<text class="tab-text">{{ tab.text }}</text>
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="head">
|
||||
<template>
|
||||
<view class="page" :class="{ embedded }">
|
||||
<view v-if="!embedded" class="head" :style="headPad">
|
||||
<view class="head-row" @tap="goBackNav">
|
||||
<FaIcon name="chevron-left" color="#1f2b48" :size="18" />
|
||||
<text class="head-back">返回导航</text>
|
||||
</view>
|
||||
<view class="head-title-row">
|
||||
<text class="head-title">{{ sub.title }}</text>
|
||||
<text class="head-count">{{ filteredMenus.length }} 个功能</text>
|
||||
</view>
|
||||
<view class="search-bar">
|
||||
<FaIcon name="magnifying-glass" color="#8a94ad" :size="15" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="搜索功能名称"
|
||||
placeholder-class="search-placeholder"
|
||||
confirm-type="search"
|
||||
/>
|
||||
<view v-if="keyword" class="search-clear" @tap="keyword = ''">
|
||||
<FaIcon name="circle-xmark" color="#c0c4cc" :size="15" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="head-title">{{ sub.title }}</text>
|
||||
</view>
|
||||
|
||||
<view class="body">
|
||||
@@ -34,39 +17,49 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<view v-if="filteredMenus.length === 0" class="empty-box">
|
||||
<FaIcon name="magnifying-glass" color="#c0c4cc" :size="30" />
|
||||
<text class="empty-text">{{ keyword.trim() ? '没有匹配的功能' : '该子系统下暂无你可见的功能' }}</text>
|
||||
</view>
|
||||
<view v-else-if="groups.length === 0" class="empty-box">
|
||||
<FaIcon name="table-cells" color="#c0c4cc" :size="30" />
|
||||
<text class="empty-text">该子系统下暂无你可见的功能</text>
|
||||
</view>
|
||||
|
||||
<!-- 功能卡片:两列宫格,与子系统导航页保持一致的视觉语言 -->
|
||||
<view v-else class="card-grid">
|
||||
<view
|
||||
v-for="(item, index) in filteredMenus"
|
||||
:key="item.id"
|
||||
class="fn-card"
|
||||
:class="{ locked: item.locked }"
|
||||
@tap="openMenu(item, item.title)"
|
||||
>
|
||||
<view class="tint-icon fn-icon" :class="item.locked ? 'tint-off' : 'tint-' + (index % 8)">
|
||||
<FaIcon :name="item.icon || fallbackIcon" color="#ffffff" :size="20" />
|
||||
<!-- 功能分组:每个分组一个 card,组内为功能 card(一行 4 个,仅图标 + 名称) -->
|
||||
<view v-else class="group-list">
|
||||
<view v-for="group in groups" :key="group.id" class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">{{ group.title }}</text>
|
||||
<text class="group-count">{{ group.items.length }}</text>
|
||||
</view>
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="item in group.items"
|
||||
: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-' + (item.tintIndex % 8)">
|
||||
<FaIcon :name="item.icon || fallbackIcon" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<text class="fn-name">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="fn-name">{{ item.title }}</text>
|
||||
<text class="fn-sub">{{ item.leaf === false ? (item.docs || 0) + ' 个下级' : shortPath(item.path) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
|
||||
<AppTabbar :group="slug" current="functions" />
|
||||
<AppTabbar v-if="!embedded" :group="slug" current="functions" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { 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 { openMenu } from '@/utils/nav.js'
|
||||
@@ -74,11 +67,13 @@ import { useSubsystem } from '@/utils/subsystem.js'
|
||||
import AppTabbar from '@/components/AppTabbar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
slug: { type: String, required: true }
|
||||
slug: { type: String, required: true },
|
||||
embedded: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['switch-tab', 'back'])
|
||||
|
||||
const { sub, loading, loadError, allMenus, load } = useSubsystem(props.slug)
|
||||
const keyword = ref('')
|
||||
|
||||
/** 模块 code → 图标,用于菜单自身没配图标时的兜底 */
|
||||
const fallbackIcon = computed(() => {
|
||||
@@ -92,23 +87,75 @@ const fallbackIcon = computed(() => {
|
||||
return map[String(sub.module || '').toLowerCase()] || 'file-lines'
|
||||
})
|
||||
|
||||
const filteredMenus = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase()
|
||||
const list = allMenus.value.slice().sort((a, b) => a.sort - b.sort)
|
||||
if (!q) return list
|
||||
return list.filter((m) => String(m.title || '').toLowerCase().indexOf(q) > -1)
|
||||
/**
|
||||
* 按「一级分类目录」分组:
|
||||
* 菜单层级形如 模块根 → 个人办公 → 通讯录,分组取一级目录(个人办公 / 人事管理 …),
|
||||
* 组内为该目录下的全部可见功能。无分类归属的快捷功能不在功能页展示,由首页「常用功能」承载。
|
||||
*/
|
||||
const groups = computed(() => {
|
||||
const menus = allMenus.value.slice().sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
const byId = {}
|
||||
menus.forEach((m) => { byId[m.id] = m })
|
||||
|
||||
// 取该菜单所属的「一级分组」节点
|
||||
function groupNodeOf(m) {
|
||||
// 自底向上收集祖先链:chain[0] 为链上最顶层节点
|
||||
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()
|
||||
|
||||
menus.forEach((m) => {
|
||||
const g = groupNodeOf(m)
|
||||
if (!g) return
|
||||
if (g.id === m.id) {
|
||||
// 自身即分组节点:目录作组标题(不重复出现在组内),叶子无分类归属不展示
|
||||
if (g.leaf === false && !groupMap.has(g.id)) {
|
||||
groupMap.set(g.id, { id: g.id, title: g.title, sort: g.sort, items: [] })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!groupMap.has(g.id)) {
|
||||
groupMap.set(g.id, { id: g.id, title: g.title, sort: g.sort, items: [] })
|
||||
}
|
||||
groupMap.get(g.id).items.push(m)
|
||||
})
|
||||
|
||||
// 功能页只展示「归属分类目录且有可见功能」的分组;无归属功能由首页「常用功能」承载
|
||||
const list = []
|
||||
groupMap.forEach((g) => {
|
||||
if (g.items.length) list.push(g)
|
||||
})
|
||||
list.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
|
||||
// 颜色下标按展开顺序统一编号,避免每个分组都从 tint-0 开始
|
||||
let colorIndex = 0
|
||||
return list.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((item) => ({ ...item, tintIndex: colorIndex++ }))
|
||||
}))
|
||||
})
|
||||
|
||||
/** 摘要行:去掉模块前缀,只留相对路径,太长时截断 */
|
||||
function shortPath(path) {
|
||||
const p = String(path || '')
|
||||
if (!p) return '功能'
|
||||
const parts = p.split('/').filter(Boolean)
|
||||
if (parts.length <= 2) return p
|
||||
return '…/' + parts.slice(-2).join('/')
|
||||
}
|
||||
|
||||
function goBackNav() {
|
||||
if (props.embedded) {
|
||||
emit('back')
|
||||
return
|
||||
}
|
||||
uni.reLaunch({ url: '/pages/nav/nav' })
|
||||
}
|
||||
|
||||
@@ -130,11 +177,19 @@ onShow(() => {
|
||||
|
||||
.page {
|
||||
@include page-shell;
|
||||
|
||||
// 嵌入模式下由父页(tabs.vue)的 scroll-view 统一滚动,这里改为自然高度
|
||||
&.embedded {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
flex-shrink: 0;
|
||||
padding: calc(var(--status-bar-height, 44px) + 20rpx) $page-padding-x 24rpx;
|
||||
/* #ifdef MP-WEIXIN */
|
||||
padding-top: calc(env(safe-area-inset-top, 25px) + 84rpx);
|
||||
/* #endif */
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
@@ -150,62 +205,20 @@ onShow(() => {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.head-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.head-title {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.head-count {
|
||||
font-size: 23rpx;
|
||||
color: $color-text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
height: 74rpx;
|
||||
margin-top: 22rpx;
|
||||
padding: 0 24rpx;
|
||||
background: $color-bg-page;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 74rpx;
|
||||
font-size: 27rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: $color-text-muted;
|
||||
font-size: 27rpx;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
padding: 28rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.empty-box {
|
||||
@@ -252,64 +265,100 @@ onShow(() => {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ============ 功能卡片:两列宫格 ============
|
||||
与 nav.vue 一致用 flex 换行而非 grid,避免小程序端 wxss 对 grid 支持不稳。 */
|
||||
.card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fn-card {
|
||||
/* ============ 分组卡:外层一个 card,内含若干功能 card ============ */
|
||||
.group-card {
|
||||
@include card;
|
||||
width: calc(50% - 10rpx);
|
||||
margin-right: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 24rpx 20rpx 20rpx;
|
||||
padding: 24rpx 20rpx 8rpx;
|
||||
|
||||
& + & {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&.locked {
|
||||
.fn-name,
|
||||
.fn-sub {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 0 4rpx 20rpx;
|
||||
}
|
||||
|
||||
.fn-icon {
|
||||
@include tint-icon(76rpx, 20rpx);
|
||||
.group-bar {
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
border-radius: $radius-full;
|
||||
background: $color-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fn-name {
|
||||
margin-top: 18rpx;
|
||||
.group-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fn-sub {
|
||||
margin-top: 6rpx;
|
||||
font-size: 21rpx;
|
||||
color: $color-text-muted;
|
||||
.group-count {
|
||||
@include chip;
|
||||
}
|
||||
|
||||
/* ============ 功能宫格:一行 4 个 ============
|
||||
cell 占 25% 宽并自带右侧间隙,容器用负 margin 抵消末列间隙,
|
||||
避免使用 calc 除法——小程序端 wxss 对复杂 calc 支持不稳。 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 48rpx;
|
||||
.fn-card.locked {
|
||||
.fn-name {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.bottom-space {
|
||||
height: 32rpx;
|
||||
}
|
||||
</style>
|
||||
+633
-195
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部标识条 -->
|
||||
<view class="hero" :class="sub.tint">
|
||||
<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>
|
||||
@@ -37,115 +37,354 @@
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<!-- 常用功能 -->
|
||||
<!-- OA 工作台仪表盘(Web 版内容搬到首页顶部) -->
|
||||
<SubOaDashboard
|
||||
v-if="isOa && dash"
|
||||
:data="dash"
|
||||
@view-notices="goNotices"
|
||||
@view-schedules="goSchedules"
|
||||
/>
|
||||
|
||||
<!-- 概览 -->
|
||||
<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>
|
||||
|
||||
<!-- 常用功能:仪表盘式快捷入口,点「全部」切到功能页签 -->
|
||||
<view class="block">
|
||||
<view class="block-head">
|
||||
<text class="block-title">常用功能</text>
|
||||
<text class="block-sub">{{ leafMenus.length }} 个功能</text>
|
||||
<view class="block-more" @tap="goFunctions">
|
||||
<text class="block-more-text">全部</text>
|
||||
<FaIcon name="chevron-right" color="#8a94ad" :size="11" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="leafMenus.length === 0" class="empty-box">
|
||||
<text class="empty-text">该子系统下暂无你可见的功能</text>
|
||||
</view>
|
||||
<view v-else class="grid">
|
||||
<view
|
||||
v-for="item in leafMenus.slice(0, 8)"
|
||||
:key="item.id"
|
||||
class="grid-item"
|
||||
@tap="openMenu(item, item.title)"
|
||||
>
|
||||
<view class="tint-icon grid-icon" :class="sub.tint">
|
||||
<FaIcon name="circle-check" color="#ffffff" :size="20" />
|
||||
<view class="quick-card">
|
||||
<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>
|
||||
<text class="grid-name">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 子系统目录 -->
|
||||
<view v-if="dirMenus.length" class="block">
|
||||
<view class="block-head">
|
||||
<text class="block-title">功能分组</text>
|
||||
</view>
|
||||
<view class="list-card">
|
||||
<view
|
||||
v-for="item in dirMenus"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
@tap="goFunctions"
|
||||
>
|
||||
<view class="dot" />
|
||||
<view class="list-body">
|
||||
<text class="list-title">{{ item.title }}</text>
|
||||
<text class="list-meta">{{ item.docs || 0 }} 个功能</text>
|
||||
<!-- 末位固定「+」:点击选择要加入常用功能的功能 -->
|
||||
<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>
|
||||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 打开 Web 端完整系统(仅该子系统在 Web 端确有对应系统时展示,见 config/subsystems.js 的 webEntry) -->
|
||||
<view v-if="sub.webEntry" class="block">
|
||||
<view class="enter-card" @tap="enterSystem">
|
||||
<view class="tint-icon enter-icon" :class="sub.tint">
|
||||
<FaIcon name="display" color="#ffffff" :size="22" />
|
||||
</view>
|
||||
<view class="enter-meta">
|
||||
<text class="enter-title">进入完整{{ sub.name }}系统</text>
|
||||
<text class="enter-desc">在浏览器中打开,支持全部功能</text>
|
||||
</view>
|
||||
<FaIcon name="arrow-right" color="#c8cfdf" :size="14" />
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</block>
|
||||
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
|
||||
<AppTabbar :group="slug" current="home" />
|
||||
<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, onMounted } from 'vue'
|
||||
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 { openMenu, openWebPage } from '@/utils/nav.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 }
|
||||
slug: { type: String, required: true },
|
||||
embedded: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const { sub, loading, loadError, enabled, leafMenus, dirMenus, moduleMeta, load } = useSubsystem(props.slug)
|
||||
const entered = ref(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: '/app/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: '/app/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: '/app/schedule/list', data: { status: 'pending', pageSize: 50 } }),
|
||||
request({ url: '/app/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: '/app/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` })
|
||||
}
|
||||
|
||||
/** 打开 Web 端该模块首页(模块 path 优先,否则用第一个功能页),走票据免登录 */
|
||||
async function enterSystem() {
|
||||
if (entered.value) return
|
||||
const target =
|
||||
(moduleMeta.value && moduleMeta.value.path) ||
|
||||
(leafMenus.value[0] && leafMenus.value[0].path) ||
|
||||
''
|
||||
if (!target) {
|
||||
uni.showToast({ title: '该系统暂未开放', icon: 'none' })
|
||||
return
|
||||
}
|
||||
entered.value = true
|
||||
try {
|
||||
await openWebPage(target, sub.name)
|
||||
} finally {
|
||||
entered.value = false
|
||||
}
|
||||
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(() => {
|
||||
@@ -154,10 +393,17 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
load()
|
||||
loadTodo()
|
||||
loadNotices()
|
||||
loadDashboard()
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (isLoggedIn() && !loading.value) load()
|
||||
if (isLoggedIn() && !loading.value) {
|
||||
loadTodo()
|
||||
loadNotices()
|
||||
loadDashboard()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -166,11 +412,19 @@ onShow(() => {
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -232,7 +486,11 @@ onShow(() => {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 24rpx $page-padding-x 0;
|
||||
padding: 28rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
@@ -243,15 +501,129 @@ onShow(() => {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.block-sub {
|
||||
.block-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.block-more-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.empty-box {
|
||||
/* ============ 概览卡 ============ */
|
||||
.kpi-card {
|
||||
@include card;
|
||||
padding: 56rpx 24rpx;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
padding: 30rpx 0;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -259,6 +631,182 @@ onShow(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 末位「+」添加格:虚线框,区别于普通功能 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -311,117 +859,7 @@ onShow(() => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.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(84rpx, 22rpx);
|
||||
}
|
||||
|
||||
.grid-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%;
|
||||
background: $color-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.list-meta {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.enter-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 26rpx 24rpx;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.enter-icon {
|
||||
@include tint-icon(76rpx, 20rpx);
|
||||
}
|
||||
|
||||
.enter-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.enter-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.enter-desc {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 40rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
+264
-223
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="head">
|
||||
<template>
|
||||
<view class="page" :class="{ embedded }">
|
||||
<!-- 独立页模式:渐变用户头部(嵌入 tabs.vue 时由父页提供顶部条) -->
|
||||
<view v-if="!embedded" class="head" :style="headPad">
|
||||
<view class="head-row" @tap="goBackNav">
|
||||
<FaIcon name="chevron-left" color="#ffffff" :size="18" />
|
||||
<text class="head-back">返回导航</text>
|
||||
@@ -25,68 +26,69 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 当前子系统 -->
|
||||
<view class="block">
|
||||
<view class="block-head"><text class="block-title">当前子系统</text></view>
|
||||
<view class="card-row">
|
||||
<view class="tint-icon row-icon" :class="sub.tint">
|
||||
<FaIcon :name="sub.icon" color="#ffffff" :size="22" />
|
||||
</view>
|
||||
<view class="row-meta">
|
||||
<text class="row-title">{{ sub.title }}</text>
|
||||
<text class="row-sub">{{ allMenus.length }} 个功能 · {{ enabled ? '已开通' : '未开通' }}</text>
|
||||
</view>
|
||||
<text v-if="!enabled" class="lock-tag">未开通</text>
|
||||
<!-- 用户卡(嵌入模式显示,独立页模式的用户信息已在头部) -->
|
||||
<view v-if="embedded" class="user-card" @tap="goProfile">
|
||||
<view class="avatar avatar-light">
|
||||
<text class="avatar-text avatar-text-dark">{{ avatarText }}</text>
|
||||
</view>
|
||||
<view class="user-meta">
|
||||
<text class="user-name user-name-dark">{{ user.name || '云泽用户' }}</text>
|
||||
<text class="user-sub">{{ user.tenant || '云泽平台' }}<text v-if="user.account"> · {{ user.account }}</text></text>
|
||||
</view>
|
||||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
</view>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<view class="kpi-card">
|
||||
<view class="kpi-item">
|
||||
<text class="kpi-value">{{ pendingCount }}</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 class="kpi-item">
|
||||
<text class="kpi-value">{{ leafMenus.length }}</text>
|
||||
<text class="kpi-label">可用功能</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的数据 -->
|
||||
<view class="block">
|
||||
<view class="block-head"><text class="block-title">我的数据</text></view>
|
||||
<view class="kpi">
|
||||
<view class="kpi-item">
|
||||
<text class="kpi-value" :class="{ warn: overdue > 0 }">{{ pendingCount }}</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 class="kpi-item">
|
||||
<text class="kpi-value">{{ doneCount }}</text>
|
||||
<text class="kpi-label">已完成</text>
|
||||
</view>
|
||||
<view class="kpi-item">
|
||||
<text class="kpi-value">{{ allMenus.length }}</text>
|
||||
<text class="kpi-label">可用功能</text>
|
||||
</view>
|
||||
<!-- 常用操作:分组卡片模板,一行 4 个 -->
|
||||
<view class="group-card">
|
||||
<view class="group-head">
|
||||
<view class="group-bar" />
|
||||
<text class="group-title">常用操作</text>
|
||||
<text class="group-count">3</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作 -->
|
||||
<view class="block">
|
||||
<view class="block-head"><text class="block-title">操作</text></view>
|
||||
<view class="list-card">
|
||||
<!-- 仅该子系统在 Web 端确有对应系统时展示(config/subsystems.js 的 webEntry) -->
|
||||
<view v-if="sub.webEntry" class="list-item" @tap="enterSystem">
|
||||
<view class="item-icon"><FaIcon name="display" color="#3973ff" :size="16" /></view>
|
||||
<text class="item-text">进入完整系统(Web 端)</text>
|
||||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
<view class="grid">
|
||||
<view class="cell" @tap="goFunctions">
|
||||
<view class="fn-card">
|
||||
<view class="tint-icon fn-icon tint-0">
|
||||
<FaIcon name="table-cells" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<text class="fn-name">全部功能</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="list-item" @tap="goFunctions">
|
||||
<view class="item-icon"><FaIcon name="table-cells" color="#3973ff" :size="16" /></view>
|
||||
<text class="item-text">全部功能</text>
|
||||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
<view class="cell" @tap="goProfile">
|
||||
<view class="fn-card">
|
||||
<view class="tint-icon fn-icon tint-1">
|
||||
<FaIcon name="user" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<text class="fn-name">账号与安全</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="list-item" @tap="goProfile">
|
||||
<view class="item-icon"><FaIcon name="user" color="#3973ff" :size="16" /></view>
|
||||
<text class="item-text">账号与安全</text>
|
||||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
</view>
|
||||
<view class="list-item" @tap="goBackNav">
|
||||
<view class="item-icon"><FaIcon name="table-cells" color="#3973ff" :size="16" /></view>
|
||||
<text class="item-text">切换子系统</text>
|
||||
<FaIcon name="chevron-right" color="#c8cfdf" :size="12" />
|
||||
<view class="cell" @tap="goBackNav">
|
||||
<view class="fn-card">
|
||||
<view class="tint-icon fn-icon tint-2">
|
||||
<FaIcon name="arrow-right-arrow-left" color="#ffffff" :size="18" />
|
||||
</view>
|
||||
<text class="fn-name">切换子系统</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -98,15 +100,17 @@
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
|
||||
<AppTabbar :group="slug" current="mine" />
|
||||
<AppTabbar v-if="!embedded" :group="slug" current="mine" />
|
||||
</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, logout, isSSOEnabled, getToken, clearPendingAuth } from '@/utils/auth.js'
|
||||
import { openWebPage } from '@/utils/nav.js'
|
||||
import { logoutUAC, logoutApi } from '@/api/auth.js'
|
||||
import { request } from '@/api/request.js'
|
||||
import { clearHomeCache } from '@/api/workspace.js'
|
||||
@@ -114,15 +118,17 @@ import { useSubsystem } from '@/utils/subsystem.js'
|
||||
import AppTabbar from '@/components/AppTabbar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
slug: { type: String, required: true }
|
||||
slug: { type: String, required: true },
|
||||
embedded: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const { sub, loading, loadError, user, allMenus, enabled, moduleMeta, load } = useSubsystem(props.slug)
|
||||
const emit = defineEmits(['switch-tab', 'back'])
|
||||
|
||||
const { sub, loading, loadError, user, leafMenus, load } = useSubsystem(props.slug)
|
||||
|
||||
const pendingCount = ref(0)
|
||||
const overdue = ref(0)
|
||||
const doneCount = ref(0)
|
||||
const entering = ref(false)
|
||||
|
||||
const avatarText = computed(() => {
|
||||
const name = user.value.name || user.value.account || '云'
|
||||
@@ -158,10 +164,19 @@ function todayStr() {
|
||||
}
|
||||
|
||||
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` })
|
||||
}
|
||||
|
||||
@@ -169,25 +184,6 @@ function goProfile() {
|
||||
uni.redirectTo({ url: '/pages/profile/profile' })
|
||||
}
|
||||
|
||||
/** 打开 Web 端该模块首页(走票据免登录) */
|
||||
async function enterSystem() {
|
||||
if (entering.value) return
|
||||
const target =
|
||||
(moduleMeta.value && moduleMeta.value.path) ||
|
||||
(allMenus.value[0] && allMenus.value[0].path) ||
|
||||
''
|
||||
if (!target) {
|
||||
uni.showToast({ title: '该系统暂未开放', icon: 'none' })
|
||||
return
|
||||
}
|
||||
entering.value = true
|
||||
try {
|
||||
await openWebPage(target, sub.name)
|
||||
} finally {
|
||||
entering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
@@ -232,12 +228,20 @@ onShow(() => {
|
||||
|
||||
.page {
|
||||
@include page-shell;
|
||||
|
||||
// 嵌入模式下由父页(tabs.vue)的 scroll-view 统一滚动,这里改为自然高度
|
||||
&.embedded {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 顶部用户区 ============ */
|
||||
/* ============ 独立页模式的渐变用户头部 ============ */
|
||||
.head {
|
||||
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 */
|
||||
background: $hero-gradient;
|
||||
}
|
||||
|
||||
@@ -273,7 +277,7 @@ onShow(() => {
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 40rpx;
|
||||
font-size: 38rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -293,40 +297,204 @@ onShow(() => {
|
||||
.user-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 23rpx;
|
||||
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: 24rpx $page-padding-x 0;
|
||||
padding: 28rpx $page-padding-x 0;
|
||||
}
|
||||
|
||||
.block {
|
||||
/* ============ 嵌入模式的用户卡 ============ */
|
||||
.user-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 32rpx;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-light {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
background: $color-primary-bg;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.avatar-text-dark {
|
||||
font-size: 34rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.user-name-dark {
|
||||
font-size: 32rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.user-card .user-sub {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ============ 数据概览 ============ */
|
||||
.kpi-card {
|
||||
@include card;
|
||||
display: flex;
|
||||
padding: 30rpx 0;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
@include block-head;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.block-title {
|
||||
@include section-title;
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ============ 常用操作:分组卡 + 一行 4 个格子 ============ */
|
||||
.group-card {
|
||||
@include card;
|
||||
padding: 24rpx 20rpx 8rpx;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
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-count {
|
||||
@include chip;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* cell 占 25% 并自带右间隙,容器负 margin 抵消末列间隙 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.logout {
|
||||
@include card;
|
||||
margin-top: 40rpx;
|
||||
padding: 28rpx 0;
|
||||
text-align: center;
|
||||
|
||||
&:active {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
font-size: 28rpx;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
/* ============ 错误态 ============ */
|
||||
.err-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 24rpx 22rpx;
|
||||
margin-bottom: 24rpx;
|
||||
background: #fef3f2;
|
||||
border: 1rpx solid #fdd8d4;
|
||||
border-radius: $radius-md;
|
||||
@@ -352,134 +520,7 @@ onShow(() => {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 当前子系统行 */
|
||||
.card-row {
|
||||
@include card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
padding: 26rpx 24rpx;
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
@include tint-icon(80rpx, 20rpx);
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.row-sub {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.lock-tag {
|
||||
@include chip(#fef3f2, $color-danger);
|
||||
}
|
||||
|
||||
/* 数据概览 */
|
||||
.kpi {
|
||||
@include card;
|
||||
display: flex;
|
||||
padding: 28rpx 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: 36rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
|
||||
&.warn {
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 21rpx;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
@include icon-box(56rpx);
|
||||
}
|
||||
|
||||
.item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
/* 退出 */
|
||||
.logout {
|
||||
@include card;
|
||||
height: 100rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12rpx;
|
||||
background: linear-gradient(135deg, rgba(240, 68, 56, 0.08) 0%, rgba(240, 68, 56, 0.04) 100%);
|
||||
border: 1rpx solid rgba(240, 68, 56, 0.2);
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 48rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<view class="oa-dash">
|
||||
<!-- 待办概览:今日 / 本周 / 本月 未完成 -->
|
||||
<view class="dash-card">
|
||||
<view class="dash-head">
|
||||
<text class="dash-title">待办概览</text>
|
||||
<text v-if="overdue > 0" class="dash-badge">逾期 {{ overdue }}</text>
|
||||
</view>
|
||||
<view class="todo-row">
|
||||
<view class="todo-cell">
|
||||
<text class="todo-value">{{ todayPending }}</text>
|
||||
<text class="todo-label">今日待办</text>
|
||||
<text class="todo-sub">共 {{ num(ss.today_total) }} · 完成 {{ num(ss.today_done) }}</text>
|
||||
</view>
|
||||
<view class="todo-cell">
|
||||
<text class="todo-value">{{ weekPending }}</text>
|
||||
<text class="todo-label">本周待办</text>
|
||||
<text class="todo-sub">共 {{ num(ss.week_total) }} · 完成 {{ num(ss.week_done) }}</text>
|
||||
</view>
|
||||
<view class="todo-cell">
|
||||
<text class="todo-value">{{ monthPending }}</text>
|
||||
<text class="todo-label">本月待办</text>
|
||||
<text class="todo-sub">共 {{ num(ss.month_total) }} · 完成 {{ num(ss.month_done) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的报销:本周 / 本月 / 本年 已打款金额 -->
|
||||
<view class="dash-card">
|
||||
<view class="dash-head">
|
||||
<text class="dash-title">我的报销</text>
|
||||
</view>
|
||||
<view class="money-row">
|
||||
<view v-for="item in moneyCards" :key="item.label" class="money-cell">
|
||||
<text class="money-label">{{ item.label }}</text>
|
||||
<view class="money-amount">
|
||||
<text class="currency">¥</text>
|
||||
<text class="number">{{ money(item.data.paid) }}</text>
|
||||
</view>
|
||||
<text class="money-meta">{{ num(item.data.count) }} 单 · 待报 ¥{{ money(item.data.unpaid) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 近 6 个月报销趋势(CSS 双色堆叠柱,移动端不引 echarts) -->
|
||||
<view v-if="trend.length" class="dash-card">
|
||||
<view class="dash-head">
|
||||
<text class="dash-title">近6个月报销趋势</text>
|
||||
<view class="legend">
|
||||
<view class="legend-item"><view class="legend-dot paid" /><text>已报销</text></view>
|
||||
<view class="legend-item"><view class="legend-dot unpaid" /><text>未报销</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trend-chart">
|
||||
<view v-for="t in trend" :key="t.month" class="trend-col">
|
||||
<view class="trend-bar-wrap">
|
||||
<view class="trend-bar unpaid" :style="{ height: barH(t.unpaid) }" />
|
||||
<view class="trend-bar paid" :style="{ height: barH(t.paid) }" />
|
||||
</view>
|
||||
<text class="trend-month">{{ monthLabel(t.month) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 状态分布(CSS 进度条) -->
|
||||
<view v-if="statusList.length" class="dash-card">
|
||||
<view class="dash-head">
|
||||
<text class="dash-title">状态分布</text>
|
||||
</view>
|
||||
<view v-for="s in statusList" :key="s.key" class="dist-row">
|
||||
<text class="dist-name" :style="{ color: statusColor(s.key) }">{{ s.name }}</text>
|
||||
<view class="dist-bar-wrap">
|
||||
<view class="dist-bar" :style="{ width: s.pct, background: statusColor(s.key) }" />
|
||||
</view>
|
||||
<text class="dist-count">{{ num(s.count) }}单 / ¥{{ money(s.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 报销类型分析(仅已打款费用明细) -->
|
||||
<view v-if="expenseTypes.length" class="dash-card">
|
||||
<view class="dash-head">
|
||||
<text class="dash-title">报销类型分析</text>
|
||||
</view>
|
||||
<view v-for="e in expenseTypesTop" :key="e.name" class="type-row">
|
||||
<text class="type-name">{{ e.name }}</text>
|
||||
<view class="type-bar-wrap">
|
||||
<view class="type-bar" :style="{ width: typePct(e.amount) }" />
|
||||
</view>
|
||||
<text class="type-amount">¥{{ money(e.amount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通知公告 -->
|
||||
<view class="dash-card">
|
||||
<view class="dash-head">
|
||||
<text class="dash-title">通知公告</text>
|
||||
<view class="dash-more" @tap="emit('view-notices')">
|
||||
<text class="dash-more-text">查看全部</text>
|
||||
<FaIcon name="chevron-right" color="#94a3b8" :size="11" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!notices.length" class="dash-empty">
|
||||
<FaIcon name="bullhorn" color="#c0c4cc" :size="24" />
|
||||
<text class="dash-empty-text">暂无公告</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="n in notices"
|
||||
:key="n.id"
|
||||
class="notice-item"
|
||||
@tap="emit('view-notices')"
|
||||
>
|
||||
<view class="notice-main">
|
||||
<text class="notice-tag" :class="'nt-' + (n.notice_type || 0)">
|
||||
{{ noticeTypeLabel(n.notice_type) }}
|
||||
</text>
|
||||
<text v-if="n.is_top === 1" class="notice-top">置顶</text>
|
||||
<text class="notice-title">{{ n.title }}</text>
|
||||
</view>
|
||||
<text class="notice-time">{{ noticeTime(n.publish_time) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 工作日程提醒(今日) -->
|
||||
<view class="dash-card">
|
||||
<view class="dash-head">
|
||||
<text class="dash-title">工作日程提醒</text>
|
||||
<view class="dash-more" @tap="emit('view-schedules')">
|
||||
<text class="dash-more-text">查看全部</text>
|
||||
<FaIcon name="chevron-right" color="#94a3b8" :size="11" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!todaySchedules.length" class="dash-empty">
|
||||
<FaIcon name="calendar-day" color="#c0c4cc" :size="24" />
|
||||
<text class="dash-empty-text">今日暂无日程</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="s in todaySchedules"
|
||||
:key="s.id"
|
||||
class="sched-item"
|
||||
:class="{ done: s.status === 1 }"
|
||||
@tap="emit('view-schedules')"
|
||||
>
|
||||
<view class="sched-dot" :style="{ background: s.status === 1 ? '#cbd5e1' : (s.color || '#2563eb') }" />
|
||||
<view class="sched-main">
|
||||
<view class="sched-title-row">
|
||||
<text class="sched-title" :class="{ done: s.status === 1 }">{{ s.title }}</text>
|
||||
<text v-if="s.priority > 0" class="sched-priority" :class="s.priority === 2 ? 'p-urgent' : 'p-important'">
|
||||
{{ s.priority === 2 ? '紧急' : '重要' }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="sched-time">{{ schedTime(s) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, default: () => ({}) }
|
||||
})
|
||||
const emit = defineEmits(['view-notices', 'view-schedules'])
|
||||
|
||||
const num = (v) => Number(v || 0)
|
||||
|
||||
// ---------- 待办概览 ----------
|
||||
const ss = computed(() => props.data?.schedule_stats || {})
|
||||
const overdue = computed(() => num(ss.value.overdue))
|
||||
const todayPending = computed(() => num(ss.value.today_total) - num(ss.value.today_done))
|
||||
const weekPending = computed(() => num(ss.value.week_total) - num(ss.value.week_done))
|
||||
const monthPending = computed(() => num(ss.value.month_total) - num(ss.value.month_done))
|
||||
|
||||
// ---------- 报销 ----------
|
||||
const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 })
|
||||
const rb = computed(() => props.data?.reimburse || {})
|
||||
const moneyCards = computed(() => [
|
||||
{ label: '本周', data: rb.value.week || emptyStat() },
|
||||
{ label: '本月', data: rb.value.month || emptyStat() },
|
||||
{ label: '本年', data: rb.value.year || emptyStat() }
|
||||
])
|
||||
|
||||
// ---------- 趋势(近6月,堆叠柱) ----------
|
||||
const trend = computed(() => rb.value.trend || [])
|
||||
const trendMax = computed(() => {
|
||||
let m = 1
|
||||
trend.value.forEach((t) => { m = Math.max(m, num(t.total)) })
|
||||
return m
|
||||
})
|
||||
const MAX_BAR = 160
|
||||
const barH = (v) => Math.round((num(v) / trendMax.value) * MAX_BAR) + 'rpx'
|
||||
const monthLabel = (m) => String(m || '').slice(5) + '月'
|
||||
|
||||
// ---------- 状态分布 ----------
|
||||
const statusColors = { 0: '#94a3b8', 1: '#f59e0b', 2: '#10b981', 3: '#ef4444', 4: '#94a3b8', 5: '#2563eb' }
|
||||
const statusColor = (k) => statusColors[Number(k)] || '#94a3b8'
|
||||
const statusList = computed(() => {
|
||||
const d = rb.value.status_dist || {}
|
||||
const arr = Object.keys(d).map((k) => ({ key: Number(k), name: d[k].name, count: d[k].count, amount: d[k].amount }))
|
||||
const total = arr.reduce((sum, x) => sum + num(x.count), 0) || 1
|
||||
return arr
|
||||
.sort((a, b) => a.key - b.key)
|
||||
.map((x) => ({ ...x, pct: Math.round((num(x.count) / total) * 100) + '%' }))
|
||||
})
|
||||
|
||||
// ---------- 报销类型分析 ----------
|
||||
const expenseTypes = computed(() => rb.value.expense_types || [])
|
||||
const expenseTypesTop = computed(() => expenseTypes.value.slice(0, 6))
|
||||
const expMax = computed(() => {
|
||||
let m = 1
|
||||
expenseTypes.value.forEach((e) => { m = Math.max(m, num(e.amount)) })
|
||||
return m
|
||||
})
|
||||
const typePct = (a) => Math.round((num(a) / expMax.value) * 100) + '%'
|
||||
|
||||
// ---------- 公告 / 日程 ----------
|
||||
const notices = computed(() => props.data?.notices || [])
|
||||
const todaySchedules = computed(() => props.data?.today_schedules || [])
|
||||
|
||||
const noticeTypeLabel = (type) => ({ 1: '公告', 2: '活动' }[type] || '通知')
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, '0')
|
||||
const fmtDate = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||
// 公告右侧时间:今天显示 HH:mm,本年显示 M/D,跨年显示 YYYY-M-D
|
||||
function noticeTime(ts) {
|
||||
if (!ts) return ''
|
||||
const d = new Date(String(ts).replace(/-/g, '/'))
|
||||
if (Number.isNaN(d.getTime())) return String(ts).slice(5, 10)
|
||||
const now = new Date()
|
||||
if (fmtDate(d) === fmtDate(now)) return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
||||
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
|
||||
}
|
||||
|
||||
function schedTime(s) {
|
||||
if (s.all_day === 1) return '全天'
|
||||
if (s.end_time) return `${s.start_time || ''} - ${s.end_time}`
|
||||
return s.start_time || '时间待定'
|
||||
}
|
||||
|
||||
const money = (value) =>
|
||||
Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/src/styles/page-common.scss';
|
||||
|
||||
.oa-dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.dash-card {
|
||||
@include card;
|
||||
padding: 26rpx 24rpx;
|
||||
}
|
||||
|
||||
.dash-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.dash-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.dash-badge {
|
||||
font-size: 22rpx;
|
||||
color: $color-danger;
|
||||
background: #fef3f2;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
.dash-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.dash-more-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.dash-empty {
|
||||
padding: 36rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.dash-empty-text {
|
||||
font-size: 24rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ============ 待办概览 ============ */
|
||||
.todo-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.todo-cell {
|
||||
flex: 1;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-value {
|
||||
font-size: 44rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.todo-label {
|
||||
font-size: 24rpx;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.todo-sub {
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ============ 报销统计 ============ */
|
||||
.money-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.money-cell {
|
||||
flex: 1;
|
||||
padding: 20rpx 16rpx;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.money-label {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.money-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.currency {
|
||||
font-size: 22rpx;
|
||||
color: $color-text;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
}
|
||||
|
||||
.money-meta {
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============ 趋势柱状 ============ */
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 4rpx;
|
||||
|
||||
&.paid {
|
||||
background: $color-success;
|
||||
}
|
||||
|
||||
&.unpaid {
|
||||
background: $color-warning;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
height: 220rpx;
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
|
||||
.trend-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.trend-bar-wrap {
|
||||
flex: 1;
|
||||
width: 44rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
width: 100%;
|
||||
min-height: 2rpx;
|
||||
|
||||
&.unpaid {
|
||||
background: $color-warning;
|
||||
border-radius: 6rpx 6rpx 0 0;
|
||||
}
|
||||
|
||||
&.paid {
|
||||
background: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-month {
|
||||
margin-top: 10rpx;
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ============ 分布 / 类型 进度条 ============ */
|
||||
.dist-row,
|
||||
.type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
|
||||
& + & {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.dist-name {
|
||||
width: 96rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.type-name {
|
||||
width: 140rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dist-bar-wrap,
|
||||
.type-bar-wrap {
|
||||
flex: 1;
|
||||
height: 14rpx;
|
||||
background: $color-divider;
|
||||
border-radius: $radius-full;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dist-bar,
|
||||
.type-bar {
|
||||
height: 100%;
|
||||
border-radius: $radius-full;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.type-bar {
|
||||
background: linear-gradient(90deg, $color-primary, $color-primary-light);
|
||||
}
|
||||
|
||||
.dist-count,
|
||||
.type-amount {
|
||||
flex-shrink: 0;
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============ 公告 ============ */
|
||||
.notice-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 18rpx 0;
|
||||
|
||||
& + & {
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.notice-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 20rpx;
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: $radius-sm;
|
||||
background: $color-primary-bg;
|
||||
color: $color-primary;
|
||||
|
||||
&.nt-2 {
|
||||
background: #ecfdf5;
|
||||
color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-top {
|
||||
flex-shrink: 0;
|
||||
font-size: 20rpx;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notice-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
/* ============ 日程 ============ */
|
||||
.sched-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14rpx;
|
||||
padding: 18rpx 0;
|
||||
|
||||
& + & {
|
||||
border-top: 1rpx solid $color-divider;
|
||||
}
|
||||
|
||||
&.done {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.sched-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
margin-top: 12rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sched-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sched-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.sched-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 26rpx;
|
||||
color: $color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.done {
|
||||
text-decoration: line-through;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.sched-priority {
|
||||
flex-shrink: 0;
|
||||
font-size: 18rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&.p-important {
|
||||
background: #fffbeb;
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
&.p-urgent {
|
||||
background: #fef3f2;
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.sched-time {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="head">
|
||||
<template>
|
||||
<view class="page" :class="{ embedded }">
|
||||
<view v-if="!embedded" class="head" :style="headPad">
|
||||
<view class="head-row" @tap="goBackNav">
|
||||
<FaIcon name="chevron-left" color="#1f2b48" :size="18" />
|
||||
<text class="head-back">返回导航</text>
|
||||
@@ -49,12 +49,15 @@
|
||||
<view class="bottom-space" />
|
||||
</view>
|
||||
|
||||
<AppTabbar :group="slug" current="todo" />
|
||||
<AppTabbar v-if="!embedded" :group="slug" current="todo" />
|
||||
</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'
|
||||
@@ -62,9 +65,12 @@ import { useSubsystem } from '@/utils/subsystem.js'
|
||||
import AppTabbar from '@/components/AppTabbar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
slug: { type: String, required: true }
|
||||
slug: { type: String, required: true },
|
||||
embedded: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['switch-tab', 'back'])
|
||||
|
||||
const { sub, loading, load } = useSubsystem(props.slug)
|
||||
const pending = ref([])
|
||||
const finished = ref([])
|
||||
@@ -98,6 +104,10 @@ function todayStr() {
|
||||
}
|
||||
|
||||
function goBackNav() {
|
||||
if (props.embedded) {
|
||||
emit('back')
|
||||
return
|
||||
}
|
||||
uni.reLaunch({ url: '/pages/nav/nav' })
|
||||
}
|
||||
|
||||
@@ -124,11 +134,19 @@ onShow(() => {
|
||||
|
||||
.page {
|
||||
@include page-shell;
|
||||
|
||||
// 嵌入模式下由父页(tabs.vue)的 scroll-view 统一滚动,这里改为自然高度
|
||||
&.embedded {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
flex-shrink: 0;
|
||||
padding: calc(var(--status-bar-height, 44px) + 20rpx) $page-padding-x 28rpx;
|
||||
/* #ifdef MP-WEIXIN */
|
||||
padding-top: calc(env(safe-area-inset-top, 25px) + 84rpx);
|
||||
/* #endif */
|
||||
background: $color-card;
|
||||
box-shadow: $shadow-header;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user