920 lines
21 KiB
Vue
920 lines
21 KiB
Vue
<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> |