feat(steady): 实现台账指标树默认选中及图标展示功能
- 添加findFirstSelectableLedgerNode和findFirstLeafIndicator工具函数 - 实现台账树首次加载后默认选中第一个可查询监测点 - 实现指标树首次加载后默认选中第一个叶子指标 - 添加台账层级图标展示及样式配置 - 集成defaultCheckedKeys属性到台账和指标树组件 - 更新趋势查询参数移除bucket字段 - 修复数据质量标识默认值设置问题
This commit is contained in:
@@ -42,7 +42,6 @@ export namespace SteadyDataView {
|
||||
statTypes: SteadyTrendStatType[]
|
||||
timeStart: string
|
||||
timeEnd: string
|
||||
bucket?: string
|
||||
qualityFlag?: number
|
||||
harmonicOrders?: number[]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
:key="selectorResetKey"
|
||||
:tree-data="treeData"
|
||||
:loading="loading"
|
||||
:default-checked-keys="defaultCheckedKeys"
|
||||
@refresh="emit('refresh')"
|
||||
@change="emit('change', $event)"
|
||||
/>
|
||||
@@ -39,6 +40,7 @@ defineProps<{
|
||||
collapsed: boolean
|
||||
treeData: SteadyDataView.SteadyIndicatorNode[]
|
||||
loading: boolean
|
||||
defaultCheckedKeys: string[]
|
||||
selectorResetKey: number
|
||||
}>()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
node-key="treeKey"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:default-checked-keys="defaultCheckedKeys"
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="handleCheck"
|
||||
@@ -29,7 +30,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import type { TreeInstance } from 'element-plus'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
@@ -42,6 +43,7 @@ defineOptions({
|
||||
const props = defineProps<{
|
||||
treeData: SteadyDataView.SteadyIndicatorNode[]
|
||||
loading: boolean
|
||||
defaultCheckedKeys: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -70,6 +72,19 @@ const handleCheck = () => {
|
||||
const checkedNodes = (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyDataView.SteadyIndicatorNode[]
|
||||
emit('change', collectLeafIndicators(checkedNodes))
|
||||
}
|
||||
|
||||
const applyDefaultCheckedKeys = async () => {
|
||||
await nextTick()
|
||||
treeRef.value?.setCheckedKeys(props.defaultCheckedKeys, false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [normalizedTreeData.value, props.defaultCheckedKeys],
|
||||
() => {
|
||||
applyDefaultCheckedKeys()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -20,13 +20,19 @@
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:default-checked-keys="defaultCheckedKeys"
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="handleCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node">
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
<span class="node-main">
|
||||
<el-icon :class="['node-icon', `is-level-${normalizeLedgerLevel(data.level)}`]">
|
||||
<component :is="resolveLedgerIcon(data.level)" />
|
||||
</el-icon>
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
</span>
|
||||
<span class="node-count">
|
||||
<template v-if="Number(data.deviceCount) || Number(data.lineCount)">
|
||||
{{ Number(data.deviceCount || 0) }} / {{ Number(data.lineCount || 0) }}
|
||||
@@ -40,8 +46,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { Folder, Location, Monitor, OfficeBuilding, Refresh } from '@element-plus/icons-vue'
|
||||
import type { TreeInstance } from 'element-plus'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
|
||||
@@ -49,10 +56,11 @@ defineOptions({
|
||||
name: 'SteadyLedgerTree'
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
treeData: SteadyDataView.SteadyLedgerNode[]
|
||||
loading: boolean
|
||||
keyword: string
|
||||
defaultCheckedKeys: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -62,6 +70,26 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const treeRef = ref<TreeInstance>()
|
||||
type LedgerLevel = SteadyDataView.SteadyLedgerNode['level']
|
||||
const ledgerIcons: Record<LedgerLevel, Component> = {
|
||||
0: OfficeBuilding,
|
||||
1: Folder,
|
||||
2: Monitor,
|
||||
3: Location
|
||||
}
|
||||
|
||||
const normalizeLedgerLevel = (value: unknown): LedgerLevel => {
|
||||
const level = Number(value)
|
||||
if (level === 0 || level === 1 || level === 2 || level === 3) {
|
||||
return level
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const resolveLedgerIcon = (value: unknown) => {
|
||||
return ledgerIcons[normalizeLedgerLevel(value)]
|
||||
}
|
||||
|
||||
const handleKeywordChange = (value: string) => {
|
||||
emit('search', value)
|
||||
@@ -70,6 +98,19 @@ const handleKeywordChange = (value: string) => {
|
||||
const handleCheck = () => {
|
||||
emit('change', (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyDataView.SteadyLedgerNode[])
|
||||
}
|
||||
|
||||
const applyDefaultCheckedKeys = async () => {
|
||||
await nextTick()
|
||||
treeRef.value?.setCheckedKeys(props.defaultCheckedKeys, false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.treeData, props.defaultCheckedKeys],
|
||||
() => {
|
||||
applyDefaultCheckedKeys()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -105,6 +146,35 @@ const handleCheck = () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node-main {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
flex: none;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.node-icon.is-level-0 {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.node-icon.is-level-1 {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.node-icon.is-level-2 {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.node-icon.is-level-3 {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
|
||||
.node-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -25,17 +25,6 @@
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-field">
|
||||
<span class="toolbar-field__label">粒度:</span>
|
||||
<el-select
|
||||
:model-value="modelValue.bucket"
|
||||
placeholder="选择时间粒度"
|
||||
@update:model-value="updateField('bucket', $event)"
|
||||
>
|
||||
<el-option v-for="item in bucketOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-field">
|
||||
<span class="toolbar-field__label">数据:</span>
|
||||
<el-select
|
||||
@@ -44,8 +33,8 @@
|
||||
placeholder="选择数据质量"
|
||||
@update:model-value="updateField('qualityFlag', $event)"
|
||||
>
|
||||
<el-option label="仅有效数据" :value="1" />
|
||||
<el-option label="仅无效数据" :value="0" />
|
||||
<el-option label="仅有效数据" :value="0" />
|
||||
<el-option label="仅无效数据" :value="1" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
@@ -93,13 +82,6 @@ const emit = defineEmits<{
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const bucketOptions = [
|
||||
{ label: '1分钟', value: '1m' },
|
||||
{ label: '5分钟', value: '5m' },
|
||||
{ label: '10分钟', value: '10m' },
|
||||
{ label: '30分钟', value: '30m' },
|
||||
{ label: '1小时', value: '1h' }
|
||||
]
|
||||
const harmonicOrderOptions = Array.from({ length: 49 }, (_item, index) => index + 2)
|
||||
const statLabelMap: Record<SteadyDataView.SteadyTrendStatType, string> = {
|
||||
AVG: '平均值',
|
||||
@@ -136,7 +118,7 @@ const handleTimeBaseDateChange = (value: Date) => {
|
||||
<style scoped lang="scss">
|
||||
.trend-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(312px, 1.4fr) repeat(3, minmax(178px, 0.8fr)) auto;
|
||||
grid-template-columns: minmax(312px, 1.4fr) repeat(2, minmax(178px, 0.8fr)) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
:tree-data="ledgerTree"
|
||||
:loading="loading.ledger"
|
||||
:keyword="ledgerKeyword"
|
||||
:default-checked-keys="defaultLedgerCheckedKeys"
|
||||
@refresh="emit('refreshLedger')"
|
||||
@search="emit('ledgerSearch', $event)"
|
||||
@change="emit('ledgerChange', $event)"
|
||||
@@ -30,6 +31,7 @@
|
||||
:selector-reset-key="selectorResetKey"
|
||||
:tree-data="indicatorTree"
|
||||
:loading="loading.indicator"
|
||||
:default-checked-keys="defaultIndicatorCheckedKeys"
|
||||
@refresh="emit('refreshIndicator')"
|
||||
@change="emit('indicatorChange', $event)"
|
||||
/>
|
||||
@@ -63,6 +65,8 @@ const props = defineProps<{
|
||||
trend: boolean
|
||||
}
|
||||
ledgerKeyword: string
|
||||
defaultLedgerCheckedKeys: string[]
|
||||
defaultIndicatorCheckedKeys: string[]
|
||||
indicatorPanelCollapsed: boolean
|
||||
selectorResetKey: number
|
||||
}>()
|
||||
|
||||
@@ -7,6 +7,8 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const componentDir = path.join(currentDir, '..', 'components')
|
||||
|
||||
const read = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||
const pageSource = fs.readFileSync(path.join(currentDir, '..', 'index.vue'), 'utf8')
|
||||
const selectionRulesSource = fs.readFileSync(path.join(currentDir, '..', 'utils', 'selectionRules.ts'), 'utf8')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
@@ -18,6 +20,46 @@ const expectations = [
|
||||
'indicator tree excludes half-checked parents when collecting checked nodes',
|
||||
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
|
||||
read('SteadyIndicatorTree.vue')
|
||||
],
|
||||
[
|
||||
'selection rules expose the first selectable ledger resolver',
|
||||
/export const findFirstSelectableLedgerNode/,
|
||||
selectionRulesSource
|
||||
],
|
||||
[
|
||||
'selection rules expose the first leaf indicator resolver',
|
||||
/export const findFirstLeafIndicator/,
|
||||
selectionRulesSource
|
||||
],
|
||||
[
|
||||
'steady page applies default selected ledger keys after ledger tree load',
|
||||
/defaultLedgerCheckedKeys\.value\s*=/,
|
||||
pageSource
|
||||
],
|
||||
[
|
||||
'steady page applies default selected indicator keys after indicator tree load',
|
||||
/defaultIndicatorCheckedKeys\.value\s*=/,
|
||||
pageSource
|
||||
],
|
||||
[
|
||||
'ledger tree receives default checked keys',
|
||||
/defaultCheckedKeys/,
|
||||
read('SteadyLedgerTree.vue')
|
||||
],
|
||||
[
|
||||
'ledger tree renders level icons',
|
||||
/<component\s+:is="resolveLedgerIcon\(data\.level\)"/,
|
||||
read('SteadyLedgerTree.vue')
|
||||
],
|
||||
[
|
||||
'ledger tree resolves icons by ledger level',
|
||||
/const\s+ledgerIcons[\s\S]*0:[\s\S]*1:[\s\S]*2:[\s\S]*3:/,
|
||||
read('SteadyLedgerTree.vue')
|
||||
],
|
||||
[
|
||||
'indicator tree receives default checked keys',
|
||||
/defaultCheckedKeys/,
|
||||
read('SteadyIndicatorTree.vue')
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -53,17 +53,21 @@ const expectations = [
|
||||
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
|
||||
['components reuse LineChart', /<LineChart/],
|
||||
['toolbar uses shared time period search', /TimePeriodSearch/],
|
||||
['toolbar labels stat bucket quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*粒度:[\s\S]*toolbar-field__label[\s\S]*数据:/],
|
||||
['toolbar labels stat quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*数据:/],
|
||||
['toolbar does not render bucket selector', /modelValue\.bucket|bucketOptions|粒度:|选择时间粒度/],
|
||||
['toolbar does not render phase selector', /modelValue\.phases|phaseOptions|resolvePhaseLabel/],
|
||||
['toolbar labels bucket options descriptively', /bucketOptions[\s\S]*1分钟[\s\S]*1小时/],
|
||||
['toolbar labels quality options descriptively', /仅有效数据[\s\S]*仅无效数据/],
|
||||
['toolbar binds valid quality flag to zero', /<el-option\s+label="仅有效数据"\s+:value="0"\s*\/>/],
|
||||
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
|
||||
['utilities collect selected line ids', /export const collectSelectedLineIds/],
|
||||
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
|
||||
['utilities do not require phase selection', /if\s*\(!phases\.length\)/],
|
||||
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*6/],
|
||||
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
|
||||
['utilities strip milliseconds from trend query time', /formatSteadyTrendQueryTime[\s\S]*replace\(\s*\/\\\.\[\^.\]\+\$\//],
|
||||
['utilities do not send bucket in trend query payload', /bucket:\s*formState\.bucket/],
|
||||
['utilities do not send phases in trend query payload', /phases:\s*formState\.phases/],
|
||||
['trend query params do not include bucket', /interface\s+SteadyTrendQueryParams\s*{[^}]*bucket\??:\s*string/],
|
||||
['trend query params do not include phases', /phases:\s*string\[\]/],
|
||||
['utilities build chart options', /export const buildSteadyTrendChartOptions/]
|
||||
]
|
||||
@@ -78,7 +82,6 @@ const sourceByExpectation = [
|
||||
componentSource,
|
||||
componentSource,
|
||||
pageSource,
|
||||
pageSource,
|
||||
componentSource,
|
||||
pageSource,
|
||||
apiSource,
|
||||
@@ -96,6 +99,8 @@ const sourceByExpectation = [
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
componentSource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
@@ -103,6 +108,9 @@ const sourceByExpectation = [
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
utilitySource,
|
||||
interfaceSource,
|
||||
interfaceSource,
|
||||
utilitySource
|
||||
]
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
:show-harmonic-orders="showHarmonicOrders"
|
||||
:loading="loading"
|
||||
:ledger-keyword="ledgerKeyword"
|
||||
:default-ledger-checked-keys="defaultLedgerCheckedKeys"
|
||||
:default-indicator-checked-keys="defaultIndicatorCheckedKeys"
|
||||
:selector-reset-key="selectorResetKey"
|
||||
@refresh-ledger="loadLedgerTree"
|
||||
@ledger-search="handleLedgerSearch"
|
||||
@@ -30,6 +32,8 @@ import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import SteadyTrendWorkbench from './components/SteadyTrendWorkbench.vue'
|
||||
import {
|
||||
collectSelectedLineIds,
|
||||
findFirstLeafIndicator,
|
||||
findFirstSelectableLedgerNode,
|
||||
hasHarmonicIndicator,
|
||||
resolveAvailableStats,
|
||||
validateTrendSelection
|
||||
@@ -49,6 +53,8 @@ const trendForm = ref(defaultTrendFormState())
|
||||
const ledgerKeyword = ref('')
|
||||
const indicatorPanelCollapsed = ref(false)
|
||||
const selectorResetKey = ref(0)
|
||||
const defaultLedgerCheckedKeys = ref<string[]>([])
|
||||
const defaultIndicatorCheckedKeys = ref<string[]>([])
|
||||
const loading = reactive({
|
||||
ledger: false,
|
||||
indicator: false,
|
||||
@@ -75,6 +81,10 @@ const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
|
||||
try {
|
||||
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
|
||||
ledgerTree.value = unwrapData(response) || []
|
||||
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
|
||||
// 台账树首次加载后默认选中第一个可查询监测点,避免趋势查询初始状态为空。
|
||||
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
|
||||
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
|
||||
} finally {
|
||||
loading.ledger = false
|
||||
}
|
||||
@@ -85,6 +95,11 @@ const loadIndicatorTree = async () => {
|
||||
try {
|
||||
const response = await getSteadyTrendIndicatorTree()
|
||||
indicatorTree.value = unwrapData(response) || []
|
||||
const firstIndicator = findFirstLeafIndicator(indicatorTree.value)
|
||||
const firstIndicatorKey = firstIndicator?.id || firstIndicator?.indicatorCode
|
||||
// 指标树首次加载后默认选中第一个叶子指标,并同步驱动统计类型默认值。
|
||||
selectedIndicators.value = firstIndicator ? [firstIndicator] : []
|
||||
defaultIndicatorCheckedKeys.value = firstIndicatorKey ? [firstIndicatorKey] : []
|
||||
} finally {
|
||||
loading.indicator = false
|
||||
}
|
||||
@@ -109,6 +124,8 @@ const resetTrendState = () => {
|
||||
trendForm.value = defaultTrendFormState()
|
||||
selectedLedgerNodes.value = []
|
||||
selectedIndicators.value = []
|
||||
defaultLedgerCheckedKeys.value = []
|
||||
defaultIndicatorCheckedKeys.value = []
|
||||
trendResult.value = null
|
||||
selectorResetKey.value += 1
|
||||
}
|
||||
|
||||
@@ -38,6 +38,39 @@ export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[
|
||||
return indicators
|
||||
}
|
||||
|
||||
export const findFirstSelectableLedgerNode = (
|
||||
nodes: SteadyDataView.SteadyLedgerNode[]
|
||||
): SteadyDataView.SteadyLedgerNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.level === 3 || node.selectable) {
|
||||
return node
|
||||
}
|
||||
|
||||
const childNode = findFirstSelectableLedgerNode(node.children || [])
|
||||
if (childNode) return childNode
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const findFirstLeafIndicator = (
|
||||
nodes: SteadyDataView.SteadyIndicatorNode[]
|
||||
): SteadyDataView.SteadyIndicatorNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.children?.length) {
|
||||
const childNode = findFirstLeafIndicator(node.children)
|
||||
if (childNode) return childNode
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.indicatorCode) {
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const hasHarmonicIndicator = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
return indicators.some(item => item.harmonic || Boolean(item.harmonicOrderStart || item.harmonicOrderEnd))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface SteadyTrendFormState {
|
||||
timeUnit: TimePeriodUnit
|
||||
timeBaseDate: Date
|
||||
statTypes: SteadyDataView.SteadyTrendStatType[]
|
||||
bucket: string
|
||||
qualityFlag?: number
|
||||
harmonicOrders: number[]
|
||||
}
|
||||
@@ -19,8 +18,7 @@ export const defaultTrendFormState = (): SteadyTrendFormState => {
|
||||
timeUnit: 'month',
|
||||
timeBaseDate: baseDate,
|
||||
statTypes: ['AVG'],
|
||||
bucket: '10m',
|
||||
qualityFlag: 1,
|
||||
qualityFlag: 0,
|
||||
harmonicOrders: []
|
||||
}
|
||||
}
|
||||
@@ -41,7 +39,6 @@ export const buildSteadyTrendQueryPayload = (
|
||||
statTypes: formState.statTypes,
|
||||
timeStart: formatSteadyTrendQueryTime(formState.timeRange[0] || ''),
|
||||
timeEnd: formatSteadyTrendQueryTime(formState.timeRange[1] || ''),
|
||||
bucket: formState.bucket,
|
||||
qualityFlag: formState.qualityFlag,
|
||||
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user