feat(aihr): 重构运营后台与租户知识治理
- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
<template>
|
||||
<div class="broadcast-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>银城大喇叭</h1>
|
||||
<p>向当前租户全员公开发布公司通知。发布后立即可见;撤回会停止员工端展示,但保留发布记录。</p>
|
||||
</div>
|
||||
<el-button type="danger" icon="Plus" :disabled="publishing || publishPending || !scopeTenantId || !storageAvailable" @click="openPublish">发布消息</el-button>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="loadError"
|
||||
class="data-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="消息管理暂不可读取。发布与管理仅限 superadmin 或 hr_operator,请确认当前账号权限后重试。"
|
||||
/>
|
||||
|
||||
<section class="table-card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>消息管理</h2>
|
||||
<p>当前支持立即发布、查看版本号和撤回;发布重试不会重复推送,撤回会保留初次审计原因。</p>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-select v-model="status" class="status-select" placeholder="全部状态" :disabled="publishing" @change="handleStatusChange">
|
||||
<el-option label="全部状态" value="" />
|
||||
<el-option label="已发布" value="PUBLISHED" />
|
||||
<el-option label="已撤回" value="WITHDRAWN" />
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" :loading="loading" :disabled="publishing" @click="() => void loadMessages()">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="messages" size="small" empty-text="暂无公司消息">
|
||||
<el-table-column prop="title" label="标题" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="内容摘要" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.content }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="64" align="center">
|
||||
<template #default="{ row }">v{{ row.currentVersion }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="76" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'PUBLISHED' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'PUBLISHED' ? '已发布' : '已撤回' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布信息" width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="audit-info">
|
||||
<span>管理员 #{{ row.publishedBy }}</span>
|
||||
<small class="audit-time">{{ displayTime(row.publishedAt) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="撤回信息" width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.withdrawnAt" class="withdraw-info">
|
||||
<span>管理员 #{{ row.withdrawnBy }}</span>
|
||||
<small class="audit-time">{{ displayTime(row.withdrawnAt) }}</small>
|
||||
<small v-if="row.withdrawReason">原因:{{ row.withdrawReason }}</small>
|
||||
</div>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="76" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'PUBLISHED'" link type="danger" :loading="withdrawingId === row.id" @click="handleWithdraw(row)">
|
||||
撤回
|
||||
</el-button>
|
||||
<span v-else class="muted">已撤回</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:page="pageNum" v-model:limit="pageSize" :total="total" @pagination="() => void loadMessages()" />
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="publishVisible"
|
||||
title="发布银城大喇叭消息"
|
||||
width="680px"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="!publishing"
|
||||
:before-close="beforeClosePublish"
|
||||
>
|
||||
<el-alert
|
||||
class="publish-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="消息会立即向当前租户员工端公开,请确认内容不含敏感人员、薪酬或审批材料。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="publishPending"
|
||||
class="publish-alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="上一次发布结果尚未确认。系统已保留原请求和内容;请重试发布,或明确放弃后再新建消息。"
|
||||
/>
|
||||
<el-form ref="publishFormRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
:disabled="publishing || publishPending"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="例如:夏季服务标准通知"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="正文" prop="content">
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
:disabled="publishing || publishPending"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
maxlength="10000"
|
||||
show-word-limit
|
||||
placeholder="请输入完整通知正文"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="publishing" @click="requestClosePublish">{{ publishPending ? '放弃并关闭' : '取消' }}</el-button>
|
||||
<el-button type="danger" :loading="publishing" @click="submitPublish">{{ publishPending ? '重试发布' : '确认发布' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="ContentBroadcast" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
listBroadcastAdminMessages,
|
||||
publishBroadcast,
|
||||
withdrawBroadcast,
|
||||
type BroadcastAdminMessage,
|
||||
type BroadcastStatus
|
||||
} from '@/api/aihr/broadcast';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const messages = ref<BroadcastAdminMessage[]>([]);
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const status = ref<BroadcastStatus | ''>('');
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const publishVisible = ref(false);
|
||||
const publishing = ref(false);
|
||||
const publishPending = ref(false);
|
||||
const withdrawingId = ref<number>();
|
||||
const publishFormRef = ref<FormInstance>();
|
||||
const form = reactive({ requestId: '', title: '', content: '' });
|
||||
const userStore = useUserStore();
|
||||
const scopeTenantId = ref('');
|
||||
const draftTenantId = ref('');
|
||||
const pendingTenantId = ref('');
|
||||
const storageAvailable = ref(true);
|
||||
const rules = {
|
||||
title: [{ required: true, whitespace: true, message: '请输入消息标题', trigger: 'blur' }],
|
||||
content: [{ required: true, whitespace: true, message: '请输入消息正文', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
const displayTime = (value?: string) => (value ? value.replace('T', ' ').slice(0, 16) : '—');
|
||||
const createRequestId = () => globalThis.crypto?.randomUUID?.() ?? `broadcast-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
const pendingPublishStoragePrefix = 'aihr.broadcast.pending.v2';
|
||||
type PendingPublish = { tenantId: string; requestId: string; title: string; content: string };
|
||||
|
||||
const isValidPendingPublish = (candidate: Partial<PendingPublish>): candidate is PendingPublish =>
|
||||
typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value &&
|
||||
candidate.tenantId === candidate.tenantId.trim() && candidate.tenantId.length <= 64 &&
|
||||
typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() &&
|
||||
/^[A-Za-z0-9._:-]{1,100}$/.test(candidate.requestId) &&
|
||||
typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 &&
|
||||
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000;
|
||||
|
||||
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
|
||||
tenantId ? `${pendingPublishStoragePrefix}:${tenantId}:${userStore.userId || 'unknown'}` : '';
|
||||
|
||||
const safelyReadStorage = (storageKey: string) => {
|
||||
try {
|
||||
return storageKey ? localStorage.getItem(storageKey) : null;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safelyRemoveStorage = (storageKey: string) => {
|
||||
try {
|
||||
if (storageKey) localStorage.removeItem(storageKey);
|
||||
return true;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const persistPendingPublish = () => {
|
||||
pendingTenantId.value = draftTenantId.value;
|
||||
const storageKey = pendingPublishStorageKey();
|
||||
const pending: PendingPublish = {
|
||||
tenantId: pendingTenantId.value,
|
||||
requestId: form.requestId,
|
||||
title: form.title,
|
||||
content: form.content
|
||||
};
|
||||
try {
|
||||
if (!storageKey) throw new Error('missing tenant scope');
|
||||
localStorage.setItem(storageKey, JSON.stringify(pending));
|
||||
return true;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
pendingTenantId.value = '';
|
||||
ElMessage.error('无法保存待确认发布记录,请检查浏览器存储权限后重试。');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPendingPublish = () => {
|
||||
const cleared = safelyRemoveStorage(pendingPublishStorageKey());
|
||||
if (cleared) pendingTenantId.value = '';
|
||||
return cleared;
|
||||
};
|
||||
|
||||
const readPendingPublish = (): PendingPublish | undefined => {
|
||||
const storageKey = pendingPublishStorageKey(scopeTenantId.value);
|
||||
const stored = safelyReadStorage(storageKey);
|
||||
if (!stored) return undefined;
|
||||
try {
|
||||
const candidate = JSON.parse(stored) as Partial<PendingPublish>;
|
||||
if (!isValidPendingPublish(candidate)) {
|
||||
throw new Error('invalid pending publish');
|
||||
}
|
||||
return {
|
||||
tenantId: candidate.tenantId,
|
||||
requestId: candidate.requestId,
|
||||
title: candidate.title,
|
||||
content: candidate.content
|
||||
};
|
||||
} catch {
|
||||
safelyRemoveStorage(storageKey);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingPublish = (candidate: PendingPublish) => {
|
||||
draftTenantId.value = candidate.tenantId;
|
||||
pendingTenantId.value = candidate.tenantId;
|
||||
form.requestId = candidate.requestId;
|
||||
form.title = candidate.title;
|
||||
form.content = candidate.content;
|
||||
publishPending.value = true;
|
||||
publishVisible.value = true;
|
||||
};
|
||||
|
||||
const restorePendingPublish = () => {
|
||||
if (publishPending.value && pendingTenantId.value === scopeTenantId.value) return false;
|
||||
const candidate = readPendingPublish();
|
||||
if (!candidate) return false;
|
||||
applyPendingPublish(candidate);
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadMessages = async (allowWhilePublishing = false) => {
|
||||
if (publishing.value && !allowWhilePublishing) return;
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
try {
|
||||
const response = await listBroadcastAdminMessages({
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
status: status.value || undefined
|
||||
});
|
||||
const resolvedTenantId = String(response.data?.tenantId ?? '').trim();
|
||||
if (!resolvedTenantId) throw new Error('missing broadcast tenant scope');
|
||||
if (scopeTenantId.value && scopeTenantId.value !== resolvedTenantId && publishVisible.value) {
|
||||
// A tenant switch invalidates both an unresolved request and an open draft. Keep an
|
||||
// unresolved request only in its original v2 storage key; never retarget its content.
|
||||
publishPending.value = false;
|
||||
publishVisible.value = false;
|
||||
draftTenantId.value = '';
|
||||
pendingTenantId.value = '';
|
||||
form.requestId = '';
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
}
|
||||
scopeTenantId.value = resolvedTenantId;
|
||||
restorePendingPublish();
|
||||
messages.value = response.data?.rows ?? [];
|
||||
total.value = response.data?.total ?? 0;
|
||||
} catch {
|
||||
messages.value = [];
|
||||
total.value = 0;
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (publishing.value) return;
|
||||
pageNum.value = 1;
|
||||
void loadMessages();
|
||||
};
|
||||
|
||||
const openPublish = () => {
|
||||
if (publishing.value || publishPending.value) return;
|
||||
if (!storageAvailable.value) {
|
||||
ElMessage.error('浏览器存储不可用,无法安全发起公司消息发布。');
|
||||
return;
|
||||
}
|
||||
if (!scopeTenantId.value) {
|
||||
ElMessage.warning('正在确认当前租户,请稍候。');
|
||||
return;
|
||||
}
|
||||
if (restorePendingPublish()) {
|
||||
ElMessage.warning('检测到同一租户存在结果未确认的发布,请先重试或明确放弃。');
|
||||
return;
|
||||
}
|
||||
if (!storageAvailable.value) return;
|
||||
form.requestId = createRequestId();
|
||||
draftTenantId.value = scopeTenantId.value;
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
publishVisible.value = true;
|
||||
};
|
||||
|
||||
const resetPublishForm = () => {
|
||||
if (publishPending.value && !clearPendingPublish()) return false;
|
||||
publishPending.value = false;
|
||||
draftTenantId.value = '';
|
||||
form.requestId = '';
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
publishFormRef.value?.clearValidate();
|
||||
return true;
|
||||
};
|
||||
|
||||
const confirmDiscardPendingPublish = async () => {
|
||||
if (!publishPending.value) return true;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'上一次发布结果尚未确认。继续重试会复用同一请求;放弃后若该请求其实已成功,重新发布可能产生另一条消息。',
|
||||
'确认放弃未确认发布?',
|
||||
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续重试', type: 'warning' }
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestClosePublish = async () => {
|
||||
if (publishing.value || !(await confirmDiscardPendingPublish())) return;
|
||||
if (!resetPublishForm()) return;
|
||||
publishVisible.value = false;
|
||||
};
|
||||
|
||||
const beforeClosePublish = (done: () => void) => {
|
||||
void (async () => {
|
||||
if (publishing.value || !(await confirmDiscardPendingPublish())) return;
|
||||
if (!resetPublishForm()) return;
|
||||
done();
|
||||
})();
|
||||
};
|
||||
|
||||
const submitPublish = async () => {
|
||||
if (!publishFormRef.value) return;
|
||||
const valid = await publishFormRef.value
|
||||
.validate()
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!valid) return;
|
||||
if (!scopeTenantId.value) {
|
||||
ElMessage.warning('当前租户尚未确认,请刷新页面后重试。');
|
||||
return;
|
||||
}
|
||||
if (!draftTenantId.value || draftTenantId.value !== scopeTenantId.value) {
|
||||
ElMessage.warning('当前租户已变化,已取消这条未发布草稿;请刷新后重新填写。');
|
||||
publishVisible.value = false;
|
||||
form.requestId = '';
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
draftTenantId.value = '';
|
||||
return;
|
||||
}
|
||||
const otherPending = readPendingPublish();
|
||||
if (!storageAvailable.value) return;
|
||||
if (otherPending && otherPending.requestId !== form.requestId) {
|
||||
applyPendingPublish(otherPending);
|
||||
ElMessage.warning('检测到另一窗口存在结果未确认的发布,请先重试或明确放弃。');
|
||||
return;
|
||||
}
|
||||
if (!form.requestId) form.requestId = createRequestId();
|
||||
form.title = form.title.trim();
|
||||
form.content = form.content.trim();
|
||||
publishPending.value = true;
|
||||
if (!persistPendingPublish()) {
|
||||
publishPending.value = false;
|
||||
return;
|
||||
}
|
||||
publishing.value = true;
|
||||
try {
|
||||
await publishBroadcast({
|
||||
requestId: form.requestId,
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
expectedTenantId: pendingTenantId.value
|
||||
});
|
||||
if (!resetPublishForm()) {
|
||||
ElMessage.warning('消息已发布,但浏览器无法清理重试记录;请不要新建消息,先恢复浏览器存储后重试原请求。');
|
||||
return;
|
||||
}
|
||||
publishVisible.value = false;
|
||||
ElMessage.success('消息已发布');
|
||||
pageNum.value = 1;
|
||||
await loadMessages(true);
|
||||
} catch {
|
||||
ElMessage.error('发布结果暂未确认,请使用“重试发布”复用原请求,或确认放弃。');
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (message: BroadcastAdminMessage) => {
|
||||
let reason = '';
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(`撤回“${message.title}”后,员工端将不再展示该消息。请填写撤回原因。`, '确认撤回', {
|
||||
confirmButtonText: '确认撤回',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '例如:内容需要修订后重新发布',
|
||||
inputValidator: (value) => {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) return '请填写撤回原因';
|
||||
if (normalized.length > 500) return '撤回原因不能超过 500 个字符';
|
||||
return true;
|
||||
},
|
||||
type: 'warning'
|
||||
});
|
||||
reason = String(result.value ?? '').trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
withdrawingId.value = message.id;
|
||||
try {
|
||||
if (!scopeTenantId.value) {
|
||||
ElMessage.warning('当前租户尚未确认,请刷新页面后重试。');
|
||||
return;
|
||||
}
|
||||
await withdrawBroadcast(message.id, { reason, expectedTenantId: scopeTenantId.value });
|
||||
ElMessage.success('消息已撤回');
|
||||
await loadMessages();
|
||||
} finally {
|
||||
withdrawingId.value = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadMessages();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.broadcast-page {
|
||||
min-height: calc(100vh - 84px);
|
||||
padding: 24px 28px 40px;
|
||||
background: #f4f6fa;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.section-title,
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.section-title {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
color: #858991;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-alert,
|
||||
.publish-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
.section-title {
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p,
|
||||
.muted {
|
||||
color: #858991;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.audit-info,
|
||||
.withdraw-info {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
|
||||
small {
|
||||
color: #858991;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audit-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
color: #667085;
|
||||
font-weight: 500;
|
||||
background: #fafbfc;
|
||||
}
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
flex: none;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 126px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-head,
|
||||
.section-title {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.broadcast-page {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user