整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

iPad 横屏适配经验

辑导语:虽然国内软件的iPad用户占比不大,但依然存在着横屏适配的需求。本文作者讲述了自己做iPad横屏适配的背景,并对竞品的适配方式进行了分析研究,用自己的亲身经历提供了参考,推荐对ipad横屏适配感兴趣的童鞋阅读。

一、背景

在我参与的一款资料查询 App 中,对 iPad 只支持竖屏以手机 UI 尺寸拉伸,每个季度都有用户反馈希望适配 iPad 横屏。经过询问用户发现,因为 iPad mini 尺寸刚好可以放在工作服口袋中,随时拿出来使用,而 iPad 屏幕远比手机大,浏览资料视野更大更舒服。

但另外一方面,后台数据显示当前 iPad 用户占比只有 1%,用户呼声够不上星星之火,不足以燎原。先别谈说服团队做 iPad 横屏适配,连说服自己都难。本来以为这事就像水中投石,水波消散就没有下文了。直到有一天,同样是资深用户的高管自己拿着 iPad 装上我们的 App 用了几天,终于忍不了,开始推动 iPad 横屏适配。

二、参考

我们肯定不是第一个做 iPad 横屏适配的,但在网上搜了一圈,别说横屏适配,连 iPad 界面设计的文章都很少,下面 3 篇算不错的。这也是我决定写下本文的原因,为后来者提供经验,少踩坑。

  1. 《利用好 iPad 的大屏幕 —— 如何为 iPadOS 14 设计 app?》,https://steppark.net/15942969497015.html
  2. 《iPad 交互设计探索系列:iPad 适用产品篇》,https://www.jianshu.com/p/65211fddefb9
  3. 《iPad 交互设计探索系列:iPad 导航设计篇》,https://www.jianshu.com/p/0c8e315d39d4

三、研究

没得经验参考就只能先从竞品分析开始了。经过对 iOS 系统应用、微信、QQ、微信阅读、得到、豆瓣、淘宝和有道词典的分析,我和同事总结成 5 种横屏适配模式。

1. 内容响应式

典型 App:iOS 应用商店

特征:标题栏和 Tabbar 通栏拉伸,内容区根据宽度向右响应式布局。

适用场景:全部场景。

评价:灵活性和用户体验都很好,但设计和开发成本很大。

2. 左右分栏

典型 App:iOS 设置、淘宝、微信、QQ

特征:左右分开显示,左边通常固定显示首页或者目录导航。右侧根据左侧选择显示对应的详情内容。

适用场景:频繁需要使用导航切换内容。

评价:用户体验适中,合理的利用横屏更大地展示更多的内容。设计成本小,需额外设计一个右侧默认为空的情况。开发成本要看是否改程序架构,相当于把手机两个手机界面合并成一个屏幕,可能有些程序架构很难这么修改。

3. 按竖屏宽度显示

典型 App:微信阅读

特征:标题栏和 Tabbar 通栏拉伸,内容直接按竖屏的宽度显示。

适用场景:全部场景。

评价:用户体验适中,设计与开发成本小,大多数产品采用此模式,但是没有更好的展现横屏宽屏的优势。

4. 全屏通栏拉伸

典型 App:豆瓣

特征:横屏为全屏通栏拉伸,所有元素与竖屏一致。

适用场景:全部场景。

评价:设计和开发成本最小,但是相当于没有适配。用户体验较差,横屏情况下内容集中,左侧右侧很空,或者被拉得很长,阅读体验较差。

5. 混合模式

当然也不是所有 App 都采用单一的模式。比如微信阅读,在其他页面是按竖屏宽度显示。但到了图书阅读界面,则是左右分栏充分利用 iPad 大屏幕展现内容。

以上竞品分析所有截图我们都保存在 Figma 中,有需要的读者可前往获取。

链接:https://www.figma.com/community/file/1071850659054902697/iPad-横屏适配竞品分析

四、执行

非常遗憾的是虽然高管牵头做适配,但开发资源确实有限。不能为了设计师邀功拿业绩就从头把 iOS App 重构一遍,因此我们决定用最少的资源做最核心的优化。

适配计划分为 2 期。第 1 期将所有页面用按竖屏宽度显示进行横屏适配。第 2 期挑选核心页面用内容响应式或左右分栏进行优化。

1. 先开发再验收

在第 1 期我们就踩坑了,按照原来的工作流程,我们将所有的 iPad 横屏页面做好线框图、再输出所有视觉效果图。虽然都是线上页面不用重新设计,只需要拉伸画面或者调整间距,但所有线上页面也是一个不小的工作量。

就在进行过程中,iOS 工程师就皱着眉头来提议,由于代码架构和资源所限,设计师如果调整的视觉效果图未必能 100% 实现。不如反过来,让他先把所有页面强行横屏,再由设计师走查发现问题进行修改,这样节省时间效果也可控。

可见,不同的项目类型可以采取不同的工作流程。iPad 横屏适配项目流程和常规工作流程刚好相反,以往是先设计再开发,改成先开发再走查,节省设计师产出效果图时间,也保障最终实现效果。

2. 核心场景决定核心页面

在第 2 期挑选核心页面时,我也犯了错误。最开始我觉得核心是脸面,因此挑选 Tabbar 导航的首页、个人中心等用户一打开 App 就看得到的页面进行优化。但实际上用户真正的核心使用场景是在详情页查阅资料,这才是真正的核心页面。

在得到主管纠正后,我们转而开始为资料阅读页面提供左内容右目录的布局,便于用户方便地在长文中精确定位想读的内容。

2 期计划并非适配的终结,随着 App 功能的迭代,此后老界面修改和新界面设计需要考虑到 iPad 横屏的适配问题,就成为了日常工作的内容了。

五、总结

按照以往的项目总结,最后应该汇报项目数据结果。但由于 iPad 用户本身可怜的占比,即使我们官方公众号推文宣布适配 iPad 横屏后,也没有 iPad 用户站出来点赞,而是又引发出使用华为、小米等安卓 Pad 的用户,要求也适配。

