Apariencia
4. La página del admin
Un plugin del panel no trae su propia interfaz: reutiliza el kit de componentes del panel, sus tiendas y sus estilos. Eso es lo que hace que sea indistinguible de las pantallas del núcleo.
Lo que puedes importar
vueyvue-router.@/components/ui/*(el kit: catálogo),@/composables/*,@/lib/*,@/stores/*,@/api/http,@/i18n,@/app/*.- No
@/modules/…(salvo tres rutas concretas), y nuncaimport * as. La lista exacta: Módulos del admin.
admin/index.ts
El que trae la plantilla ya vale. Su forma:
ts
export default {
feature: "notas",
routes: [{ path: "/notas", component: () => import("./pages/NotasPage.vue"), meta: { title: t("Notas"), permission: ["notas.view"] } }],
nav,
};featurees la clave del módulo: si está apagado, el panel oculta la entrada y bloquea las rutas.meta.permissionrestringe quién entra; la API vuelve a comprobarlo siempre.naves una entrada de menú plana (secciónlocal); el runtime la aplana conPROMOTE, como las del núcleo.- Opcionalmente
tour(tutorial del módulo),settingsLink(enlace en Ajustes) yslots(componentes en pantallas del núcleo).
La pantalla
Sustituye admin/pages/NotasPage.vue. Usa AppPage, UiStats/UiStat, UiCard, UiTable con StatusBadge, UiField/UiInput, UiEmpty y UiLoadError; carga datos con useResource; avisa con useFeedback y comprueba permisos con useSession:
vue
<script setup lang="ts">
import { computed, ref } from "vue";
import { t } from "@/i18n";
import { http } from "@/api/http";
import AppPage from "@/components/ui/AppPage.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiEmpty from "@/components/ui/UiEmpty.vue";
import UiField from "@/components/ui/UiField.vue";
import UiInput from "@/components/ui/UiInput.vue";
import UiLoadError from "@/components/ui/UiLoadError.vue";
import UiStat from "@/components/ui/UiStat.vue";
import UiStats from "@/components/ui/UiStats.vue";
import UiTable from "@/components/ui/UiTable.vue";
import type { Column } from "@/components/ui/types";
import { useResource } from "@/composables/useResource";
import { dateTime } from "@/lib/format";
import { useFeedback } from "@/stores/feedback";
import { useSession } from "@/stores/session";
type Note = { id: string; name: string; note: string | null; pinned: boolean; createdAt: string };
const feedback = useFeedback();
const session = useSession();
const notes = useResource(() => http.get<{ items: Note[] }>("/notas/items"));
const summary = useResource(() => http.get<{ total: number; pinned: number }>("/notas/summary"));
const name = ref("");
const saving = ref(false);
const columns = computed<Column[]>(() => [
{ key: "name", label: t("Nota"), mobile: "title" },
{ key: "pinned", label: t("Estado") },
{ key: "createdAt", label: t("Creada"), nowrap: true },
{ key: "actions", label: "", align: "right" },
]);
const STATE = computed(() => ({ true: { label: t("Fijada"), tone: "accent" as const }, false: { label: t("Normal"), tone: "neutral" as const } }));
async function reload(): Promise<void> {
await Promise.all([notes.reload(), summary.reload()]);
}
async function add(): Promise<void> {
if (!name.value.trim()) return;
saving.value = true;
try {
await http.post("/notas/items", { name: name.value });
name.value = "";
feedback.toast(t("Nota creada"));
await reload();
} catch (err) {
feedback.error(err);
} finally {
saving.value = false;
}
}
async function togglePin(note: Note): Promise<void> {
try {
await http.patch(`/notas/items/${note.id}/pin`, { pinned: !note.pinned });
await reload();
} catch (err) {
feedback.error(err);
}
}
async function remove(note: Note): Promise<void> {
if (!(await feedback.confirm({ title: t("¿Borrar «{name}»?", { name: note.name }), tone: "danger", confirmLabel: t("Borrar") }))) return;
try {
await http.del(`/notas/items/${note.id}`);
await reload();
} catch (err) {
feedback.error(err);
}
}
</script>
<template>
<AppPage :title="t('Notas')" :description="t('Tablón de notas del local con notas fijadas y aviso diario.')">
<UiStats min="180px">
<UiStat :label="t('Notas')" :value="String(summary.data.value?.total ?? 0)" :loading="summary.loading.value && !summary.data.value" />
<UiStat :label="t('Fijadas')" :value="String(summary.data.value?.pinned ?? 0)" :loading="summary.loading.value && !summary.data.value" />
</UiStats>
<UiCard v-if="session.can('notas.edit')" :title="t('Nueva nota')">
<form class="form" @submit.prevent="add">
<UiField :label="t('Texto de la nota')" required>
<UiInput v-model="name" :maxlength="80" />
</UiField>
<UiButton type="submit" variant="primary" :loading="saving">{{ t("Añadir") }}</UiButton>
</form>
</UiCard>
<UiLoadError v-if="notes.error.value" :error="notes.error.value" @retry="reload" />
<UiCard :title="t('Tablón')" flush>
<UiTable :columns="columns" :rows="notes.data.value?.items ?? []" row-key="id" :loading="notes.loading.value && !notes.data.value" bare>
<template #cell-pinned="{ row }"><StatusBadge :of="STATE" :value="row.pinned" dot /></template>
<template #cell-createdAt="{ row }">{{ dateTime(row.createdAt) }}</template>
<template #cell-actions="{ row }">
<UiButton v-if="session.can('notas.pin')" variant="ghost" size="sm" :icon="row.pinned ? 'x' : 'pin'" @click="togglePin(row)">{{ row.pinned ? t("Soltar") : t("Fijar") }}</UiButton>
<UiButton v-if="session.can('notas.edit')" variant="danger-ghost" size="sm" icon="trash" :label="t('Borrar')" @click="remove(row)" />
</template>
<template #empty><UiEmpty :title="t('Todavía no hay notas')" :text="t('Crea la primera para el equipo.')" compact /></template>
</UiTable>
</UiCard>
</AppPage>
</template>
<style scoped>
.form {
display: grid;
gap: var(--s-3);
max-width: 420px;
}
</style>Puntos a fijarse:
- Todos los textos van en
t("…"): el español es la clave de traducción. - Cero colores, radios o tamaños literales: el CSS usa
var(--s-3). Ver Tokens. - Los estados completos: carga (
:loading), vacío (#emptyconUiEmpty) y error (UiLoadErrorcon reintento). - Lo destructivo se confirma con
feedback.confirm({ tone: "danger" }); el resultado se avisa confeedback.toast(); un error de la API, confeedback.error(err). session.can("notas.pin")oculta el botón a quien no tiene el permiso. Ocultar no protege: la API lo exige igualmente.UiTablese convierte en tarjetas en móvil;mobile: "title"marca la columna que hace de título.
Comprueba los tipos y las pruebas:
bash
npm run typecheck
npm testvue-tsc pasa, pero npm test falla: has añadido textos con t() que aún no están en en.json, ca.json y fr.json, y el test de traducciones de la plantilla lo detecta («faltan en en.json»). Es el comportamiento esperado, y se arregla en el siguiente paso.