Files
xk-doctor-wx/components/workbench-features/workbench-features.vue
李琦 62ffb6fd31 feat: 工作台常用入口可批量编辑,简报改为横幅,列表补下拉刷新
各角色工作台支持批量保存常用入口和业务员看板;AI 简报改成单行横幅避免锁死滑动;仓库/提现/对账等列表统一下拉刷新,并补代付码弹窗。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 08:53:16 +08:00

454 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!--
功能中心页共享组件诊所管理员/平台管理员/业务员/诊所推广员四端功能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-layoutnavbar/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,
/** 内容区高度pxlayout 的 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 pxpaddingBottom=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>