feat(frontend): add user attributes management UI
Add Vue components and API client for managing user custom attributes. - Add userAttributes API client with CRUD operations - Add UserAttributeForm component for displaying/editing attribute values - Add UserAttributesConfigModal for attribute definition management - Support all attribute types: text, textarea, number, email, url, date, select, multi_select 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
207
frontend/src/components/user/UserAttributeForm.vue
Normal file
207
frontend/src/components/user/UserAttributeForm.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div v-if="attributes.length > 0" class="space-y-4">
|
||||
<div v-for="attr in attributes" :key="attr.id">
|
||||
<label class="input-label">
|
||||
{{ attr.name }}
|
||||
<span v-if="attr.required" class="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Text Input -->
|
||||
<input
|
||||
v-if="attr.type === 'text' || attr.type === 'email' || attr.type === 'url'"
|
||||
v-model="localValues[attr.id]"
|
||||
:type="attr.type === 'text' ? 'text' : attr.type"
|
||||
:required="attr.required"
|
||||
:placeholder="attr.placeholder"
|
||||
class="input"
|
||||
@input="emitChange"
|
||||
/>
|
||||
|
||||
<!-- Number Input -->
|
||||
<input
|
||||
v-else-if="attr.type === 'number'"
|
||||
v-model.number="localValues[attr.id]"
|
||||
type="number"
|
||||
:required="attr.required"
|
||||
:placeholder="attr.placeholder"
|
||||
:min="attr.validation?.min"
|
||||
:max="attr.validation?.max"
|
||||
class="input"
|
||||
@input="emitChange"
|
||||
/>
|
||||
|
||||
<!-- Date Input -->
|
||||
<input
|
||||
v-else-if="attr.type === 'date'"
|
||||
v-model="localValues[attr.id]"
|
||||
type="date"
|
||||
:required="attr.required"
|
||||
class="input"
|
||||
@input="emitChange"
|
||||
/>
|
||||
|
||||
<!-- Textarea -->
|
||||
<textarea
|
||||
v-else-if="attr.type === 'textarea'"
|
||||
v-model="localValues[attr.id]"
|
||||
:required="attr.required"
|
||||
:placeholder="attr.placeholder"
|
||||
rows="3"
|
||||
class="input"
|
||||
@input="emitChange"
|
||||
/>
|
||||
|
||||
<!-- Select -->
|
||||
<select
|
||||
v-else-if="attr.type === 'select'"
|
||||
v-model="localValues[attr.id]"
|
||||
:required="attr.required"
|
||||
class="input"
|
||||
@change="emitChange"
|
||||
>
|
||||
<option value="">{{ t('common.selectOption') }}</option>
|
||||
<option v-for="opt in attr.options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Multi-Select (Checkboxes) -->
|
||||
<div v-else-if="attr.type === 'multi_select'" class="space-y-2">
|
||||
<label
|
||||
v-for="opt in attr.options"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="opt.value"
|
||||
:checked="isOptionSelected(attr.id, opt.value)"
|
||||
@change="toggleMultiSelectOption(attr.id, opt.value)"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ opt.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="attr.description" class="input-hint">{{ attr.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-else-if="loading" class="flex justify-center py-4">
|
||||
<svg class="h-5 w-5 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { UserAttributeDefinition, UserAttributeValuesMap } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
userId?: number
|
||||
modelValue: UserAttributeValuesMap
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: UserAttributeValuesMap): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const loading = ref(false)
|
||||
const attributes = ref<UserAttributeDefinition[]>([])
|
||||
const localValues = ref<UserAttributeValuesMap>({})
|
||||
|
||||
const loadAttributes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
attributes.value = await adminAPI.userAttributes.listEnabledDefinitions()
|
||||
} catch (error) {
|
||||
console.error('Failed to load attributes:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadUserValues = async () => {
|
||||
if (!props.userId) return
|
||||
|
||||
try {
|
||||
const values = await adminAPI.userAttributes.getUserAttributeValues(props.userId)
|
||||
const valuesMap: UserAttributeValuesMap = {}
|
||||
values.forEach(v => {
|
||||
valuesMap[v.attribute_id] = v.value
|
||||
})
|
||||
localValues.value = { ...valuesMap }
|
||||
emit('update:modelValue', localValues.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load user attribute values:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const emitChange = () => {
|
||||
emit('update:modelValue', { ...localValues.value })
|
||||
}
|
||||
|
||||
const isOptionSelected = (attrId: number, optionValue: string): boolean => {
|
||||
const value = localValues.value[attrId]
|
||||
if (!value) return false
|
||||
try {
|
||||
const arr = JSON.parse(value)
|
||||
return Array.isArray(arr) && arr.includes(optionValue)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMultiSelectOption = (attrId: number, optionValue: string) => {
|
||||
let arr: string[] = []
|
||||
const value = localValues.value[attrId]
|
||||
if (value) {
|
||||
try {
|
||||
arr = JSON.parse(value)
|
||||
if (!Array.isArray(arr)) arr = []
|
||||
} catch {
|
||||
arr = []
|
||||
}
|
||||
}
|
||||
|
||||
const index = arr.indexOf(optionValue)
|
||||
if (index > -1) {
|
||||
arr.splice(index, 1)
|
||||
} else {
|
||||
arr.push(optionValue)
|
||||
}
|
||||
|
||||
localValues.value[attrId] = JSON.stringify(arr)
|
||||
emitChange()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal && Object.keys(newVal).length > 0) {
|
||||
localValues.value = { ...newVal }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.userId, (newUserId) => {
|
||||
if (newUserId) {
|
||||
loadUserValues()
|
||||
} else {
|
||||
// Reset for new user
|
||||
localValues.value = {}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
loadAttributes()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user