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

152 lines
4.7 KiB
Vue

<script setup lang="ts">
import type { BrandInput } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
interface BrandRow {
slug: string;
nameEn: string;
nameZh: string;
active: boolean;
}
const { $api } = useNuxtApp();
const { t } = useI18n();
const rows = ref<BrandRow[]>([]);
const loading = ref(true);
const loadError = ref("");
const saving = ref(false);
const rowErrors = ref<Record<number, string>>({});
const feedback = ref<{ ok: boolean; message: string } | null>(null);
async function loadBrands(): Promise<void> {
loading.value = true;
loadError.value = "";
try {
const brands = await $api.admin.getBrands();
rows.value = brands.map((b) => ({
slug: b.slug,
nameEn: b.name.en,
nameZh: b.name.zh,
active: b.active,
}));
} catch (error: unknown) {
loadError.value = error instanceof Error ? error.message : t("common.error");
} finally {
loading.value = false;
}
}
function addRow(): void {
feedback.value = null;
rows.value.push({ slug: "", nameEn: "", nameZh: "", active: true });
}
function removeRow(index: number): void {
feedback.value = null;
rows.value.splice(index, 1);
rowErrors.value = {};
}
function moveRow(index: number, dir: -1 | 1): void {
const target = index + dir;
if (target < 0 || target >= rows.value.length) return;
feedback.value = null;
const [row] = rows.value.splice(index, 1);
rows.value.splice(target, 0, row);
rowErrors.value = {};
}
function validate(): boolean {
const errors: Record<number, string> = {};
const seen = new Map<string, number>();
for (const [index, row] of rows.value.entries()) {
const slug = row.slug.trim();
if (!/^[a-z0-9-]+$/.test(slug)) {
errors[index] = t("admin.brandSlugInvalid");
continue;
}
if (!row.nameEn.trim() || !row.nameZh.trim()) {
errors[index] = t("admin.brandNameRequired");
continue;
}
const first = seen.get(slug);
if (first !== undefined) {
errors[first] = t("admin.brandSlugDuplicate");
errors[index] = t("admin.brandSlugDuplicate");
} else {
seen.set(slug, index);
}
}
rowErrors.value = errors;
return Object.keys(errors).length === 0;
}
async function save(): Promise<void> {
feedback.value = null;
if (!validate()) return;
saving.value = true;
try {
const items: BrandInput[] = rows.value.map((row) => ({
slug: row.slug.trim(),
name: { en: row.nameEn.trim(), zh: row.nameZh.trim() },
active: row.active,
}));
await $api.admin.replaceBrands(items);
feedback.value = { ok: true, message: t("admin.brandsSaved") };
} catch (error: unknown) {
feedback.value = {
ok: false,
message: error instanceof Error ? error.message : t("common.error"),
};
} finally {
saving.value = false;
}
}
onMounted(() => {
void loadBrands();
});
</script>
<template>
<VPage :title="$t('nav.brands')">
<p v-if="loading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
<p v-else-if="loadError" class="my-2 text-sm text-danger" role="alert">{{ loadError }}</p>
<VCard v-else>
<div v-for="(row, index) in rows" :key="index" class="mb-3 rounded-md border border-border p-3">
<div class="grid items-end gap-3 md:grid-cols-[1fr_1fr_1fr_auto_auto]">
<VField :label="$t('admin.brandSlug')" :error="rowErrors[index]">
<VInput v-model="row.slug" placeholder="acme" />
</VField>
<VField :label="$t('admin.currencyNameEn')">
<VInput v-model="row.nameEn" />
</VField>
<VField :label="$t('admin.currencyNameZh')">
<VInput v-model="row.nameZh" />
</VField>
<label class="mb-3.5 flex items-center gap-2 text-sm">
<input v-model="row.active" type="checkbox" class="h-4 w-4 accent-primary" />
{{ $t("admin.enabled") }}
</label>
<div class="mb-3.5 flex gap-1">
<VBtn size="sm" :disabled="index === 0" @click="moveRow(index, -1)">↑</VBtn>
<VBtn size="sm" :disabled="index === rows.length - 1" @click="moveRow(index, 1)">↓</VBtn>
<VBtn size="sm" @click="removeRow(index)">{{ $t("common.delete") }}</VBtn>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<VBtn size="sm" @click="addRow">{{ $t("admin.contentAddRow") }}</VBtn>
<VBtn variant="primary" :disabled="saving" @click="save">
{{ saving ? $t("common.loading") : $t("common.save") }}
</VBtn>
<p v-if="feedback" role="status" class="text-sm" :class="feedback.ok ? 'text-success' : 'text-danger'">
{{ feedback.message }}
</p>
</div>
</VCard>
</VPage>
</template>