163 lines
4.8 KiB
Vue
163 lines
4.8 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-muted text-sm">{{ $t("common.loading") }}</p>
|
|
<p v-else-if="loadError" class="text-danger my-2 text-sm" role="alert">{{ loadError }}</p>
|
|
<VCard v-else>
|
|
<div
|
|
v-for="(row, index) in rows"
|
|
:key="index"
|
|
class="border-border mb-3 rounded-md 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="accent-primary h-4 w-4" />
|
|
{{ $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>
|