考虑到不同的安卓品牌适配方式不一样,而且安卓厂商自己又有平行世界等通用兼容方案,我们就没再继续参与了。

虽然没有外部用户反馈,但公司内部同事和开发团队使用后确实感觉很棒。所以我觉得这次适配项目真正值得思考的是:如果一个需求用户反馈很少,也没有数据支撑,但对体验影响很大,如何推动团队进行优化呢?

作者:龙爪槐守望者,微信公众号:龙爪槐守望者

本文由 @龙爪槐守望者 原创发布于人人都是产品经理。未经许可,禁止转载。

题图来自 Unsplash,基于 CC0 协议

.前言

道友能来到此处,证明你我有缘,既然如此,我想送你一场造化!

本系列文章主要分享个人在多年中后台前端开发中,对于表单与列表封装的一些探索以及实践.本系列分享是基于vue3+element-plus,设计方案可能无法满足所有人的需求,但是可以解决大部分人业务中的开发需求.主要还是希望通过分享能够得到一些新的反馈与启发,进一步完善改进,分享中夯实己身,在反馈中不断成长.时间原因文章会不定期更新,有空就写.下面先展示一下一个完整的常见的表单+表格集成的列表页面开发的场景,然后再拆解ElTable表格的二次封装实现封装.

示例代码展示:

DemoService.ts

 export function queryPlatformList() {
 const platformList = [
   { name: "淘宝", code: "taobao" },
   { name: "京东", code: "jd" },
   { name: "抖音", code: "douyin" },
 ];
 return platformList;
}

const dataList: any[] = [
 {
   id: 1,
   channelType: "sms",
   channelName: "阿里短信通知",
   platforms: queryPlatformList().filter((item) => item.code !== "taobao"),
   status: 1,
   createTime: "2021-09-07 00:52:15",
   updateTime: "2021-11-07 00:52:15",
   createBy: "vshen",
   updateBy: "vshen",
   ext: {
     url: "https://sms.aliyun.com",
     account: "vshen",
     password: "vshen57",
     sign: "signVhsen123124",
   },
 },
 {
   id: 2,
   channelType: "dingtalk",
   channelName: "预警消息钉钉通知",
   platforms: queryPlatformList().filter((item) => item.code !== "jingdong"),
   status: 1,
   createTime: "2021-11-10 00:52:15",
   updateTime: "2021-11-07 00:52:15",
   createBy: "vshen",
   updateBy: "vshen",
   ext: {
     accessType: "webhook",
     address: "https://dingtalk.aliyun.com",
   },
 },
 {
   id: 3,
   channelType: "email",
   channelName: "预警消息邮件通知",
   platforms: queryPlatformList().filter((item) => item.code !== "douyin"),
   status: 0,
   ext: {
     host: "https://smpt.aliyun.com",
     account: "vshen@qq.com",
     password: "vshen@360.com",
   },
   createTime: "2021-11-07 00:52:15",
   updateTime: "2021-11-07 00:52:15",
   createBy: "vshen",
   updateBy: "vshen",
 },
];

export function queryPage({ form }: any, pagenation: any) {
 return new Promise((resolve) => {
   let result: any[] = dataList;
   Object.keys(form).forEach((key) => {
     const value = form[key];
     result = dataList.filter((item) => item[key] == value);
   });

   resolve({ success: true, data: { list: result } });
 });
}
export function create(data: any = {}) {
 return new Promise((resolve) => {
   setTimeout(() => {
     dataList.push({
       id: Date.now(),
       platform: [],
       ...data,
     });
     resolve({ success: true, message: "创建成功!" });
   }, 500);
 });
}

export function update(data: any) {
 return new Promise((resolve) => {
   setTimeout(() => {
     const index = dataList.findIndex((item) => item.id == data.id);
     const target = dataList[index];
     Object.keys(data).forEach((key) => {
       target[key] = data[key];
     });
     dataList.splice(index, 1, target);
     resolve({ success: true, message: "更新成功!" });
     console.log("update", dataList);
   }, 500);
 });
}

export function remove(id: number) {
 return new Promise((resolve) => {
   setTimeout(() => {
     const index = dataList.findIndex((item) => item.id == id);
     dataList.splice(index, 1);
     resolve({ success: true, message: "删除成功!" });
     console.log("remove", dataList);
   }, 500);
 });
}

FormDialog.ts(实现示例中的新增/编辑的动态表单)

import { createFormDialog } from "@/components/Dialogs";
import { Toast } from "@/core/adaptor";
import * as DemoService from "@/api/demo-service";

export const ChannelEnum: any = {
 sms: "短信通知",
 dingtalk: "钉钉通知",
 email: "邮件通知",
};

export const AccessTypeEnum: any = {
 webhook: "webhook",
 api: "api",
};

const DingtalkVisiable = (formData: any) => formData.channelType == "dingtalk";

const DingtalkApiVisiable = (formData: any) => {
 return (
   DingtalkVisiable(formData) && formData.accessType == AccessTypeEnum.api
 );
};

const DingtalkWebhookVisiable = (formData: any) => {
 return (
   DingtalkVisiable(formData) && formData.accessType == AccessTypeEnum.webhook
 );
};

const DingTalkFormItems = [
 {
   label: "接入方式",
   field: "accessType",
   visiable: DingtalkVisiable,
   uiType: "selector",
   props: {
     options: AccessTypeEnum,
   },
 },
 {
   label: "webhHook地址",
   field: "address",
   required: true,
   visiable: DingtalkWebhookVisiable,
   uiType: "input",
 },
 {
   label: "appKey",
   field: "appKey",
   visiable: DingtalkApiVisiable,
   uiType: "input",
 },
 {
   label: "appSecret",
   field: "appSecret",
   visiable: DingtalkApiVisiable,
   uiType: "input",
 },
 {
   label: "clientId",
   field: "clientId",
   visiable: DingtalkApiVisiable,
   uiType: "input",
 },
 {
   label: "钉钉群ID",
   field: "chatId",
   visiable: DingtalkApiVisiable,
   uiType: "input",
 },
];

