На странице Наработка добавлен сброс даты и выделение данных при наведении

This commit is contained in:
ts_salikhov 2022-10-06 18:20:54 +04:00
commit bbf15c1f35
38 changed files with 4292 additions and 627 deletions

4
.gitignore vendored
View File

@ -11,8 +11,10 @@
# testing
/coverage
# production
# build directories
/build
/dev_build
/prod_build
# misc
.DS_Store

3518
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,10 +16,15 @@
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "webpack-dev-server --mode=development --open --hot",
"build": "webpack --mode=production",
"test": "jest",
"dev": "webpack-dev-server --mode=development --open --hot",
"build": "webpack --env=\"ENV=prod\"",
"dev_build": "webpack --env=\"ENV=dev\"",
"prod_build": "webpack --env=\"ENV=prod\"",
"start": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"prod": "webpack-dev-server --env=\"ENV=prod\" --open --hot",
"dev": "webpack-dev-server --env=\"ENV=dev\" --open --hot",
"oul": "npx openapi -i http://127.0.0.1:5000/swagger/v1/swagger.json -o src/services/api",
"oud": "npx openapi -i http://192.168.1.113:5000/swagger/v1/swagger.json -o src/services/api",
"oug": "npx openapi -i https://cloud.digitaldrilling.ru/swagger/v1/swagger.json -o src/services/api",
@ -84,24 +89,30 @@
"@types/react-router-dom": "^5.3.3",
"babel-jest": "^28.1.0",
"babel-loader": "^8.2.5",
"colors": "^1.4.0",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.2.0",
"extract-loader": "^5.1.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"interpolate-html-plugin": "^4.0.0",
"jest": "^28.1.0",
"less": "^4.1.3",
"less-loader": "^11.0.0",
"mini-css-extract-plugin": "^2.6.1",
"openapi-typescript": "^5.4.0",
"openapi-typescript-codegen": "^0.23.0",
"path-browserify": "^1.0.1",
"react-test-renderer": "^18.1.0",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.3.0",
"typescript": "^4.7.4",
"url-loader": "^4.1.1",
"webpack": "^5.73.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.1"
"webpack-dev-server": "^4.9.1",
"webpack-merge": "^5.8.0"
}
}

View File

@ -16,6 +16,7 @@ import Register from '@pages/Register'
import FileDownload from '@pages/FileDownload'
import '@styles/App.less'
import '@styles/include/antd_theme.less'
//OpenAPI.BASE = 'http://localhost:3000'
OpenAPI.TOKEN = async () => getUserToken() ?? ''

View File

