功能添加
This commit is contained in:
@@ -57,24 +57,43 @@
|
||||
@click="showTypePicker = true"
|
||||
:rules="[{ required: true, message: '请选择交易类型' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.classify"
|
||||
is-link
|
||||
readonly
|
||||
name="classify"
|
||||
label="交易分类"
|
||||
placeholder="请选择或输入交易分类"
|
||||
@click="openClassifyPicker"
|
||||
/>
|
||||
<van-field
|
||||
v-model="editForm.subClassify"
|
||||
is-link
|
||||
readonly
|
||||
name="subClassify"
|
||||
label="交易子分类"
|
||||
placeholder="请选择或输入交易子分类"
|
||||
@click="showSubClassifyPicker = true"
|
||||
/>
|
||||
<van-field name="classify" label="交易分类">
|
||||
<template #input>
|
||||
<span v-if="!editForm.classify" style="color: #c8c9cc;">请选择交易分类</span>
|
||||
<span v-else>{{ editForm.classify }}</span>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<div class="classify-buttons">
|
||||
<van-button
|
||||
v-for="item in classifyColumns"
|
||||
:key="item.id"
|
||||
:type="editForm.classify === item.text ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="selectClassify(item.text)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</van-button>
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="editForm.classify"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="clearClassify"
|
||||
>
|
||||
清空
|
||||
</van-button>
|
||||
</div>
|
||||
</van-cell-group>
|
||||
|
||||
<div style="margin: 16px;">
|
||||
@@ -96,42 +115,6 @@
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 交易分类选择器 -->
|
||||
<van-popup v-model:show="showClassifyPicker" position="bottom" round>
|
||||
<van-picker
|
||||
ref="classifyPickerRef"
|
||||
:columns="classifyColumns"
|
||||
@confirm="onClassifyConfirm"
|
||||
@cancel="showClassifyPicker = false"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="picker-toolbar">
|
||||
<van-button class="toolbar-cancel" size="small" @click="clearClassify">清空</van-button>
|
||||
<van-button class="toolbar-add" size="small" type="primary" @click="showAddClassify = true">新增</van-button>
|
||||
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmClassify">确认</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</van-picker>
|
||||
</van-popup>
|
||||
|
||||
<!-- 交易子分类选择器 -->
|
||||
<van-popup v-model:show="showSubClassifyPicker" position="bottom" round>
|
||||
<van-picker
|
||||
ref="subClassifyPickerRef"
|
||||
:columns="subClassifyColumns"
|
||||
@confirm="onSubClassifyConfirm"
|
||||
@cancel="showSubClassifyPicker = false"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="picker-toolbar">
|
||||
<van-button class="toolbar-cancel" size="small" @click="clearSubClassify">清空</van-button>
|
||||
<van-button class="toolbar-add" size="small" type="primary" @click="showAddSubClassify = true">新增</van-button>
|
||||
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmSubClassify">确认</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</van-picker>
|
||||
</van-popup>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddClassify"
|
||||
@@ -141,23 +124,13 @@
|
||||
>
|
||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
|
||||
<!-- 新增子分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddSubClassify"
|
||||
title="新增交易子分类"
|
||||
show-cancel-button
|
||||
@confirm="addNewSubClassify"
|
||||
>
|
||||
<van-field v-model="newSubClassify" placeholder="请输入新的交易子分类" />
|
||||
</van-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, defineProps, defineEmits } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { updateTransaction } from '@/api/transactionRecord'
|
||||
import { getCategoryTree, createCategory } from '@/api/transactionCategory'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -184,16 +157,9 @@ const typeColumns = [
|
||||
|
||||
// 分类相关
|
||||
const classifyColumns = ref([])
|
||||
const subClassifyColumns = ref([])
|
||||
const showTypePicker = ref(false)
|
||||
const showClassifyPicker = ref(false)
|
||||
const showSubClassifyPicker = ref(false)
|
||||
const showAddClassify = ref(false)
|
||||
const showAddSubClassify = ref(false)
|
||||
const newClassify = ref('')
|
||||
const newSubClassify = ref('')
|
||||
const classifyPickerRef = ref(null)
|
||||
const subClassifyPickerRef = ref(null)
|
||||
|
||||
// 编辑表单
|
||||
const editForm = reactive({
|
||||
@@ -203,8 +169,7 @@ const editForm = reactive({
|
||||
balance: '',
|
||||
type: 0,
|
||||
typeText: '',
|
||||
classify: '',
|
||||
subClassify: ''
|
||||
classify: ''
|
||||
})
|
||||
|
||||
// 监听props变化
|
||||
@@ -222,7 +187,6 @@ watch(() => props.transaction, (newVal) => {
|
||||
editForm.type = newVal.type
|
||||
editForm.typeText = getTypeName(newVal.type)
|
||||
editForm.classify = newVal.classify || ''
|
||||
editForm.subClassify = newVal.subClassify || ''
|
||||
|
||||
// 根据交易类型加载分类
|
||||
loadClassifyList(newVal.type)
|
||||
@@ -235,9 +199,8 @@ watch(visible, (newVal) => {
|
||||
|
||||
// 监听交易类型变化,重新加载分类
|
||||
watch(() => editForm.type, (newVal) => {
|
||||
// 清空已选的分类和子分类
|
||||
// 清空已选的分类
|
||||
editForm.classify = ''
|
||||
editForm.subClassify = ''
|
||||
// 重新加载对应类型的分类列表
|
||||
loadClassifyList(newVal)
|
||||
})
|
||||
@@ -246,17 +209,15 @@ const handleVisibleChange = (newVal) => {
|
||||
emit('update:show', newVal)
|
||||
}
|
||||
|
||||
// 加载分类列表(从分类树中提取)
|
||||
// 加载分类列表
|
||||
const loadClassifyList = async (type = null) => {
|
||||
try {
|
||||
const response = await getCategoryTree(type)
|
||||
const response = await getCategoryList(type)
|
||||
if (response.success) {
|
||||
// 从树形结构中提取分类名称(Level 2)
|
||||
classifyColumns.value = (response.data || []).map(item => ({
|
||||
text: item.name,
|
||||
value: item.name,
|
||||
id: item.id,
|
||||
children: item.children || []
|
||||
id: item.id
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -264,25 +225,6 @@ const loadClassifyList = async (type = null) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载子分类列表(根据选中的分类)
|
||||
const loadSubClassifyList = async (classifyName) => {
|
||||
try {
|
||||
// 从已加载的分类树中查找对应的子分类
|
||||
const classifyItem = classifyColumns.value.find(item => item.value === classifyName)
|
||||
if (classifyItem && classifyItem.children) {
|
||||
subClassifyColumns.value = classifyItem.children.map(child => ({
|
||||
text: child.name,
|
||||
value: child.name,
|
||||
id: child.id
|
||||
}))
|
||||
} else {
|
||||
subClassifyColumns.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载子分类列表出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交编辑
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
@@ -294,8 +236,7 @@ const onSubmit = async () => {
|
||||
amount: parseFloat(editForm.amount),
|
||||
balance: parseFloat(editForm.balance),
|
||||
type: editForm.type,
|
||||
classify: editForm.classify,
|
||||
subClassify: editForm.subClassify
|
||||
classify: editForm.classify
|
||||
}
|
||||
|
||||
const response = await updateTransaction(data)
|
||||
@@ -316,11 +257,9 @@ const onSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开分类选择器
|
||||
const openClassifyPicker = async () => {
|
||||
// 先根据当前交易类型加载分类
|
||||
await loadClassifyList(editForm.type)
|
||||
showClassifyPicker.value = true
|
||||
// 选择分类
|
||||
const selectClassify = (classify) => {
|
||||
editForm.classify = classify
|
||||
}
|
||||
|
||||
// 交易类型选择确认
|
||||
@@ -330,24 +269,6 @@ const onTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||
showTypePicker.value = false
|
||||
}
|
||||
|
||||
// 交易分类选择确认
|
||||
const onClassifyConfirm = async ({ selectedOptions }) => {
|
||||
if (selectedOptions && selectedOptions[0]) {
|
||||
editForm.classify = selectedOptions[0].text
|
||||
// 加载对应的子分类
|
||||
await loadSubClassifyList(selectedOptions[0].value)
|
||||
}
|
||||
showClassifyPicker.value = false
|
||||
}
|
||||
|
||||
// 交易子分类选择确认
|
||||
const onSubClassifyConfirm = ({ selectedOptions }) => {
|
||||
if (selectedOptions && selectedOptions[0]) {
|
||||
editForm.subClassify = selectedOptions[0].text
|
||||
}
|
||||
showSubClassifyPicker.value = false
|
||||
}
|
||||
|
||||
// 新增分类
|
||||
const addNewClassify = async () => {
|
||||
if (!newClassify.value.trim()) {
|
||||
@@ -361,10 +282,7 @@ const addNewClassify = async () => {
|
||||
// 调用API创建分类
|
||||
const response = await createCategory({
|
||||
name: categoryName,
|
||||
parentId: 0,
|
||||
type: editForm.type,
|
||||
level: 2,
|
||||
sortOrder: 0
|
||||
type: editForm.type
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
@@ -381,95 +299,15 @@ const addNewClassify = async () => {
|
||||
} finally {
|
||||
newClassify.value = ''
|
||||
showAddClassify.value = false
|
||||
showClassifyPicker.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 新增子分类
|
||||
const addNewSubClassify = async () => {
|
||||
if (!newSubClassify.value.trim()) {
|
||||
showToast('请输入子分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (!editForm.classify) {
|
||||
showToast('请先选择分类')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const subCategoryName = newSubClassify.value.trim()
|
||||
|
||||
// 找到父分类的ID
|
||||
const parentCategory = classifyColumns.value.find(c => c.value === editForm.classify)
|
||||
if (!parentCategory || !parentCategory.id) {
|
||||
showToast('未找到父分类信息')
|
||||
return
|
||||
}
|
||||
|
||||
// 调用API创建子分类
|
||||
const response = await createCategory({
|
||||
name: subCategoryName,
|
||||
parentId: parentCategory.id,
|
||||
type: editForm.type,
|
||||
level: 3,
|
||||
sortOrder: 0
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('子分类创建成功')
|
||||
// 重新加载子分类列表
|
||||
await loadSubClassifyList(editForm.classify)
|
||||
editForm.subClassify = subCategoryName
|
||||
} else {
|
||||
showToast(response.message || '创建子分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建子分类出错:', error)
|
||||
showToast('创建子分类失败')
|
||||
} finally {
|
||||
newSubClassify.value = ''
|
||||
showAddSubClassify.value = false
|
||||
showSubClassifyPicker.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清空分类
|
||||
const clearClassify = () => {
|
||||
editForm.classify = ''
|
||||
showClassifyPicker.value = false
|
||||
showToast('已清空分类')
|
||||
}
|
||||
|
||||
// 清空子分类
|
||||
const clearSubClassify = () => {
|
||||
editForm.subClassify = ''
|
||||
showSubClassifyPicker.value = false
|
||||
showToast('已清空子分类')
|
||||
}
|
||||
|
||||
// 确认分类(从 picker 中获取选中值)
|
||||
const confirmClassify = () => {
|
||||
if (classifyPickerRef.value) {
|
||||
const selectedValues = classifyPickerRef.value.getSelectedOptions()
|
||||
if (selectedValues && selectedValues[0]) {
|
||||
editForm.classify = selectedValues[0].text
|
||||
}
|
||||
}
|
||||
showClassifyPicker.value = false
|
||||
}
|
||||
|
||||
// 确认子分类(从 picker 中获取选中值)
|
||||
const confirmSubClassify = () => {
|
||||
if (subClassifyPickerRef.value) {
|
||||
const selectedValues = subClassifyPickerRef.value.getSelectedOptions()
|
||||
if (selectedValues && selectedValues[0]) {
|
||||
editForm.subClassify = selectedValues[0].text
|
||||
}
|
||||
}
|
||||
showSubClassifyPicker.value = false
|
||||
}
|
||||
|
||||
// 获取交易类型名称
|
||||
const getTypeName = (type) => {
|
||||
const typeMap = {
|
||||
@@ -503,19 +341,16 @@ const formatDate = (dateString) => {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.picker-toolbar {
|
||||
.classify-buttons {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #ebedf0;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.toolbar-cancel {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.toolbar-confirm {
|
||||
margin-left: auto;
|
||||
.classify-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 70px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,47 +11,74 @@
|
||||
v-for="transaction in transactions"
|
||||
:key="transaction.id"
|
||||
>
|
||||
<div
|
||||
class="transaction-card"
|
||||
@click="handleClick(transaction)"
|
||||
>
|
||||
<div class="card-left">
|
||||
<div class="transaction-title">
|
||||
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
|
||||
<div class="transaction-row">
|
||||
<van-checkbox
|
||||
v-if="showCheckbox"
|
||||
:model-value="isSelected(transaction.id)"
|
||||
@update:model-value="toggleSelection(transaction)"
|
||||
class="checkbox-col"
|
||||
/>
|
||||
<div
|
||||
class="transaction-card"
|
||||
@click="handleClick(transaction)"
|
||||
>
|
||||
<div class="card-left">
|
||||
<div class="transaction-title">
|
||||
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
|
||||
</div>
|
||||
<div class="transaction-info">
|
||||
<div>交易时间: {{ formatDate(transaction.occurredAt) }}</div>
|
||||
<div>
|
||||
<span v-if="transaction.classify">
|
||||
分类: {{ transaction.classify }}
|
||||
</span>
|
||||
<span v-if="transaction.upsetedClassify && transaction.upsetedClassify !== transaction.classify" style="color: #ff976a">
|
||||
→ {{ transaction.upsetedClassify }}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
<div v-if="transaction.card">
|
||||
卡号: {{ transaction.card }}
|
||||
</div>
|
||||
<div v-if="transaction.importFrom">
|
||||
来源: {{ transaction.importFrom }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-middle">
|
||||
<van-tag
|
||||
:type="getTypeTagType(transaction.type)"
|
||||
size="medium"
|
||||
>
|
||||
{{ getTypeName(transaction.type) }}
|
||||
</van-tag>
|
||||
<template
|
||||
v-if="Number.isFinite(transaction.upsetedType) && transaction.upsetedType !== transaction.type"
|
||||
>
|
||||
→
|
||||
<van-tag
|
||||
:type="getTypeTagType(transaction.upsetedType)"
|
||||
size="medium"
|
||||
>
|
||||
{{ getTypeName(transaction.upsetedType) }}
|
||||
</van-tag>
|
||||
</template>
|
||||
</div>
|
||||
<div class="transaction-info">
|
||||
<div>交易时间: {{ formatDate(transaction.occurredAt) }}</div>
|
||||
<div v-if="transaction.classify">分类: {{ transaction.classify }}
|
||||
<span v-if="transaction.subClassify">/ {{ transaction.subClassify }}</span>
|
||||
</div>
|
||||
<div v-if="transaction.card">
|
||||
卡号: {{ transaction.card }}
|
||||
</div>
|
||||
<div v-if="transaction.importFrom">
|
||||
来源: {{ transaction.importFrom }}
|
||||
<div class="card-right">
|
||||
<div class="transaction-amount">
|
||||
<div :class="['amount', getAmountClass(transaction.type)]">
|
||||
{{ formatAmount(transaction.amount, transaction.type) }}
|
||||
</div>
|
||||
<div class="balance" v-if="transaction.balance && transaction.balance > 0">
|
||||
余额: {{ formatMoney(transaction.balance) }}
|
||||
</div>
|
||||
<div class="balance" v-if="transaction.refundAmount && transaction.refundAmount > 0">
|
||||
退款: {{ formatMoney(transaction.refundAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
<van-icon name="arrow" size="16" color="#c8c9cc" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-right">
|
||||
<div class="transaction-amount">
|
||||
<div :class="['amount', getAmountClass(transaction.type)]">
|
||||
{{ formatAmount(transaction.amount, transaction.type) }}
|
||||
</div>
|
||||
<div class="balance" v-if="transaction.balance && transaction.balance > 0">
|
||||
余额: {{ formatMoney(transaction.balance) }}
|
||||
</div>
|
||||
<div class="balance" v-if="transaction.refundAmount && transaction.refundAmount > 0">
|
||||
退款: {{ formatMoney(transaction.refundAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
<van-icon name="arrow" size="16" color="#c8c9cc" />
|
||||
</div>
|
||||
</div>
|
||||
<template #right v-if="showDelete">
|
||||
<van-button
|
||||
@@ -76,7 +103,7 @@
|
||||
<script setup>
|
||||
import { defineEmits } from 'vue'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
transactions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -92,10 +119,18 @@ defineProps({
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showCheckbox: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
selectedIds: {
|
||||
type: Set,
|
||||
default: () => new Set()
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['load', 'click', 'delete'])
|
||||
const emit = defineEmits(['load', 'click', 'delete', 'update:selectedIds'])
|
||||
|
||||
const onLoad = () => {
|
||||
emit('load')
|
||||
@@ -109,6 +144,20 @@ const handleDeleteClick = (transaction) => {
|
||||
emit('delete', transaction)
|
||||
}
|
||||
|
||||
const isSelected = (id) => {
|
||||
return props.selectedIds.has(id)
|
||||
}
|
||||
|
||||
const toggleSelection = (transaction) => {
|
||||
const newSelectedIds = new Set(props.selectedIds)
|
||||
if (newSelectedIds.has(transaction.id)) {
|
||||
newSelectedIds.delete(transaction.id)
|
||||
} else {
|
||||
newSelectedIds.add(transaction.id)
|
||||
}
|
||||
emit('update:selectedIds', newSelectedIds)
|
||||
}
|
||||
|
||||
// 获取交易类型名称
|
||||
const getTypeName = (type) => {
|
||||
const typeMap = {
|
||||
@@ -168,6 +217,18 @@ const formatDate = (dateString) => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transaction-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checkbox-col {
|
||||
padding: 12px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.transaction-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -175,12 +236,27 @@ const formatDate = (dateString) => {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-middle {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
@@ -191,14 +267,13 @@ const formatDate = (dateString) => {
|
||||
}
|
||||
|
||||
.transaction-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reason {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -212,6 +287,14 @@ const formatDate = (dateString) => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.original-info {
|
||||
color: #ff976a;
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.transaction-amount {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
@@ -235,10 +318,6 @@ const formatDate = (dateString) => {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.amount.neutral {
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
.balance {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
|
||||
Reference in New Issue
Block a user