201 lines
7.0 KiB
Vue
201 lines
7.0 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>
|
|
<VPage :title="$t('nav.users')">
|
|
<template #actions>
|
|
<span v-if="!loading" class="text-muted text-sm">{{
|
|
$t("admin.pageOf", { page, total: totalPages })
|
|
}}</span>
|
|
</template>
|
|
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
|
|
<p v-else-if="errorMessage" class="text-danger my-2 text-sm" role="alert">{{ errorMessage }}</p>
|
|
<VCard v-else-if="users.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
|
|
<div v-else class="overflow-x-auto">
|
|
<div class="min-w-[920px]">
|
|
<VTable>
|
|
<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>
|
|
<VBadge tone="blue">{{ $t(`admin.roles.${user.role}`) }}</VBadge>
|
|
</td>
|
|
<td>
|
|
<div class="grid min-w-[190px] gap-1.5">
|
|
<select
|
|
v-model="draftFor(user).role"
|
|
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 text-sm focus:outline-2"
|
|
: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"
|
|
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 text-sm focus:outline-2"
|
|
: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="text-muted text-sm">{{ shopName(user.shop_id) }}</span>
|
|
</div>
|
|
<p v-if="rowErrors[user.id]" class="text-danger my-1 text-xs" role="alert">
|
|
{{ rowErrors[user.id] }}
|
|
</p>
|
|
</td>
|
|
<td>{{ formatDate(user.created_at) }}</td>
|
|
<td>
|
|
<VBtn
|
|
size="sm"
|
|
variant="primary"
|
|
:disabled="savingId === user.id"
|
|
@click="saveRole(user)"
|
|
>
|
|
{{ savingId === user.id ? $t("common.loading") : $t("common.save") }}
|
|
</VBtn>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="!loading && !errorMessage && users.length > 0"
|
|
class="mt-4 flex items-center justify-center gap-3"
|
|
>
|
|
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
|
|
$t("common.prev")
|
|
}}</VBtn>
|
|
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
|
|
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
|
|
$t("common.next")
|
|
}}</VBtn>
|
|
</div>
|
|
</VPage>
|
|
</template>
|