Files
opencatd-open/frontend/src/views/dashboard/User.vue
T
Sakurasan 1e07c5903f redesign: site-wide UI/UX overhaul per web-interface-guidelines
- unified design system: focus-visible rings, reduced-motion, color-scheme,
  theme persistence, tightened emerald radius tokens (checkbox circle bug)
- redesigned all pages: Home/Login/Signup/404, dashboard shell, Overview hero
  (time-based gradient), list pages with empty states, form modals, detail pages
- role-scoped navigation: console menu vs admin console with cross-switch
  entries, Chinese route titles and breadcrumbs
- theme switcher (light/dark/auto) via composables/theme, home + dashboard
- state-driven dropdowns (touch-safe logout), menu divider pointer-events fix
- btn-primary unified to black-on-white / white-on-black per theme
- update REFACTOR_PLAN.md (increments 2-6)
2026-08-30 01:35:10 +08:00

323 lines
12 KiB
Vue

<template>
<div class="space-y-5">
<BreadcrumbHeader />
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-base-content/60">Team members and their access.</p>
<div class="flex items-center gap-2">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
<Settings2Icon class="h-4 w-4" aria-hidden="true" />
</button>
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-36 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
<li>
<button class="text-error" @click="handleBatchAction('delete')">
<TrashIcon class="h-4 w-4" aria-hidden="true" />Delete
</button>
</li>
<li>
<button @click="handleBatchAction('disable')">
<BadgeXIcon class="h-4 w-4" aria-hidden="true" />Disable
</button>
</li>
<li>
<button @click="handleBatchAction('enable')">
<BadgeCheckIcon class="h-4 w-4" aria-hidden="true" />Enable
</button>
</li>
</ul>
</div>
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new user">
<PlusIcon class="h-4 w-4" aria-hidden="true" />New User
</button>
</div>
</div>
<!-- Status filter -->
<details class="dropdown dropdown-end" v-if="users.length">
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
Status
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
</summary>
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
<li v-for="status in statusOptions" :key="status">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" class="checkbox checkbox-xs"
:checked="selectedStatuses.some(item => item.status === status)"
@change="toggleStatusFilter(status)" />
{{ status }}
</label>
</li>
</ul>
</details>
<!-- Table -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="overflow-x-auto" v-if="users.length">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4">
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
aria-label="Select all users" />
</th>
<th>ID</th>
<th>Name</th>
<th>Active</th>
<th class="text-right">Quota</th>
<th class="text-right">Used</th>
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="pl-4">
<input type="checkbox" class="checkbox checkbox-xs" v-model="user.selected"
@change="toggleUserSelection(user)" :aria-label="`Select user ${user.username}`" />
</td>
<td class="tabular-nums text-base-content/60">{{ user.id }}</td>
<td class="max-w-40 truncate font-medium">
<div class="flex items-center gap-2">
<div class="placeholder avatar">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral text-neutral-content">
<span class="text-[10px] font-medium">{{ user.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
{{ user.username }}
</div>
</td>
<td>
<input type="checkbox" class="toggle toggle-success toggle-sm"
:class="!user.active && 'toggle-error'" v-model="user.active" @change="updateStatus(user)"
:aria-label="`Toggle user ${user.username}`" />
</td>
<td class="text-right tabular-nums">
<template v-if="user.unlimited_quota">
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
<span class="sr-only">Unlimited</span>
</template>
<template v-else>{{ user.quota }}</template>
</td>
<td class="text-right tabular-nums">{{ user.used_quota }}</td>
<td class="pr-3">
<div class="flex items-center justify-end gap-1">
<button class="btn btn-ghost btn-xs btn-square" @click="viewUser(user)" aria-label="View user details">
<EyeIcon class="h-4 w-4" aria-hidden="true" />
</button>
<button v-if="user.role < 20" class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmDeleteUser(user)" aria-label="Delete user">
<TrashIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state -->
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
<UsersRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
<h2 class="text-sm font-semibold">No users yet</h2>
<p class="max-w-xs text-sm text-base-content/60">
Invite teammates by creating accounts with their own quotas.
</p>
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create User
</button>
</div>
</div>
<!-- Pagination -->
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
<!-- New user modal -->
<dialog ref="modalRef" class="modal">
<div class="modal-box max-w-3xl px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<UserNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import Pagination from '@/components/common/Pagination.vue';
import UserNew from '@/views/dashboard/UserNew.vue';
import { useUserStore } from '@/stores/user';
import { useToast } from '@/composables/toast';
import type { UserInfo } from '@/types';
import {
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
TrashIcon, UsersRoundIcon, Infinity, ListFilterIcon
} from '@lucide/vue';
const router = useRouter();
const userStore = useUserStore();
const users = computed(() => userStore.users);
const { setToast } = useToast();
// 用户数据
const currentPage = ref(1);
const pageSize = ref(10);
const totalItems = computed(() => userStore.totalUsers);
// 封装公共的用户列表获取方法
const listUsers = async (size?: number, page?: number, active?: boolean[] | boolean) => {
currentPage.value = page || currentPage.value;
await userStore.listUser(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
};
// 组件挂载时加载用户数据
onMounted(() => {
listUsers();
});
// 分页与页面大小变化
const changePage = async (page: number, size: number) => {
if (page == currentPage.value && size == pageSize.value) {
return
}
currentPage.value = page;
pageSize.value = size;
await listUsers();
};
// 复选框选择状态
const selectAll = ref(false)
const selectedUsers = ref<UserInfo[]>([])
const toggleSelectAll = () => {
users.value.forEach(key => key.selected = selectAll.value)
if (selectAll.value) {
// Select all users on the current page
selectedUsers.value = users.value.map(user => user)
} else {
// Clear all selections
selectedUsers.value = []
}
}
const toggleUserSelection = (user: UserInfo) => {
if (selectedUsers.value.includes(user)) {
selectedUsers.value = selectedUsers.value.filter(selectedUser => selectedUser !== user);
} else {
selectedUsers.value.push(user);
}
selectAll.value = selectedUsers.value.length === users.value.length;
};
// 状态筛选
const statusOptions = ['Active', 'Inactive'];
const selectedStatuses = reactive<{ status: string; value: boolean }[]>([]);
const toggleStatusFilter = async (status: string) => {
const statusValue = status === 'Active';
const index = selectedStatuses.findIndex(item => item.status === status);
if (index > -1) {
selectedStatuses.splice(index, 1);
} else {
selectedStatuses.push({ status, value: statusValue });
}
await listUsers(undefined, 1, undefined);
};
// 处理批量操作
const handleBatchAction = async (action: string) => {
if (selectedUsers.value.length === 0) {
return setToast('请选择用户', 'error');
}
if (!['enable', 'disable', 'delete'].includes(action)) {
return setToast(`无效的操作 ${action}`, 'error');
}
try {
const res = await userStore.userOption(action, selectedUsers.value.map(user => user.id));
if (res.data?.code === 200) {
setToast(`Users ${action} Success`, 'success');
} else {
setToast(res.data?.error || `${action} Failed`, 'error');
}
selectedUsers.value = [];
selectAll.value = false;
await listUsers();
} catch (error: any) {
console.error(`批量操作 ${action} 失败:`, error);
setToast('批量操作失败', 'error');
}
};
// 更新用户状态
const updateStatus = async (user: UserInfo) => {
try {
const action = user.active ? 'enable' : 'disable';
const res = await userStore.userOption(action, [user.id]);
if (res.data?.code === 200) {
setToast(`User ${user.name} has been ${action}`, 'success');
} else {
setToast(res.data?.error || `用户 ${user.id} ${action} 失败`, 'error');
}
await listUsers();
} catch (error: any) {
console.error('状态更新失败:', error);
setToast('状态更新失败', 'error');
}
};
const viewUser = (user: UserInfo) => {
router.push({ name: 'UserView', query: { id: user.id } });
}
const confirmDeleteUser = (user: UserInfo) => {
if (confirm(`确认删除 ${user.username}?`)) {
deleteUser(user);
}
};
// 删除用户
const deleteUser = async (user: UserInfo) => {
try {
const res = await userStore.userOption('delete', [user.id]);
if (res.data?.code === 200) {
setToast('用户删除成功', 'success');
} else {
setToast(res.data?.error || '删除失败', 'error');
}
await listUsers();
} catch (error: any) {
console.error('删除失败:', error);
setToast('删除失败', 'error');
}
};
// 关闭模态框
const modalRef = ref<HTMLDialogElement | null>(null);
const openModal = () => {
modalRef.value?.showModal();
};
const closeModal = async () => {
if (modalRef.value) {
modalRef.value.close();
}
await listUsers();
};
</script>