chore: initialize mini program frontend
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
const api = require('../../utils/api')
|
||||
|
||||
const emptyForm = {
|
||||
phone: '',
|
||||
name: '',
|
||||
role: 'worker',
|
||||
attendancePointNames: [],
|
||||
}
|
||||
|
||||
const allRoleOptions = [
|
||||
{ label: '冲压工人', value: 'worker' },
|
||||
{ label: '管理员', value: 'admin' },
|
||||
{ label: '经理', value: 'manager' },
|
||||
]
|
||||
|
||||
const workerRoleOptions = [
|
||||
{ label: '冲压工人', value: 'worker' },
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
readonly: false,
|
||||
keyword: '',
|
||||
rows: [],
|
||||
page: 1,
|
||||
pageInput: '1',
|
||||
totalPages: 1,
|
||||
form: { ...emptyForm },
|
||||
formVisible: false,
|
||||
formTitle: '新增人员',
|
||||
importFileName: '',
|
||||
refreshing: false,
|
||||
currentUser: null,
|
||||
attendancePoints: [],
|
||||
roleOptions: allRoleOptions,
|
||||
roleIndex: 0,
|
||||
},
|
||||
onLoad(options) {
|
||||
this.setData({
|
||||
readonly: options.readonly === '1',
|
||||
})
|
||||
},
|
||||
onShow() {
|
||||
this.init()
|
||||
},
|
||||
async init() {
|
||||
const currentUser = api.getCurrentUser()
|
||||
const roleOptions = this.roleOptionsForUser(currentUser)
|
||||
this.setData({ currentUser, roleOptions })
|
||||
await this.loadAttendancePoints()
|
||||
await this.load()
|
||||
},
|
||||
roleOptionsForUser(user = this.data.currentUser) {
|
||||
return user && user.role === 'admin' ? workerRoleOptions : allRoleOptions
|
||||
},
|
||||
async loadAttendancePoints() {
|
||||
try {
|
||||
const attendancePoints = await api.listAccessibleAttendancePoints()
|
||||
this.setData({ attendancePoints: attendancePoints || [] })
|
||||
} catch (error) {
|
||||
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
async load(page = this.data.page) {
|
||||
try {
|
||||
const result = await api.listPeople({
|
||||
keyword: this.data.keyword,
|
||||
page,
|
||||
pageSize: 8,
|
||||
})
|
||||
this.setData({
|
||||
rows: result.rows,
|
||||
page: result.page,
|
||||
pageInput: String(result.page || 1),
|
||||
totalPages: result.totalPages,
|
||||
})
|
||||
} catch (error) {
|
||||
wx.showToast({ title: error.message, icon: 'none' })
|
||||
}
|
||||
},
|
||||
async onPullDownRefresh() {
|
||||
this.setData({ refreshing: true })
|
||||
try {
|
||||
await this.load(1)
|
||||
} finally {
|
||||
this.setData({ refreshing: false })
|
||||
if (wx.stopPullDownRefresh) {
|
||||
wx.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
},
|
||||
onKeywordInput(e) {
|
||||
this.setData({ keyword: e.detail.value, page: 1 })
|
||||
this.load(1)
|
||||
},
|
||||
onInput(e) {
|
||||
const field = e.currentTarget.dataset.field
|
||||
this.setData({
|
||||
[`form.${field}`]: e.detail.value,
|
||||
})
|
||||
},
|
||||
onRoleChange(e) {
|
||||
const roleIndex = Number(e.detail.value)
|
||||
const role = this.data.roleOptions[roleIndex].value
|
||||
const updates = {
|
||||
roleIndex,
|
||||
'form.role': role,
|
||||
}
|
||||
if (role === 'manager') {
|
||||
if (!this.data.form.originalPhone) {
|
||||
updates['form.attendancePointNames'] = []
|
||||
updates.attendancePoints = this.checkedPointMap([])
|
||||
}
|
||||
} else if (!(this.data.form.attendancePointNames || []).length && this.data.attendancePoints[0]) {
|
||||
const defaultPointNames = [this.data.attendancePoints[0].name]
|
||||
updates['form.attendancePointNames'] = defaultPointNames
|
||||
updates.attendancePoints = this.checkedPointMap(defaultPointNames)
|
||||
}
|
||||
this.setData({
|
||||
...updates,
|
||||
})
|
||||
},
|
||||
onPointCheckboxChange(e) {
|
||||
const names = e.detail.value || []
|
||||
this.setData({
|
||||
'form.attendancePointNames': names,
|
||||
attendancePoints: this.checkedPointMap(names),
|
||||
})
|
||||
},
|
||||
checkedPointMap(names = []) {
|
||||
const selected = new Set(names || [])
|
||||
return this.data.attendancePoints.map(point => ({
|
||||
...point,
|
||||
checked: selected.has(point.name),
|
||||
}))
|
||||
},
|
||||
noop() {},
|
||||
openCreate() {
|
||||
const defaultPointNames = this.data.attendancePoints[0] ? [this.data.attendancePoints[0].name] : []
|
||||
this.setData({
|
||||
form: { ...emptyForm, attendancePointNames: defaultPointNames },
|
||||
attendancePoints: this.checkedPointMap(defaultPointNames),
|
||||
roleIndex: 0,
|
||||
formTitle: '新增人员',
|
||||
formVisible: true,
|
||||
})
|
||||
},
|
||||
closeForm() {
|
||||
this.setData({
|
||||
formVisible: false,
|
||||
form: { ...emptyForm },
|
||||
attendancePoints: this.checkedPointMap([]),
|
||||
roleOptions: this.roleOptionsForUser(),
|
||||
roleIndex: 0,
|
||||
})
|
||||
},
|
||||
async save() {
|
||||
try {
|
||||
const form = { ...this.data.form }
|
||||
if (form.role !== 'manager' && !(form.attendancePointNames || []).length) {
|
||||
wx.showToast({ title: '请选择考勤点', icon: 'none' })
|
||||
return
|
||||
}
|
||||
await api.savePerson(form)
|
||||
wx.showToast({ title: '已保存', icon: 'success' })
|
||||
this.setData({
|
||||
form: { ...emptyForm },
|
||||
attendancePoints: this.checkedPointMap([]),
|
||||
roleOptions: this.roleOptionsForUser(),
|
||||
roleIndex: 0,
|
||||
formVisible: false,
|
||||
})
|
||||
await this.load(1)
|
||||
} catch (error) {
|
||||
wx.showToast({ title: error.message, icon: 'none' })
|
||||
}
|
||||
},
|
||||
edit(e) {
|
||||
const index = e.currentTarget.dataset.index
|
||||
const person = this.data.rows[index]
|
||||
const roleIndex = Math.max(0, this.data.roleOptions.findIndex(item => item.value === person.role))
|
||||
const pointNames = person.attendancePointNames || []
|
||||
this.setData({
|
||||
form: {
|
||||
phone: person.phone,
|
||||
originalPhone: person.phone,
|
||||
name: person.name,
|
||||
role: person.role,
|
||||
attendancePointNames: pointNames,
|
||||
},
|
||||
attendancePoints: this.checkedPointMap(pointNames),
|
||||
roleIndex,
|
||||
roleOptions: this.roleOptionsForUser(),
|
||||
formTitle: '编辑人员',
|
||||
formVisible: true,
|
||||
})
|
||||
},
|
||||
addRole(e) {
|
||||
const index = e.currentTarget.dataset.index
|
||||
const person = this.data.rows[index]
|
||||
if (!person) {
|
||||
return
|
||||
}
|
||||
const existingRoles = new Set(person.roles || [])
|
||||
const roleOptions = this.roleOptionsForUser().filter(item => !existingRoles.has(item.value))
|
||||
if (!roleOptions.length) {
|
||||
wx.showToast({ title: '该人员已拥有全部可添加角色', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const role = roleOptions[0].value
|
||||
const pointNames = person.attendancePointNames || []
|
||||
this.setData({
|
||||
form: {
|
||||
phone: person.phone,
|
||||
originalPhone: person.phone,
|
||||
name: person.name,
|
||||
role,
|
||||
attendancePointNames: role === 'manager' ? pointNames : pointNames,
|
||||
},
|
||||
attendancePoints: this.checkedPointMap(pointNames),
|
||||
roleOptions,
|
||||
roleIndex: 0,
|
||||
formTitle: '添加角色',
|
||||
formVisible: true,
|
||||
})
|
||||
},
|
||||
removeRole(e) {
|
||||
const phone = e.currentTarget.dataset.phone
|
||||
const role = e.currentTarget.dataset.role
|
||||
const roleName = e.currentTarget.dataset.roleName
|
||||
wx.showModal({
|
||||
title: '删除角色',
|
||||
content: `${phone} · ${roleName}`,
|
||||
success: async res => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await api.deletePerson(phone, role)
|
||||
await this.load(1)
|
||||
} catch (error) {
|
||||
wx.showToast({ title: error.message, icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
removePerson(e) {
|
||||
const index = e.currentTarget.dataset.index
|
||||
const person = this.data.rows[index]
|
||||
if (!person) {
|
||||
return
|
||||
}
|
||||
const currentUser = api.getCurrentUser()
|
||||
if (currentUser && currentUser.phone === person.phone) {
|
||||
wx.showToast({ title: '不能删除当前登录账号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.showModal({
|
||||
title: '删除人员',
|
||||
content: `确定删除 ${person.name}(${person.phone})?历史报工记录会保留。`,
|
||||
confirmText: '删除',
|
||||
confirmColor: '#d92d20',
|
||||
success: async res => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await api.deletePerson(person.phone)
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
await this.load(1)
|
||||
} catch (error) {
|
||||
wx.showToast({ title: error.message, icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
clearForm() {
|
||||
this.setData({
|
||||
form: { ...emptyForm },
|
||||
attendancePoints: this.checkedPointMap([]),
|
||||
roleOptions: this.roleOptionsForUser(),
|
||||
roleIndex: 0,
|
||||
})
|
||||
},
|
||||
chooseFile() {
|
||||
wx.chooseMessageFile({
|
||||
count: 1,
|
||||
type: 'file',
|
||||
extension: ['xlsx', 'xls'],
|
||||
success: async res => {
|
||||
const file = res.tempFiles[0]
|
||||
this.setData({ importFileName: file.name })
|
||||
wx.showLoading({ title: '导入中' })
|
||||
try {
|
||||
const result = await api.importPeople(file.path)
|
||||
wx.showToast({ title: `导入${result.imported}条`, icon: 'success' })
|
||||
await this.load(1)
|
||||
} catch (error) {
|
||||
wx.showToast({ title: error.message, icon: 'none' })
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
async exportFile() {
|
||||
wx.showLoading({ title: '导出中' })
|
||||
try {
|
||||
const filePath = await api.exportPeople()
|
||||
wx.openDocument({
|
||||
filePath,
|
||||
fileType: 'xlsx',
|
||||
showMenu: true,
|
||||
})
|
||||
} catch (error) {
|
||||
wx.showToast({ title: error.message, icon: 'none' })
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
},
|
||||
prevPage() {
|
||||
if (this.data.page > 1) {
|
||||
this.load(this.data.page - 1)
|
||||
}
|
||||
},
|
||||
nextPage() {
|
||||
if (this.data.page < this.data.totalPages) {
|
||||
this.load(this.data.page + 1)
|
||||
}
|
||||
},
|
||||
onPageInput(e) {
|
||||
this.setData({ pageInput: e.detail.value })
|
||||
},
|
||||
jumpToPage(e) {
|
||||
wx.hideKeyboard()
|
||||
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||
if (target === this.data.page) {
|
||||
this.setData({ pageInput: String(this.data.page || 1) })
|
||||
return
|
||||
}
|
||||
this.load(target)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "人员清单"
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<scroll-view
|
||||
class="page safe-bottom"
|
||||
scroll-y
|
||||
type="list"
|
||||
refresher-enabled
|
||||
refresher-triggered="{{refreshing}}"
|
||||
bindrefresherrefresh="onPullDownRefresh"
|
||||
>
|
||||
<view class="header">
|
||||
<text class="title">人员清单</text>
|
||||
<text class="subtitle">电话号唯一,角色决定可用功能</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!readonly}}" class="card">
|
||||
<view class="button-row">
|
||||
<button class="btn secondary full" bindtap="chooseFile">导入人员清单</button>
|
||||
<button class="btn primary full" bindtap="exportFile">导出人员清单</button>
|
||||
</view>
|
||||
<button class="btn primary full add-btn" bindtap="openCreate">新增人员</button>
|
||||
<text wx:if="{{importFileName}}" class="subtitle">已选择:{{importFileName}}</text>
|
||||
</view>
|
||||
|
||||
<input class="input search" placeholder="搜索姓名、电话、角色" value="{{keyword}}" bindinput="onKeywordInput" />
|
||||
|
||||
<view wx:if="{{!rows.length}}" class="list-empty">暂无人员</view>
|
||||
<view wx:for="{{rows}}" wx:key="phone" wx:for-index="index" class="card person-card {{item.temporaryExpired ? 'person-expired' : ''}}">
|
||||
<view class="row">
|
||||
<view class="row-left">
|
||||
<text class="value">{{item.name}}</text>
|
||||
<text class="subtitle">{{item.phone}}</text>
|
||||
<text wx:if="{{item.role !== 'manager'}}" class="subtitle">考勤点 {{item.attendancePointsText || '-'}}</text>
|
||||
<text wx:if="{{item.temporaryStatusText}}" class="temporary-status {{item.temporaryExpired ? 'expired' : ''}}">
|
||||
{{item.temporaryStatusText}}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{readonly}}" class="role-row">
|
||||
<view
|
||||
wx:for="{{item.rolesDisplay}}"
|
||||
wx:key="role"
|
||||
wx:for-item="roleItem"
|
||||
class="role-chip readonly-chip"
|
||||
>
|
||||
{{roleItem.roleName}}
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="role-row">
|
||||
<button
|
||||
wx:for="{{item.rolesDisplay}}"
|
||||
wx:key="role"
|
||||
wx:for-item="roleItem"
|
||||
class="role-chip"
|
||||
data-phone="{{item.phone}}"
|
||||
data-role="{{roleItem.role}}"
|
||||
data-role-name="{{roleItem.roleName}}"
|
||||
bindtap="removeRole"
|
||||
>
|
||||
{{roleItem.roleName}} ×
|
||||
</button>
|
||||
</view>
|
||||
<view wx:if="{{!readonly}}" class="button-row">
|
||||
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
|
||||
<button wx:if="{{currentUser.role === 'manager'}}" class="btn secondary full" data-index="{{index}}" bindtap="addRole">添加角色</button>
|
||||
<button class="btn danger full" data-index="{{index}}" bindtap="removePerson">删除人员</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="button-row pager-row" wx:if="{{rows.length}}">
|
||||
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||
<view class="page-jump-control">
|
||||
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||
<text class="page-total-label">/ {{totalPages}}</text>
|
||||
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||
</view>
|
||||
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view wx:if="{{formVisible}}" class="modal-mask" catchtap="closeForm">
|
||||
<view class="modal-panel" catchtap="noop">
|
||||
<view class="modal-head">
|
||||
<text class="modal-title">{{formTitle}}</text>
|
||||
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">电话号</text>
|
||||
<input class="input" type="number" value="{{form.phone}}" disabled="{{!!form.originalPhone}}" data-field="phone" bindinput="onInput" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">姓名</text>
|
||||
<input class="input" value="{{form.name}}" data-field="name" bindinput="onInput" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="label">角色</text>
|
||||
<picker mode="selector" range="{{roleOptions}}" range-key="label" value="{{roleIndex}}" bindchange="onRoleChange">
|
||||
<view class="picker">{{roleOptions[roleIndex].label}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view wx:if="{{form.role !== 'manager'}}" class="form-item">
|
||||
<text class="label">考勤点</text>
|
||||
<checkbox-group class="checkbox-grid" bindchange="onPointCheckboxChange">
|
||||
<label
|
||||
wx:for="{{attendancePoints}}"
|
||||
wx:key="name"
|
||||
class="checkbox-chip {{item.checked ? 'checked' : ''}}"
|
||||
>
|
||||
<checkbox value="{{item.name}}" checked="{{item.checked}}" color="#1463ff"></checkbox>
|
||||
<text>{{item.name}}</text>
|
||||
</label>
|
||||
</checkbox-group>
|
||||
</view>
|
||||
<view class="button-row">
|
||||
<button class="btn secondary full" bindtap="closeForm">关闭</button>
|
||||
<button class="btn primary full" bindtap="save">保存人员</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,157 @@
|
||||
.search {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.role-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.role-chip {
|
||||
min-width: 128rpx;
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
|
||||
color: #1456c2;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
box-shadow:
|
||||
0 6rpx 0 rgba(144, 178, 235, 0.62),
|
||||
0 10rpx 16rpx rgba(20, 86, 194, 0.09),
|
||||
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.role-chip::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.person-expired {
|
||||
background: linear-gradient(180deg, #f2f4f7 0%, #e4e7ec 100%);
|
||||
border-color: #98a2b3;
|
||||
filter: grayscale(0.8);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.person-expired .value,
|
||||
.person-expired .subtitle {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.person-card .button-row .btn {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
padding: 0 10rpx;
|
||||
font-size: 25rpx;
|
||||
}
|
||||
|
||||
.temporary-status {
|
||||
display: inline-flex;
|
||||
margin-top: 10rpx;
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #e7f7ef;
|
||||
color: #087443;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.temporary-status.expired {
|
||||
background: #eef0f3;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.readonly-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32rpx;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
width: 100%;
|
||||
max-height: 82vh;
|
||||
overflow-y: auto;
|
||||
padding: 30rpx;
|
||||
border: 1rpx solid #e4e9f1;
|
||||
border-radius: 18rpx;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||
box-shadow:
|
||||
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
color: #162232;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.checkbox-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
min-height: 72rpx;
|
||||
padding: 0 18rpx;
|
||||
border: 2rpx solid #cbd6e6;
|
||||
border-radius: 14rpx;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%);
|
||||
color: #162232;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
box-shadow:
|
||||
0 6rpx 0 rgba(166, 181, 203, 0.42),
|
||||
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.checkbox-chip.checked {
|
||||
border-color: #7aa7f5;
|
||||
background: linear-gradient(180deg, #eef5ff 0%, #ddeaff 100%);
|
||||
color: #1456c2;
|
||||
}
|
||||
Reference in New Issue
Block a user