Files
Wyndham-RSVN/client/src/views/system/SystemUsersView.vue
T
2026-07-10 18:32:34 +08:00

698 lines
20 KiB
Vue

<template>
<section class="system-admin-panel">
<div class="th-section filter-section">
<div class="th-section-header">
<h2 class="th-section-title">{{ t('systemAdmin.users.title') }}</h2>
<button
type="button"
class="text-button"
@click="resetFilters"
>
{{ t('workbench.reset') }}
</button>
</div>
<div class="filter-grid">
<label>
<span>{{ t('systemAdmin.common.keyword') }}</span>
<input
v-model="filters.keyword"
type="search"
:placeholder="t('systemAdmin.users.keywordPlaceholder')"
@input="resetToFirstPage"
>
</label>
<label>
<span>{{ t('systemAdmin.users.userStatus') }}</span>
<select
v-model="filters.user_status"
@change="resetToFirstPage"
>
<option value="">{{ t('common.all') }}</option>
<option value="ACTIVE">{{ t('systemAdmin.status.ACTIVE') }}</option>
<option value="DISABLED">{{ t('systemAdmin.status.DISABLED') }}</option>
</select>
</label>
</div>
</div>
<section class="th-section action-section">
<div class="th-section-header">
<h2 class="th-section-title">{{ t('systemAdmin.users.createTitle') }}</h2>
<p
v-if="successMessage"
class="form-message"
>
{{ successMessage }}
</p>
</div>
<form
class="form-grid"
@submit.prevent="createUser"
>
<label>
<span>{{ t('systemAdmin.users.username') }}</span>
<input
v-model="createForm.username"
type="text"
autocomplete="off"
>
</label>
<label>
<span>{{ t('systemAdmin.users.initialPassword') }}</span>
<input
v-model="createForm.initial_password"
type="password"
autocomplete="new-password"
>
</label>
<label>
<span>{{ t('systemAdmin.users.displayName') }}</span>
<input
v-model="createForm.display_name"
type="text"
>
</label>
<label>
<span>{{ t('systemAdmin.users.userStatus') }}</span>
<select v-model="createForm.user_status">
<option value="ACTIVE">{{ t('systemAdmin.status.ACTIVE') }}</option>
<option value="DISABLED">{{ t('systemAdmin.status.DISABLED') }}</option>
</select>
</label>
<label>
<span>{{ t('systemAdmin.users.roles') }}</span>
<select
v-model="createForm.role_ids"
multiple
>
<option
v-for="role in availableRoles"
:key="role.id"
:value="role.id"
>
{{ role.role_name }}
</option>
</select>
</label>
<label>
<span>{{ t('systemAdmin.users.hotels') }}</span>
<select
v-model="createForm.hotel_ids"
multiple
>
<option
v-for="hotel in activeHotels"
:key="hotel.hotel_id"
:value="hotel.hotel_id"
>
{{ hotel.hotel_name }}
</option>
</select>
</label>
<label>
<span>{{ t('systemAdmin.users.defaultHotel') }}</span>
<select v-model="createForm.default_hotel_id">
<option
v-for="hotel in activeHotels"
:key="hotel.hotel_id"
:value="hotel.hotel_id"
>
{{ hotel.hotel_name }}
</option>
</select>
</label>
<button
type="submit"
class="primary-button"
:disabled="saving"
>
{{ t('systemAdmin.common.create') }}
</button>
</form>
</section>
<div class="system-admin-grid">
<section class="th-section table-section">
<div class="th-section-header">
<div>
<h2 class="th-section-title">{{ t('systemAdmin.users.tableTitle') }}</h2>
<p class="th-muted">{{ t('workbench.totalPrefix') }} {{ total }} {{ t('systemAdmin.users.totalSuffix') }}</p>
</div>
</div>
<div
v-if="loading"
class="empty-state"
>
{{ t('common.loading') }}
</div>
<div
v-else-if="errorMessage"
class="empty-state empty-state--error"
>
{{ errorMessage }}
</div>
<div
v-else-if="!users.length"
class="empty-state"
>
{{ t('common.noData') }}
</div>
<div
v-else
class="table-wrap"
>
<table>
<thead>
<tr>
<th>{{ t('systemAdmin.users.columns.user') }}</th>
<th>{{ t('systemAdmin.users.columns.status') }}</th>
<th>{{ t('systemAdmin.users.columns.roles') }}</th>
<th>{{ t('systemAdmin.users.columns.defaultHotel') }}</th>
<th>{{ t('workbench.columns.updatedAt') }}</th>
<th>{{ t('workbench.columns.operation') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
>
<td>
<strong>{{ user.display_name || user.username }}</strong>
<small class="th-code">{{ user.username }}</small>
</td>
<td><span class="status-pill">{{ statusLabel(user.user_status) }}</span></td>
<td>{{ roleSummary(user.roles) }}</td>
<td>{{ user.default_hotel_id || '-' }}</td>
<td>{{ formatReservationDateTime(user.updated_at) }}</td>
<td>
<button
type="button"
class="row-action"
@click="selectUser(user.id)"
>
{{ t('systemAdmin.common.viewDetail') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="pagination-bar">
<label class="page-size-control">
<span>{{ t('common.pageSize') }}</span>
<select
v-model.number="filters.page_size"
@change="resetToFirstPage"
>
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
</select>
</label>
<div class="pagination-actions">
<button
type="button"
class="secondary-button"
:disabled="currentPage <= 1 || loading"
@click="goToPage(currentPage - 1)"
>
{{ t('common.previousPage') }}
</button>
<span>{{ t('common.pageStatus', { current: currentPage, total: totalPages }) }}</span>
<button
type="button"
class="secondary-button"
:disabled="currentPage >= totalPages || loading"
@click="goToPage(currentPage + 1)"
>
{{ t('common.nextPage') }}
</button>
</div>
</footer>
</section>
<aside class="th-section detail-panel">
<div class="th-section-header">
<h2 class="th-section-title">{{ t('systemAdmin.users.detailTitle') }}</h2>
</div>
<div
v-if="detailLoading"
class="empty-state empty-state--compact"
>
{{ t('common.loading') }}
</div>
<div
v-else-if="selectedUser"
class="detail-list"
>
<div>
<span>{{ t('systemAdmin.users.columns.user') }}</span>
<strong>{{ selectedUser.display_name || selectedUser.username }}</strong>
<small class="th-code">{{ selectedUser.id }}</small>
</div>
<div>
<span>{{ t('systemAdmin.users.email') }}</span>
<strong>{{ selectedUser.email || '-' }}</strong>
</div>
<div>
<span>{{ t('systemAdmin.users.phone') }}</span>
<strong>{{ selectedUser.phone || '-' }}</strong>
</div>
<div>
<span>{{ t('systemAdmin.users.hotels') }}</span>
<strong>{{ hotelSummary(selectedUser.hotels) }}</strong>
</div>
<form
class="stacked-form"
@submit.prevent="updateSelectedUser"
>
<label>
<span>{{ t('systemAdmin.users.displayName') }}</span>
<input
v-model="editForm.display_name"
type="text"
>
</label>
<label>
<span>{{ t('systemAdmin.users.email') }}</span>
<input
v-model="editForm.email"
type="email"
>
</label>
<label>
<span>{{ t('systemAdmin.users.phone') }}</span>
<input
v-model="editForm.phone"
type="text"
>
</label>
<label>
<span>{{ t('systemAdmin.users.userStatus') }}</span>
<select v-model="editForm.user_status">
<option value="ACTIVE">{{ t('systemAdmin.status.ACTIVE') }}</option>
<option value="DISABLED">{{ t('systemAdmin.status.DISABLED') }}</option>
</select>
</label>
<button
type="submit"
class="secondary-button"
:disabled="saving"
>
{{ t('systemAdmin.common.save') }}
</button>
</form>
<form
class="stacked-form"
@submit.prevent="assignSelectedUserRoles"
>
<label>
<span>{{ t('systemAdmin.users.roles') }}</span>
<select
v-model="editRoleIds"
multiple
>
<option
v-for="role in availableRoles"
:key="role.id"
:value="role.id"
>
{{ role.role_name }}
</option>
</select>
</label>
<button
type="submit"
class="secondary-button"
:disabled="saving"
>
{{ t('systemAdmin.users.saveRoles') }}
</button>
</form>
<form
class="stacked-form"
@submit.prevent="assignSelectedUserHotels"
>
<label>
<span>{{ t('systemAdmin.users.hotels') }}</span>
<select
v-model="editHotelIds"
multiple
>
<option
v-for="hotel in activeHotels"
:key="hotel.hotel_id"
:value="hotel.hotel_id"
>
{{ hotel.hotel_name }}
</option>
</select>
</label>
<label>
<span>{{ t('systemAdmin.users.defaultHotel') }}</span>
<select v-model="editDefaultHotelId">
<option
v-for="hotel in activeHotels"
:key="hotel.hotel_id"
:value="hotel.hotel_id"
>
{{ hotel.hotel_name }}
</option>
</select>
</label>
<button
type="submit"
class="secondary-button"
:disabled="saving"
>
{{ t('systemAdmin.users.saveHotels') }}
</button>
</form>
<button
type="button"
class="secondary-button"
:disabled="saving"
@click="resetSelectedUserPassword"
>
{{ t('systemAdmin.users.resetPassword') }}
</button>
<p
v-if="temporaryPassword"
class="form-message"
>
{{ t('systemAdmin.users.temporaryPassword') }} {{ temporaryPassword }}
</p>
</div>
<div
v-else
class="empty-state empty-state--compact"
>
{{ t('systemAdmin.common.selectRow') }}
</div>
</aside>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
assignAdminUserHotels,
assignAdminUserRoles,
createAdminUser,
fetchAdminHotels,
fetchAdminRoles,
fetchAdminUserDetail,
fetchAdminUsers,
resetAdminUserPassword,
updateAdminUser,
} from '@/services/systemAdminService'
import type {
AdminHotelResult,
AdminRoleListItemResult,
AdminUserCreateRequest,
AdminUserDetailResult,
AdminUserHotelResult,
AdminUserListFilters,
AdminUserListItemResult,
AdminUserRoleResult,
PlatformPaginationResult,
} from '@/types/systemAdmin'
import { formatReservationDateTime } from '@/utils/reservationFormat'
const { t } = useI18n()
const loading = ref(false)
const detailLoading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const saving = ref(false)
const temporaryPassword = ref('')
const users = ref<AdminUserListItemResult[]>([])
const selectedUser = ref<AdminUserDetailResult | null>(null)
const availableRoles = ref<AdminRoleListItemResult[]>([])
const availableHotels = ref<AdminHotelResult[]>([])
const activeHotels = computed(() => availableHotels.value.filter((hotel) => hotel.hotel_status === 'ACTIVE'))
const page = ref(createPage(1, 20, 0))
const total = computed(() => page.value.total)
const currentPage = computed(() => page.value.page_num)
const totalPages = computed(() => Math.max(1, Math.ceil(page.value.total / Math.max(page.value.page_size, 1))))
const filters = reactive<AdminUserListFilters>({
keyword: '',
user_status: '',
page_num: 1,
page_size: 20,
})
const createForm = reactive<AdminUserCreateRequest>({
username: '',
initial_password: '',
display_name: '',
email: '',
phone: '',
user_status: 'ACTIVE',
role_ids: [],
hotel_ids: [],
default_hotel_id: '',
})
const editForm = reactive({
display_name: '',
email: '',
phone: '',
user_status: 'ACTIVE',
})
const editRoleIds = ref<string[]>([])
const editHotelIds = ref<string[]>([])
const editDefaultHotelId = ref('')
let requestSequence = 0
let detailRequestSequence = 0
onMounted(() => {
void loadUsers()
void loadReferenceData()
})
watch(filters, () => {
void loadUsers()
})
async function loadUsers(): Promise<void> {
const currentRequest = ++requestSequence
loading.value = true
errorMessage.value = ''
try {
const result = await fetchAdminUsers({ ...filters })
if (currentRequest !== requestSequence) {
return
}
users.value = result.items
page.value = result.page
if (!selectedUser.value && result.items[0]) {
void selectUser(result.items[0].id)
}
} catch (error) {
if (currentRequest !== requestSequence) {
return
}
users.value = []
page.value = createPage(filters.page_num ?? 1, filters.page_size ?? 20, 0)
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
if (currentRequest === requestSequence) {
loading.value = false
}
}
}
async function selectUser(userId: string): Promise<void> {
const currentRequest = ++detailRequestSequence
detailLoading.value = true
try {
const result = await fetchAdminUserDetail(userId)
if (currentRequest === detailRequestSequence) {
selectedUser.value = result
populateEditForms(result)
temporaryPassword.value = ''
}
} finally {
if (currentRequest === detailRequestSequence) {
detailLoading.value = false
}
}
}
async function loadReferenceData(): Promise<void> {
const [rolesResult, hotelsResult] = await Promise.all([
fetchAdminRoles({ page_num: 1, page_size: 100 }),
fetchAdminHotels({ page_num: 1, page_size: 100 }),
])
availableRoles.value = rolesResult.items
availableHotels.value = hotelsResult.items
if (!createForm.default_hotel_id && activeHotels.value[0]) {
createForm.hotel_ids = [activeHotels.value[0].hotel_id]
createForm.default_hotel_id = activeHotels.value[0].hotel_id
}
}
async function createUser(): Promise<void> {
saving.value = true
successMessage.value = ''
errorMessage.value = ''
try {
const result = await createAdminUser({ ...createForm })
successMessage.value = t('systemAdmin.users.created')
resetCreateForm()
await loadUsers()
await selectUser(result.id)
} catch (error) {
setOperationError(error)
} finally {
saving.value = false
}
}
async function updateSelectedUser(): Promise<void> {
if (!selectedUser.value) {
return
}
saving.value = true
errorMessage.value = ''
try {
const result = await updateAdminUser(selectedUser.value.id, { ...editForm })
selectedUser.value = result
populateEditForms(result)
await loadUsers()
} catch (error) {
setOperationError(error)
} finally {
saving.value = false
}
}
async function assignSelectedUserRoles(): Promise<void> {
if (!selectedUser.value) {
return
}
saving.value = true
errorMessage.value = ''
try {
const result = await assignAdminUserRoles(selectedUser.value.id, {
role_ids: editRoleIds.value,
})
selectedUser.value = result
populateEditForms(result)
await loadUsers()
} catch (error) {
setOperationError(error)
} finally {
saving.value = false
}
}
async function assignSelectedUserHotels(): Promise<void> {
if (!selectedUser.value) {
return
}
saving.value = true
errorMessage.value = ''
try {
const result = await assignAdminUserHotels(selectedUser.value.id, {
hotel_ids: editHotelIds.value,
default_hotel_id: editDefaultHotelId.value,
})
selectedUser.value = result
populateEditForms(result)
await loadUsers()
} catch (error) {
setOperationError(error)
} finally {
saving.value = false
}
}
async function resetSelectedUserPassword(): Promise<void> {
if (!selectedUser.value) {
return
}
saving.value = true
temporaryPassword.value = ''
errorMessage.value = ''
try {
const result = await resetAdminUserPassword(selectedUser.value.id)
temporaryPassword.value = result.temporary_password
} catch (error) {
setOperationError(error)
} finally {
saving.value = false
}
}
function resetFilters(): void {
filters.keyword = ''
filters.user_status = ''
filters.page_num = 1
filters.page_size = 20
}
function resetCreateForm(): void {
createForm.username = ''
createForm.initial_password = ''
createForm.display_name = ''
createForm.email = ''
createForm.phone = ''
createForm.user_status = 'ACTIVE'
createForm.role_ids = []
createForm.hotel_ids = activeHotels.value[0] ? [activeHotels.value[0].hotel_id] : []
createForm.default_hotel_id = activeHotels.value[0]?.hotel_id ?? ''
}
function populateEditForms(user: AdminUserDetailResult): void {
editForm.display_name = user.display_name || ''
editForm.email = user.email || ''
editForm.phone = user.phone || ''
editForm.user_status = user.user_status
editRoleIds.value = user.roles.map((role) => role.id)
editHotelIds.value = user.hotels.map((hotel) => hotel.hotel_id)
editDefaultHotelId.value = user.hotels.find((hotel) => hotel.default_hotel)?.hotel_id ?? editHotelIds.value[0] ?? ''
}
function resetToFirstPage(): void {
filters.page_num = 1
}
function goToPage(pageNum: number): void {
filters.page_num = Math.min(Math.max(pageNum, 1), totalPages.value)
}
function createPage(pageNum: number, pageSize: number, total: number): PlatformPaginationResult {
return {
page_num: pageNum,
page_size: pageSize,
total,
}
}
function roleSummary(roles: AdminUserRoleResult[]): string {
return roles.map((role) => role.role_name || role.role_code).join(', ') || '-'
}
function hotelSummary(hotels: AdminUserHotelResult[]): string {
return hotels.map((hotel) => hotel.hotel_name || hotel.hotel_id).join(', ') || '-'
}
function statusLabel(status: string): string {
return t(`systemAdmin.status.${status}`)
}
function setOperationError(error: unknown): void {
errorMessage.value = error instanceof Error ? error.message : String(error)
}
</script>
<style scoped>
@import './system-admin.css';
</style>