/*******
支持的规则描述
interface RuleType {
 equals?: string;
 not?: string;
 in?: string;
 notIn?: string;
 includes?: string | string[];
 excludes?: string | string[];
 empty?: boolean;
 lt?: number;
 lte?: number;
 gt?: number;
 gte?: number;
}
* 
* 
* ********/

const SmsVisiable = {
 channelType: {
   equals: "sms",
 },
};

const SmsFormItems = [
 {
   label: "消息推送地址",
   field: "url",
   visiable: SmsVisiable,
   uiType: "input",
 },
 {
   label: "账号",
   field: "account",
   visiable: SmsVisiable,
   uiType: "input",
 },
 {
   label: "密码",
   field: "password",
   visiable: SmsVisiable,
   uiType: "input",
 },
 {
   label: "签名",
   field: "sign",
   initValue: "signature",
   visiable: SmsVisiable,
   uiType: "input",
 },
];

const EmailVisiable = (formData: any) => formData.channelType == "email";

const EmailFormItems = [
 {
   label: "smtp服务器地址",
   field: "host",
   visiable: EmailVisiable,
   uiType: "input",
 },
 {
   label: "邮箱账号",
   field: "account",
   visiable: EmailVisiable,
   uiType: "input",
 },
 {
   label: "邮箱密码",
   field: "password",
   visiable: EmailVisiable,
   uiType: "input",
 },
];

function createFormItems(isEditMode: boolean, extJson: any = null) {
 return [
   {
     label: "渠道名称",
     field: "channelName",
     uiType: "input",
     required: true,
   },
   {
     label: "渠道类型",
     field: "channelType",
     required: true,
     uiType: "selector",
     disabled: isEditMode,
     props: {
       options: ChannelEnum,
     },
   },
   ...DingTalkFormItems,
   ...SmsFormItems,
   ...EmailFormItems,
   {
     label: "应用于平台",
     field: "platforms",
     required: true,
     uiType: "selector",
     props: {
       multiple: true,
       options: () => DemoService.queryPlatformList(),
     },
   },
 ];
}
export async function createOrUpdateChannel(row: any, table: any) {
 const isEditMode = !!row;
 let rowData = null;
 if (isEditMode) {
   rowData = {
     ...row,
     ...row.ext,
     platforms: row.platforms.map((item: any) => item.code),
   };
 }
 const dialogInsatcne = createFormDialog({
   dialogProps: {
     title: isEditMode ? "编辑渠道" : "新增渠道",
   },
   formProps: {
     labelWidth: 130,
     primaryKey: "id",//编辑操作需要传给后端用来更新的主键,不传默认为id
   },
   formItems: createFormItems(isEditMode, rowData),
 });

 dialogInsatcne.open(rowData)
 .onConfirm((formData: any) => {
    /****
    *只有表单所有必填字段校验通过才会调用此回调函数
    *formData只包含可视的字段与primaryKey,保证数据干净
    ****/
    
   const action = !isEditMode ? "create" : "update";
   DemoService[action](formData).then(({ success, errorMsg }) => {
     if (!success) {
       Toast.error(errorMsg);
       return;
     }
     Toast.success(errorMsg);
     table.refresh();
     dialogInsatcne.close();
   });
 })
 .onClose(()=>{});
}

demo-list-page.vue

 <template>
  <list-page v-bind="table">
    <template #expand="{ row }">
      <el-table :data="row.platforms" border stripe style="padding: 10px; width: 100%">
        <el-table-column label="平台名称" prop="name" />
        <el-table-column label="平台编码" prop="code" />
      </el-table>
    </template>
    <template #status="{ row }">
      <el-tag :type="row.status == 1 ? 'info' : 'danger'">{{ statusEnum[row.status] }}
      </el-tag>
    </template>
  </list-page>
</template>
<script setup lang="ts">
import { Toast, Dialog } from "@/core/adaptor";
import * as demoService from "@/api/demo-service";
import { createOrUpdateChannel, ChannelEnum } from "./formDialog";

const statusEnum: any = {
  0: "禁用",
  1: "启用",
};

const table = reactive({
  //支持el-table的所有属性
  props: {},
  //支持el-table的所有事件
  events: {},

  loader: (queryForm, pagenation): any => demoService.queryPage(queryForm, pagenation),
  //过滤条件选项
  filterItems: [
    {
      label: "渠道类型",
      field: "channelType",
      uiType: "selector",
      props: { options: ChannelEnum },
    },
    {
      label: "启用状态",
      field: "status",
      uiType: "selector",
      props: { options: statusEnum },
    },
    {
      label: "创建时间",
      field: ["stratTime", "endTime"],
      uiType: "dateTimePicker",
      props: {
        type: "daterange",
      },
    },
  ],

  columns: [
    { type: "selection", label: "全选" },
    { type: "index", label: "序号" },
    { type: "expand", label: "使用平台" },
    { label: "渠道名称", key: "channelName" },

    {
      label: "通知方式",
      key: "channelType",
      formatter: (row) => ChannelEnum[row.channelType],
    },
    {
      label: "密钥",
      text: "查看密钥",
      click: () => {
        Toast("查看密钥");
      },
    },
    { label: "启用状态", slot: "status" },
    { label: "创建时间", key: "createTime" },
    { label: "创建人", key: "createBy" },
    { label: "更新时间", key: "updateTime" },
    { label: "更新人", key: "updateBy" },
  ],
  toolbar: [
    {
      text: "新增消息渠道",
      click: (table: any, searchForm: any) => createOrUpdateChannel(null, table),
    },
    {
      text: "批量删除",
      click: (table: any) => {
        const rows = table.instance.getSelectionRows();
        if (rows.length == 0) {
          Toast.info(`请先选择要删除的数据`);
          return;
        }
        Dialog.confirm(
          `确定要删除消息渠道配置${rows.map((row) => row.channelName)}吗?`
        ).then((res) => {
          if (res != "confirm") {
            return;
          }
          table.refresh();
        });
      },
    },
  ],
  actions: [
    {
      text: "编辑",
      props: { type: "warning" },
      click: ({ row }: any, table: any) => createOrUpdateChannel(row, table),
    },
    {
      text: (row) => (row.status == 1 ? "禁用" : "启用"),
      props: (row) => (row.status == 1 ? { type: "danger" } : { type: "success" }),
      confirm: (row) => `确定${row.status == 1 ? "禁用" : "启用"}${row.channelName}吗?`,
      click: ({ row }: any, table: any, searchForm: any) => {
        demoService
          .update({ id: row.id, status: row.status == 1 ? 0 : 1 })
          .then(({ success, message }) => {
            const action = success ? "success" : "error";
            Toast[action](message);
            success && table.refresh();
          });
      },
    },
  ],
});
</script>

