Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
輯導語:雖然國內(nèi)軟件的iPad用戶占比不大,但依然存在著橫屏適配的需求。本文作者講述了自己做iPad橫屏適配的背景,并對競品的適配方式進行了分析研究,用自己的親身經(jīng)歷提供了參考,推薦對ipad橫屏適配感興趣的童鞋閱讀。
在我參與的一款資料查詢 App 中,對 iPad 只支持豎屏以手機 UI 尺寸拉伸,每個季度都有用戶反饋希望適配 iPad 橫屏。經(jīng)過詢問用戶發(fā)現(xiàn),因為 iPad mini 尺寸剛好可以放在工作服口袋中,隨時拿出來使用,而 iPad 屏幕遠比手機大,瀏覽資料視野更大更舒服。
但另外一方面,后臺數(shù)據(jù)顯示當前 iPad 用戶占比只有 1%,用戶呼聲夠不上星星之火,不足以燎原。先別談說服團隊做 iPad 橫屏適配,連說服自己都難。本來以為這事就像水中投石,水波消散就沒有下文了。直到有一天,同樣是資深用戶的高管自己拿著 iPad 裝上我們的 App 用了幾天,終于忍不了,開始推動 iPad 橫屏適配。
我們肯定不是第一個做 iPad 橫屏適配的,但在網(wǎng)上搜了一圈,別說橫屏適配,連 iPad 界面設(shè)計的文章都很少,下面 3 篇算不錯的。這也是我決定寫下本文的原因,為后來者提供經(jīng)驗,少踩坑。
沒得經(jīng)驗參考就只能先從競品分析開始了。經(jīng)過對 iOS 系統(tǒng)應用、微信、QQ、微信閱讀、得到、豆瓣、淘寶和有道詞典的分析,我和同事總結(jié)成 5 種橫屏適配模式。
典型 App:iOS 應用商店
特征:標題欄和 Tabbar 通欄拉伸,內(nèi)容區(qū)根據(jù)寬度向右響應式布局。
適用場景:全部場景。
評價:靈活性和用戶體驗都很好,但設(shè)計和開發(fā)成本很大。
典型 App:iOS 設(shè)置、淘寶、微信、QQ
特征:左右分開顯示,左邊通常固定顯示首頁或者目錄導航。右側(cè)根據(jù)左側(cè)選擇顯示對應的詳情內(nèi)容。
適用場景:頻繁需要使用導航切換內(nèi)容。
評價:用戶體驗適中,合理的利用橫屏更大地展示更多的內(nèi)容。設(shè)計成本小,需額外設(shè)計一個右側(cè)默認為空的情況。開發(fā)成本要看是否改程序架構(gòu),相當于把手機兩個手機界面合并成一個屏幕,可能有些程序架構(gòu)很難這么修改。
典型 App:微信閱讀
特征:標題欄和 Tabbar 通欄拉伸,內(nèi)容直接按豎屏的寬度顯示。
適用場景:全部場景。
評價:用戶體驗適中,設(shè)計與開發(fā)成本小,大多數(shù)產(chǎn)品采用此模式,但是沒有更好的展現(xiàn)橫屏寬屏的優(yōu)勢。
典型 App:豆瓣
特征:橫屏為全屏通欄拉伸,所有元素與豎屏一致。
適用場景:全部場景。
評價:設(shè)計和開發(fā)成本最小,但是相當于沒有適配。用戶體驗較差,橫屏情況下內(nèi)容集中,左側(cè)右側(cè)很空,或者被拉得很長,閱讀體驗較差。
當然也不是所有 App 都采用單一的模式。比如微信閱讀,在其他頁面是按豎屏寬度顯示。但到了圖書閱讀界面,則是左右分欄充分利用 iPad 大屏幕展現(xiàn)內(nèi)容。
以上競品分析所有截圖我們都保存在 Figma 中,有需要的讀者可前往獲取。
鏈接:https://www.figma.com/community/file/1071850659054902697/iPad-橫屏適配競品分析
非常遺憾的是雖然高管牽頭做適配,但開發(fā)資源確實有限。不能為了設(shè)計師邀功拿業(yè)績就從頭把 iOS App 重構(gòu)一遍,因此我們決定用最少的資源做最核心的優(yōu)化。
適配計劃分為 2 期。第 1 期將所有頁面用按豎屏寬度顯示進行橫屏適配。第 2 期挑選核心頁面用內(nèi)容響應式或左右分欄進行優(yōu)化。
在第 1 期我們就踩坑了,按照原來的工作流程,我們將所有的 iPad 橫屏頁面做好線框圖、再輸出所有視覺效果圖。雖然都是線上頁面不用重新設(shè)計,只需要拉伸畫面或者調(diào)整間距,但所有線上頁面也是一個不小的工作量。
就在進行過程中,iOS 工程師就皺著眉頭來提議,由于代碼架構(gòu)和資源所限,設(shè)計師如果調(diào)整的視覺效果圖未必能 100% 實現(xiàn)。不如反過來,讓他先把所有頁面強行橫屏,再由設(shè)計師走查發(fā)現(xiàn)問題進行修改,這樣節(jié)省時間效果也可控。
可見,不同的項目類型可以采取不同的工作流程。iPad 橫屏適配項目流程和常規(guī)工作流程剛好相反,以往是先設(shè)計再開發(fā),改成先開發(fā)再走查,節(jié)省設(shè)計師產(chǎn)出效果圖時間,也保障最終實現(xiàn)效果。
在第 2 期挑選核心頁面時,我也犯了錯誤。最開始我覺得核心是臉面,因此挑選 Tabbar 導航的首頁、個人中心等用戶一打開 App 就看得到的頁面進行優(yōu)化。但實際上用戶真正的核心使用場景是在詳情頁查閱資料,這才是真正的核心頁面。
在得到主管糾正后,我們轉(zhuǎn)而開始為資料閱讀頁面提供左內(nèi)容右目錄的布局,便于用戶方便地在長文中精確定位想讀的內(nèi)容。
2 期計劃并非適配的終結(jié),隨著 App 功能的迭代,此后老界面修改和新界面設(shè)計需要考慮到 iPad 橫屏的適配問題,就成為了日常工作的內(nèi)容了。
按照以往的項目總結(jié),最后應該匯報項目數(shù)據(jù)結(jié)果。但由于 iPad 用戶本身可憐的占比,即使我們官方公眾號推文宣布適配 iPad 橫屏后,也沒有 iPad 用戶站出來點贊,而是又引發(fā)出使用華為、小米等安卓 Pad 的用戶,要求也適配。
考慮到不同的安卓品牌適配方式不一樣,而且安卓廠商自己又有平行世界等通用兼容方案,我們就沒再繼續(xù)參與了。
雖然沒有外部用戶反饋,但公司內(nèi)部同事和開發(fā)團隊使用后確實感覺很棒。所以我覺得這次適配項目真正值得思考的是:如果一個需求用戶反饋很少,也沒有數(shù)據(jù)支撐,但對體驗影響很大,如何推動團隊進行優(yōu)化呢?
作者:龍爪槐守望者,微信公眾號:龍爪槐守望者
本文由 @龍爪槐守望者 原創(chuàng)發(fā)布于人人都是產(chǎn)品經(jīng)理。未經(jīng)許可,禁止轉(zhuǎn)載。
題圖來自 Unsplash,基于 CC0 協(xié)議
道友能來到此處,證明你我有緣,既然如此,我想送你一場造化!
本系列文章主要分享個人在多年中后臺前端開發(fā)中,對于表單與列表封裝的一些探索以及實踐.本系列分享是基于vue3+element-plus,設(shè)計方案可能無法滿足所有人的需求,但是可以解決大部分人業(yè)務中的開發(fā)需求.主要還是希望通過分享能夠得到一些新的反饋與啟發(fā),進一步完善改進,分享中夯實己身,在反饋中不斷成長.時間原因文章會不定期更新,有空就寫.下面先展示一下一個完整的常見的表單+表格集成的列表頁面開發(fā)的場景,然后再拆解ElTable表格的二次封裝實現(xiàn)封裝.
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: "創(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);
});
}
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: "賬號",
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) => {
/****
*只有表單所有必填字段校驗通過才會調(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(()=>{});
}
<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: "啟用狀態(tài)",
field: "status",
uiType: "selector",
props: { options: statusEnum },
},
{
label: "創(chuàng)建時間",
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: "啟用狀態(tài)", slot: "status" },
{ label: "創(chuàng)建時間", key: "createTime" },
{ label: "創(chuàng)建人", 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(`請先選擇要刪除的數(shù)據(jù)`);
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>
至于此種開發(fā)方式對開發(fā)效率有沒有提升,看完上面示例的代碼后讀者朋友可以嘗試實現(xiàn)圖示中的效果,然后從時間耗費、代碼量、拓展性與可維護性等多個維度做下對比,本示例開發(fā)連同構(gòu)造數(shù)據(jù)模擬花了差不多2h,因為思考示例中如何才能將封裝的東西更多地展現(xiàn)出來,也稍微花了點時間。社區(qū)中確實看到有很不少人對這種配置式開發(fā)嗤之以鼻,但是在我看來至少有以下幾個優(yōu)點:
接下來我們進入主題,拆解下(ListPage.vue)這個頁面的組件分封裝。對于頁面展示的各個部分,在代碼封裝設(shè)計上我們按照圖示中圈出來的各個部分來做封裝設(shè)計。
代碼組織如下:
整個列表列頁面在設(shè)計上主要由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: {},
});
/**表格數(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
);
/***表格動態(tài)高度計算***/
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>
在實際開發(fā)過程中列表數(shù)據(jù)源可能來源于各個地方,可能是接口,也可能是手動枚舉的數(shù)據(jù)。設(shè)計上我們支持傳入數(shù)組與方法,這一層主要是對數(shù)據(jù)的輸入=>輸出做歸一化處理,減少應用時對數(shù)據(jù)格式的心智負擔。 具體可以參考下面完整的代碼:
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,
};
}
對列配置單獨提取出來做二次處理,可以方便我們做一些中間的轉(zhuǎn)換與列更新的操作的控制。對于業(yè)務開發(fā)中的一些開發(fā)拓展也很方便。(以我自身經(jīng)歷的一個業(yè)務場景來說,某項目需要支持私有化部署跟saas環(huán)境部署,但是有多個頁面在不同環(huán)境需要展示不同的字段。按照常規(guī)操作需要一個個頁面去讀取環(huán)境變量來做控制,操作起來就很復雜。我采用的就是在列配置上拓展一個環(huán)境支持的字段,然后在tableColumns引入環(huán)境變量做統(tǒng)一的過濾處理) 此外,這一層可以支持對多種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; // 列標題 可以是函數(shù)或字符串,根據(jù)需要在頁面上顯示在列
key?: string;
property?: string; // 列的屬性, 如果沒有指定,則使用列名稱 如果是函數(shù)
slot?: string;
align?: string;
width?: number | string; // 列寬度 可選參數(shù),默認為100 可以是整數(shù)或浮點數(shù),但不
minWidth?: number | string; // 最小列寬度 可選參數(shù),默認為10 可以是整數(shù)或浮點
fixed?: FixedType; // 列寬對齊方式 left right none 默認為left 可選參數(shù),表示對齊方
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,
};
}
對el-table組件二次封裝,首先我們要保證對原組件所有的方法與屬性可以完全的支持,在不影響原組件的功能上增加拓展。這里用屬性/事件透傳,然后用v-bind,v-on分別做綁定即可實現(xiàn)。不清楚的道友可以看下官方的這兩個指令。在拓展上我們這里除了支持action,slot,還增加了一個click配置,這個主要針某個列展示的數(shù)據(jù)我們希望點擊的時候可以進行跳轉(zhuǎn)等操作。所有配置的支持都是根據(jù)平時業(yè)務開發(fā)中的真實場景來設(shè)計的。看懂了下面的代碼,可以根據(jù)自己的業(yè)務進行拓展支持。
<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會存在數(shù)據(jù)更新頁面不更新
},
$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>
對操作列中的按鈕單獨封裝,可以方便我們給操作提供更多豐富的個性化定制配置,根據(jù)項目中的需求而定,保證設(shè)計的靈活性
<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>
個性化定制也是列表常見的需求之一,對于B端業(yè)務可能會有不同角色對同一個列表操作的需求,但是相互之間所關(guān)注的信息可能不一樣。這部分主要是控制對應搜索條件與列表的列展示進行個性化定制。對于存儲設(shè)計的話可以用當前頁面的路由訪問路徑作為鍵來保存,如果同個頁面彈窗中還有列表,設(shè)計上可以用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)">恢復默認設(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可以實現(xiàn)異步獲取配置實時更新**/
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: "左側(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è)計開發(fā)修煉中能有所幫助。一切大道,皆有因果。喜歡的話,可以動動你的小手點點贊。修行路上愿我們都不必獨伴大道,回首望去無故人。
下期預告:動態(tài)表單設(shè)計封裝,敬請期待
本文已首發(fā)掘金社區(qū),純原創(chuàng)文章,轉(zhuǎn)載請聲明來源
色權(quán)限系統(tǒng)設(shè)計可以更好的優(yōu)化工作的流程步驟,本文分享角色權(quán)限系統(tǒng)設(shè)計的幾個主要步驟。
公司的商戶后臺剛建立不久,之前僅能支持系統(tǒng)管理員和商戶管理員兩種角色使用,隨著產(chǎn)品和業(yè)務線逐漸成熟,參與到整個產(chǎn)品中的人員越來越多了,涉及的部門和角色也由從前的一兩種變成了多種,故由我主導了角色權(quán)限系統(tǒng)的重構(gòu)升級。在此將工作心得記錄下來,分享給需要用到的人。
先簡單介紹下我司的后臺產(chǎn)品功能,我司主要業(yè)務是向B端企業(yè)客戶銷售一些智能硬件,客戶買到產(chǎn)品之后會將產(chǎn)品關(guān)聯(lián)到自己的商戶后臺,硬件會上傳一些核心數(shù)據(jù)到后臺供商戶管理查看,所以我們的后臺核心功能是:設(shè)備管理、商戶管理、用戶管理、數(shù)據(jù)管理、產(chǎn)品銷售管理。
了解完我司后臺的大概功能后,我們來聊下角色權(quán)限系統(tǒng)。
角色權(quán)限系統(tǒng)屬于策略設(shè)計范疇,它的設(shè)計非常考驗一個PM對業(yè)務的理解力以及對自己后臺所有功能的熟悉程度。做角色權(quán)限系統(tǒng)之前一定要先深度了解業(yè)務流程以及后臺的所有功能模塊,在不了解的情況下,多向相關(guān)同事請教,避免角色權(quán)限系統(tǒng)設(shè)計過程中出差錯和邏輯漏洞。由于角色權(quán)限系統(tǒng)屬于功能底層系統(tǒng),很多的業(yè)務功能、前端功能都深度依賴角色權(quán)限系統(tǒng),所以盡量在第一次出產(chǎn)品方案時就盡可能的考慮全面,減少后續(xù)不必要的返工,如果前期產(chǎn)品方案不夠縝密,后期改動成本會非常大。
目前市場主流的角色權(quán)限模型是RBAC權(quán)限模型,具體技術(shù)原理可以閱讀下這個博客http://www.cnblogs.com/lhyqzx/p/5962826.html,有人好奇為什么做角色權(quán)限系統(tǒng)設(shè)計還要了解技術(shù)架構(gòu)呢?這個是為了讓設(shè)計者能夠設(shè)計出高效、安全、靈活且技術(shù)可實現(xiàn)的角色權(quán)限系統(tǒng)。
RBAC權(quán)限模型核心就是功能權(quán)限控制和角色產(chǎn)生關(guān)聯(lián),角色再和用戶賬號關(guān)聯(lián),即創(chuàng)建用戶賬號時選定一種角色,該角色里已經(jīng)分配好了功能和權(quán)限。拿我們系統(tǒng)為例,由于有系統(tǒng)管理員和商戶管理員的區(qū)別,即系統(tǒng)管理員可以查看所有的商戶和設(shè)備數(shù)據(jù),商戶管理員邏輯上只允許查看自己商戶下 的設(shè)備數(shù)據(jù)。所以我為了更靈活高效的去創(chuàng)建用戶角色(比如:商務經(jīng)理、商務專員、客服經(jīng)理、客服專員等),我在用戶角色之前又設(shè)置了角色類型,詳見下圖:
關(guān)于用戶角色的創(chuàng)建權(quán)限上這里需要說明的是:如果貴司組織結(jié)構(gòu)比較龐大,使用后臺的角色人員涉及到各個職能部門,且不同職能部門又有不同的角色,那么創(chuàng)建、管理角色的權(quán)限應該下放到各個部門的leader,便于管理系統(tǒng)用戶的效率。由于我司業(yè)務的特殊性,可以預估到會參與使用后臺的角色大概十來種,所以我為了更加集中、高效、安全的管理用戶角色,設(shè)定的只有超級管理員可以創(chuàng)建和修改角色。
角色權(quán)限系統(tǒng)設(shè)計的大概流程如下:
思維導圖工具(mindmanager、Xmind都可,我用的Xmind)、word 、Axure.
功能架構(gòu)圖梳理是為了讓設(shè)計者清晰理解后臺所有的產(chǎn)品功能模塊,以及各個產(chǎn)品功能之間層級關(guān)系,給每個角色類型都梳理一份功能架構(gòu)圖,可以讓產(chǎn)品自身和開發(fā)以及項目成員都了解每個角色類型的區(qū)別。
梳理功能架構(gòu)圖時可以根據(jù)一、二、三級這樣的功能層次來畫思維導圖,有的后臺系統(tǒng)可能非常龐大,那么是否需要把一級功能到一直到末級的所有功能包括界面按鈕都全部羅列出來呢?這個需要看業(yè)務需求,看公司組織架構(gòu),多方面綜合考慮再決定權(quán)限控制到哪一層,羅列到哪一級別的產(chǎn)品功能。通常情況下,權(quán)限控制到二/三層級基本能滿足一個中小型公司的權(quán)限管理需求,再大型一點的公司,可以控制到更深層級的功能權(quán)限。
此外,我并不建議將權(quán)限控制到非常精細的級別,精細到可以控制前端頁面上的每一個按鈕,甚至每一個按鈕的顏色以及交互效果,因為后臺產(chǎn)品的核心是管理平臺。管理無非增刪改查四個操作,對于后臺而言,管理的效率非常重要,如果權(quán)限控制的過于精細,在進行創(chuàng)建、修改角色時,效率會非常低。并且,如果不是系統(tǒng)的設(shè)計者理解起來會非常困難,對于頁面上的一些關(guān)鍵按鈕和操作可以加以控制。
注意事項:
之所以給不同角色類型的默認設(shè)定了一些功能權(quán)限,是為了創(chuàng)建角色和維護角色更加方便。比如:高級全局管理員角色類型對應的用戶角色可能是各部門leader,像研發(fā)總監(jiān)、客服經(jīng)理、商務總監(jiān)這樣的用戶角色,這些用戶角色普遍會擁有較大的權(quán)限,同時又有所區(qū)分。假設(shè)系統(tǒng)有100個功能,那么我默認將這些角色可能會共同擁有的70個權(quán)限全部默認設(shè)定給高級全局管理員,將其余的功能設(shè)定為可自由配置的功能,那么當超級管理員去創(chuàng)建一個客服經(jīng)理角色時,就只要配置剩余的30個權(quán)限即可。
角色權(quán)限的內(nèi)在規(guī)則邏輯設(shè)計好了,先和組內(nèi)討論,通過產(chǎn)品評審后可以考慮出產(chǎn)品功能原型了。
角色權(quán)限系統(tǒng)的開發(fā)一定是角色和功能是獨立的兩個模塊,他們二者通過配置關(guān)系產(chǎn)生關(guān)聯(lián)繼而會出現(xiàn)不同的用戶角色登錄系統(tǒng)后會看到不同的功能界面,所以在畫原型時只需要畫出最全的功能即可。當系統(tǒng)內(nèi)功能和角色數(shù)量相對而言都比較少的時候,角色權(quán)限管理功能可以考慮用橫豎列表形式展現(xiàn)。當系統(tǒng)內(nèi)的角色和功能數(shù)量比較多的時候,可以考慮模仿windows文件夾展開的交互用多面板形式來展現(xiàn)角色和功能的關(guān)系。
將原型和腦圖都梳理完畢后,最后就是把流程、細節(jié)從頭捋一遍,將要點全部整理到PRD里,最后拿著PRD去和技術(shù)同學開技術(shù)評審會了。
完成以上四個步驟,基本就完成了一套后臺角色權(quán)限系統(tǒng)的設(shè)計,如果覺得有用,請轉(zhuǎn)發(fā)分享。
作者:Michael,Sensoro高級產(chǎn)品經(jīng)理,產(chǎn)品設(shè)計經(jīng)驗豐富,主導過移動產(chǎn)品、IM產(chǎn)品、web前后臺產(chǎn)品的多次重大升級。
本文由 @Michael 原創(chuàng)發(fā)布于人人都是產(chǎn)品經(jīng)理。未經(jīng)許可,禁止轉(zhuǎn)載。
*請認真填寫需求信息,我們會在24小時內(nèi)與您取得聯(lián)系。