輯導(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),少踩坑。
沒(méi)得經(jīng)驗(yàn)參考就只能先從競(jìng)品分析開(kāi)始了。經(jīng)過(guò)對(duì) iOS 系統(tǒng)應(yīng)用、微信、QQ、微信閱讀、得到、豆瓣、淘寶和有道詞典的分析,我和同事總結(jié)成 5 種橫屏適配模式。
典型 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ā)成本很大。
典型 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)很難這么修改。
典型 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ì)。
典型 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)較差。
當(dāng)然也不是所有 App 都采用單一的模式。比如微信閱讀,在其他頁(yè)面是按豎屏寬度顯示。但到了圖書(shū)閱讀界面,則是左右分欄充分利用 iPad 大屏幕展現(xiàn)內(nèi)容。
以上競(jìng)品分析所有截圖我們都保存在 Figma 中,有需要的讀者可前往獲取。
鏈接:https://www.figma.com/community/file/1071850659054902697/iPad-橫屏適配競(jìng)品分析
非常遺憾的是雖然高管牽頭做適配,但開(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 期我們就踩坑了,按照原來(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 期挑選核心頁(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)容了。
按照以往的項(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)封裝.
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);
});
}
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(()=>{});
}
<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):
接下來(lái)我們進(jìn)入主題,拆解下(ListPage.vue)這個(gè)頁(yè)面的組件分封裝。對(duì)于頁(yè)面展示的各個(gè)部分,在代碼封裝設(shè)計(jì)上我們按照?qǐng)D示中圈出來(lái)的各個(gè)部分來(lái)做封裝設(shè)計(jì)。
代碼組織如下:
整個(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>
在實(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,
};
}
對(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,
};
}
對(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>
對(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>
個(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)售管理。
了解完我司后臺(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ì)的大概流程如下:
思維導(dǎo)圖工具(mindmanager、Xmind都可,我用的Xmind)、word 、Axure.
功能架構(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ū)別。
梳理功能架構(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):
之所以給不同角色類(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)限即可。
角色權(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ì)節(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)載。
*請(qǐng)認(rèn)真填寫(xiě)需求信息,我們會(huì)在24小時(shí)內(nèi)與您取得聯(lián)系。