至于此种开发方式对开发效率有没有提升,看完上面示例的代码后读者朋友可以尝试实现图示中的效果,然后从时间耗费、代码量、拓展性与可维护性等多个维度做下对比,本示例开发连同构造数据模拟花了差不多2h,因为思考示例中如何才能将封装的东西更多地展现出来,也稍微花了点时间。社区中确实看到有很不少人对这种配置式开发嗤之以鼻,但是在我看来至少有以下几个优点:

  1. 统一了项目中的列表页开发规范,无论谁开发都可以保证每一个列表页面其他人都可以看得懂,改得动。
  2. 在前端人手不足情况下,即使后端不会css跟布局,只要给与相关文档看一下,也能动手写出一样的列表页面开发(后端开发的道友对不住了,哈哈哈)
  3. 没有一个功能代码需要反复横跳查看的,每一个方法逻辑都可以很好很清晰的剥离与替换,解耦业务逻辑。例如示例中的新增与编辑操作,将相关业务逻辑内聚,从页面代码中剥离出来单独维护,需求变动时任何方法都可以很方便地直接拿掉或者重写,无需担心会影响其他业务代码。

2.代码拆解

接下来我们进入主题,拆解下(ListPage.vue)这个页面的组件分封装。对于页面展示的各个部分,在代码封装设计上我们按照图示中圈出来的各个部分来做封装设计。

代码组织如下:

1. 列表页面 (ListPage.vue)

整个列表列页面在设计上主要由SearchForm、Toolbar、Pagenation、ElTablePlus、TableCustomSetting几个部分组合而成,整体代码量不多,完整代码如下:

<template>
  <div ref="listPageRef" class="list-page">
    <!-- 搜索框 -->
    <SearchForm v-show="props.filterItems?.length > 0" v-model:height="searchFormHeight" :filterItems="props.filterItems"
      @search="diapatchSearch">
    </SearchForm>
    <!--  -->
    <el-row class="table-grid" justify="start" flex>
      <!-- 表格操作 -->
      <div class="toolbar-actions">
        <el-button v-for="action in props.toolbar"
          v-bind="Object.assign({ size: 'small', type: 'primary' }, action.props)"
          @click="() => action.click(tableInstance, {})">
          <el-icon style="vertical-align: middle" v-if="action.props && action.props.icon">
          </el-icon>
          <span>{{ action.text }}</span>
        </el-button>
        <el-button type="warning" size="small" @click="refreshTableData(searchFormModel)">
          <el-icon style="vertical-align: middle">
            <Refresh />
          </el-icon>
        </el-button>
        <el-button type="info" size="small" @click.stop="tableSettingDialog.open()">
          <el-icon style="vertical-align: middle">
            <Setting />
          </el-icon>
        </el-button>
        <el-button type="success" size="small" @click="requestFullScreen.toggle()">
          <el-icon style="vertical-align: middle">
            <FullScreen />
          </el-icon>
        </el-button>
      </div>
      <!-- 表格主体 -->
      <el-table-plus ref="tableInstance" :data="tableData.list" :is-loading="tableData.isLoading" :columns="tableColumns"
        :tableHeight="tableHeight" :props="props.props" :events="props.props"
        v-bind="Object.assign($attrs.props || {}, {})" @refresh="() => refreshTableData(searchFormModel)">
        <template v-for="column in tableColumns.filter((col) => col.slot)" #[column.slot]="{ row, col, index }">
          <slot :name="column.slot" :row="row" :col="col" :index="index"></slot>
        </template>
      </el-table-plus>
      <!-- 分页 -->
      <Pagenation type="custom" :pagenation="searchFormModel.pagenation" :total="tableData.total"
        @change="onPagenationChange" v-model:height="pagenationHeight">
      </Pagenation>
    </el-row>

    <TableCustomSettingDialog ref="tableSettingDialog" v-model:columns="tableColumns" @refresh-column="refreshColumn"
      @reset="resetColumns" />
  </div>
</template>
<script setup lang="ts">
import SearchForm from "@/components/Forms/SearchForm.vue";
import Pagenation from "./components/Pagenation.vue";
import ElTablePlus from "@/components/Table/Table.vue";
import TableCustomSettingDialog from "./components/TableSettingDialog.vue";
import { FullScreen, Refresh, Setting } from "@element-plus/icons-vue";
import { useTable, ISearchForm } from "@/components/Table/useTable";
import { useColumn } from "@/components/Table/tableColumns";
import { useTableSetting } from "@/components/Table/tableCustomSetting";
import { useFullscreen } from "@vueuse/core";

export interface Action {
  text: string | Function;
  click: (row: any, table: any) => {};
  props: any;
}

export interface IProps {
  loader: Function | Array<any>;
  filterItems?: any[];
  columns: any[];
  actions?: Action[];
  toolbar?: Action[];
  tableHeight?: string;
  props?: any;
  events?: any;
}

const props = withDefaults(defineProps<IProps>(), {
  props: {},
  events: {},
});

/**表格数据获取与刷新逻辑**/
const searchFormModel = reactive<ISearchForm>({
  form: {},
  pagenation: { pageNum: 1, pageSize: 20 },
});

const { tableData, refreshTableData } = useTable(
  props.loader,
  props.filterItems?.length > 0 ? null : searchFormModel
);

