整合營(yíng)銷(xiāo)服務(wù)商

          電腦端+手機(jī)端+微信端=數(shù)據(jù)同步管理

          免費(fèi)咨詢(xún)熱線:

          iPad 橫屏適配經(jīng)驗(yàn)

          輯導(dǎo)語(yǔ):雖然國(guó)內(nèi)軟件的iPad用戶(hù)占比不大,但依然存在著橫屏適配的需求。本文作者講述了自己做iPad橫屏適配的背景,并對(duì)競(jìng)品的適配方式進(jìn)行了分析研究,用自己的親身經(jīng)歷提供了參考,推薦對(duì)ipad橫屏適配感興趣的童鞋閱讀。

          一、背景

          在我參與的一款資料查詢(xún) App 中,對(duì) iPad 只支持豎屏以手機(jī) UI 尺寸拉伸,每個(gè)季度都有用戶(hù)反饋希望適配 iPad 橫屏。經(jīng)過(guò)詢(xún)問(wèn)用戶(hù)發(fā)現(xiàn),因?yàn)?iPad mini 尺寸剛好可以放在工作服口袋中,隨時(shí)拿出來(lái)使用,而 iPad 屏幕遠(yuǎn)比手機(jī)大,瀏覽資料視野更大更舒服。

          但另外一方面,后臺(tái)數(shù)據(jù)顯示當(dāng)前 iPad 用戶(hù)占比只有 1%,用戶(hù)呼聲夠不上星星之火,不足以燎原。先別談?wù)f服團(tuán)隊(duì)做 iPad 橫屏適配,連說(shuō)服自己都難。本來(lái)以為這事就像水中投石,水波消散就沒(méi)有下文了。直到有一天,同樣是資深用戶(hù)的高管自己拿著 iPad 裝上我們的 App 用了幾天,終于忍不了,開(kāi)始推動(dòng) iPad 橫屏適配。

          二、參考

          我們肯定不是第一個(gè)做 iPad 橫屏適配的,但在網(wǎng)上搜了一圈,別說(shuō)橫屏適配,連 iPad 界面設(shè)計(jì)的文章都很少,下面 3 篇算不錯(cuò)的。這也是我決定寫(xiě)下本文的原因,為后來(lái)者提供經(jīng)驗(yàn),少踩坑。

          1. 《利用好 iPad 的大屏幕 —— 如何為 iPadOS 14 設(shè)計(jì) app?》,https://steppark.net/15942969497015.html
          2. 《iPad 交互設(shè)計(jì)探索系列:iPad 適用產(chǎn)品篇》,https://www.jianshu.com/p/65211fddefb9
          3. 《iPad 交互設(shè)計(jì)探索系列:iPad 導(dǎo)航設(shè)計(jì)篇》,https://www.jianshu.com/p/0c8e315d39d4

          三、研究

          沒(méi)得經(jīng)驗(yàn)參考就只能先從競(jìng)品分析開(kāi)始了。經(jīng)過(guò)對(duì) iOS 系統(tǒng)應(yīng)用、微信、QQ、微信閱讀、得到、豆瓣、淘寶和有道詞典的分析,我和同事總結(jié)成 5 種橫屏適配模式。

          1. 內(nèi)容響應(yīng)式

          典型 App:iOS 應(yīng)用商店

          特征:標(biāo)題欄和 Tabbar 通欄拉伸,內(nèi)容區(qū)根據(jù)寬度向右響應(yīng)式布局。

          適用場(chǎng)景:全部場(chǎng)景。

          評(píng)價(jià):靈活性和用戶(hù)體驗(yàn)都很好,但設(shè)計(jì)和開(kāi)發(fā)成本很大。

          2. 左右分欄

          典型 App:iOS 設(shè)置、淘寶、微信、QQ

          特征:左右分開(kāi)顯示,左邊通常固定顯示首頁(yè)或者目錄導(dǎo)航。右側(cè)根據(jù)左側(cè)選擇顯示對(duì)應(yīng)的詳情內(nèi)容。

          適用場(chǎng)景:頻繁需要使用導(dǎo)航切換內(nèi)容。

          評(píng)價(jià):用戶(hù)體驗(yàn)適中,合理的利用橫屏更大地展示更多的內(nèi)容。設(shè)計(jì)成本小,需額外設(shè)計(jì)一個(gè)右側(cè)默認(rèn)為空的情況。開(kāi)發(fā)成本要看是否改程序架構(gòu),相當(dāng)于把手機(jī)兩個(gè)手機(jī)界面合并成一個(gè)屏幕,可能有些程序架構(gòu)很難這么修改。

          3. 按豎屏寬度顯示

          典型 App:微信閱讀

          特征:標(biāo)題欄和 Tabbar 通欄拉伸,內(nèi)容直接按豎屏的寬度顯示。

          適用場(chǎng)景:全部場(chǎng)景。

          評(píng)價(jià):用戶(hù)體驗(yàn)適中,設(shè)計(jì)與開(kāi)發(fā)成本小,大多數(shù)產(chǎn)品采用此模式,但是沒(méi)有更好的展現(xiàn)橫屏寬屏的優(yōu)勢(shì)。

          4. 全屏通欄拉伸

          典型 App:豆瓣

          特征:橫屏為全屏通欄拉伸,所有元素與豎屏一致。

          適用場(chǎng)景:全部場(chǎng)景。

          評(píng)價(jià):設(shè)計(jì)和開(kāi)發(fā)成本最小,但是相當(dāng)于沒(méi)有適配。用戶(hù)體驗(yàn)較差,橫屏情況下內(nèi)容集中,左側(cè)右側(cè)很空,或者被拉得很長(zhǎng),閱讀體驗(yàn)較差。

          5. 混合模式

          當(dāng)然也不是所有 App 都采用單一的模式。比如微信閱讀,在其他頁(yè)面是按豎屏寬度顯示。但到了圖書(shū)閱讀界面,則是左右分欄充分利用 iPad 大屏幕展現(xiàn)內(nèi)容。

          以上競(jìng)品分析所有截圖我們都保存在 Figma 中,有需要的讀者可前往獲取。

          鏈接:https://www.figma.com/community/file/1071850659054902697/iPad-橫屏適配競(jìng)品分析

          四、執(zhí)行

          非常遺憾的是雖然高管牽頭做適配,但開(kāi)發(fā)資源確實(shí)有限。不能為了設(shè)計(jì)師邀功拿業(yè)績(jī)就從頭把 iOS App 重構(gòu)一遍,因此我們決定用最少的資源做最核心的優(yōu)化。

          適配計(jì)劃分為 2 期。第 1 期將所有頁(yè)面用按豎屏寬度顯示進(jìn)行橫屏適配。第 2 期挑選核心頁(yè)面用內(nèi)容響應(yīng)式或左右分欄進(jìn)行優(yōu)化。

          1. 先開(kāi)發(fā)再驗(yàn)收

          在第 1 期我們就踩坑了,按照原來(lái)的工作流程,我們將所有的 iPad 橫屏頁(yè)面做好線框圖、再輸出所有視覺(jué)效果圖。雖然都是線上頁(yè)面不用重新設(shè)計(jì),只需要拉伸畫(huà)面或者調(diào)整間距,但所有線上頁(yè)面也是一個(gè)不小的工作量。

          就在進(jìn)行過(guò)程中,iOS 工程師就皺著眉頭來(lái)提議,由于代碼架構(gòu)和資源所限,設(shè)計(jì)師如果調(diào)整的視覺(jué)效果圖未必能 100% 實(shí)現(xiàn)。不如反過(guò)來(lái),讓他先把所有頁(yè)面強(qiáng)行橫屏,再由設(shè)計(jì)師走查發(fā)現(xiàn)問(wèn)題進(jìn)行修改,這樣節(jié)省時(shí)間效果也可控。

          可見(jiàn),不同的項(xiàng)目類(lèi)型可以采取不同的工作流程。iPad 橫屏適配項(xiàng)目流程和常規(guī)工作流程剛好相反,以往是先設(shè)計(jì)再開(kāi)發(fā),改成先開(kāi)發(fā)再走查,節(jié)省設(shè)計(jì)師產(chǎn)出效果圖時(shí)間,也保障最終實(shí)現(xiàn)效果。

          2. 核心場(chǎng)景決定核心頁(yè)面

          在第 2 期挑選核心頁(yè)面時(shí),我也犯了錯(cuò)誤。最開(kāi)始我覺(jué)得核心是臉面,因此挑選 Tabbar 導(dǎo)航的首頁(yè)、個(gè)人中心等用戶(hù)一打開(kāi) App 就看得到的頁(yè)面進(jìn)行優(yōu)化。但實(shí)際上用戶(hù)真正的核心使用場(chǎng)景是在詳情頁(yè)查閱資料,這才是真正的核心頁(yè)面。

          在得到主管糾正后,我們轉(zhuǎn)而開(kāi)始為資料閱讀頁(yè)面提供左內(nèi)容右目錄的布局,便于用戶(hù)方便地在長(zhǎng)文中精確定位想讀的內(nèi)容。

          2 期計(jì)劃并非適配的終結(jié),隨著 App 功能的迭代,此后老界面修改和新界面設(shè)計(jì)需要考慮到 iPad 橫屏的適配問(wèn)題,就成為了日常工作的內(nèi)容了。

          五、總結(jié)

          按照以往的項(xiàng)目總結(jié),最后應(yīng)該匯報(bào)項(xiàng)目數(shù)據(jù)結(jié)果。但由于 iPad 用戶(hù)本身可憐的占比,即使我們官方公眾號(hào)推文宣布適配 iPad 橫屏后,也沒(méi)有 iPad 用戶(hù)站出來(lái)點(diǎn)贊,而是又引發(fā)出使用華為、小米等安卓 Pad 的用戶(hù),要求也適配。

          考慮到不同的安卓品牌適配方式不一樣,而且安卓廠商自己又有平行世界等通用兼容方案,我們就沒(méi)再繼續(xù)參與了。

          雖然沒(méi)有外部用戶(hù)反饋,但公司內(nèi)部同事和開(kāi)發(fā)團(tuán)隊(duì)使用后確實(shí)感覺(jué)很棒。所以我覺(jué)得這次適配項(xiàng)目真正值得思考的是:如果一個(gè)需求用戶(hù)反饋很少,也沒(méi)有數(shù)據(jù)支撐,但對(duì)體驗(yàn)影響很大,如何推動(dòng)團(tuán)隊(duì)進(jìn)行優(yōu)化呢?

          作者:龍爪槐守望者,微信公眾號(hào):龍爪槐守望者

          本文由 @龍爪槐守望者 原創(chuàng)發(fā)布于人人都是產(chǎn)品經(jīng)理。未經(jīng)許可,禁止轉(zhuǎn)載。

          題圖來(lái)自 Unsplash,基于 CC0 協(xié)議

          .前言

          道友能來(lái)到此處,證明你我有緣,既然如此,我想送你一場(chǎng)造化!

          本系列文章主要分享個(gè)人在多年中后臺(tái)前端開(kāi)發(fā)中,對(duì)于表單與列表封裝的一些探索以及實(shí)踐.本系列分享是基于vue3+element-plus,設(shè)計(jì)方案可能無(wú)法滿(mǎn)足所有人的需求,但是可以解決大部分人業(yè)務(wù)中的開(kāi)發(fā)需求.主要還是希望通過(guò)分享能夠得到一些新的反饋與啟發(fā),進(jìn)一步完善改進(jìn),分享中夯實(shí)己身,在反饋中不斷成長(zhǎng).時(shí)間原因文章會(huì)不定期更新,有空就寫(xiě).下面先展示一下一個(gè)完整的常見(jiàn)的表單+表格集成的列表頁(yè)面開(kāi)發(fā)的場(chǎng)景,然后再拆解ElTable表格的二次封裝實(shí)現(xiàn)封裝.

          示例代碼展示:

          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: "預(yù)警消息釘釘通知",
             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: "預(yù)警消息郵件通知",
             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: "創(chuàng)建成功!" });
             }, 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(實(shí)現(xiàn)示例中的新增/編輯的動(dòng)態(tài)表單)

          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",
           },
          ];
          
          /*******
          支持的規(guī)則描述
          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: "賬號(hào)",
             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服務(wù)器地址",
             field: "host",
             visiable: EmailVisiable,
             uiType: "input",
           },
           {
             label: "郵箱賬號(hào)",
             field: "account",
             visiable: EmailVisiable,
             uiType: "input",
           },
           {
             label: "郵箱密碼",
             field: "password",
             visiable: EmailVisiable,
             uiType: "input",
           },
          ];
          
          function createFormItems(isEditMode: boolean, extJson: any = null) {
           return [
             {
               label: "渠道名稱(chēng)",
               field: "channelName",
               uiType: "input",
               required: true,
             },
             {
               label: "渠道類(lèi)型",
               field: "channelType",
               required: true,
               uiType: "selector",
               disabled: isEditMode,
               props: {
                 options: ChannelEnum,
               },
             },
             ...DingTalkFormItems,
             ...SmsFormItems,
             ...EmailFormItems,
             {
               label: "應(yīng)用于平臺(tái)",
               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",//編輯操作需要傳給后端用來(lái)更新的主鍵,不傳默認(rèn)為id
             },
             formItems: createFormItems(isEditMode, rowData),
           });
          
           dialogInsatcne.open(rowData)
           .onConfirm((formData: any) => {
              /****
              *只有表單所有必填字段校驗(yàn)通過(guò)才會(huì)調(diào)用此回調(diào)函數(shù)
              *formData只包含可視的字段與primaryKey,保證數(shù)據(jù)干凈
              ****/
              
             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="平臺(tái)名稱(chēng)" prop="name" />
                  <el-table-column label="平臺(tái)編碼" 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),
            //過(guò)濾條件選項(xiàng)
            filterItems: [
              {
                label: "渠道類(lèi)型",
                field: "channelType",
                uiType: "selector",
                props: { options: ChannelEnum },
              },
              {
                label: "啟用狀態(tài)",
                field: "status",
                uiType: "selector",
                props: { options: statusEnum },
              },
              {
                label: "創(chuàng)建時(shí)間",
                field: ["stratTime", "endTime"],
                uiType: "dateTimePicker",
                props: {
                  type: "daterange",
                },
              },
            ],
          
            columns: [
              { type: "selection", label: "全選" },
              { type: "index", label: "序號(hào)" },
              { type: "expand", label: "使用平臺(tái)" },
              { label: "渠道名稱(chēng)", key: "channelName" },
          
              {
                label: "通知方式",
                key: "channelType",
                formatter: (row) => ChannelEnum[row.channelType],
              },
              {
                label: "密鑰",
                text: "查看密鑰",
                click: () => {
                  Toast("查看密鑰");
                },
              },
              { label: "啟用狀態(tài)", slot: "status" },
              { label: "創(chuàng)建時(shí)間", key: "createTime" },
              { label: "創(chuàng)建人", key: "createBy" },
              { label: "更新時(shí)間", 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(`請(qǐng)先選擇要?jiǎng)h除的數(shù)據(jù)`);
                    return;
                  }
                  Dialog.confirm(
                    `確定要?jiǎng)h除消息渠道配置${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>
          

          至于此種開(kāi)發(fā)方式對(duì)開(kāi)發(fā)效率有沒(méi)有提升,看完上面示例的代碼后讀者朋友可以嘗試實(shí)現(xiàn)圖示中的效果,然后從時(shí)間耗費(fèi)、代碼量、拓展性與可維護(hù)性等多個(gè)維度做下對(duì)比,本示例開(kāi)發(fā)連同構(gòu)造數(shù)據(jù)模擬花了差不多2h,因?yàn)樗伎际纠腥绾尾拍軐⒎庋b的東西更多地展現(xiàn)出來(lái),也稍微花了點(diǎn)時(shí)間。社區(qū)中確實(shí)看到有很不少人對(duì)這種配置式開(kāi)發(fā)嗤之以鼻,但是在我看來(lái)至少有以下幾個(gè)優(yōu)點(diǎn):

          1. 統(tǒng)一了項(xiàng)目中的列表頁(yè)開(kāi)發(fā)規(guī)范,無(wú)論誰(shuí)開(kāi)發(fā)都可以保證每一個(gè)列表頁(yè)面其他人都可以看得懂,改得動(dòng)。
          2. 在前端人手不足情況下,即使后端不會(huì)css跟布局,只要給與相關(guān)文檔看一下,也能動(dòng)手寫(xiě)出一樣的列表頁(yè)面開(kāi)發(fā)(后端開(kāi)發(fā)的道友對(duì)不住了,哈哈哈)
          3. 沒(méi)有一個(gè)功能代碼需要反復(fù)橫跳查看的,每一個(gè)方法邏輯都可以很好很清晰的剝離與替換,解耦業(yè)務(wù)邏輯。例如示例中的新增與編輯操作,將相關(guān)業(yè)務(wù)邏輯內(nèi)聚,從頁(yè)面代碼中剝離出來(lái)單獨(dú)維護(hù),需求變動(dòng)時(shí)任何方法都可以很方便地直接拿掉或者重寫(xiě),無(wú)需擔(dān)心會(huì)影響其他業(yè)務(wù)代碼。

          2.代碼拆解

          接下來(lái)我們進(jìn)入主題,拆解下(ListPage.vue)這個(gè)頁(yè)面的組件分封裝。對(duì)于頁(yè)面展示的各個(gè)部分,在代碼封裝設(shè)計(jì)上我們按照?qǐng)D示中圈出來(lái)的各個(gè)部分來(lái)做封裝設(shè)計(jì)。

          代碼組織如下:

          1. 列表頁(yè)面 (ListPage.vue)

          整個(gè)列表列頁(yè)面在設(shè)計(jì)上主要由SearchForm、Toolbar、Pagenation、ElTablePlus、TableCustomSetting幾個(gè)部分組合而成,整體代碼量不多,完整代碼如下:

          <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>
                <!-- 分頁(yè) -->
                <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: {},
          });
          
          /**表格數(shù)據(jù)獲取與刷新邏輯**/
          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
          );
          
          /***表格動(dòng)態(tài)高度計(jì)算***/
          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. 列表數(shù)據(jù)請(qǐng)求(useTable.ts)

          在實(shí)際開(kāi)發(fā)過(guò)程中列表數(shù)據(jù)源可能來(lái)源于各個(gè)地方,可能是接口,也可能是手動(dòng)枚舉的數(shù)據(jù)。設(shè)計(jì)上我們支持傳入數(shù)組與方法,這一層主要是對(duì)數(shù)據(jù)的輸入=>輸出做歸一化處理,減少應(yīng)用時(shí)對(duì)數(shù)據(jù)格式的心智負(fù)擔(dān)。 具體可以參考下面完整的代碼:

          
          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("----表格數(shù)據(jù)必須是方法或者數(shù)組----");
                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)

          對(duì)列配置單獨(dú)提取出來(lái)做二次處理,可以方便我們做一些中間的轉(zhuǎn)換與列更新的操作的控制。對(duì)于業(yè)務(wù)開(kāi)發(fā)中的一些開(kāi)發(fā)拓展也很方便。(以我自身經(jīng)歷的一個(gè)業(yè)務(wù)場(chǎng)景來(lái)說(shuō),某項(xiàng)目需要支持私有化部署跟saas環(huán)境部署,但是有多個(gè)頁(yè)面在不同環(huán)境需要展示不同的字段。按照常規(guī)操作需要一個(gè)個(gè)頁(yè)面去讀取環(huán)境變量來(lái)做控制,操作起來(lái)就很復(fù)雜。我采用的就是在列配置上拓展一個(gè)環(huán)境支持的字段,然后在tableColumns引入環(huán)境變量做統(tǒng)一的過(guò)濾處理) 此外,這一層可以支持對(duì)多種UI框架的table組件進(jìn)行支持。例如列屬性字段,對(duì)應(yīng)到不同框架中有的可能是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; // 列標(biāo)題 可以是函數(shù)或字符串,根據(jù)需要在頁(yè)面上顯示在列
            key?: string;
            property?: string; // 列的屬性, 如果沒(méi)有指定,則使用列名稱(chēng) 如果是函數(shù)
            slot?: string;
            align?: string;
            width?: number | string; // 列寬度 可選參數(shù),默認(rèn)為100 可以是整數(shù)或浮點(diǎn)數(shù),但不
            minWidth?: number | string; // 最小列寬度 可選參數(shù),默認(rèn)為10 可以是整數(shù)或浮點(diǎn)
            fixed?: FixedType; // 列寬對(duì)齊方式 left right none 默認(rèn)為left 可選參數(shù),表示對(duì)齊方
            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)

          對(duì)el-table組件二次封裝,首先我們要保證對(duì)原組件所有的方法與屬性可以完全的支持,在不影響原組件的功能上增加拓展。這里用屬性/事件透?jìng)鳎缓笥胿-bind,v-on分別做綁定即可實(shí)現(xiàn)。不清楚的道友可以看下官方的這兩個(gè)指令。在拓展上我們這里除了支持action,slot,還增加了一個(gè)click配置,這個(gè)主要針某個(gè)列展示的數(shù)據(jù)我們希望點(diǎn)擊的時(shí)候可以進(jìn)行跳轉(zhuǎn)等操作。所有配置的支持都是根據(jù)平時(shí)業(yè)務(wù)開(kāi)發(fā)中的真實(shí)場(chǎng)景來(lái)設(shè)計(jì)的。看懂了下面的代碼,可以根據(jù)自己的業(yè)務(wù)進(jìn)行拓展支持。

          <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會(huì)存在數(shù)據(jù)更新頁(yè)面不更新
                },
                $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)

          對(duì)操作列中的按鈕單獨(dú)封裝,可以方便我們給操作提供更多豐富的個(gè)性化定制配置,根據(jù)項(xiàng)目中的需求而定,保證設(shè)計(jì)的靈活性

          <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 類(lèi)型錯(cuò)誤");
                  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 類(lèi)型錯(cuò)誤");
          });
          
          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.列表個(gè)性化定制封裝 (tableSettingDrawer.vue)

          個(gè)性化定制也是列表常見(jiàn)的需求之一,對(duì)于B端業(yè)務(wù)可能會(huì)有不同角色對(duì)同一個(gè)列表操作的需求,但是相互之間所關(guān)注的信息可能不一樣。這部分主要是控制對(duì)應(yīng)搜索條件與列表的列展示進(jìn)行個(gè)性化定制。對(duì)于存儲(chǔ)設(shè)計(jì)的話可以用當(dāng)前頁(yè)面的路由訪問(wèn)路徑作為鍵來(lái)保存,如果同個(gè)頁(yè)面彈窗中還有列表,設(shè)計(jì)上可以用routePath+id方式來(lái)保存,給彈窗中的列表加個(gè)id即可。

          <template>
            <el-drawer v-model="dialogVisible" title="個(gè)性化定制" 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="`請(qǐng)輸入${column.label}`"></el-input>
                        <!-- 選擇器 -->
                        <el-select v-else-if="column.uiType == 'select'" v-model="row[column.field]"
                          :placeholder="`請(qǐng)選擇${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="定制查詢(xún)條件" 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)">恢復(fù)默認(rèn)設(shè)置</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可以實(shí)現(xiàn)異步獲取配置實(shí)時(shí)更新**/
          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: "對(duì)齊方式",
              uiType: "select",
              options: [
                { value: "left", name: "左對(duì)齊" },
                { value: "right", name: "右對(duì)齊" },
                { value: "center", name: "居中" },
              ],
            },
            {
              field: "fixed",
              label: "固定類(lèi)型",
              uiType: "select",
              options: [
                { value: "left", name: "左側(cè)" },
                { value: "right", name: "右側(cè)" },
                { value: "none", name: "不固定" },
              ],
            },
          ];
          
          const dialogVisible = ref(false);
          
          const open = () => {
            dialogVisible.value = true;
          };
          
          const close = () => {
            dialogVisible.value = false;
          };
          
          defineExpose({
            open,
            close,
          });
          </script>
          
          

          至此,ElTable二次封裝相關(guān)代碼已經(jīng)結(jié)束。希望此中代碼能夠助各位道友在表格二次封裝的設(shè)計(jì)開(kāi)發(fā)修煉中能有所幫助。一切大道,皆有因果。喜歡的話,可以動(dòng)動(dòng)你的小手點(diǎn)點(diǎn)贊。修行路上愿我們都不必獨(dú)伴大道,回首望去無(wú)故人。

          下期預(yù)告:動(dòng)態(tài)表單設(shè)計(jì)封裝,敬請(qǐng)期待

          本文已首發(fā)掘金社區(qū),純?cè)瓌?chuàng)文章,轉(zhuǎn)載請(qǐng)聲明來(lái)源

          色權(quán)限系統(tǒng)設(shè)計(jì)可以更好的優(yōu)化工作的流程步驟,本文分享角色權(quán)限系統(tǒng)設(shè)計(jì)的幾個(gè)主要步驟。

          公司的商戶(hù)后臺(tái)剛建立不久,之前僅能支持系統(tǒng)管理員和商戶(hù)管理員兩種角色使用,隨著產(chǎn)品和業(yè)務(wù)線逐漸成熟,參與到整個(gè)產(chǎn)品中的人員越來(lái)越多了,涉及的部門(mén)和角色也由從前的一兩種變成了多種,故由我主導(dǎo)了角色權(quán)限系統(tǒng)的重構(gòu)升級(jí)。在此將工作心得記錄下來(lái),分享給需要用到的人。

          先簡(jiǎn)單介紹下我司的后臺(tái)產(chǎn)品功能,我司主要業(yè)務(wù)是向B端企業(yè)客戶(hù)銷(xiāo)售一些智能硬件,客戶(hù)買(mǎi)到產(chǎn)品之后會(huì)將產(chǎn)品關(guān)聯(lián)到自己的商戶(hù)后臺(tái),硬件會(huì)上傳一些核心數(shù)據(jù)到后臺(tái)供商戶(hù)管理查看,所以我們的后臺(tái)核心功能是:設(shè)備管理、商戶(hù)管理、用戶(hù)管理、數(shù)據(jù)管理、產(chǎn)品銷(xiāo)售管理。

          角色權(quán)限系統(tǒng)

          了解完我司后臺(tái)的大概功能后,我們來(lái)聊下角色權(quán)限系統(tǒng)。

          角色權(quán)限系統(tǒng)屬于策略設(shè)計(jì)范疇,它的設(shè)計(jì)非常考驗(yàn)一個(gè)PM對(duì)業(yè)務(wù)的理解力以及對(duì)自己后臺(tái)所有功能的熟悉程度。做角色權(quán)限系統(tǒng)之前一定要先深度了解業(yè)務(wù)流程以及后臺(tái)的所有功能模塊,在不了解的情況下,多向相關(guān)同事請(qǐng)教,避免角色權(quán)限系統(tǒng)設(shè)計(jì)過(guò)程中出差錯(cuò)和邏輯漏洞。由于角色權(quán)限系統(tǒng)屬于功能底層系統(tǒng),很多的業(yè)務(wù)功能、前端功能都深度依賴(lài)角色權(quán)限系統(tǒng),所以盡量在第一次出產(chǎn)品方案時(shí)就盡可能的考慮全面,減少后續(xù)不必要的返工,如果前期產(chǎn)品方案不夠縝密,后期改動(dòng)成本會(huì)非常大。

          目前市場(chǎng)主流的角色權(quán)限模型是RBAC權(quán)限模型,具體技術(shù)原理可以閱讀下這個(gè)博客http://www.cnblogs.com/lhyqzx/p/5962826.html,有人好奇為什么做角色權(quán)限系統(tǒng)設(shè)計(jì)還要了解技術(shù)架構(gòu)呢?這個(gè)是為了讓設(shè)計(jì)者能夠設(shè)計(jì)出高效、安全、靈活且技術(shù)可實(shí)現(xiàn)的角色權(quán)限系統(tǒng)。

          RBAC權(quán)限模型核心就是功能權(quán)限控制和角色產(chǎn)生關(guān)聯(lián),角色再和用戶(hù)賬號(hào)關(guān)聯(lián),即創(chuàng)建用戶(hù)賬號(hào)時(shí)選定一種角色,該角色里已經(jīng)分配好了功能和權(quán)限。拿我們系統(tǒng)為例,由于有系統(tǒng)管理員和商戶(hù)管理員的區(qū)別,即系統(tǒng)管理員可以查看所有的商戶(hù)和設(shè)備數(shù)據(jù),商戶(hù)管理員邏輯上只允許查看自己商戶(hù)下 的設(shè)備數(shù)據(jù)。所以我為了更靈活高效的去創(chuàng)建用戶(hù)角色(比如:商務(wù)經(jīng)理、商務(wù)專(zhuān)員、客服經(jīng)理、客服專(zhuān)員等),我在用戶(hù)角色之前又設(shè)置了角色類(lèi)型,詳見(jiàn)下圖:

          關(guān)于用戶(hù)角色的創(chuàng)建權(quán)限上這里需要說(shuō)明的是:如果貴司組織結(jié)構(gòu)比較龐大,使用后臺(tái)的角色人員涉及到各個(gè)職能部門(mén),且不同職能部門(mén)又有不同的角色,那么創(chuàng)建、管理角色的權(quán)限應(yīng)該下放到各個(gè)部門(mén)的leader,便于管理系統(tǒng)用戶(hù)的效率。由于我司業(yè)務(wù)的特殊性,可以預(yù)估到會(huì)參與使用后臺(tái)的角色大概十來(lái)種,所以我為了更加集中、高效、安全的管理用戶(hù)角色,設(shè)定的只有超級(jí)管理員可以創(chuàng)建和修改角色。

          角色權(quán)限系統(tǒng)設(shè)計(jì)流程

          角色權(quán)限系統(tǒng)設(shè)計(jì)的大概流程如下:

          一、工具準(zhǔn)備

          思維導(dǎo)圖工具(mindmanager、Xmind都可,我用的Xmind)、word 、Axure.

          二、給每個(gè)角色類(lèi)型梳理功能架構(gòu)圖

          功能架構(gòu)圖梳理是為了讓設(shè)計(jì)者清晰理解后臺(tái)所有的產(chǎn)品功能模塊,以及各個(gè)產(chǎn)品功能之間層級(jí)關(guān)系,給每個(gè)角色類(lèi)型都梳理一份功能架構(gòu)圖,可以讓產(chǎn)品自身和開(kāi)發(fā)以及項(xiàng)目成員都了解每個(gè)角色類(lèi)型的區(qū)別。

          • 比如:超級(jí)管理員這個(gè)角色類(lèi)型,它應(yīng)該可以管理后臺(tái)的所有產(chǎn)品功能,并且擁有一些自己獨(dú)有的功能權(quán)限;
          • 比如:角色管理和賬號(hào)管理功能我設(shè)定的只讓超級(jí)管理員有權(quán)訪問(wèn),其他角色類(lèi)型全部訪問(wèn)不了,所以也不需要配置;
          • 再比如:高級(jí)全局管理員應(yīng)該有管理低級(jí)別角色類(lèi)型的權(quán)限,而低級(jí)別角色類(lèi)型不能管理高級(jí)別角色類(lèi)型。

          梳理功能架構(gòu)圖時(shí)可以根據(jù)一、二、三級(jí)這樣的功能層次來(lái)畫(huà)思維導(dǎo)圖,有的后臺(tái)系統(tǒng)可能非常龐大,那么是否需要把一級(jí)功能到一直到末級(jí)的所有功能包括界面按鈕都全部羅列出來(lái)呢?這個(gè)需要看業(yè)務(wù)需求,看公司組織架構(gòu),多方面綜合考慮再?zèng)Q定權(quán)限控制到哪一層,羅列到哪一級(jí)別的產(chǎn)品功能。通常情況下,權(quán)限控制到二/三層級(jí)基本能滿(mǎn)足一個(gè)中小型公司的權(quán)限管理需求,再大型一點(diǎn)的公司,可以控制到更深層級(jí)的功能權(quán)限。

          此外,我并不建議將權(quán)限控制到非常精細(xì)的級(jí)別,精細(xì)到可以控制前端頁(yè)面上的每一個(gè)按鈕,甚至每一個(gè)按鈕的顏色以及交互效果,因?yàn)楹笈_(tái)產(chǎn)品的核心是管理平臺(tái)。管理無(wú)非增刪改查四個(gè)操作,對(duì)于后臺(tái)而言,管理的效率非常重要,如果權(quán)限控制的過(guò)于精細(xì),在進(jìn)行創(chuàng)建、修改角色時(shí),效率會(huì)非常低。并且,如果不是系統(tǒng)的設(shè)計(jì)者理解起來(lái)會(huì)非常困難,對(duì)于頁(yè)面上的一些關(guān)鍵按鈕和操作可以加以控制。

          注意事項(xiàng):

          • 層級(jí)劃分清晰,不同級(jí)別的功能盡量用不同的字體區(qū)分開(kāi)。
          • 同類(lèi)型的功能權(quán)限用不同的色塊兒填充,一般來(lái)講每種角色類(lèi)型都至少會(huì)有兩種類(lèi)型的功能權(quán)限,(1)默認(rèn)該角色類(lèi)型擁有的功能權(quán)限,無(wú)須配置 (2)需要配置的功能權(quán)限。

          之所以給不同角色類(lèi)型的默認(rèn)設(shè)定了一些功能權(quán)限,是為了創(chuàng)建角色和維護(hù)角色更加方便。比如:高級(jí)全局管理員角色類(lèi)型對(duì)應(yīng)的用戶(hù)角色可能是各部門(mén)leader,像研發(fā)總監(jiān)、客服經(jīng)理、商務(wù)總監(jiān)這樣的用戶(hù)角色,這些用戶(hù)角色普遍會(huì)擁有較大的權(quán)限,同時(shí)又有所區(qū)分。假設(shè)系統(tǒng)有100個(gè)功能,那么我默認(rèn)將這些角色可能會(huì)共同擁有的70個(gè)權(quán)限全部默認(rèn)設(shè)定給高級(jí)全局管理員,將其余的功能設(shè)定為可自由配置的功能,那么當(dāng)超級(jí)管理員去創(chuàng)建一個(gè)客服經(jīng)理角色時(shí),就只要配置剩余的30個(gè)權(quán)限即可。

          三、設(shè)計(jì)功能原型

          角色權(quán)限的內(nèi)在規(guī)則邏輯設(shè)計(jì)好了,先和組內(nèi)討論,通過(guò)產(chǎn)品評(píng)審后可以考慮出產(chǎn)品功能原型了。

          角色權(quán)限系統(tǒng)的開(kāi)發(fā)一定是角色和功能是獨(dú)立的兩個(gè)模塊,他們二者通過(guò)配置關(guān)系產(chǎn)生關(guān)聯(lián)繼而會(huì)出現(xiàn)不同的用戶(hù)角色登錄系統(tǒng)后會(huì)看到不同的功能界面,所以在畫(huà)原型時(shí)只需要畫(huà)出最全的功能即可。當(dāng)系統(tǒng)內(nèi)功能和角色數(shù)量相對(duì)而言都比較少的時(shí)候,角色權(quán)限管理功能可以考慮用橫豎列表形式展現(xiàn)。當(dāng)系統(tǒng)內(nèi)的角色和功能數(shù)量比較多的時(shí)候,可以考慮模仿windows文件夾展開(kāi)的交互用多面板形式來(lái)展現(xiàn)角色和功能的關(guān)系。

          四、細(xì)化產(chǎn)品方案,形成PRD

          將原型和腦圖都梳理完畢后,最后就是把流程、細(xì)節(jié)從頭捋一遍,將要點(diǎn)全部整理到PRD里,最后拿著PRD去和技術(shù)同學(xué)開(kāi)技術(shù)評(píng)審會(huì)了。

          完成以上四個(gè)步驟,基本就完成了一套后臺(tái)角色權(quán)限系統(tǒng)的設(shè)計(jì),如果覺(jué)得有用,請(qǐng)轉(zhuǎn)發(fā)分享。

          作者:Michael,Sensoro高級(jí)產(chǎn)品經(jīng)理,產(chǎn)品設(shè)計(jì)經(jīng)驗(yàn)豐富,主導(dǎo)過(guò)移動(dòng)產(chǎn)品、IM產(chǎn)品、web前后臺(tái)產(chǎn)品的多次重大升級(jí)。

          本文由 @Michael 原創(chuàng)發(fā)布于人人都是產(chǎn)品經(jīng)理。未經(jīng)許可,禁止轉(zhuǎn)載。


          主站蜘蛛池模板: 国产另类ts人妖一区二区三区| 中文字幕无线码一区| 亚欧色一区W666天堂| 一级毛片完整版免费播放一区 | 无码人妻啪啪一区二区| 日韩精品一区二区三区大桥未久| 三级韩国一区久久二区综合| 免费在线视频一区| 亚洲第一区精品日韩在线播放| 精彩视频一区二区| 无码人妻精品一区二| 成人在线一区二区| 清纯唯美经典一区二区| 亚洲爆乳无码一区二区三区| 亚洲国产一区二区三区青草影视| 精品人妻中文av一区二区三区| 精品永久久福利一区二区| 亚洲一区二区三区丝袜| 日韩精品午夜视频一区二区三区| 日韩久久精品一区二区三区 | 亚洲日韩精品国产一区二区三区| 亚洲综合一区国产精品| 国产在线一区二区三区在线| 一区二区三区四区无限乱码| 国产AⅤ精品一区二区三区久久| 中文字幕在线视频一区| 人妻无码一区二区不卡无码av| 国产精品福利一区二区久久| 国产乱人伦精品一区二区在线观看 | 精品国产不卡一区二区三区| 国产一区二区不卡在线播放| 精品亚洲综合在线第一区| 精品无码一区二区三区爱欲| 国产Av一区二区精品久久| 国产人妖视频一区在线观看| 亚洲无线码一区二区三区| 国产主播福利一区二区| 综合激情区视频一区视频二区| 国产主播一区二区| 精品国产亚洲第一区二区三区| 亚洲av高清在线观看一区二区|