Files
vmall/apps/admin/pages/users.vue
T

194 lines
6.6 KiB
Vue

<script setup lang="ts">
import type { Shop, User, UserRole } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
interface UserDraft {
role: UserRole;
shopId: string | null;
}
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const users = ref<User[]>([]);
const shops = ref<Shop[]>([]);
const drafts = reactive<Record<string, UserDraft>>({});
const rowErrors = reactive<Record<string, string>>({});
const page = ref(1);
const total = ref(0);
const perPage = ref(20);
const loading = ref(true);
const errorMessage = ref("");
const savingId = ref<string | null>(null);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
function formatDate(value: string): string {
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
}
function draftFor(user: User): UserDraft {
const existing = drafts[user.id];
if (existing) return existing;
const draft: UserDraft = { role: user.role, shopId: user.shop_id };
drafts[user.id] = draft;
return draft;
}
function shopName(shopId: string | null): string {
if (!shopId) return t("admin.noAssignedShop");
const shop = shops.value.find((item) => item.id === shopId);
return shop ? tText(shop.name, locale.value) : t("admin.noAssignedShop");
}
function tText(text: Record<string, string>, currentLocale: string): string {
return text[currentLocale] ?? text.en ?? Object.values(text)[0] ?? "";
}
function roleNeedsShop(role: UserRole): boolean {
return role === "shop_owner" || role === "shop_staff";
}
function normalizeDraft(draft: UserDraft): void {
if (!roleNeedsShop(draft.role)) draft.shopId = null;
}
async function loadUsers(): Promise<void> {
loading.value = true;
errorMessage.value = "";
try {
const [paged, shopList] = await Promise.all([
$api.admin.listUsers(page.value),
$api.admin.listShops(),
]);
users.value = paged.items;
total.value = paged.total;
perPage.value = paged.per_page;
shops.value = shopList;
for (const user of users.value) {
drafts[user.id] = { role: user.role, shopId: user.shop_id };
delete rowErrors[user.id];
}
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
async function saveRole(user: User): Promise<void> {
const draft = draftFor(user);
normalizeDraft(draft);
rowErrors[user.id] = "";
if (roleNeedsShop(draft.role) && !draft.shopId) {
rowErrors[user.id] = t("admin.shopRequired");
return;
}
savingId.value = user.id;
try {
await $api.admin.setUserRole(user.id, draft.role, draft.shopId);
await loadUsers();
} catch (error: unknown) {
rowErrors[user.id] = error instanceof Error ? error.message : t("common.error");
} finally {
savingId.value = null;
}
}
async function changePage(nextPage: number): Promise<void> {
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
page.value = nextPage;
await loadUsers();
}
onMounted(() => {
void loadUsers();
});
</script>
<template>
<div class="page">
<div class="page-head">
<h1 class="page-title">{{ $t("nav.users") }}</h1>
<span v-if="!loading" class="muted">{{ $t("admin.pageOf", { page, total: totalPages }) }}</span>
</div>
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
<p v-else-if="errorMessage" class="error-text" role="alert">{{ errorMessage }}</p>
<div v-else-if="users.length === 0" class="card muted">{{ $t("common.empty") }}</div>
<div v-else class="table-wrap card">
<table class="table">
<thead>
<tr>
<th>{{ $t("common.email") }}</th>
<th>{{ $t("common.displayName") }}</th>
<th>{{ $t("admin.role") }}</th>
<th>{{ $t("admin.assignShop") }}</th>
<th>{{ $t("admin.created") }}</th>
<th>{{ $t("common.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.email }}</td>
<td>{{ user.display_name }}</td>
<td>
<span class="badge blue">{{ $t(`admin.roles.${user.role}`) }}</span>
</td>
<td>
<div class="role-editor">
<select
v-model="draftFor(user).role"
:aria-label="$t('admin.role')"
@change="normalizeDraft(draftFor(user))"
>
<option value="platform_admin">{{ $t("admin.roles.platform_admin") }}</option>
<option value="shop_owner">{{ $t("admin.roles.shop_owner") }}</option>
<option value="shop_staff">{{ $t("admin.roles.shop_staff") }}</option>
<option value="customer">{{ $t("admin.roles.customer") }}</option>
</select>
<select
v-if="roleNeedsShop(draftFor(user).role)"
v-model="draftFor(user).shopId"
:aria-label="$t('admin.assignShop')"
>
<option :value="null">{{ $t("admin.noAssignedShop") }}</option>
<option v-for="shop in shops" :key="shop.id" :value="shop.id">
{{ tText(shop.name, locale) }}
</option>
</select>
<span v-else class="muted">{{ shopName(user.shop_id) }}</span>
</div>
<p v-if="rowErrors[user.id]" class="error-text row-error" role="alert">{{ rowErrors[user.id] }}</p>
</td>
<td>{{ formatDate(user.created_at) }}</td>
<td>
<button class="btn sm primary" type="button" :disabled="savingId === user.id" @click="saveRole(user)">
{{ savingId === user.id ? $t("common.loading") : $t("common.save") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!loading && !errorMessage && users.length > 0" class="pagination">
<button class="btn sm" type="button" :disabled="page <= 1" @click="changePage(page - 1)">
{{ $t("common.prev") }}
</button>
<span class="muted">{{ page }} / {{ totalPages }}</span>
<button class="btn sm" type="button" :disabled="page >= totalPages" @click="changePage(page + 1)">
{{ $t("common.next") }}
</button>
</div>
</div>
</template>
<style scoped>
.table-wrap { overflow-x: auto; padding: 0; }
.table { min-width: 920px; }
.role-editor { display: grid; gap: 6px; min-width: 190px; }
.row-error { font-size: 12px; margin: 4px 0 0; }
.pagination { align-items: center; display: flex; gap: 12px; justify-content: center; margin-top: 16px; }
</style>