const onPagenationChange = ({ pageNum, pageSize }) => {
  searchFormModel.pagenation.pageNum = pageNum;
  searchFormModel.pagenation.pageSize = pageSize;
  refreshTableData(searchFormModel);
};

const diapatchSearch = (form) => {
  searchFormModel.form = form;
  searchFormModel.pagenation.pageNum = 1;
  refreshTableData(searchFormModel);
};

const tableInstance = ref(null);
const tableSettingDialog = ref(null);

const { tableColumns, updateTableColumns } = useColumn(props.columns, props.actions);

const { refreshColumn, resetColumns } = useTableSetting(
  tableInstance,
  updateTableColumns
);

/***表格动态高度计算***/
const listPageRef = ref<HTMLElement>(null);
const searchFormHeight = ref(0);
const pagenationHeight = ref(0);
const tableHeight = ref(0);

const updateTableHeight = () => {
  tableHeight.value =
    listPageRef.value?.clientHeight -
    searchFormHeight.value -
    pagenationHeight.value -
    50;
};

let cancelWatch = null;

onMounted(() => {
  cancelWatch = watchEffect(() => updateTableHeight());
  window.addEventListener("resize", () => nextTick(() => updateTableHeight()));
});

onUnmounted(() => {
  cancelWatch();
  window.removeEventListener("resize", () => nextTick(() => updateTableHeight()));
});

const requestFullScreen = useFullscreen(listPageRef.value);
</script>

2. 列表数据请求(useTable.ts)

在实际开发过程中列表数据源可能来源于各个地方,可能是接口,也可能是手动枚举的数据。设计上我们支持传入数组与方法,这一层主要是对数据的输入=>输出做归一化处理,减少应用时对数据格式的心智负担。 具体可以参考下面完整的代码:


import { isArray, isFunction } from "@vue/shared";

export interface IPagination {
  pageSize: number;
  pageNum: number;
}
export interface ISearchForm {
  form?: any;
  pagenation: IPagination;
}

export interface TableData {
  list: any[];
  total: number;
  isLoading: boolean;
}

export function useTable(
  dataLoader: Function | any[],
  searchForm?: ISearchForm
) {
  const tableRef = ref<HTMLElement>();

  const tableData = reactive<TableData>({
    list: [],
    total: 0,
    isLoading: false,
  });

  async function requestTableData(dataLoader: any, searchForm: ISearchForm) {
    tableData.isLoading = true;

    if (!isArray(dataLoader) && !isFunction(dataLoader)) {
      console.error("----表格数据必须是方法或者数组----");
      return;
    }

    let promiseLoader = (searchForm) =>
      Promise.resolve(
        isArray(dataLoader) ? dataLoader : dataLoader(searchForm)
      );

    try {
      const result = await promiseLoader(searchForm);

      if (Array.isArray(result)) {
        tableData.list = result;
        tableData.total = result.length;
        tableData.isLoading = false;
        return;
      }

      const { success, data, rows }: any = result;

      if (!success) {
        tableData.list = [];
        tableData.total = 0;
        tableData.isLoading = false;
        return;
      }
      tableData.list = Array.isArray(data) ? data : data.list || rows;
      tableData.total = data.total||tableData.list.length;
       
    } catch (error) {
      console.error(error);
    } finally {
      tableData.isLoading = false;
    }
  }

  function refreshTableData(searchFormModel = {}) {
    requestTableData(
      dataLoader,
      Object.assign({}, searchFormModel, searchForm)
    );
  }

  if (searchForm) {
    requestTableData(dataLoader, searchForm);
  }

  return {
    tableRef,
    tableData,
    listData,
    requestTableData,
    refreshTableData,
  };
}

3. 列表列配置二次处理 (tableColumns.ts)

对列配置单独提取出来做二次处理,可以方便我们做一些中间的转换与列更新的操作的控制。对于业务开发中的一些开发拓展也很方便。(以我自身经历的一个业务场景来说,某项目需要支持私有化部署跟saas环境部署,但是有多个页面在不同环境需要展示不同的字段。按照常规操作需要一个个页面去读取环境变量来做控制,操作起来就很复杂。我采用的就是在列配置上拓展一个环境支持的字段,然后在tableColumns引入环境变量做统一的过滤处理) 此外,这一层可以支持对多种UI框架的table组件进行支持。例如列属性字段,对应到不同框架中有的可能是prop,有的是property,有的是field。

import { IColumnSetting } from "@/api/table-setting-service";
import { isFunction } from "@vue/shared";

export type FixedType = "left" | "right" | "none" | boolean;

export type ElColumnType = "selection" | "index" | "expand";

export type CustomColumnType = "text" | "action";

export type ColumnType = ElColumnType | CustomColumnType;

export type Action = {
  text: Function & string;
  click: Function;
} & {
  [key: string]: string;
};

export interface TColumn {
  label: string; // 列标题 可以是函数或字符串,根据需要在页面上显示在列
  key?: string;
  property?: string; // 列的属性, 如果没有指定,则使用列名称 如果是函数
  slot?: string;
  align?: string;
  width?: number | string; // 列宽度 可选参数,默认为100 可以是整数或浮点数,但不
  minWidth?: number | string; // 最小列宽度 可选参数,默认为10 可以是整数或浮点
  fixed?: FixedType; // 列宽对齐方式 left right none 默认为left 可选参数,表示对齐方
  type?: string;
  actions?: any[];
  visiable?: boolean;
  click?: Function;
  text?: Function | string;
}

export type TableType = "VXE-TABLE" | "EL-TABLE";

export type TColumnConfig = {};

export const actionColumn: TColumn = {
  label: "操作",
  fixed: "right",
  type: "action",
  visiable: true,
  actions: [],
};