@ -30,7 +30,7 @@ export type DataType<T = any> = Record<string, T>
export type RenderMethod<T = any> = (value: T, dataset?: DataType<T>, index?: number) => ReactNode
export type SorterMethod<T = any> = (a?: DataType<T> | null, b?: DataType<T> | null) => number
export type columnPropsOther<T = any> = ColumnProps<T> & {
export type columnPropsOther<T = any> = ColumnProps<DataType<T>> & {
// редактируемая колонка
editable?: boolean
// react компонента редактора

View File

@ -2,11 +2,14 @@ import { InputNumber } from 'antd'
import { ReactNode } from 'react'
import { makeNumericSorter } from '../sorters'
import { columnPropsOther, makeGroupColumn, RenderMethod } from '.'
import makeColumn, { columnPropsOther, DataType, makeGroupColumn, RenderMethod } from '.'
import { ColumnFilterItem, CompareFn } from 'antd/lib/table/interface'
export const RegExpIsFloat = /^[-+]?\d+\.?\d*$/
export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMethod<T> => (value) => {
type FilterMethod<T> = (value: string | number | boolean, record: DataType<T>) => boolean
export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMethod<T> => (value: T) => {
let val = '-'
if ((value ?? null) !== null && Number.isFinite(+value)) {
val = (fixed ?? null) !== null
@ -21,77 +24,74 @@ export const makeNumericRender = <T extends unknown>(fixed?: number): RenderMeth
)
}
export const makeNumericColumnOptions = (fixed?: number, sorterKey?: string): columnPropsOther => ({
export const makeNumericColumnOptions = <T extends unknown = any>(fixed?: number, sorterKey?: string): columnPropsOther<T> => ({
editable: true,
initialValue: 0,
width: 100,
sorter: sorterKey ? makeNumericSorter(sorterKey) : undefined,
sorter: sorterKey ? makeNumericSorter<T>(sorterKey) : undefined,
formItemRules: [{
required: true,
message: 'Введите число',
pattern: RegExpIsFloat,
}],
render: makeNumericRender(fixed),
render: makeNumericRender<T>(fixed),
})
export const makeNumericColumn = (
export const makeNumericColumn = <T extends unknown = any>(
title: ReactNode,
dataIndex: string,
filters: object[],
filterDelegate: (key: string | number) => any,
renderDelegate: (_: any, row: object) => any,
width: string,
other?: columnPropsOther
) => ({
title: title,
dataIndex: dataIndex,
key: dataIndex,
filters: filters,
onFilter: filterDelegate ? filterDelegate(dataIndex) : null,
sorter: makeNumericSorter(dataIndex),
width: width,
key: string,
filters?: ColumnFilterItem[],
filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate?: RenderMethod<T>,
width?: string | number,
other?: columnPropsOther,
) => makeColumn(title, key, {
filters,
onFilter: filterDelegate ? filterDelegate(key) : undefined,
sorter: makeNumericSorter(key),
width,
input: <InputNumber style={{ width: '100%' }}/>,
render: renderDelegate ?? makeNumericRender(),
render: renderDelegate ?? makeNumericRender<T>(2),
align: 'right',
...other
})
export const makeNumericColumnPlanFact = (
export const makeNumericColumnPlanFact = <T extends unknown = any>(
title: ReactNode,
dataIndex: string,
filters: object[],
filterDelegate: (key: string | number) => any,
renderDelegate: (_: any, row: object) => any,
width: string
key: string,
filters?: ColumnFilterItem[],
filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate?: RenderMethod<T>,
width?: string | number
) => makeGroupColumn(title, [
makeNumericColumn('п', dataIndex + 'Plan', filters, filterDelegate, renderDelegate, width),
makeNumericColumn('ф', dataIndex + 'Fact', filters, filterDelegate, renderDelegate, width),
makeNumericColumn<T>('п', key + 'Plan', filters, filterDelegate, renderDelegate, width),
makeNumericColumn<T>('ф', key + 'Fact', filters, filterDelegate, renderDelegate, width),
])
export const makeNumericStartEnd = (
export const makeNumericStartEnd = <T extends unknown = any>(
title: ReactNode,
dataIndex: string,
key: string,
fixed: number,
filters: object[],
filterDelegate: (key: string | number) => any,
renderDelegate: (_: any, row: object) => any,
width: string,
filters?: ColumnFilterItem[],
filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate?: RenderMethod<T>,
width?: string | number,
) => makeGroupColumn(title, [
makeNumericColumn('старт', dataIndex + 'Start', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Start')),
makeNumericColumn('конец', dataIndex + 'End', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'End'))
makeNumericColumn<T>('старт', key + 'Start', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Start')),
makeNumericColumn<T>('конец', key + 'End', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'End'))
])
export const makeNumericMinMax = (
export const makeNumericMinMax = <T extends unknown = any>(
title: ReactNode,
dataIndex: string,
key: string,
fixed: number,
filters: object[],
filterDelegate: (key: string | number) => any,
renderDelegate: (_: any, row: object) => any,
width: string,
filters?: ColumnFilterItem[],
filterDelegate?: (key: string | number) => FilterMethod<T>,
renderDelegate?: RenderMethod<T>,
width?: string | number,
) => makeGroupColumn(title, [
makeNumericColumn('мин', dataIndex + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Min')),
makeNumericColumn('макс', dataIndex + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, dataIndex + 'Max')),
makeNumericColumn<T>('мин', key + 'Min', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Min')),
makeNumericColumn<T>('макс', key + 'Max', filters, filterDelegate, renderDelegate, width, makeNumericColumnOptions(fixed, key + 'Max')),
])
export default makeNumericColumn

View File

@ -1,6 +1,7 @@
import { ColumnFilterItem } from 'antd/lib/table/interface'
import { ReactNode } from 'react'
import { columnPropsOther, DataType, RenderMethod, SorterMethod } from '.'
import makeColumn, { columnPropsOther, DataType, RenderMethod, SorterMethod } from '.'
import { makeStringSorter } from '../sorters'
export const makeFilterTextMatch = <T extends unknown>(key: keyof DataType<T>) =>
@ -8,18 +9,15 @@ export const makeFilterTextMatch = <T extends unknown>(key: keyof DataType<T>) =
export const makeTextColumn = <T extends unknown = any>(
title: ReactNode,
dataIndex: string,
filters: object[],
key: string,
filters?: ColumnFilterItem[],
sorter?: SorterMethod<T>,
render?: RenderMethod<T>,
other?: columnPropsOther
) => ({
title: title,
dataIndex: dataIndex,
key: dataIndex,
filters: filters,
onFilter: filters ? makeFilterTextMatch(dataIndex) : null,
sorter: sorter ?? makeStringSorter(dataIndex),
) => makeColumn(title, key, {
filters,
onFilter: filters ? makeFilterTextMatch(key) : undefined,
sorter: sorter ?? makeStringSorter(key),
render: render,
...other
})

View File

@ -10,18 +10,17 @@ import { tryAddKeys } from './EditableTable'
import '@styles/index.css'
export type BaseTableColumn<T = any> = ColumnGroupType<T> | ColumnType<T>
export type TableColumns<T = any> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>[]
export type BaseTableColumn<T> = ColumnGroupType<T> | ColumnType<T>
export type TableColumns<T> = OmitExtends<BaseTableColumn<T>, TableColumnSettings>[]
export type TableContainer = TableProps<any> & {
columns: TableColumns
dataSource: any[]
export type TableContainer<T> = TableProps<T> & {
columns: TableColumns<T>
tableName?: string
showSettingsChanger?: boolean
}
export const Table = memo<TableContainer>(({ columns, dataSource, tableName, showSettingsChanger, ...other }) => {
const [newColumns, setNewColumns] = useState<TableColumns>([])
const _Table = <T extends object>({ columns, dataSource, tableName, showSettingsChanger, ...other }: TableContainer<T>) => {
const [newColumns, setNewColumns] = useState<TableColumns<T>>([])
const [settings, setSettings] = useState<TableSettings>({})
const onSettingsChanged = useCallback((settings?: TableSettings | null) => {
@ -52,6 +51,8 @@ export const Table = memo<TableContainer>(({ columns, dataSource, tableName, sho
{...other}
/>
)
})
}
export const Table = memo(_Table) as typeof _Table
export default Table

View File

@ -7,7 +7,7 @@ import { TableColumnSettings, makeTableSettings, mergeTableSettings, TableSettin
import { TableColumns } from './Table'
import { makeColumn } from '.'
const parseSettings = (columns?: TableColumns, settings?: TableSettings | null): TableColumnSettings[] => {
const parseSettings = <T extends object>(columns?: TableColumns<T>, settings?: TableSettings | null): TableColumnSettings[] => {
const newSettings = mergeTableSettings(makeTableSettings(columns ?? []), settings ?? {})
return Object.values(newSettings).map((set, i) => ({ ...set, key: i }))
}
@ -15,14 +15,14 @@ const parseSettings = (columns?: TableColumns, settings?: TableSettings | null):
const unparseSettings = (columns: TableColumnSettings[]): TableSettings =>
Object.fromEntries(columns.map((column) => [column.columnName, column]))
export type TableSettingsChangerProps = {
export type TableSettingsChangerProps<T extends object> = {
title?: string
columns?: TableColumns
columns?: TableColumns<T>
settings?: TableSettings | null
onChange: (settings: TableSettings | null) => void
}
export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, columns, settings, onChange }) => {
const _TableSettingsChanger = <T extends object>({ title, columns, settings, onChange }: TableSettingsChangerProps<T>) => {
const [visible, setVisible] = useState<boolean>(false)
const [newSettings, setNewSettings] = useState<TableColumnSettings[]>(parseSettings(columns, settings))
const [tableColumns, setTableColumns] = useState<ColumnsType<TableColumnSettings>>([])
@ -36,10 +36,12 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
}, [])
const toogleAll = useCallback((show: boolean) => {
setNewSettings((oldSettings) => oldSettings.map((column) => {
setNewSettings((oldSettings) =>
oldSettings.map((column) => {
column.visible = show
return column
}))
})
)
}, [])
useEffect(() => {
@ -49,7 +51,9 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
title: () => (
<>
Показать
<Button type={'link'} onClick={() => toogleAll(true)}>Показать все</Button>
<Button type={'link'} onClick={() => toogleAll(true)}>
Показать все
</Button>
</>
),
render: (visible: boolean, _?: TableColumnSettings, index: number = NaN) => (
@ -59,7 +63,7 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
unCheckedChildren={'Скрыт'}
onChange={(visible) => onVisibilityChange(index, visible)}
/>
)
),
}),
])
}, [toogleAll, onVisibilityChange])
@ -88,9 +92,17 @@ export const TableSettingsChanger = memo<TableSettingsChangerProps>(({ title, co
>
<Table columns={tableColumns} dataSource={newSettings} />
</Modal>
<Button size={'small'} style={{ position: 'absolute', left: 0, top: 0, opacity: .5 }} type={'link'} onClick={() => setVisible(true)} icon={<SettingOutlined />}/>
<Button
size={'small'}
style={{ position: 'absolute', left: 0, top: 0, opacity: 0.5 }}
type={'link'}
onClick={() => setVisible(true)}
icon={<SettingOutlined />}
/>
</>
)
})
}
export const TableSettingsChanger = memo(_TableSettingsChanger) as typeof _TableSettingsChanger
export default TableSettingsChanger

View File

@ -3,8 +3,9 @@ import { isRawDate } from '@utils'
import { TimeDto } from '@api'
import { DataType } from './Columns'
import { CompareFn } from 'antd/lib/table/interface'
export const makeNumericSorter = <T extends unknown>(key: keyof DataType<T>) =>
export const makeNumericSorter = <T extends unknown>(key: keyof DataType<T>): CompareFn<DataType<T>> =>
(a: DataType<T>, b: DataType<T>) => Number(a[key]) - Number(b[key])
export const makeNumericObjSorter = (key: [string, string]) =>

View File

@ -1,144 +0,0 @@
import React, { memo, useEffect } from 'react'
import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal'
import { useElementSize } from 'usehooks-ts'
import '@styles/d3.less'
export type HorizontalChartDataType = {
name: string
percent: number
}
type D3HorizontalChartProps = {
width?: string
height?: string
data: HorizontalChartDataType[]
colors?: string[]
selected?: string | null
onMouseOver?: (e: MouseEvent, d: HorizontalChartDataType) => void
onMouseOut?: (e: MouseEvent, d: HorizontalChartDataType) => void
}
export const D3HorizontalChart = memo((
{
width: givenWidth = '100%',
height: givenHeight = '100%',
selected,
data,
colors,
onMouseOver,
onMouseOut,
}: D3HorizontalChartProps) => {
const [rootRef, { width, height }] = useElementSize()
const margin = { top: 50, right: 100, bottom: 50, left: 100 }
useEffect(() => {
if (width < 100 || height < 100) return
const chartWidth = width - margin.left - margin.right
const chartHeight = height - margin.top - margin.bottom
const svg = d3.select('#d3-horizontal-chart')
.attr('width', '100%')
.attr('height', '100%')
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
const names = data.map(d => d.name)
const xMax = 100
// scales
const x = d3.scaleLinear()
.domain([0, xMax])
.range([0, chartWidth])
const y = d3.scaleBand()
.domain(names)
.range([0, chartHeight])
.padding(0.25)
// axes
const tickValues = [0, 25, 50, 75, 100]
const xAxisTop = d3.axisTop(x)
.tickValues(tickValues)
.tickFormat(d => d + '%')
const xAxisBottom = d3.axisBottom(x)
.tickValues(tickValues)
.tickFormat(d => d + '%')
const yAxisLeft = d3.axisLeft(y)
const gridlines = d3.axisBottom(x)
.tickValues(tickValues)
.tickFormat(() => '')
.tickSize(chartHeight)
const yAxisRight = d3.axisRight(y)
.ticks(0)
.tickValues([])
.tickFormat(() => '')
svg.append('g')
.attr('transform', `translate(0,0)`)
.attr("class", "grid-line")
.call(g => g.select('.domain').remove())
.call(gridlines)
svg.append('g')
.attr('transform', `translate(0,0)`)
.call(xAxisTop)
svg.append("g")
.call(yAxisLeft)
svg.append('g')
.attr('transform', `translate(0,${chartHeight})`)
.call(xAxisBottom)
svg.append('g')
.attr('transform', `translate(${chartWidth},0)`)
.call(yAxisRight)
const layers = svg.append('g')
.selectAll('g')
.data(data)
.join('g')
layers.each(function() {
d3.select(this)
.selectAll('rect')
.data(data)
.join('rect')
.attr('fill', (d, i) => colors ? colors[i] : 'black')
.attr('y', d => String(y(d.name)))
.attr('height', y.bandwidth())
.attr('width', d => d.percent >= 0 ? x(d.percent) : 0)
.attr('stroke', d => selected && d.name === selected ? 'black' : '')
.attr('stroke-width', d => selected && d.name === selected ? '2' : '0')
.on('mouseover', onMouseOver ? onMouseOver : () => {})
.on('mouseout', onMouseOut ? onMouseOut : () => {})
})
return () => {
svg.remove()
}
}, [width, height, data, colors, selected, onMouseOver, onMouseOut])
return (
<LoaderPortal show={false} style={{width: givenWidth, height: givenHeight}}>
<div ref={rootRef} style={{width: '100%', height: '100%'}}>
<svg id={'d3-horizontal-chart'}></svg>
</div>
</LoaderPortal>
)
})
export default D3HorizontalChart

View File

@ -0,0 +1,106 @@
import { memo, useEffect, useMemo, useRef } from 'react'
import { useElementSize } from 'usehooks-ts'
import { Property } from 'csstype'
import * as d3 from 'd3'
import LoaderPortal from '@components/LoaderPortal'
import { ChartOffset } from './types'
import '@styles/d3.less'
import { usePartialProps } from '@asb/utils'
export type PercentChartDataType = {
name: string
percent: number
color?: Property.Color
}
export type D3HorizontalChartProps = {
width?: Property.Width
height?: Property.Height
data: PercentChartDataType[]
offset?: Partial<ChartOffset>
selected?: string | null
onMouseOver?: (e: MouseEvent, d: PercentChartDataType) => void
onMouseOut?: (e: MouseEvent, d: PercentChartDataType) => void
}
const defaultOffset = { top: 50, right: 100, bottom: 50, left: 100 }
export const D3HorizontalPercentChart = memo<D3HorizontalChartProps>(({
width: givenWidth = '100%',
height: givenHeight = '100%',
offset: givenOffset,
data,
selected = null,
onMouseOver = () => {},
onMouseOut= () => {},
}) => {
const offset = usePartialProps<ChartOffset>(givenOffset, defaultOffset)
const [divRef, { width, height }] = useElementSize()
const rootRef = useRef<SVGGElement | null>(null)
const root = useMemo(() => rootRef.current ? d3.select(rootRef.current) : null, [rootRef.current])
const inlineWidth = useMemo(() => width - offset.left - offset.right, [width])
const inlineHeight = useMemo(() => height - offset.top - offset.bottom, [height])
const xScale = useMemo(() => d3.scaleLinear().domain([0, 100]).range([0, inlineWidth]), [inlineWidth])
const yScale = useMemo(() => d3.scaleBand().domain(data.map((d) => d.name)).range([0, inlineHeight]).padding(0.25), [data, inlineHeight])
useEffect(() => { /// Отрисовываем оси X сверху и снизу
if (width < 100 || height < 100 || !root) return
const xAxisTop = d3.axisTop(xScale).tickFormat((d) => `${d}%`).tickValues([0, 25, 50, 75, 100]).tickSize(-inlineHeight)
const xAxisBottom = d3.axisBottom(xScale).tickFormat((d) => `${d}%`).tickValues([0, 25, 50, 75, 100])
root.selectChild<SVGGElement>('.axis.x.bottom').call(xAxisBottom)
root.selectChild<SVGGElement>('.axis.x.top').call(xAxisTop)
.selectAll('.tick')
.attr('class', 'tick grid-line')
}, [root, width, height, xScale, inlineHeight])
useEffect(() => { /// Отрисовываем ось Y слева
if (width < 100 || height < 100 || !root) return
root.selectChild<SVGGElement>('.axis.y.left').call(d3.axisLeft(yScale))
}, [root, width, height, yScale])
useEffect(() => {
if (width < 100 || height < 100 || !root) return
const delay = d3.transition().duration(500).ease(d3.easeLinear)
const rects = root.selectChild('.data').selectAll('rect').data(data)
rects.enter().append('rect')
rects.exit().remove()
root.selectChild<SVGGElement>('.data')
.selectAll<SVGRectElement, PercentChartDataType>('rect')
.attr('fill', (d) => d.color || 'black')
.attr('y', (d) => yScale(d.name) ?? null)
.attr('stroke', d => selected && d.name === selected ? 'black' : '')
.attr('stroke-width', d => selected && d.name === selected ? '2' : '0')
.attr('height', yScale.bandwidth())
.on('mouseover', onMouseOver)
.on('mouseout', onMouseOut)
.transition(delay)
.attr('width', (d) => d.percent > 0 ? xScale(d.percent) : 0)
}, [data, width, height, root, yScale, xScale, selected])
return (
<LoaderPortal show={false} style={{ width: givenWidth, height: givenHeight }}>
<div ref={divRef} style={{ width: '100%', height: '100%' }}>
<svg id={'d3-horizontal-chart'} width={'100%'} height={'100%'}>
<g ref={rootRef} transform={`translate(${offset.left}, ${offset.top})`}>
<g className={'axis x top'}></g>
<g className={'axis x bottom'} transform={`translate(0, ${inlineHeight})`}></g>
<g className={'data'}></g>
<g className={'axis y left'}></g>
</g>
</svg>
</div>
</LoaderPortal>
)
})
export default D3HorizontalPercentChart

View File

@ -559,7 +559,6 @@ const _D3MonitoringCharts = <DataType extends Record<string, unknown>>({
<D3MonitoringCurrentValues<DataType>
groups={groups}
data={data}
left={offset.left}
sizes={sizes}
/>
<D3MouseZone width={width} height={height} offset={{ ...offset, top: sizes.chartsTop }}>

View File

@ -5,20 +5,24 @@ import { ChartGroup, ChartSizes } from '@components/d3/monitoring/D3MonitoringCh
import { makeDisplayValue } from '@utils'
export type D3MonitoringCurrentValuesProps<DataType extends BaseDataType> = {
/** Группы графиков */
groups: ChartGroup<DataType>[]
/** Массив данных графика */
data: DataType[]
left: number
/** Объект, хранящий полезные размеры и отступы графика (нужен только groupWidth, chartsTop и groupLeft) */
sizes: ChartSizes
}
const display = makeDisplayValue({ def: '---', fixed: 2 })
const _D3MonitoringCurrentValues = <DataType extends BaseDataType>({ groups, data, left, sizes }: D3MonitoringCurrentValuesProps<DataType>) => (
<g transform={`translate(${left}, ${sizes.chartsTop})`} pointerEvents={'none'}>
/// `Array.at` вместе с `??` возвращает странный тип, поэтому его пока пришлось пометить как `any`
/// TODO: Исправить тип
const _D3MonitoringCurrentValues = <DataType extends BaseDataType>({ groups, data, sizes }: D3MonitoringCurrentValuesProps<DataType>) => (
<g transform={`translate(${sizes.left}, ${sizes.chartsTop})`} pointerEvents={'none'}>
{groups.map((group) => (
<g key={group.key} transform={`translate(${sizes.groupLeft(group.key)}, 0)`}>
{group.charts.filter((chart) => chart.showCurrentValue).map((chart, i) => (
<g key={chart.key} stroke={'white'} fill={chart.color} strokeWidth={3} paintOrder={'stroke'}>
<g key={chart.key} stroke={'white'} fill={chart.color} strokeWidth={4} paintOrder={'stroke'} style={{ fontWeight: 600 }}>
<text x={sizes.groupWidth / 2 - 10} textAnchor={'end'} y={15 + i * 20}>{chart.shortLabel ?? chart.label}:</text>
<text x={sizes.groupWidth / 2 + 10} textAnchor={'start'} y={15 + i * 20}>{display(chart.x((data.at(-1) ?? {}) as any))}</text>
</g>
@ -28,6 +32,15 @@ const _D3MonitoringCurrentValues = <DataType extends BaseDataType>({ groups, dat
</g>
)
/**
* Отрисовывает последние значения графиков
*
* @typeParam DataType - тип данных для отрисовки графиков
*
* @param groups - Массив групп графиков
* @param data - Массив данных графиков
* @param sizes - Объект с полезными размерами и отступами внутри svg
*/
export const D3MonitoringCurrentValues = memo(_D3MonitoringCurrentValues) as typeof _D3MonitoringCurrentValues
export default D3MonitoringCurrentValues

View File

@ -164,7 +164,7 @@ const _D3MonitoringEditor = <DataType extends BaseDataType>({
}}
height={250}
/>
<Button onClick={() => setMode('limit')}>Ограничение подачи</Button>
{/* <Button onClick={() => setMode('limit')}>Ограничение подачи</Button> */}
</div>
<Divider type={'vertical'} style={{ height: '100%', padding: '0 5px' }} />
<div style={divStyle}>

View File

@ -30,7 +30,7 @@ export const notify = (body?: ReactNode, notifyType: NotifyType = 'info', well?:
const message = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{instance.message}</span>
<WellView well={well} />
<WellView placement={'leftBottom'} well={well} />
</div>
)

View File

@ -99,6 +99,8 @@ export const WellTreeSelector = memo<WellTreeSelectorProps>(({ show, expand, cur
const navigate = useNavigate()
const location = useLocation()
console.log(location.pathname)
useEffect(() => {
if (current) setSelected([current])
}, [current])
@ -156,7 +158,18 @@ export const WellTreeSelector = memo<WellTreeSelectorProps>(({ show, expand, cur
}, [wellsTree])
const onSelect = useCallback((value: Key[]): void => {
navigate(String(value), { state: { from: location.pathname }})
const newRoot = /\/(\w+)\/\d+/.exec(String(value))
const oldRoot = /\/(\w+)(?:\/\d+)?/.exec(location.pathname)
if (!newRoot || !oldRoot) return
let newPath = newRoot[0]
if (oldRoot[1] === newRoot[1]) {
/// Если типы страницы одинаковые (deposit, cluster, well), добавляем остаток старого пути
const url = location.pathname.substring(oldRoot[0].length)
newPath = newPath + url
}
navigate(newPath, { state: { from: location.pathname }})
}, [navigate, location])
useEffect(() => onChange(location.pathname), [onChange, location])

View File

@ -1,5 +1,5 @@
import { memo } from 'react'
import { Tooltip } from 'antd'
import { Tooltip, TooltipProps } from 'antd'
import { Grid, GridItem } from '@components/Grid'
import { WellIcon, WellIconState } from '@components/icons'
@ -13,12 +13,12 @@ const wellState: Record<number, { enum: WellIconState, label: string }> = {
2: { enum: 'inactive', label: 'Завершена' },
}
export type WellViewProps = {
export type WellViewProps = TooltipProps & {
well?: WellDto
}
export const WellView = memo<WellViewProps>(({ well }) => well ? (
<Tooltip title={(
export const WellView = memo<WellViewProps>(({ well, ...other }) => well ? (
<Tooltip {...other} title={(
<Grid style={{ columnGap: '8px' }}>
<GridItem row={1} col={1}>Название:</GridItem>
<GridItem row={1} col={2}>{well.caption ?? '---'}</GridItem>

View File

@ -40,7 +40,7 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
),
},
makeDateColumn('Дата загрузки', 'uploadDate'),
makeNumericColumn('Размер', 'size', null, null, formatBytes),
makeNumericColumn('Размер', 'size', null, null, (value) => formatBytes(value)),
makeColumn('Автор', 'author', { render: item => <UserView user={item}/> }),
makeColumn('Компания', 'company', { render: (_, record) => <CompanyView company={record?.author?.company}/> }),
...(customColumns ?? [])
@ -49,12 +49,8 @@ export const DocumentsTemplate = ({ idCategory, well: givenWell, mimeTypes, head
const filenames = useMemo(() => files.map(file => file.name).filter(Boolean).filter(unique), [files])
const update = useCallback(() => {
let begin = null
let end = null
if (filterDataRange?.length > 1) {
begin = filterDataRange[0].toISOString()
end = filterDataRange[1].toISOString()
}
const begin = filterDataRange?.length > 1 ? filterDataRange[0].toISOString() : null
const end = filterDataRange?.length > 1 ? filterDataRange[1].toISOString() : null
invokeWebApiWrapperAsync(
async () => {

View File

@ -11,7 +11,8 @@ import { makeColumn, makeDateColumn, makeNumericColumn, makeNumericSorter, makeT
import { wrapPrivateComponent } from '@utils'
import { MessageService } from '@api'
import '@styles/message.css'
import '@styles/filter.less'
import '@styles/message.less'
const pageSize = 26
const { Search } = Input
@ -103,14 +104,16 @@ const Messages = memo(() => {
return (
<>
<div className={'filter-group'}>
<h3 className={'filter-group-heading'}>Фильтр сообщений</h3>
<h3 className={'head'}>Фильтр сообщений</h3>
<div className={'body'}>
<Select
mode={'multiple'}
allowClear
mode={'multiple'}
className={'type-filter'}
placeholder={'Фильтр сообщений'}
className={'filter-selector'}
onChange={setCategories}
value={categories}
onChange={setCategories}>
>
{children}
</Select>
<RangePicker showTime onChange={(range) => setRange(range)} />
@ -120,6 +123,7 @@ const Messages = memo(() => {
onSearch={onChangeSearchString}
/>
</div>
</div>
<LoaderPortal show={showLoader}>
<Table
columns={columns}

View File

@ -0,0 +1,159 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Select } from 'antd'
import moment from 'moment'
import { useWell } from '@asb/context'
import LoaderPortal from '@components/LoaderPortal'
import { invokeWebApiWrapperAsync } from '@components/factory'
import D3HorizontalPercentChart from '@components/d3/D3HorizontalPercentChart'
import { DateRangeWrapper, makeColumn, makeNumericColumn, makeNumericRender, makeTextColumn, Table } from '@components/Table'
import { arrayOrDefault, range, wrapPrivateComponent } from '@utils'
import { SubsystemOperationTimeService } from '@api'
import '@styles/filter.less'
import '@styles/operation_time.less'
const subsystemColors = [
'#1abc9c', '#16a085', '#2ecc71', '#27ae60',
'#3498db', '#2980b9', '#9b59b6', '#8e44ad',
'#34495e', '#2c3e50', '#f1c40f', '#f39c12',
'#e67e22', '#d35400', '#e74c3c', '#c0392b',
'#ecf0f1', '#bdc3c7', '#95a5a6', '#7f8c8d',
]
const tableColumns = [
makeColumn('Цвет', 'color', { width: 60, render: (backgroundColor) => (
<div className={'table_color'} style={{ backgroundColor }} />
)}),
makeTextColumn('Подсистема', 'subsystemName'),
makeNumericColumn('Использование, %', 'kUsage', undefined, undefined, val => (+val * 100).toFixed(2), 200),
makeNumericColumn('Проходка, м', 'sumDepthInterval', undefined, undefined, undefined, 200),
makeNumericColumn('Время работы, ч', 'usedTimeHours', undefined, undefined, undefined, 200),
makeNumericColumn('Кол-во запусков', 'operationCount', undefined, undefined, makeNumericRender(0), 200),
]
// Выбор доступен только до текущей даты
const disabledDates = (current) => current && moment(current).isAfter(moment(), 'day', '[]')
// Выбор доступен только до текущего времени
const disabledTimes = (date) => ({
disabledHours: () => range(24).filter(h => date && moment(date).hours(h).isAfter(moment(), 'hour', '[]')),
disabledMinutes: () => range(60).filter(m => date && moment(date).minutes(m).isAfter(moment(), 'minute', '[]')),
disabledSeconds: () => range(60).filter(s => date && moment(date).seconds(s).isBetween(moment(), 'second', '[]'))
})
export const OperationTime = memo(() => {
const [showLoader, setShowLoader] = useState(false)
const [data, setData] = useState([])
const [selected, setSelected] = useState([])
const [selectedOnHover, setSelectedOnHover] = useState(null)
const [dateRange, setDateRange] = useState([])
const [well] = useWell()
// Создаём массив пунктов для селектора подсистем
const typeOptions = useMemo(() => data.map((d) => ({ label: d.subsystemName, value: d.idSubsystem })), [data])
// Фильтруем данные по выбранным подсистемам
const selectedData = useMemo(() => data.filter((d) => selected.includes(d.idSubsystem)), [selected, data])
// Подготавливаем данные для отображения на графике
const chartData = useMemo(() => selectedData.map((d) => ({
name: d.subsystemName,
percent: d.kUsage * 100,
color: d.color,
})), [selectedData])
const onRow = useCallback((item) => {
const out = {
onMouseEnter: () => {
setSelectedOnHover(item.subsystemName)
},
onMouseLeave: () => {
setSelectedOnHover(null)
},
}
if (item.subsystemName === selectedOnHover) {
out.style = { background: '#FAFAFA', fontSize: '16px', fontWeight: '600' }
}
return out
}, [selectedOnHover])
const onMouseOver = useCallback(function (e, d) {
setSelectedOnHover(d.name)
}, [])
const onMouseOut = useCallback(function (e, d) {
setSelectedOnHover(null)
}, [])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
if (!well.id) return
// Ограничение задаётся только если выбраны обе даты
const startDate = dateRange && dateRange[1] ? dateRange[0]?.toISOString() : undefined
const endDate = dateRange && dateRange[1] ? dateRange[1]?.toISOString() : undefined
const data = await SubsystemOperationTimeService.getStat(well.id, undefined, startDate, endDate)
// Выбираем цвета для подсистем (если цветов не хватает начинаем сначала)
const coloredData = arrayOrDefault(data).map((d, i) => ({ ...d, color: subsystemColors[i % subsystemColors.length] }))
setData(coloredData)
setSelected(data?.map((d) => d.idSubsystem)) // По-умолчанию выбираем все подсистемы
},
setShowLoader,
`Не удалось загрузить данные`,
{ actionName: 'Получение данных по скважине', well }
)
}, [dateRange, well])
return (
<LoaderPortal show={showLoader}>
<div className={'filter-group'}>
<h3 className={'head'}>Фильтр подсистем</h3>
<div className={'body'}>
<Select
allowClear
mode={'multiple'}
options={typeOptions}
className={'filter-selector'}
onChange={setSelected}
value={selected}
/>
<DateRangeWrapper
allowClear
onCalendarChange={setDateRange}
disabledDate={disabledDates}
disabledTime={disabledTimes}
value={dateRange}
/>
</div>
</div>
<D3HorizontalPercentChart
data={chartData}
colors={subsystemColors}
selected={selectedOnHover}
width={'100%'}
height={'50vh'}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
/>
<Table
bordered
size={'small'}
columns={tableColumns}
dataSource={selectedData}
scroll={{ y: '25vh', x: true }}
pagination={false}
onRow={onRow}
/>
</LoaderPortal>
)
})
export default wrapPrivateComponent(OperationTime, {
requirements: [], // SubsystemOperationTime.get
title: 'Наработка',
route: 'operation_time',
key: 'operation_time',
})

View File

@ -1,187 +0,0 @@
import React, { memo, ReactNode, useCallback, useEffect, useState } from 'react'
import { Col, Row, Select } from 'antd'
import { Moment } from 'moment'
import { DateRangeWrapper, makeColumn, makeNumericRender, Table } from '@components/Table'
import LoaderPortal from '@components/LoaderPortal'
import { arrayOrDefault, wrapPrivateComponent } from '@utils'
import { D3HorizontalChart, HorizontalChartDataType } from '@components/d3/D3HorizontalChart'
import { useWell } from '@asb/context'
import { invokeWebApiWrapperAsync } from '@components/factory'
import { SubsystemOperationTimeService } from '@api'
const { Option } = Select;
type DataType = {
idSubsystem: number
subsystemName: string
usedTimeHours: number
kUsage: number
sumDepthInterval: number
operationCount: number
}
const subsystemColors = [
'#1abc9c',
'#16a085',
'#2ecc71',
'#27ae60',
'#3498db',
'#2980b9',
'#9b59b6',
'#8e44ad',
'#34495e',
'#2c3e50',
'#f1c40f',
'#f39c12',
'#e67e22',
'#d35400',
'#e74c3c',
'#c0392b',
'#ecf0f1',
'#bdc3c7',
'#95a5a6',
'#7f8c8d',
]
const tableColumns = [
makeColumn('Цвет', 'color', { width: 50, render: (color) => (
<div style={{ backgroundColor: color, padding: '5px 0' }} />
) }),
makeColumn('Подсистема', 'subsystemName'),
makeColumn('% использования', 'kUsage', { width: 200, render: val => makeNumericRender(2)(+val * 100) }),
makeColumn('Проходка, м', 'sumDepthInterval', { width: 200, render: makeNumericRender(2) }),
makeColumn('Время работы, ч', 'usedTimeHours', { width: 200, render: makeNumericRender(2) }),
makeColumn('Кол-во запусков', 'operationCount', { width: 200, render: makeNumericRender(0) }),
]
const OperationTime = memo(() => {
const [showLoader, setShowLoader] = useState(false)
const [data, setData] = useState<DataType[]>([])
const [selectedData, setSelectedData] = useState<DataType[]>([])
const [dateRange, setDateRange] = useState<Moment[] | null>([])
const [selected, setSelected] = useState<string | null>(null)
const [childrenData, setChildrenData] = useState<ReactNode[]>([])
const [well] = useWell()
const errorNotifyText = `Не удалось загрузить данные`
const onRow = useCallback((item: DataType) => {
const out = {
onMouseEnter: () => {
setSelected(item.subsystemName)
},
onMouseLeave: () => {
setSelected(null)
},
style: {}
}
if (item.subsystemName === selected) {
out.style = { background: '#FAFAFA', fontSize: '16px', fontWeight: '600' }
}
return out
}, [selected])
const onMouseOver = useCallback(function (e: MouseEvent, d: HorizontalChartDataType) {
setSelected(d.name)
}, [])
const onMouseOut = useCallback(function (e: MouseEvent, d: HorizontalChartDataType) {
setSelected(null)
}, [])
useEffect(() => {
invokeWebApiWrapperAsync(
async () => {
if (!well.id) return
try {
const responseData:DataType[] = arrayOrDefault(await SubsystemOperationTimeService.getStat(
well.id,
undefined,
dateRange && dateRange[1] ? dateRange[0]?.toISOString() : undefined,
dateRange && dateRange[1] ? dateRange[1].toISOString() : undefined,
))
setData(responseData)
setSelectedData(responseData)
} catch(e) {
setData([])
throw e
}
},
setShowLoader,
errorNotifyText,
{ actionName: 'Получение данных по скважине', well }
)
}, [dateRange])
useEffect(() => {
setChildrenData(data.map((item) => (
<Option key={item.subsystemName}>
{item.subsystemName}
</Option>
)))
}, [data])
const selectChange = useCallback((value: string[]) => {
setSelectedData(selectedData.reduce((previousValue: DataType[], currentValue) => {
if (value.includes(currentValue.subsystemName)) {
previousValue.push(currentValue)
}
return previousValue
}, []))
},[data]);
return (
<LoaderPortal show={showLoader}>
<h3 className={'filter-group-heading'}>Фильтр подсистем</h3>
<Row align={'middle'} justify={'space-between'} wrap={false} style={{ backgroundColor: 'white' }}>
<Col span={18}>
<Select
allowClear
mode="multiple"
value={selectedData.map(d => d.subsystemName)}
onChange={selectChange}
style={{ width: '100%' }}
>
{childrenData}
</Select>
</Col>
<Col span={6}>
<DateRangeWrapper
allowClear
allowEmpty={[true, true]}
onCalendarChange={(dateRange: any) => setDateRange(dateRange)}
value={[dateRange && dateRange[0], dateRange && dateRange[1]]}
/>
</Col>
</Row>
<D3HorizontalChart
data={selectedData.map(item => ({name: item.subsystemName, percent: item.kUsage * 100}))}
colors={subsystemColors}
selected={selected}
width={'100%'}
height={'50vh'}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
/>
<Table
size={'small'}
columns={tableColumns}
dataSource={selectedData.map((d, i) => ({...d, color: subsystemColors[i]}))}
scroll={{ y: '25vh', x: true }}
pagination={false}
onRow={onRow}
/>
</LoaderPortal>
)
})
export default wrapPrivateComponent(OperationTime, {
requirements: [],
title: 'Наработка',
route: 'operation_time',
})

View File

@ -9,7 +9,7 @@ import { MessageService } from '@api'
import { makeMessageColumns } from '../Messages'
import '@styles/message.css'
import '@styles/message.less'
export const ActiveMessagesOnline = memo(({ well: givenWell }) => {
const [messages, setMessages] = useState([])

View File

@ -32,14 +32,14 @@ import SpinPicEnabled from '@images/SpinEnabled.png'
import SpinPicDisabled from '@images/SpinDisabled.png'
import '@styles/monitoring.less'
import '@styles/message.css'
import '@styles/message.less'
const { Option } = Select
export const yAxis = {
type: 'time',
accessor: (d) => new Date(d.date),
format: (d) => formatDate(d, undefined, 'YYYY-MM-DD HH:mm:ss'),
format: (d) => formatDate(d, undefined, 'DD.MM.YYYY HH:mm:ss'),
}
const dash = [7, 3]

View File

@ -1,18 +1,4 @@
@import '~antd/dist/antd.less';
@import './loader.css';
/*
* ЭТО ФАЙЛ НАСТРОЙКИ ТЕМЫ И КОМПОНЕНТОВ ТЕМЫ.
* НЕ ПИШИТЕ ТУТ СТИЛИ ДЛЯ КАСТОМНЫХ КОМПОНЕНТОВ.
*/
// Переменные для темы тут:
// https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less
//@primary-color: rgba(124, 124, 124, 0.3);
@primary-color: rgb(195, 40,40);
//@primary-color:rgb(65, 63, 61);
//@layout-header-background: rgb(195, 40,40);
@layout-header-background: rgb(65, 63, 61);
@header-height: 64px;
@layout-min-height: calc(100vh - @header-height);
@ -154,9 +140,3 @@ html {
tr.table_row_size {
height: 10px;
}
.filter-selector {
width: 25%;
margin-right: 5px;
margin-left: 5px;
}

View File

@ -116,9 +116,9 @@
}
}
.grid-line line {
.grid-line:not(:first-of-type):not(:last-of-type) line {
stroke: #ddd;
stroke-dasharray: 4
stroke-dasharray: 4;
}
@media (max-width: 1800px) {

22
src/styles/filter.less Normal file
View File

@ -0,0 +1,22 @@
.filter-group {
margin: 0 0 5px 0;
& .head {
margin: 5px auto;
align-items: center;
}
& .body {
width: 100%;
display: flex;
gap: 5px;
& .type-filter {
width: 25%;
}
& .filter-selector {
flex: 1;
}
}
}

View File

@ -0,0 +1,14 @@
@import '~antd/dist/antd.less';
/*
* ЭТО ФАЙЛ НАСТРОЙКИ ТЕМЫ И КОМПОНЕНТОВ ТЕМЫ.
* НЕ ПИШИТЕ ТУТ СТИЛИ ДЛЯ КАСТОМНЫХ КОМПОНЕНТОВ.
*/
// Переменные для темы тут:
// https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less
//@primary-color: rgba(124, 124, 124, 0.3);
@primary-color: rgb(195, 40,40);
//@primary-color:rgb(65, 63, 61);
//@layout-header-background: rgb(195, 40,40);
@layout-header-background: rgb(65, 63, 61);

9
src/styles/message.css → src/styles/message.less Executable file → Normal file
View File

@ -39,15 +39,6 @@
background: #505060;
}
.filter-group {
margin: 0 0 5px 0;
}
.filter-group-heading {
margin: 5px auto;
align-items: center;
}
td.ant-table-column-sort {
color: black;
background-color: #fafafa;

View File

@ -0,0 +1,3 @@
.table_color {
padding: 5px 0;
}

View File

@ -3,6 +3,7 @@
display: flex;
flex-direction: column;
justify-content: stretch;
min-height: 80vh;
.tvd-top {
display: flex;
@ -57,7 +58,6 @@
left: 55px;
}
}
}
.tvd-right {

View File

@ -8,7 +8,7 @@
* @param data Копируемые данные
* @returns Полная копия `data`
*/
export const deepCopy = <T extends any>(data: T): T => JSON.parse(JSON.stringify(data ?? null))
export const deepCopy = <T,>(data: T): T => JSON.parse(JSON.stringify(data ?? null))
/**
* Маппинг полей объекта

View File

@ -21,7 +21,7 @@ export const getArrayFromLocalStorage = <T extends string = string>(name: string
return raw.split(sep).map<T>(elm => elm as T)
}
export const getJSON = <T extends any>(name: StorageNames): T | null => {
export const getJSON = <T,>(name: StorageNames): T | null => {
const raw = localStorage.getItem(name)
if (!raw) return null
try {
@ -30,7 +30,7 @@ export const getJSON = <T extends any>(name: StorageNames): T | null => {
return null
}
export const setJSON = <T extends any>(name: StorageNames, data: T | null): boolean => {
export const setJSON = <T,>(name: StorageNames, data: T | null): boolean => {
try {
localStorage.setItem(name, JSON.stringify(data))
return true

View File

@ -9,7 +9,7 @@ export type TableColumnSettings = {
export type TableSettings = Record<string, TableColumnSettings>
export type TableSettingsStore = Record<string, TableSettings | null>
export const makeTableSettings = (columns: TableColumns): TableSettings => {
export const makeTableSettings = <T extends object>(columns: TableColumns<T>): TableSettings => {
const settings: TableSettings = {}
columns.forEach((column) => {
if (!column.key) return
@ -47,8 +47,8 @@ export const optimizeTableColumn = (column: TableColumnSettings): TableColumnSet
visible: column.visible ?? true,
})
export const applyTableSettings = (columns: TableColumns, settings: TableSettings): TableColumns => {
let newColumns: TableColumns = columns.map((column) => ({ ...column }))
export const applyTableSettings = <T extends object>(columns: TableColumns<T>, settings: TableSettings): TableColumns<T> => {
let newColumns: TableColumns<T> = columns.map((column) => ({ ...column }))
newColumns = newColumns.filter((column) => {
const name = String(column.key)
return !(name in settings) || settings[name]?.visible

106
webpack.config.base.js Normal file
View File

@ -0,0 +1,106 @@
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const InterpolateHtmlPlugin = require('interpolate-html-plugin')
const proxy = require('./package.json').proxy
module.exports = {
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].[contenthash:8].js',
publicPath: '/',
},
devServer: {
historyApiFallback: true,
port: 3000,
proxy: {
context: ['/api', '/auth', '/hubs'],
target: proxy,
},
},
optimization: {
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
removeAvailableModules: true,
removeEmptyChunks: true,
mergeDuplicateChunks: true,
providedExports: true,
runtimeChunk: true,
},
resolve: {
fallback: { 'path': require.resolve('path-browserify') }, // TODO: Remove
modules: [path.join(__dirname, 'src'), 'node_modules'],
alias: {
react: path.join(__dirname, 'node_modules', 'react'),
'@asb': path.resolve(__dirname, 'src'),
'@api': path.resolve(__dirname, 'src/services/api'),
'@components': path.resolve(__dirname, 'src/components'),
'@services': path.resolve(__dirname, 'src/services'),
'@pages': path.resolve(__dirname, 'src/pages'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@images': path.resolve(__dirname, 'src/images'),
'@styles': path.resolve(__dirname, 'src/styles'),
},
extensions: [ '', '.tsx', '.ts', '.jsx', '.js', '.json', '.d.ts', '.svg', '.png' ],
},
module: {
rules: [
{
test: /\.js$/,
enforce: 'pre',
use: [ 'source-map-loader' ],
},
{
test: /\.m?jsx?$/i,
exclude: /node_modules/,
use: [ 'babel-loader' ],
},
{
test: /\.m?tsx?$/i,
exclude: /node_modules/,
use: [ 'babel-loader', 'ts-loader' ],
},
{
test: /\.(jpe?g|gif|png)$/i,
use: [
{
loader: 'file-loader',
options: {
publicPath: '/images/',
outputPath: 'images',
name: '[name].[contenthash:8].[ext]',
},
},
],
},
{
test: /\.svg$/i,
issuer: /\.m?[jt]sx?$/,
use: [
'@svgr/webpack',
{
loader: 'file-loader',
options: {
publicPath: '/images/svg/',
outputPath: 'images/svg',
name: '[name].[contenthash:8].[ext]',
},
},
],
},
],
},
plugins: [
new InterpolateHtmlPlugin({ PUBLIC_URL: 'static' }),
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
}

76
webpack.config.dev.js Normal file
View File

@ -0,0 +1,76 @@
const { HotModuleReplacementPlugin, SourceMapDevToolPlugin } = require('webpack')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const path = require('path')
const lessLoader = {
loader: 'less-loader',
options: {
lessOptions: {
javascriptEnabled: true,
},
},
}
module.exports = {
cache: true,
performance: false,
mode: 'development',
devtool: 'source-map',
optimization: {
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin(),
],
flagIncludedChunks: false,
usedExports: false,
sideEffects: false,
concatenateModules: false,
emitOnErrors: true,
nodeEnv: 'development',
minimize: false,
},
output: {
path: path.resolve(__dirname, 'dev_build'),
pathinfo: true,
},
stats: 'errors-warnings',
module: {
unsafeCache: true,
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
],
},
{
test: /\.less$/i,
exclude: path.resolve(__dirname, 'src/styles/include/'),
use: [
MiniCssExtractPlugin.loader,
'css-loader',
lessLoader,
],
},
{
test: /\.less$/i,
include: path.resolve(__dirname, 'src/styles/include/'),
use: [
'style-loader',
'css-loader',
lessLoader,
]
},
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
}),
new HotModuleReplacementPlugin(),
new SourceMapDevToolPlugin({ filename: 'maps/[file].map' }),
],
}

View File

@ -1,141 +1,55 @@
const { merge } = require('webpack-merge')
const process = require('process')
const colors = require('colors')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { HotModuleReplacementPlugin, SourceMapDevToolPlugin } = require('webpack')
const InterpolateHtmlPlugin = require('interpolate-html-plugin')
const fs = require('fs')
const proxy = require('./package.json').proxy
colors.enable()
module.exports = {
entry: './src/index.tsx',
devtool: 'source-map',
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].[contenthash:8].js',
publicPath: '/',
},
optimization: {
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
resolve: {
fallback: { 'path': require.resolve('path-browserify') }, // TODO: Remove
modules: [path.join(__dirname, 'src'), 'node_modules'],
alias: {
react: path.join(__dirname, 'node_modules', 'react'),
const baseConfigPath = path.resolve(__dirname, 'webpack.config.base.js')
'@asb': path.resolve(__dirname, 'src'),
'@api': path.resolve(__dirname, 'src/services/api'),
'@components': path.resolve(__dirname, 'src/components'),
'@services': path.resolve(__dirname, 'src/services'),
'@pages': path.resolve(__dirname, 'src/pages'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@images': path.resolve(__dirname, 'src/images'),
'@styles': path.resolve(__dirname, 'src/styles'),
},
extensions: [ '', '.tsx', '.ts', '.jsx', '.js', '.json', '.d.ts', '.svg', '.png' ],
},
devServer: {
historyApiFallback: true,
port: 3000,
proxy: {
context: ['/api', '/auth', '/hubs'],
target: proxy,
},
},
stats: 'errors-warnings',
// {
// colors: true,
// hash: false,
// version: false,
// timings: false,
// assets: false,
// chunks: false,
// modules: false,
// reasons: false,
// children: false,
// source: false,
// errors: false,
// errorDetails: false,
// warnings: false,
// publicPath: false,
// },
module: {
rules: [
{
test: /\.js$/,
enforce: 'pre',
use: [ 'source-map-loader' ],
},
{
test: /\.m?jsx?$/i,
exclude: /node_modules/,
use: [ 'babel-loader' ],
},
{
test: /\.m?tsx?$/i,
exclude: /node_modules/,
use: [ 'babel-loader', 'ts-loader' ],
},
{
test: /\.css$/i,
use: [ 'style-loader', 'css-loader' ],
},
{
test: /\.less$/i,
use: [
'style-loader',
'css-loader',
{
loader: 'less-loader',
options: {
lessOptions: {
javascriptEnabled: true,
},
},
},
],
},
{
test: /\.(jpe?g|gif|png)$/i,
use: [
{
loader: 'file-loader',
options: {
publicPath: '/images/',
outputPath: 'images',
name: '[name].[contenthash:8].[ext]',
},
},
],
},
{
test: /\.svg$/i,
issuer: /\.m?[jt]sx?$/,
use: [
'@svgr/webpack',
{
loader: 'file-loader',
options: {
publicPath: '/images/svg/',
outputPath: 'images/svg',
name: '[name].[contenthash:8].[ext]',
},
},
],
},
],
},
plugins: [
new HotModuleReplacementPlugin(),
new InterpolateHtmlPlugin({ PUBLIC_URL: 'static' }),
new SourceMapDevToolPlugin({ filename: '[file].map' }),
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
const quit = (error = false) => {
console.log('Exiting...')
process.exit(error ? 1 : 0)
}
module.exports = (env) => {
if (!fs.existsSync(path.resolve(__dirname, 'src/services/api'))) {
console.error(`Error: Services not found! Generate it first!`.bold.red)
console.info('To generate services you can use node scripts.')
console.info('For example: `npm run oug_dev` for generate services from dev-server.')
console.error(`Ошибка: Сервисы не найдены! Сначала сгенерируйте их!`.bold.red)
console.info('Для генерации сервисов воспользуйтесь node-скриптами.')
console.info('Например: `npm run oug_dev` для генерации от dev-сервера.')
quit(1)
}
if (!fs.existsSync(baseConfigPath)) {
console.error(`Error: Base webpack config file not found!`.bold.red)
console.error(`Ошибка: базовый конфигурационный файл webpack не найден!`.bold.red)
quit(1)
}
const mode = String(env.ENV)
const configPath = path.resolve(__dirname, `webpack.config.${env.ENV}.js`)
if (!fs.existsSync(configPath)) {
console.error(`Error: Webpack config file for mode "${mode.underline}" not found!`.bold.red)
console.error(`Ошибка: конфигурационный файл webpack для режима "${mode.underline}" не найден!`.bold.red)
quit(1)
}
console.info('Make sure you update your npm packages and services!'.bold.yellow)
console.info('Убедитесь, что вы обновили npm-пакеты и сервисы!'.bold.yellow)
console.info(`Building in mode: ${String(env.ENV).blue.bold}`)
console.info(`Using base config: ${baseConfigPath.underline.italic}`)
console.info(`Using main config: ${configPath.underline.italic}`)
console.info(`Собрка в режиме: ${String(env.ENV).blue.bold}`)
console.info(`Базовый конфигурационный файл: ${baseConfigPath.underline.italic}`)
console.info(`Режимный конфигурационный файл: ${configPath.underline.italic}`)
return merge(require(baseConfigPath), require(configPath))
}

58
webpack.config.prod.js Normal file
View File

@ -0,0 +1,58 @@
const path = require('path')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
mode: 'production',
performance: {
assetFilter: (assetFilename) => !/\.map$/.test(assetFilename),
hints: 'error',
maxAssetSize: 300 * 1024, // 300KB
},
cache: false,
output: {
path: path.resolve(__dirname, 'prod_build'),
pathinfo: false,
},
optimization: {
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin(),
],
flagIncludedChunks: true,
usedExports: true,
sideEffects: true,
concatenateModules: true,
emitOnErrors: false,
nodeEnv: 'production',
minimize: true,
},
stats: 'errors-warnings',
module: {
unsafeCache: false,
rules: [
{
test: /\.css$/i,
use: [
'style-loader',
'css-loader'
],
},
{
test: /\.less$/i,
use: [
'style-loader',
'css-loader',
{
loader: 'less-loader',
options: {
lessOptions: {
javascriptEnabled: true,
},
},
},
],
}
]
},
}