mirror of
https://github.com/kuaifan/dootask.git
synced 2025-12-10 18:02:55 +00:00
perf: 优化时间组件
This commit is contained in:
parent
a7bd0e0dac
commit
a93345afbd
@ -82,13 +82,7 @@ class DialogController extends AbstractController
|
||||
$unreadAt = Request::input('unread_at');
|
||||
$todoAt = Request::input('todo_at');
|
||||
//
|
||||
$unreadAt = Base::isNumber($unreadAt) ? intval($unreadAt) : trim($unreadAt);
|
||||
$unreadAt = Carbon::parse($unreadAt)->setTimezone(config('app.timezone'));
|
||||
//
|
||||
$todoAt = Base::isNumber($todoAt) ? intval($todoAt) : trim($todoAt);
|
||||
$todoAt = Carbon::parse($todoAt)->setTimezone(config('app.timezone'));
|
||||
//
|
||||
$data = WebSocketDialog::getDialogBeyond($user->userid, $unreadAt, $todoAt);
|
||||
$data = WebSocketDialog::getDialogBeyond($user->userid, Base::newCarbon($unreadAt), Base::newCarbon($todoAt));
|
||||
//
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
|
||||
@ -53,8 +53,8 @@ class ReportController extends AbstractController
|
||||
$builder->whereType($keys['type']);
|
||||
}
|
||||
if (is_array($keys['created_at'])) {
|
||||
if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', date('Y-m-d H:i:s', Base::dayTimeF($keys['created_at'][0])));
|
||||
if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', date('Y-m-d H:i:s', Base::dayTimeE($keys['created_at'][1])));
|
||||
if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', Base::newCarbon($keys['created_at'][0])->startOfDay());
|
||||
if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', Base::newCarbon($keys['created_at'][1])->endOfDay());
|
||||
}
|
||||
}
|
||||
$list = $builder->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20));
|
||||
@ -99,14 +99,14 @@ class ReportController extends AbstractController
|
||||
$builder->whereType($keys['type']);
|
||||
}
|
||||
if (is_array($keys['created_at'])) {
|
||||
if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', date('Y-m-d H:i:s', Base::dayTimeF($keys['created_at'][0])));
|
||||
if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', date('Y-m-d H:i:s', Base::dayTimeE($keys['created_at'][1])));
|
||||
if ($keys['created_at'][0] > 0) $builder->where('created_at', '>=', Base::newCarbon($keys['created_at'][0])->startOfDay());
|
||||
if ($keys['created_at'][1] > 0) $builder->where('created_at', '<=', Base::newCarbon($keys['created_at'][1])->endOfDay());
|
||||
}
|
||||
}
|
||||
$list = $builder->orderByDesc('created_at')->paginate(Base::getPaginate(50, 20));
|
||||
if ($list->items()) {
|
||||
foreach ($list->items() as $item) {
|
||||
$item->receive_time = ReportReceive::query()->whereRid($item["id"])->whereUserid($user->userid)->value("receive_time");
|
||||
$item->receive_at = ReportReceive::query()->whereRid($item["id"])->whereUserid($user->userid)->value("receive_at");
|
||||
}
|
||||
}
|
||||
return Base::retSuccess('success', $list);
|
||||
@ -174,7 +174,7 @@ class ReportController extends AbstractController
|
||||
|
||||
foreach ($input["receive"] as $userid) {
|
||||
$input["receive_content"][] = [
|
||||
"receive_time" => Carbon::now()->toDateTimeString(),
|
||||
"receive_at" => Carbon::now()->toDateTimeString(),
|
||||
"userid" => $userid,
|
||||
"read" => 0,
|
||||
];
|
||||
|
||||
@ -34,6 +34,26 @@ class AbstractModel extends Model
|
||||
const ID = 'id';
|
||||
|
||||
protected $dates = [
|
||||
'top_at',
|
||||
'last_at',
|
||||
|
||||
'start_at',
|
||||
'end_at',
|
||||
|
||||
'archived_at',
|
||||
'complete_at',
|
||||
'loop_at',
|
||||
|
||||
'receive_at',
|
||||
|
||||
'line_at',
|
||||
'disable_at',
|
||||
|
||||
'clear_at',
|
||||
|
||||
'read_at',
|
||||
'done_at',
|
||||
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
|
||||
@ -963,8 +963,6 @@ class ProjectTask extends AbstractModel
|
||||
}
|
||||
}
|
||||
$this->save();
|
||||
if ($this->start_at instanceof \DateTimeInterface) $this->start_at = $this->start_at->format('Y-m-d H:i:s');
|
||||
if ($this->end_at instanceof \DateTimeInterface) $this->end_at = $this->end_at->format('Y-m-d H:i:s');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -74,7 +74,7 @@ class Report extends AbstractModel
|
||||
public function receivesUser(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, ReportReceive::class, "rid", "userid")
|
||||
->withPivot("receive_time", "read");
|
||||
->withPivot("receive_at", "read");
|
||||
}
|
||||
|
||||
public function sendUser()
|
||||
|
||||
@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $rid
|
||||
* @property string|null $receive_time 接收时间
|
||||
* @property string|null $receive_at 接收时间
|
||||
* @property int $userid 接收人
|
||||
* @property int $read 是否已读
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
|
||||
@ -24,7 +24,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereRead($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereReceiveTime($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereReceiveAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereRid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ReportReceive whereUserid($value)
|
||||
* @mixin \Eloquent
|
||||
@ -38,7 +38,7 @@ class ReportReceive extends AbstractModel
|
||||
|
||||
protected $fillable = [
|
||||
"rid",
|
||||
"receive_time",
|
||||
"receive_at",
|
||||
"userid",
|
||||
"read",
|
||||
];
|
||||
|
||||
@ -71,7 +71,7 @@ class UserDelete extends AbstractModel
|
||||
}
|
||||
$cache = $row->cache;
|
||||
$cache = array_intersect_key($cache, array_flip(array_merge(User::$basicField, ['department_name'])));
|
||||
$cache['delete_at'] = $row->created_at->format($row->dateFormat ?: 'Y-m-d H:i:s');
|
||||
$cache['delete_at'] = $row->created_at->toDateTimeString();
|
||||
return $cache;
|
||||
}
|
||||
}
|
||||
|
||||
@ -201,7 +201,7 @@ class WebSocketDialog extends AbstractModel
|
||||
if (isset($this->search_msg_id)) {
|
||||
// 最后消息 (搜索预览消息)
|
||||
$this->last_msg = WebSocketDialogMsg::whereDialogId($this->id)->find($this->search_msg_id);
|
||||
$this->last_at = $this->last_msg ? Carbon::parse($this->last_msg->created_at)->format('Y-m-d H:i:s') : null;
|
||||
$this->last_at = $this->last_msg ? Carbon::parse($this->last_msg->created_at)->toDateTimeString() : null;
|
||||
} else {
|
||||
// 未读信息
|
||||
if (Base::judgeClientVersion("0.34.0")) {
|
||||
|
||||
@ -47,8 +47,6 @@ use Carbon\Carbon;
|
||||
*/
|
||||
class WebSocketDialogUser extends AbstractModel
|
||||
{
|
||||
protected $dateFormat = 'Y-m-d H:i:s.v';
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
*/
|
||||
|
||||
@ -6,6 +6,7 @@ use App\Exceptions\ApiException;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Tmp;
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use Overtrue\Pinyin\Pinyin;
|
||||
use Redirect;
|
||||
use Request;
|
||||
@ -1661,7 +1662,7 @@ class Base
|
||||
public static function forumDate($date)
|
||||
{
|
||||
$dur = time() - $date;
|
||||
if ($date > strtotime(date("Y-m-d"))) {
|
||||
if ($date > Carbon::now()->startOf('day')->timestamp) {
|
||||
//今天
|
||||
if ($dur < 60) {
|
||||
return max($dur, 1) . '秒前';
|
||||
@ -1672,10 +1673,10 @@ class Base
|
||||
} else {
|
||||
return date("H:i", $date);
|
||||
}
|
||||
} elseif ($date > strtotime(date("Y-m-d", strtotime("-1 day")))) {
|
||||
} elseif ($date > Carbon::now()->subDays()->startOf('day')->timestamp) {
|
||||
//昨天
|
||||
return '昨天';
|
||||
} elseif ($date > strtotime(date("Y-m-d", strtotime("-2 day")))) {
|
||||
} elseif ($date > Carbon::now()->subDays(2)->startOf('day')->timestamp) {
|
||||
//前天
|
||||
return '前天';
|
||||
} elseif ($dur > 86400) {
|
||||
@ -1686,23 +1687,22 @@ class Base
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时间戳今天的第一秒时间戳
|
||||
* @param $time
|
||||
* @return false|int
|
||||
* 创建Carbon对象
|
||||
* @param $var
|
||||
* @return Carbon
|
||||
*/
|
||||
public static function dayTimeF($time)
|
||||
public static function newCarbon($var)
|
||||
{
|
||||
return strtotime(date("Y-m-d 00:00:00", self::isNumber($time) ? $time : strtotime($time)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时间戳今天的最后一秒时间戳
|
||||
* @param $time
|
||||
* @return false|int
|
||||
*/
|
||||
public static function dayTimeE($time)
|
||||
{
|
||||
return strtotime(date("Y-m-d 23:59:59", self::isNumber($time) ? $time : strtotime($time)));
|
||||
if (self::isNumber($var)) {
|
||||
if (preg_match("/^\d{13,}$/", $var)) {
|
||||
$var = $var / 1000;
|
||||
}
|
||||
return Carbon::createFromTimestamp($var);
|
||||
} elseif (is_string($var)) {
|
||||
return Carbon::parse(trim($var));
|
||||
} else {
|
||||
return Carbon::now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2631,18 +2631,6 @@ class Base
|
||||
return array_values($arr[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前是本月第几个星期
|
||||
* @return float
|
||||
*/
|
||||
public static function getMonthWeek()
|
||||
{
|
||||
$time = strtotime(date("Y-m-01"));
|
||||
$w = date('w', $time);
|
||||
$j = date("j");
|
||||
return ceil(($j . $w) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把返回的数据集转换成Tree
|
||||
* @param array $list 要转换的数据集
|
||||
|
||||
@ -31,12 +31,8 @@ class TimeRange
|
||||
$range = $this->format($data);
|
||||
}
|
||||
//
|
||||
$updated = Base::isNumber($range[0]) ? intval($range[0]) : trim($range[0]);
|
||||
$deleted = Base::isNumber($range[1]) ? intval($range[1]) : trim($range[1]);
|
||||
//
|
||||
$timezone = config('app.timezone');
|
||||
$this->updated = $updated ? Carbon::parse($updated)->setTimezone($timezone) : null;
|
||||
$this->deleted = $deleted ? Carbon::parse($deleted)->setTimezone($timezone) : null;
|
||||
$this->updated = $range[0] ? Base::newCarbon($range[0]) : null;
|
||||
$this->deleted = $range[1] ? Base::newCarbon($range[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -72,7 +68,7 @@ class TimeRange
|
||||
private function format($timerange)
|
||||
{
|
||||
$search = str_contains($timerange, ":") ? ["|"] : ["|", "-"];
|
||||
return explode(",", str_replace($search, ",", $timerange));
|
||||
return Base::newTrim(explode(",", str_replace($search, ",", $timerange)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class RenamePreReportReceivesReceiveTime extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('report_receives', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('report_receives', 'receive_time')) {
|
||||
$table->renameColumn('receive_time', 'receive_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('report_receives', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
}
|
||||
77
resources/assets/js/functions/common.js
vendored
77
resources/assets/js/functions/common.js
vendored
@ -409,8 +409,8 @@ const timezone = require("dayjs/plugin/timezone");
|
||||
* @returns {*}
|
||||
*/
|
||||
cloneJSON(myObj) {
|
||||
if(typeof(myObj) !== 'object') return myObj;
|
||||
if(myObj === null) return myObj;
|
||||
if (typeof (myObj) !== 'object') return myObj;
|
||||
if (myObj === null) return myObj;
|
||||
//
|
||||
return $A.jsonParse($A.jsonStringify(myObj))
|
||||
},
|
||||
@ -1043,34 +1043,65 @@ const timezone = require("dayjs/plugin/timezone");
|
||||
|
||||
/**
|
||||
* 对象中有Date格式的转成指定格式
|
||||
* @param params
|
||||
* @param value
|
||||
* @param format 默认格式:YYYY-MM-DD HH:mm:ss
|
||||
* @returns {*}
|
||||
*/
|
||||
date2string(params, format) {
|
||||
if (params === null) {
|
||||
return params;
|
||||
newDateString(value, format = "YYYY-MM-DD HH:mm:ss") {
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
if (typeof format === "undefined") {
|
||||
format = "YYYY-MM-DD HH:mm:ss";
|
||||
}
|
||||
if (params instanceof dayjs) {
|
||||
params = params.format(format);
|
||||
} else if (params instanceof Date) {
|
||||
params = $A.dayjs(params).format(format);
|
||||
} else if ($A.isJson(params)) {
|
||||
params = Object.assign({}, params)
|
||||
for (let key in params) {
|
||||
if (!params.hasOwnProperty(key)) continue;
|
||||
params[key] = $A.date2string(params[key], format);
|
||||
if (value instanceof dayjs || value instanceof Date) {
|
||||
value = $A.dayjs(value).format(format);
|
||||
} else if ($A.isJson(value)) {
|
||||
value = Object.assign({}, value)
|
||||
for (let key in value) {
|
||||
if (!value.hasOwnProperty(key)) continue;
|
||||
value[key] = $A.newDateString(value[key], format);
|
||||
}
|
||||
} else if ($A.isArray(params)) {
|
||||
params = Object.assign([], params)
|
||||
params.forEach((val, index) => {
|
||||
params[index] = $A.date2string(val, format);
|
||||
} else if ($A.isArray(value)) {
|
||||
value = Object.assign([], value)
|
||||
value.forEach((val, index) => {
|
||||
value[index] = $A.newDateString(val, format);
|
||||
});
|
||||
}
|
||||
return params;
|
||||
return value;
|
||||
},
|
||||
|
||||
/**
|
||||
* 对象中有Date格式的转成时间戳
|
||||
* @param value
|
||||
* @returns {number|*}
|
||||
*/
|
||||
newTimestamp(value) {
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof dayjs || value instanceof Date || $A.isDateString(value)) {
|
||||
value = $A.dayjs(value).unix();
|
||||
} else if ($A.isJson(value)) {
|
||||
value = Object.assign({}, value)
|
||||
for (let key in value) {
|
||||
if (!value.hasOwnProperty(key)) continue;
|
||||
value[key] = $A.newTimestamp(value[key]);
|
||||
}
|
||||
} else if ($A.isArray(value)) {
|
||||
value = Object.assign([], value)
|
||||
value.forEach((val, index) => {
|
||||
value[index] = $A.newTimestamp(val);
|
||||
});
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
/**
|
||||
* 判断是否是日期格式
|
||||
* 支持格式:YYYY-MM-DD HH:mm:ss、YYYY-MM-DD HH:mm、YYYY-MM-DD HH、YYYY-MM-DD
|
||||
* @param value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isDateString(value) {
|
||||
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}( \d{2}(:\d{2}(:\d{2})?)?)?$/i.test(value);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -134,7 +134,7 @@ export default {
|
||||
width: 90,
|
||||
}, {
|
||||
title: this.$L("接收时间"),
|
||||
key: 'receive_time',
|
||||
key: 'receive_at',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
width: 180,
|
||||
|
||||
@ -282,7 +282,7 @@ export default {
|
||||
|
||||
taskDays() {
|
||||
const {times} = this.addData;
|
||||
const temp = $A.date2string(times, "YYYY-MM-DD HH:mm");
|
||||
const temp = $A.newDateString(times, "YYYY-MM-DD HH:mm");
|
||||
if (temp[0] && temp[1]) {
|
||||
const d = Math.ceil($A.dayjs(temp[1]).diff(temp[0], 'day', true));
|
||||
if (d > 0) {
|
||||
@ -384,7 +384,7 @@ export default {
|
||||
},
|
||||
|
||||
async taskTimeChange(data) {
|
||||
const times = $A.date2string(data.times, "YYYY-MM-DD HH:mm");
|
||||
const times = $A.newDateString(data.times, "YYYY-MM-DD HH:mm");
|
||||
if ($A.rightExists(times[0], '00:00') && $A.rightExists(times[1], '23:59')) {
|
||||
this.$set(data, 'times', await this.$store.dispatch("taskDefaultTime", times))
|
||||
}
|
||||
@ -395,7 +395,7 @@ export default {
|
||||
},
|
||||
|
||||
timeTitle(value) {
|
||||
return value ? $A.date2string(value) : null
|
||||
return value ? $A.newDateString(value) : null
|
||||
},
|
||||
|
||||
onKeydown(e) {
|
||||
@ -432,7 +432,7 @@ export default {
|
||||
const days = $A.runNum(item.days);
|
||||
if (days > 0) {
|
||||
const end = start.clone().add(days, 'day');
|
||||
this.$set(this.addData, 'times', await this.$store.dispatch("taskDefaultTime", $A.date2string([start, end])))
|
||||
this.$set(this.addData, 'times', await this.$store.dispatch("taskDefaultTime", $A.newDateString([start, end])))
|
||||
} else {
|
||||
this.$set(this.addData, 'times', [])
|
||||
}
|
||||
|
||||
@ -242,7 +242,7 @@ export default {
|
||||
if ($A.runNum(item.days) > 0) {
|
||||
let start = $A.dayjs();
|
||||
let end = start.clone().add($A.runNum(item.days), 'day');
|
||||
this.$set(this.addData, 'times', $A.date2string([start, end]))
|
||||
this.$set(this.addData, 'times', $A.newDateString([start, end]))
|
||||
} else {
|
||||
this.$set(this.addData, 'times', [])
|
||||
}
|
||||
|
||||
@ -1184,7 +1184,7 @@ export default {
|
||||
},
|
||||
|
||||
async taskTimeChange() {
|
||||
const times = $A.date2string(this.timeValue, "YYYY-MM-DD HH:mm");
|
||||
const times = $A.newDateString(this.timeValue, "YYYY-MM-DD HH:mm");
|
||||
if ($A.rightExists(times[0], '00:00') && $A.rightExists(times[1], '23:59')) {
|
||||
this.timeValue = await this.$store.dispatch("taskDefaultTime", times)
|
||||
}
|
||||
@ -1202,7 +1202,7 @@ export default {
|
||||
$A.messageError("任务已被领取");
|
||||
return;
|
||||
}
|
||||
const times = $A.date2string(this.timeValue, "YYYY-MM-DD HH:mm");
|
||||
const times = $A.newDateString(this.timeValue, "YYYY-MM-DD HH:mm");
|
||||
if (!(times[0] && times[1])) {
|
||||
$A.messageError("请设置计划时间");
|
||||
return;
|
||||
@ -1300,7 +1300,7 @@ export default {
|
||||
},
|
||||
|
||||
timeOk() {
|
||||
const times = $A.date2string(this.timeValue, "YYYY-MM-DD HH:mm");
|
||||
const times = $A.newDateString(this.timeValue, "YYYY-MM-DD HH:mm");
|
||||
this.updateData('times', {
|
||||
start_at: times[0],
|
||||
end_at: times[1],
|
||||
|
||||
6
resources/assets/js/store/actions.js
vendored
6
resources/assets/js/store/actions.js
vendored
@ -113,7 +113,7 @@ export default {
|
||||
}
|
||||
}
|
||||
params.url = $A.apiUrl(params.url)
|
||||
params.data = $A.date2string(params.data)
|
||||
params.data = $A.newDateString(params.data)
|
||||
//
|
||||
const cloneParams = $A.cloneJSON(params)
|
||||
return new Promise(async (resolve, reject) => {
|
||||
@ -2073,7 +2073,7 @@ export default {
|
||||
*/
|
||||
taskAdd({state, dispatch}, data) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const post = $A.cloneJSON($A.date2string(data));
|
||||
const post = $A.cloneJSON($A.newDateString(data));
|
||||
if ($A.isArray(post.column_id)) post.column_id = post.column_id.find((val) => val)
|
||||
//
|
||||
dispatch("call", {
|
||||
@ -2168,7 +2168,7 @@ export default {
|
||||
*/
|
||||
taskBeforeUpdate({state, dispatch}, data) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
let post = $A.cloneJSON($A.date2string(data));
|
||||
let post = $A.cloneJSON($A.newDateString(data));
|
||||
let title = "温馨提示";
|
||||
let content = null;
|
||||
// 修改时间前置判断
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 2f2353e8c1f935e41d5358794110ef45df56dfec
|
||||
Subproject commit 1bd2740471c4f4b7e8c8bcb4a21e79fedf47972e
|
||||
Loading…
x
Reference in New Issue
Block a user