开发者中心包含此范例模板(强化项目管理),请前往开发者中心下载学习。
Index
概述
这是一篇 React 在 kintone 上的实战,我们需要利用看板和甘特图来来强化项目管理 app。另外这次用到了 webpack,想了解基本配置思路的可以看这里
效果图
需求整理
看板
卡片上需要显示负责人、时间、类型、标题、详细信息
看板上不同的跑道代表不同的状态,需要按照顺序显示
允许卡片在各个跑道自由切换,当移动完成时需要同步更新记录的状态信息
点击卡片能进入详细画面
不建议用流程管理来设置状态,因为它需要设置每个 action 动作
甘特图
甘特图需要能标示今天日期
甘特图要有不同的 viewmode 来控制时间长度信息
能显示父子任务关系图
任务能被自由拖拽,时间被改变后需要同步更新记录的时间信息
任务状态以进度百分比的方式显示
任务标题和时间信息能以文字的形式显示出来
点击任务能进入详细画面
其他
要有切换功能
不同的任务类型需要用不同的颜色显示
和 kintone 的列表功能结合起来,来控制数据大小
详细画面要有甘特图,只显示自己的父子任务
lookup 只能查其他 app 的信息,无法利用它来实现父子关系绑定,需要有其他的手段
kintone App 设置
字段名 | 类型 | 字段代码 | 备注 |
---|---|---|---|
Type | 下拉菜单 | type | 任务类型 |
Priority | 下拉菜单 | priority | 优先度 |
Fix version | 下拉菜单 | version | 版本号,列表中用于任务分类 |
Status | 下拉菜单 | status | 状态,看板的跑道 |
Start | 日期 | startDate | 开始时间 |
End | 日期 | endDate | 结束时间 |
Assignee | 选择用户 | assignee | 负责人 |
Summary | 单行文本框 | summary | 标题 |
Detail | 多行文本框 | detail | 详细信息 |
Parent Task | 数值 | parent | 父任务 ID |
Subtasks | 关联记录列表 | subtasks | 子任务列表 |
空白元素 | addSub | 子任务添加按钮 |
代码详解
部分代码由于系统的原因无法完全显示,请以github上源文件为准
第三方包选择
甘特图
看板
React UI library
任务类型名转不同颜色
index.tsx
import React from 'react' import ReactDOM from 'react-dom' import { AppSwitcher } from './components/App' import GanttCharts from './components/GanttCharts' import AddSub from './components/AddSub' interface KintoneEvent { record: kintone.types.SavedFields; } /** * 一览画面上显示一个switcher开关,来控制图表的显示 */ kintone.events.on('app.record.index.show', (event: KintoneEvent) => { ReactDOM.render(, kintone.app.getHeaderMenuSpaceElement()) return event }) /** * 详细画面上显示甘特图和子任务按钮,由于考虑到代码的通用性,这里传一个query语句给甘特图,查找父id、id、子id */ kintone.events.on('app.record.detail.show', (event: KintoneEvent) => { let query = `parent = ${event.record.$id.value} or $id= ${event.record.$id.value}` event.record.parent.value && (query += ` or $id = ${event.record.parent.value}`) ReactDOM.render(, kintone.app.record.getHeaderMenuSpaceElement()) ReactDOM.render(, kintone.app.record.getSpaceElement('addSub')) return event }) /** * 添加画面如果是addSub打开的,那么url里面会传入pid的参数,读取该参数放入event的父ID中 */ kintone.events.on('app.record.create.show', (event: KintoneEvent) => { const queryString = window.location.search const urlParams = new URLSearchParams(queryString) const pid = urlParams.get('pid') if (pid) { event.record.parent.value = pid } return event })
App.tsx
import React, { useEffect } from 'react' import ReactDOM from 'react-dom' import { Radio } from 'antd' import GanttCharts from './GanttCharts' import 'antd/dist/antd.css' import Kanban from './Kanban' // eslint-disable-next-line no-shadow export enum AppType { Gantt = 'Gantt', Board = 'Kanban', } /** *切换按钮,使用antd的radio样式 */ export const AppSwitcher = () => { /** *由于渲染区域不同,这里选择强行渲染 */ const onAppChange = (app: AppType) => { switch (app) { case AppType.Gantt: ReactDOM.render( //kintone.app.getQueryCondition()获取当前列表的查询参数,无limit、order信息, kintone.app.getHeaderSpaceElement(), ) break default: ReactDOM.render(, kintone.app.getHeaderSpaceElement()) } } /** *模拟componentDidMount,在加载结束后只渲染一次看板画面 */ useEffect(() => { ReactDOM.render(, kintone.app.getHeaderSpaceElement()) }, []) return ( <>onAppChange(e.target.value)} >{AppType.Gantt}{AppType.Board}</> ) }
KintoneAppRepository.tsx
import { KintoneRestAPIClient, KintoneRecordField, KintoneFormFieldProperty } from '@kintone/rest-api-client' import stc from 'string-to-color' export type AppRecord = { $id: KintoneRecordField.ID parent: KintoneRecordField.Number summary: KintoneRecordField.SingleLineText startDate: KintoneRecordField.Date endDate: KintoneRecordField.Date type: KintoneRecordField.Dropdown status: KintoneRecordField.Dropdown } export type AppProperty = { type: KintoneFormFieldProperty.Dropdown status: KintoneFormFieldProperty.Dropdown } const getRecordsByApi = (query?: string) => { return new KintoneRestAPIClient().record.getRecords({ app: kintone.app.getId()!, query: `${query ? query : ''} order by $id asc`, }) } const getFieldsByApi = () => { return new KintoneRestAPIClient().app.getFormFields({ app: kintone.app.getId()! }) } export const updateStatus = async (recordID: string, status: string) => { await new KintoneRestAPIClient().record.updateRecord({ app: kintone.app.getId()!, id: recordID, record: { status: { value: status, }, }, }) } export const updateDate = async (recordID: string, start: string, end: string) => { await new KintoneRestAPIClient().record.updateRecord({ app: kintone.app.getId()!, id: recordID, record: { startDate: { value: start, }, endDate: { value: end, }, }, }) } /*** * 列表查询,使用Promise.all让api并发执行 */ export const getRecords = async ( cb: (records: AppRecord[], status: Map, type: Map) => void, query?: string, ) => { const [fields, list] = await kintone.Promise.all([getFieldsByApi(), getRecordsByApi(query)]) //把转换的颜色和进度进行存储,防止多次运算浪费资源 const type = new Map() const status = new Map() Object.keys(fields.properties.type.options).forEach((k) => { type.set(k, stc(k)) }) Object.keys(fields.properties.status.options).forEach((k) => { //status按照顺序排列 status.set(k, fields.properties.status.options[k].index) }) cb(list.records, status, type) }
Card.tsx
import { KintoneRecordField } from '@kintone/rest-api-client' import { Card, Avatar, Tag } from 'antd' import React from 'react' const { Meta } = Card export interface KCard extends ReactTrello.DraggableCard { labelColor?: string assignee: KintoneRecordField.UserSelect startDate: string endDate: string onClick?: () => void } /** * 负责人部分,需要显示多个负责人 */ const Avatars = (props: { assignee: KintoneRecordField.UserSelect }) => { return ( <Avatar.Group maxCount={2} size="large" maxStyle={{ color: '#f56a00', backgroundColor: '#fde3cf', }} > {props.assignee.value.map((element) => { return ( <Avatar // src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png" style={{ backgroundColor: '#15dad2', }} key={element.code} > {element.name} </Avatar> ) })} </Avatar.Group> ) } /** * 看板卡片部分,详见antd api */ export const AntdCard = (props: KCard) => { return ( <Card extra={<Tag color={props.labelColor}>{props.label}</Tag>} style={{ width: 300 }} title={props.title} onClick={props.onClick} > <Meta avatar={<Avatars assignee={props.assignee} />} title={`${props.startDate.substring(5)}~${props.endDate.substring(5)}`} description={props.description} /> </Card> ) }
Kanban.tsx
import React, { useEffect } from 'react' import Board from 'react-trello' import { AppRecord, getRecords, updateStatus } from '../KintoneAppRepository' import { AntdCard, KCard } from './Card' import { Spin, Result } from 'antd' import './app.css' const Kanban = () => { const [data, setData] = React.useState<ReactTrello.BoardData>() const [isLoading, setLoading] = React.useState<boolean>(true) const display = (records: AppRecord[], status: Map<string, number>, type: Map<string, string>) => { // 无数据停止加载 if (records.length === 0) { setLoading(false) } else { const lanes = new Array(status.size) status.forEach((v, k) => { lanes[v] = { id: k, title: k, cards: new Array<KCard>(), } }) // 转换 records.forEach((record) => lanes[status.get(record.status.value!)!].cards!.push({ id: record.$id.value, title: record.summary.value, label: record.type.value!, labelColor: type.get(record.type.value!), description: record.detail.value, assignee: record.assignee, startDate: record.startDate.value!, endDate: record.endDate.value!, }), ) setData({ lanes }) } } // 模拟componentDidMount,在这里进行异步数据加载 useEffect(() => { // 回调处理 getRecords(display, kintone.app.getQueryCondition() || undefined) }, []) // 点击卡片后通过组合url的方式打开详细画面 const onCardClick = (cardId: string) => { const url = window.location.protocol + '//' + window.location.host + window.location.pathname + 'show#record=' + cardId window.location.assign(url) } // 拖拽结束后更新记录状态 const handleDragEnd = (cardId: string, _sourceLandId: string, targetLaneId: string) => { updateStatus(cardId, targetLaneId) } // 显示内容,在一开始显示加载画面,没得到任何数据时显示nodata画面,在得到数据后显示看板 let content if (data) { content = ( <Board data={data} draggable onCardClick={onCardClick} hideCardDeleteIcon handleDragEnd={handleDragEnd} style={{ padding: '30px 20px', backgroundColor: '#5F9AF8' }} components={{ Card: AntdCard }} /> ) } else if (isLoading) { content = ( <div className="center"> <Spin size="large" tip="Loading..." /> </div> ) } else { content = <Result title="No data" /> } return <>{content}</> } export default Kanban
GanttViewSwitcher.tsx && GanttCharts.tsx
基本思路和看板模块差不多,这里不再解读,有兴趣的可以去看源码学习