- 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)
323 lines
12 KiB
Vue
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>
|