export const computedActionName = (button: Action, row: TColumn) => {
  return !isFunction(button.text)
    ? button.text
    : computed(() => button.text(row)).value?.replace(/\"/g, "");
};

const tableColumns = ref<Array<TColumn>>([]);

export const specificTypes = ["selection", "index", "expand"];

const calcColumnWidth = (columnsLength: number) => {
  if (columnsLength <= 6) return `${100 / columnsLength}%`;
  return `${12}%`;
};

const formatColumns = (columns: Array<TColumn>, actions: any[] = []) => {
  const hasAction = actions?.length > 0;

  actionColumn.actions = [...actions];

  const _columns = hasAction ? [...columns, actionColumn] : [...columns];

  const newColumns = [];

  for (let column of _columns) {
    column = Object.assign({}, column);

    if (column.visiable == false) {
      continue;
    }

    column.property = column.key || column.slot;
    column.align = column.align || "center";
    column.visiable = true;
    column.width = column.width || "auto" || calcColumnWidth(_columns.length);

    if (specificTypes.includes(column.type)) {
      column.width = column.width || 60;
    }

    if (column.type === "expand") {
      column.slot = column.slot || "expand";
    }

    if (column.type === "action") {
      column.minWidth = 100;
      column.fixed = "right";
    }

    newColumns.push(column);
  }
  return newColumn;
};

const updateTableColumns = (columnSettings: IColumnSetting[]) => {
  if (columnSettings.length == 0) return false;

  const columnSettingMap = new Map();

  columnSettings.forEach((col) => columnSettingMap.set(col.field, col));

  tableColumns.value = tableColumns.value.map((col) => {
    const colSetting = columnSettingMap.get(col.key) || {};
    Object.keys(colSetting).forEach((key) => {
      col[key] = colSetting[key];
    });
    return col;
  });

  return true;
};

export function useColumn(columns: Array<TColumn>, actions: any[]) {
  tableColumns.value = formatColumns(columns, actions);
  console.log("tableColumns", tableColumns);
  return {
    tableColumns,
    updateTableColumns,
    computedActionName,
  };
}

4. 列表组装(table.vue)

对el-table组件二次封装,首先我们要保证对原组件所有的方法与属性可以完全的支持,在不影响原组件的功能上增加拓展。这里用属性/事件透传,然后用v-bind,v-on分别做绑定即可实现。不清楚的道友可以看下官方的这两个指令。在拓展上我们这里除了支持action,slot,还增加了一个click配置,这个主要针某个列展示的数据我们希望点击的时候可以进行跳转等操作。所有配置的支持都是根据平时业务开发中的真实场景来设计的。看懂了下面的代码,可以根据自己的业务进行拓展支持。

<template>
  <el-table ref="tableInstance" :data="props.data" :loading="props.isLoading" v-on="Object.assign({}, $attrs.events)"
    v-bind="Object.assign(
      {
        tableLayout: 'auto',
        maxHeight: `${props.tableHeight}px`,
        border: true,
        stripe: true,
        resizable: true,
        key: Date.now(), //不配置key会存在数据更新页面不更新
      },
      $attrs.props || {}
    )
      ">
    <template v-for="column in props.columns">
      <!-- 操作 -->
      <el-table-column v-if="column.type == 'action'" v-bind="column" #default="scope">
        <template v-for="button in column.actions">
          <action-button :button="button" :scope="scope" @click="() => button.click(scope, exposeObject)">
          </action-button>
        </template>
      </el-table-column>
      <el-table-column v-else-if="isFunction(column.click)" v-bind="column">
        <template #default="{ row, col, index }">
          <el-button v-bind="Object.assign({ type: 'primary', size: 'small' }, column.props || {})"
            @click="column.click(row, col, index)">
            {{
              isFunction(column.text)
              ? column.text(row, col, index)
              : column.text || row[column.key]
            }}
          </el-button>
        </template>
      </el-table-column>

      <el-table-column v-else-if="column.slot" v-bind="column">
        <template #default="{ row, col, $index }">
          <slot :name="column.slot" :row="row" :col="col" :index="$index" :key="$index">
              </slot>
        </template>
      </el-table-column>

      <el-table-column v-else v-bind="column"> </el-table-column>
    </template>
  </el-table>
</template>
<script setup lang="ts">
import { TColumn, Action } from "./tableColumns";
import { isFunction } from "@vue/shared";
import ActionButton from "./ActionButton.vue";
import { TableInstance } from "element-plus";
import { toValue } from "vue";

export interface Props {
  columns?: TColumn[];
  actions?: Action[];
  data?: any;
  isLoading: boolean;
  tableHeight: number;
 }

const props = withDefaults(defineProps<Props>(), {
  columns: () => [],
  actions:()=>[],
  data: () => [],
  tableHeight: 200,
  isLoading: false,
});

const emit = defineEmits(["refresh"]);

const refresh = () => {
  emit("refresh");
};

const tableInstance = ref<TableInstance>();

const exposeObject: any = reactive({
  instance: tableInstance,
  refresh,
  selectionRows: toValue(computed(() => tableInstance.value?.getSelectionRows())),
});

defineExpose(exposeObject);
</script>

5.操作列按钮封装 (action-button.vue)

对操作列中的按钮单独封装,可以方便我们给操作提供更多丰富的个性化定制配置,根据项目中的需求而定,保证设计的灵活性

<template>
    <el-popconfirm v-if="confirmProps" v-bind="confirmProps" @confirm="handleConfirm(button, props.scope)">
        <template #reference>
            <el-button v-bind="buttonProps">
                {{ computedActionName(button, props.scope.row) }}
            </el-button>
        </template>
    </el-popconfirm>
    <el-button v-else v-bind="buttonProps" @click="handleConfirm(button, props.scope)">
        {{ computedActionName(button, props.scope.row) }}
    </el-button>
</template>
<script setup lang="ts">
import { Action, TColumn } from "./tableColumns";
import { isFunction, isString, isObject } from "@/components/utils/valueTypeCheck";

const props = withDefaults(
    defineProps<{ button: Action; scope: { row: any; col: any; $index: number } }>(),
    {}
);

const buttonProps = computed(() => {
    let customeProps: any = props.button.props || {};

    return Object.assign(
        {
            marginRight: "10px",
            type: "primary",
            size: "small",
        },
        isFunction(customeProps) ? customeProps(props.scope.row) : customeProps
    );
});

