Files
xk-doctor-wx/components/workbench-features/workbench-features.vue

454 lines
14 KiB
Vue
Raw Normal View History

<template>
<!--
功能中心页共享组件(诊所管理员/平台管理员/业务员/诊所推广员四端「功能」tab 复用)
结构:顶部搜索框 + u-tabs/swiper 联动(全部 | 各分组),金刚区宫格展示入口
常用/最近只在首页,本页不再重复;长按仍可收藏到首页「我的常用」
放主包 components/ 原因:四端页面分属不同分包,微信不允许跨分包引用组件(同 xk-service-card)
-->
<view class="wf-wrap" :style="{ height: wrapHeight }">
<!-- 搜索框:本地过滤当前角色全部入口,输入即出结果 -->
<view class="wf-search">
<u-search
v-model="keyword"
placeholder="搜索功能"
shape="round"
bg-color="#FFFFFF"
:show-action="false"
:clearabled="true"
></u-search>
</view>
<!-- 加载/失败/空配置三态:占满内容区居中展示 -->
<view v-if="loading" class="wf-center">
<text class="wf-center-text">加载中...</text>
</view>
<view v-else-if="failed" class="wf-center">
<text class="wf-center-text">功能列表加载失败</text>
<view class="wf-retry" hover-class="wf-retry-hover" @tap="reload">
<text>重新加载</text>
</view>
</view>
<view v-else-if="sections.length === 0" class="wf-center">
<text class="wf-center-text">暂无可用功能,请联系管理员配置</text>
</view>
<!-- 搜索态:覆盖 tabs/swiper,展示扁平匹配结果 -->
<scroll-view
v-else-if="isSearching"
scroll-y
class="wf-search-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresherRefresh"
>
<view class="wf-page">
<view class="wf-panel" v-if="searchItems.length">
<view class="wf-grid">
<xk-service-card
v-for="item in searchItems" :key="item.key"
mode="grid"
:icon="item.icon" :label="item.label" :theme="item.theme"
@click="onItemTap(item)" @longpress="onItemLongpress(item)"
></xk-service-card>
</view>
</view>
<view class="wf-hint" v-else>
<text>没有找到「{{ keyword }}」相关功能</text>
</view>
</view>
</scroll-view>
<!-- 常规态:tabs + swiper 双向联动(点 tab / 左右滑都走 switchTab) -->
<block v-else>
<view class="wf-tabs">
<u-tabs
:list="tabs"
:is-scroll="tabs.length > 4"
:current="current"
:height="88"
active-color="#6ACDBB"
@change="switchTab"
></u-tabs>
</view>
<swiper class="wf-swiper" :current="current" @change="onSwiperChange">
<swiper-item v-for="(page, pi) in swiperPages" :key="pi">
<scroll-view
scroll-y
class="wf-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresherRefresh"
>
<!-- 全部 tab:所有分组一页看全(分组标题 + 各组金刚区) -->
<view v-if="page.type === 'all'" class="wf-page">
<view v-for="section in sections" :key="section.key">
<view class="wf-group-title">{{ section.title }}</view>
<view class="wf-panel">
<view class="wf-grid">
<xk-service-card
v-for="item in section.items" :key="item.key"
mode="grid"
:icon="item.icon" :label="item.label" :theme="item.theme"
@click="onItemTap(item)" @longpress="onItemLongpress(item)"
></xk-service-card>
</view>
</view>
</view>
</view>
<!-- 分组 tab:该组全部入口金刚区 -->
<view v-else class="wf-page">
<view class="wf-panel">
<view class="wf-grid">
<xk-service-card
v-for="item in page.items" :key="item.key"
mode="grid"
:icon="item.icon" :label="item.label" :theme="item.theme"
@click="onItemTap(item)" @longpress="onItemLongpress(item)"
></xk-service-card>
</view>
</view>
</view>
</scroll-view>
</swiper-item>
</swiper>
</block>
<!-- 右下角悬浮:打开全部入口的加减编辑(已加减号 / 未加加号,支持批量) -->
<view v-if="!loading && !failed && sections.length" class="wf-fab" hover-class="wf-fab-hover" @tap="openEditor">
<u-icon name="plus" color="#FFFFFF" size="40"></u-icon>
</view>
<workbench-fav-editor ref="favEditor" :api="api" @saved="onEditorSaved"></workbench-fav-editor>
</view>
</template>
<script>
/**
* workbench-features 功能中心共享组件
*
* 为什么做成组件而不是共享页面:四端「功能」tab 页需要套各自的 page-layout(navbar/tabbar 配置不同),
* 页面壳由各端提供,数据与交互全部收敛到这里,避免四份复制。
*
* props.api 约定(由各端壳页注入对应角色的接口,全部返回 Promise<{ res, data, ok }>):
* - getEntries() GET 当前角色启用入口(扁平数组)
* - getUserData() GET { favorites: [], recents: [] }
* - toggleFavorite(code) POST 收藏/取消收藏,data = { is_favorite }
* - batchSaveFavorites(codes) POST 批量保存常用
* - reportUsage(code) POST 使用上报(静默,失败不影响跳转)
*
* 事件:
* - @special(item):点击了无 path 的特殊入口(如推广员端 cs_transfer 传方弹窗),由壳页按 code 处理
*/
import { groupEntriesToSections, normalizeEntry } from '@/utils/workbenchEntries.js';
import pullRefreshMixin from '@/subPackages/sub_business_shared/common/pullRefreshMixin.js';
export default {
name: 'WorkbenchFeatures',
mixins: [pullRefreshMixin],
props: {
/** 角色接口集合:{ getEntries, getUserData, toggleFavorite, batchSaveFavorites, reportUsage } */
api: { type: Object, required: true },
},
data() {
return {
loading: true,
failed: false,
/** 分组入口(groupEntriesToSections 输出结构) */
sections: [],
/** 仅用于长按菜单判断是否已收藏,不在本页展示列表(展示在首页) */
favItems: [],
keyword: '',
current: 0,
/** 内容区高度(px):layout 的 navbar 与 tabbar 之间,swiper 需要确定高度才能滚动 */
bodyHeight: 600,
};
},
computed: {
wrapHeight() {
return this.bodyHeight + 'px';
},
isSearching() {
return this.keyword.trim() !== '';
},
/** swiper 页序列:固定「全部」在前,其后每个分组一页(常用/最近只在首页) */
swiperPages() {
const pages = [
{ key: '__all__', type: 'all', title: '全部', items: [] },
];
this.sections.forEach((s) => {
pages.push({ key: s.key, type: 'group', title: s.title, items: s.items });
});
return pages;
},
/** u-tabs 数据源(name 字段) */
tabs() {
return this.swiperPages.map((p) => ({ name: p.title }));
},
/** 全部入口扁平列表(搜索用) */
allItems() {
const list = [];
this.sections.forEach((s) => {
s.items.forEach((item) => list.push(item));
});
return list;
},
/** 搜索结果:名称/描述包含关键词(大小写不敏感) */
searchItems() {
const kw = this.keyword.trim().toLowerCase();
if (!kw) return [];
return this.allItems.filter((item) => {
const label = (item.label || '').toLowerCase();
const desc = (item.desc || '').toLowerCase();
return label.indexOf(kw) !== -1 || desc.indexOf(kw) !== -1;
});
},
/** 已收藏 code 集合(长按菜单文案判断用) */
favCodes() {
return this.favItems.map((item) => item.key);
},
},
mounted() {
this.calcHeight();
this.reload();
},
methods: {
/**
* 计算内容区高度:窗口高度 - layout 顶部(状态栏+navbar)- layout 底部 tabbar 预留
* 与各端 page-layout 的 bodyStyle 计算保持一致(paddingTop=状态栏+navbar px,paddingBottom=100rpx+安全区)
*/
calcHeight() {
const sys = uni.getSystemInfoSync();
const rpx = sys.windowWidth / 750;
const top = (this.$statusBarHeight || sys.statusBarHeight || 0) + (this.$navbarHeight || 44);
let safeBottom = 0;
if (sys.safeAreaInsets && sys.safeAreaInsets.bottom) {
safeBottom = sys.safeAreaInsets.bottom;
} else if (sys.safeArea && sys.screenHeight) {
safeBottom = sys.screenHeight - sys.safeArea.bottom;
}
this.bodyHeight = Math.max(400, sys.windowHeight - top - 100 * rpx - safeBottom);
},
/**
* 全量加载入口 + 收藏状态。
* 下拉刷新时不切整页 loading,否则 scroll-view 被卸掉转圈收不起来。
*/
reload() {
const silent = this.isRefreshing;
if (!silent) {
this.loading = true;
this.failed = false;
}
const entriesP = this.api.getEntries().then(({ data, ok }) => {
if (ok && Array.isArray(data)) {
this.sections = groupEntriesToSections(data);
if (this.current >= this.sections.length + 1) this.current = 0;
this.failed = false;
} else if (!silent) {
this.failed = true;
}
}).catch(() => {
if (!silent) this.failed = true;
}).finally(() => {
this.loading = false;
});
return Promise.all([entriesP, this.refreshUserData()]);
},
/** 刷新收藏状态(长按文案用;列表本身只在首页展示) */
refreshUserData() {
if (!this.api.getUserData) return Promise.resolve();
return this.api.getUserData().then(({ data, ok }) => {
if (!ok || !data) return;
this.favItems = this.normalizeList(data.favorites);
}).catch(() => {
// 收藏状态是增强信息,失败静默(不打断功能页主流程)
});
},
/** 后端入口对象数组 → 渲染项数组(与首页同一映射,字段完全一致) */
normalizeList(list) {
if (!Array.isArray(list)) return [];
const items = [];
list.forEach((entry) => {
const item = normalizeEntry(entry);
if (item) items.push(item);
});
return items;
},
/** tabs 与 swiper 统一切换入口 */
switchTab(index) {
if (this.current === index) return;
this.current = index;
},
/** swiper 手势滑动回调:回写 current 让顶部 tab 高亮跟随 */
onSwiperChange(e) {
this.switchTab(e.detail.current);
},
/**
* 点击入口:静默上报使用 → 跳转
* 无 path 的特殊入口(如 cs_transfer 传方弹窗)抛给壳页按 code 处理
*/
onItemTap(item) {
if (this.api.reportUsage) {
// 上报是辅助行为:不等待、不处理失败,绝不阻塞跳转
this.api.reportUsage(item.key).catch(() => {});
}
if (!item.path) {
this.$emit('special', item);
return;
}
uni.navigateTo({
url: item.path,
fail: () => {
// 目标是当前页栈不允许 navigateTo 的页面(如各端首页)时降级 reLaunch
uni.reLaunch({ url: item.path });
},
});
},
/** 长按入口:弹收藏/取消收藏操作单 */
onItemLongpress(item) {
const isFav = this.favCodes.indexOf(item.key) !== -1;
uni.showActionSheet({
itemList: [isFav ? '从我的常用移除' : '添加到我的常用'],
success: (r) => {
if (r.tapIndex === 0) this.doToggleFavorite(item);
},
});
},
/** 打开全部入口编辑抽屉(悬浮按钮) */
openEditor() {
if (this.$refs.favEditor) this.$refs.favEditor.open();
},
/** 批量保存后刷新收藏状态,长按菜单文案与首页常用保持一致 */
onEditorSaved() {
this.refreshUserData();
},
/** 收藏切换:成功后按后端返回状态提示并刷新常用列表 */
doToggleFavorite(item) {
if (!this.api.toggleFavorite) return;
this.api.toggleFavorite(item.key).then(({ res, data, ok }) => {
if (ok) {
const added = !!(data && data.is_favorite);
uni.showToast({ title: added ? '已添加到常用' : '已从常用移除', icon: 'none' });
this.refreshUserData();
} else {
uni.showToast({ title: (res && res.message) || '操作失败,请稍后重试', icon: 'none' });
}
}).catch(() => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
});
},
},
};
</script>
<style lang="scss" scoped>
/* 纵向 flex:搜索/tabs 固定高度,swiper 吃满剩余空间(swiper 必须有确定高度才能滚动) */
.wf-wrap {
position: relative;
display: flex;
flex-direction: column;
background: #f9fafb;
}
.wf-search {
flex-shrink: 0;
padding: 20rpx 24rpx 12rpx;
}
.wf-tabs {
flex-shrink: 0;
background: #FFFFFF;
border-bottom: 1rpx solid rgba(60, 60, 67, 0.06);
}
.wf-swiper {
flex: 1;
min-height: 0;
}
.wf-scroll {
height: 100%;
}
.wf-search-scroll {
flex: 1;
min-height: 0;
}
.wf-page {
padding: 20rpx 24rpx 40rpx;
}
/* 分区标题:对齐首页 panel-title 规格但更轻量 */
.wf-group-title {
font-size: 28rpx;
font-weight: 600;
color: #1E293B;
padding: 8rpx 6rpx 16rpx;
}
/* 金刚区白卡容器:iOS 卡片规格与首页/入口卡一致 */
.wf-panel {
background: #FFFFFF;
border-radius: 32rpx;
border: 1rpx solid rgba(60, 60, 67, 0.08);
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.04);
padding: 16rpx 12rpx;
margin-bottom: 28rpx;
}
/* 4 列金刚区:minmax(0,1fr) 防止长标签撑爆轨道 */
.wf-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
row-gap: 8rpx;
}
/* 空态提示条:浅灰圆角块,弱引导 */
.wf-hint {
display: flex;
align-items: center;
justify-content: center;
background: rgba(60, 60, 67, 0.04);
border-radius: 24rpx;
padding: 36rpx 24rpx;
margin-bottom: 28rpx;
text {
font-size: 24rpx;
color: #8E8E93;
}
}
/* 加载/失败/空配置居中态 */
.wf-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.wf-center-text {
font-size: 26rpx;
color: #8E8E93;
}
.wf-retry {
margin-top: 24rpx;
padding: 14rpx 48rpx;
background: #6ACDBB;
border-radius: 40rpx;
transition: opacity 0.2s ease;
text {
font-size: 26rpx;
color: #FFFFFF;
}
}
.wf-retry-hover {
opacity: 0.8;
}
/* 悬浮添加:贴内容区右下,wrap 已扣掉 tabbar 高度 */
.wf-fab {
position: absolute;
right: 32rpx;
bottom: 32rpx;
width: 104rpx;
height: 104rpx;
border-radius: 52rpx;
background: #6ACDBB;
box-shadow: 0 8rpx 24rpx rgba(106, 205, 187, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
}
.wf-fab-hover {
opacity: 0.85;
}
</style>