Files
sub2api/frontend/src/components/user/UserAttributeForm.vue
Edric Li f44cf642bc 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>
2026-01-01 18:59:06 +08:00

208 lines
5.6 KiB
Vue

<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>