const confirmProps = computed(() => {
    const propsConfirm: any = props.button.confirm;
    if (propsConfirm === undefined) {
        return false;
    }

    if (!isString(propsConfirm) && !isObject(propsConfirm) && !isFunction(propsConfirm)) {
        console.error("confirmProps 类型错误");
        return {};
    }

    if (isString(propsConfirm)) {
        return {
            title: propsConfirm,
        };
    }

    if (isFunction(propsConfirm)) {
        const res = propsConfirm(props.scope.row);
        if (isObject(res)) {
            return res;
        }
        if (isString(res)) {
            return {
                title: res,
            };
        }
    }

    if (isObject(propsConfirm) && propsConfirm.title !== undefined) {
        return isFunction(propsConfirm.title)
            ? {
                ...propsConfirm,
                title: propsConfirm.title(props.scope.row),
            }
            : propsConfirm;
    }
    console.error("confirmProps 类型错误");
});

const emits = defineEmits(["click"]);

const handleConfirm = (button, scope: any) => {
    if (isFunction(button.click)) {
        emits("click");
    }
};

const computedActionName = (button: Action, row: TColumn) => {
    return !isFunction(button.text)
        ? button.text
        : computed(() => button.text(row)).value?.replace(/\"/g, "");
};
</script>

6.列表个性化定制封装 (tableSettingDrawer.vue)

个性化定制也是列表常见的需求之一,对于B端业务可能会有不同角色对同一个列表操作的需求,但是相互之间所关注的信息可能不一样。这部分主要是控制对应搜索条件与列表的列展示进行个性化定制。对于存储设计的话可以用当前页面的路由访问路径作为键来保存,如果同个页面弹窗中还有列表,设计上可以用routePath+id方式来保存,给弹窗中的列表加个id即可。

<template>
  <el-drawer v-model="dialogVisible" title="个性化定制" direction="rtl" size="50%">
    <el-tabs v-model="currentTab">
      <el-tab-pane label="定制列" class="setting-content" name="list" @keyup.enter="confirm(originColumns)">
        <el-table :data="originColumns" style="width: 100%" table-layout="auto" border stripe resizable
          default-expand-all>
          <template v-for="column in colunms">
            <el-table-column v-bind="column" #default="{ row, col, $index }">
              <span v-if="column.uiType == 'text'">{{ row.label }}</span>
              <!-- 输入框 -->
              <el-input v-else-if="column.uiType == 'input'" v-model="row[column.field]"
                :placeholder="`请输入${column.label}`"></el-input>
              <!-- 选择器 -->
              <el-select v-else-if="column.uiType == 'select'" v-model="row[column.field]"
                :placeholder="`请选择${column.label}`">
                <el-option v-for="option in column.options" :key="option.value" :label="option.name"
                  :value="option.value"></el-option>
              </el-select>
              <!-- 多选 -->
              <el-switch v-else-if="column.uiType == 'switch'" v-model="row[column.field]"></el-switch>
            </el-table-column>
          </template>
        </el-table>
      </el-tab-pane>
      <el-tab-pane label="定制查询条件" name="condition"> </el-tab-pane>
    </el-tabs>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="close">取消</el-button>
        <el-button @click="$emit('reset', false)">恢复默认设置</el-button>
        <el-button type="primary" @click="confirm(originColumns)">确定</el-button>
      </span>
    </template>
  </el-drawer>
</template>
<script setup lang="ts">
const currentTab = ref("list");

interface IProps {
  tableRef?: Element;
  columns: any[];
  modelValue?: boolean;
}

const props = withDefaults(defineProps<IProps>(), {
  columns: () => [],
  modelValue: false,
});

const deepCopy = (data) => {
  return JSON.parse(JSON.stringify(data));
};

/**采用computed可以实现异步获取配置实时更新**/
const originColumns = computed(() => deepCopy(props.columns));

const emit = defineEmits([
  "update:modelValue",
  "update:columns",
  "refreshColumn",
  "reset",
]);

const confirm = (tableColumns) => {
  const columns = deepCopy(tableColumns);
  emit("update:modelValue", false);
  emit("update:columns", columns);
  emit("refreshColumn", columns);
};

const colunms = [
  { field: "seq", label: "排序", width: 60 },
  { field: "visible", label: "是否展示", uiType: "switch", width: 120 },
  { field: "label", label: "列名", uiType: "text" },
  { field: "width", label: "宽度", uiType: "input" },
  {
    field: "align",
    label: "对齐方式",
    uiType: "select",
    options: [
      { value: "left", name: "左对齐" },
      { value: "right", name: "右对齐" },
      { value: "center", name: "居中" },
    ],
  },
  {
    field: "fixed",
    label: "固定类型",
    uiType: "select",
    options: [
      { value: "left", name: "左侧" },
      { value: "right", name: "右侧" },
      { value: "none", name: "不固定" },
    ],
  },
];

const dialogVisible = ref(false);

const open = () => {
  dialogVisible.value = true;
};

const close = () => {
  dialogVisible.value = false;
};

defineExpose({
  open,
  close,
});
</script>

至此,ElTable二次封装相关代码已经结束。希望此中代码能够助各位道友在表格二次封装的设计开发修炼中能有所帮助。一切大道,皆有因果。喜欢的话,可以动动你的小手点点赞。修行路上愿我们都不必独伴大道,回首望去无故人。

下期预告:动态表单设计封装,敬请期待

本文已首发掘金社区,纯原创文章,转载请声明来源

色权限系统设计可以更好的优化工作的流程步骤,本文分享角色权限系统设计的几个主要步骤。

公司的商户后台刚建立不久,之前仅能支持系统管理员和商户管理员两种角色使用,随着产品和业务线逐渐成熟,参与到整个产品中的人员越来越多了,涉及的部门和角色也由从前的一两种变成了多种,故由我主导了角色权限系统的重构升级。在此将工作心得记录下来,分享给需要用到的人。

先简单介绍下我司的后台产品功能,我司主要业务是向B端企业客户销售一些智能硬件,客户买到产品之后会将产品关联到自己的商户后台,硬件会上传一些核心数据到后台供商户管理查看,所以我们的后台核心功能是:设备管理、商户管理、用户管理、数据管理、产品销售管理。

角色权限系统

了解完我司后台的大概功能后,我们来聊下角色权限系统。

角色权限系统属于策略设计范畴,它的设计非常考验一个PM对业务的理解力以及对自己后台所有功能的熟悉程度。做角色权限系统之前一定要先深度了解业务流程以及后台的所有功能模块,在不了解的情况下,多向相关同事请教,避免角色权限系统设计过程中出差错和逻辑漏洞。由于角色权限系统属于功能底层系统,很多的业务功能、前端功能都深度依赖角色权限系统,所以尽量在第一次出产品方案时就尽可能的考虑全面,减少后续不必要的返工,如果前期产品方案不够缜密,后期改动成本会非常大。

目前市场主流的角色权限模型是RBAC权限模型,具体技术原理可以阅读下这个博客http://www.cnblogs.com/lhyqzx/p/5962826.html,有人好奇为什么做角色权限系统设计还要了解技术架构呢?这个是为了让设计者能够设计出高效、安全、灵活且技术可实现的角色权限系统。

RBAC权限模型核心就是功能权限控制和角色产生关联,角色再和用户账号关联,即创建用户账号时选定一种角色,该角色里已经分配好了功能和权限。拿我们系统为例,由于有系统管理员和商户管理员的区别,即系统管理员可以查看所有的商户和设备数据,商户管理员逻辑上只允许查看自己商户下 的设备数据。所以我为了更灵活高效的去创建用户角色(比如:商务经理、商务专员、客服经理、客服专员等),我在用户角色之前又设置了角色类型,详见下图:

关于用户角色的创建权限上这里需要说明的是:如果贵司组织结构比较庞大,使用后台的角色人员涉及到各个职能部门,且不同职能部门又有不同的角色,那么创建、管理角色的权限应该下放到各个部门的leader,便于管理系统用户的效率。由于我司业务的特殊性,可以预估到会参与使用后台的角色大概十来种,所以我为了更加集中、高效、安全的管理用户角色,设定的只有超级管理员可以创建和修改角色。

角色权限系统设计流程

角色权限系统设计的大概流程如下:

一、工具准备

思维导图工具(mindmanager、Xmind都可,我用的Xmind)、word 、Axure.

二、给每个角色类型梳理功能架构图

功能架构图梳理是为了让设计者清晰理解后台所有的产品功能模块,以及各个产品功能之间层级关系,给每个角色类型都梳理一份功能架构图,可以让产品自身和开发以及项目成员都了解每个角色类型的区别。

  • 比如:超级管理员这个角色类型,它应该可以管理后台的所有产品功能,并且拥有一些自己独有的功能权限;
  • 比如:角色管理和账号管理功能我设定的只让超级管理员有权访问,其他角色类型全部访问不了,所以也不需要配置;
  • 再比如:高级全局管理员应该有管理低级别角色类型的权限,而低级别角色类型不能管理高级别角色类型。

梳理功能架构图时可以根据一、二、三级这样的功能层次来画思维导图,有的后台系统可能非常庞大,那么是否需要把一级功能到一直到末级的所有功能包括界面按钮都全部罗列出来呢?这个需要看业务需求,看公司组织架构,多方面综合考虑再决定权限控制到哪一层,罗列到哪一级别的产品功能。通常情况下,权限控制到二/三层级基本能满足一个中小型公司的权限管理需求,再大型一点的公司,可以控制到更深层级的功能权限。

此外,我并不建议将权限控制到非常精细的级别,精细到可以控制前端页面上的每一个按钮,甚至每一个按钮的颜色以及交互效果,因为后台产品的核心是管理平台。管理无非增删改查四个操作,对于后台而言,管理的效率非常重要,如果权限控制的过于精细,在进行创建、修改角色时,效率会非常低。并且,如果不是系统的设计者理解起来会非常困难,对于页面上的一些关键按钮和操作可以加以控制。

注意事项:

  • 层级划分清晰,不同级别的功能尽量用不同的字体区分开。
  • 同类型的功能权限用不同的色块儿填充,一般来讲每种角色类型都至少会有两种类型的功能权限,(1)默认该角色类型拥有的功能权限,无须配置 (2)需要配置的功能权限。

之所以给不同角色类型的默认设定了一些功能权限,是为了创建角色和维护角色更加方便。比如:高级全局管理员角色类型对应的用户角色可能是各部门leader,像研发总监、客服经理、商务总监这样的用户角色,这些用户角色普遍会拥有较大的权限,同时又有所区分。假设系统有100个功能,那么我默认将这些角色可能会共同拥有的70个权限全部默认设定给高级全局管理员,将其余的功能设定为可自由配置的功能,那么当超级管理员去创建一个客服经理角色时,就只要配置剩余的30个权限即可。

三、设计功能原型

角色权限的内在规则逻辑设计好了,先和组内讨论,通过产品评审后可以考虑出产品功能原型了。

角色权限系统的开发一定是角色和功能是独立的两个模块,他们二者通过配置关系产生关联继而会出现不同的用户角色登录系统后会看到不同的功能界面,所以在画原型时只需要画出最全的功能即可。当系统内功能和角色数量相对而言都比较少的时候,角色权限管理功能可以考虑用横竖列表形式展现。当系统内的角色和功能数量比较多的时候,可以考虑模仿windows文件夹展开的交互用多面板形式来展现角色和功能的关系。

四、细化产品方案,形成PRD

将原型和脑图都梳理完毕后,最后就是把流程、细节从头捋一遍,将要点全部整理到PRD里,最后拿着PRD去和技术同学开技术评审会了。

完成以上四个步骤,基本就完成了一套后台角色权限系统的设计,如果觉得有用,请转发分享。

作者:Michael,Sensoro高级产品经理,产品设计经验丰富,主导过移动产品、IM产品、web前后台产品的多次重大升级。

本文由 @Michael 原创发布于人人都是产品经理。未经许可,禁止转载。