各角色工作台支持批量保存常用入口和业务员看板;AI 简报改成单行横幅避免锁死滑动;仓库/提现/对账等列表统一下拉刷新,并补代付码弹窗。 Co-authored-by: Cursor <cursoragent@cursor.com>
454 lines
14 KiB
Vue
454 lines
14 KiB
Vue
<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>
|