feat: add admin components for input, metrics, tables, and user forms

- Introduced AdminInput component for standardized input fields.
- Created AdminMetricCard for displaying metrics with customizable tones.
- Added AdminPlaceholderTable for loading states in tables.
- Developed AdminSectionCard for consistent section layouts.
- Implemented AdminSectionShell for organizing admin sections.
- Added AdminSelect for dropdown selections with v-model support.
- Created AdminTable for displaying tabular data with loading and empty states.
- Introduced AdminTextarea for multi-line text input.
- Developed AdminUserFormFields for user creation and editing forms.
- Added useAdminPageHeader composable for managing admin page header state.
This commit is contained in:
2026-03-24 07:08:44 +00:00
parent e854c68ad0
commit b60f65e4d1
100 changed files with 9270 additions and 2204 deletions

View File

@@ -1,369 +1,218 @@
<script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue';
import BaseTable from '@/components/ui/BaseTable.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
import { useQuery } from '@pinia/colada';
import type { ColumnDef } from '@tanstack/vue-table';
import { useTranslation } from 'i18next-vue';
import { computed, ref, watch } from 'vue';
import DomainsDnsDialog from './components/DomainsDnsDialog.vue';
import DomainsDnsEmbedCode from './components/DomainsDnsEmbedCode.vue';
import DomainsDnsNotices from './components/DomainsDnsNotices.vue';
import DomainsDnsTable from './components/DomainsDnsTable.vue';
import DomainsDnsToolbar from './components/DomainsDnsToolbar.vue';
import { mapDomainItem, normalizeDomainInput } from './helpers';
import type { DomainItem } from './types';
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
type DomainApiItem = {
id?: string;
name?: string;
created_at?: string;
};
type DomainItem = {
id: string;
name: string;
addedAt: string;
};
const newDomain = ref('');
const showAddDialog = ref(false);
const adding = ref(false);
const removingId = ref<string | null>(null);
const normalizeDomainInput = (value: string) => value
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/\/$/, '');
const formatDate = (value?: string) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value.split('T')[0] || value;
}
return date.toISOString().split('T')[0];
};
const mapDomainItem = (item: DomainApiItem): DomainItem => ({
id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`,
name: item.name || '',
addedAt: formatDate(item.created_at),
});
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'domains'],
query: async () => {
const response = await rpcClient.listDomains();
return (response.domains || []).map(mapDomainItem);
},
key: () => ['settings', 'domains'],
query: async () => {
const response = await rpcClient.listDomains();
return (response.domains || []).map(mapDomainItem);
},
});
const domains = computed(() => domainsSnapshot.value || []);
const isInitialLoading = computed(() => isPending.value && !domainsSnapshot.value);
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
const refetchDomains = () => refetch((fetchError) => {
throw fetchError;
});
watch(error, (value, previous) => {
if (!value || value === previous || adding.value || removingId.value !== null) return;
if (!value || value === previous || adding.value || removingId.value !== null) return;
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.failedSummary'),
detail: (value as any)?.message || t('settings.domainsDns.toast.failedDetail'),
life: 5000,
});
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.failedSummary'),
detail: (value as any)?.message || t('settings.domainsDns.toast.failedDetail'),
life: 5000,
});
});
const openAddDialog = () => {
newDomain.value = '';
showAddDialog.value = true;
newDomain.value = '';
showAddDialog.value = true;
};
const closeAddDialog = () => {
showAddDialog.value = false;
newDomain.value = '';
showAddDialog.value = false;
newDomain.value = '';
};
const handleAddDomain = async () => {
if (adding.value) return;
if (adding.value) return;
const domainName = normalizeDomainInput(newDomain.value);
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.invalidSummary'),
detail: t('settings.domainsDns.toast.invalidDetail'),
life: 3000,
});
return;
}
const exists = domains.value.some(domain => domain.name === domainName);
if (exists) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.duplicateSummary'),
detail: t('settings.domainsDns.toast.duplicateDetail'),
life: 3000,
});
return;
}
adding.value = true;
try {
await rpcClient.createDomain({
name: domainName,
});
await refetchDomains();
closeAddDialog();
toast.add({
severity: 'success',
summary: t('settings.domainsDns.toast.addedSummary'),
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
life: 3000,
});
} catch (e: any) {
console.error(e);
const message = String(e?.message || '').toLowerCase();
if (message.includes('already exists')) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.duplicateSummary'),
detail: t('settings.domainsDns.toast.duplicateDetail'),
life: 3000,
});
} else if (message.includes('invalid domain')) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.invalidSummary'),
detail: t('settings.domainsDns.toast.invalidDetail'),
life: 3000,
});
} else {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.failedSummary'),
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
life: 5000,
});
}
} finally {
adding.value = false;
const domainName = normalizeDomainInput(newDomain.value);
if (!domainName || !domainName.includes('.') || /[/\s]/.test(domainName)) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.invalidSummary'),
detail: t('settings.domainsDns.toast.invalidDetail'),
life: 3000,
});
return;
}
const exists = domains.value.some(domain => domain.name === domainName);
if (exists) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.duplicateSummary'),
detail: t('settings.domainsDns.toast.duplicateDetail'),
life: 3000,
});
return;
}
adding.value = true;
try {
await rpcClient.createDomain({
name: domainName,
});
await refetch();
closeAddDialog();
toast.add({
severity: 'success',
summary: t('settings.domainsDns.toast.addedSummary'),
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
life: 3000,
});
} catch (e: any) {
console.error(e);
const message = String(e?.message || '').toLowerCase();
if (message.includes('already exists')) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.duplicateSummary'),
detail: t('settings.domainsDns.toast.duplicateDetail'),
life: 3000,
});
} else if (message.includes('invalid domain')) {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.invalidSummary'),
detail: t('settings.domainsDns.toast.invalidDetail'),
life: 3000,
});
} else {
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.failedSummary'),
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
life: 5000,
});
}
} finally {
adding.value = false;
}
};
const handleRemoveDomain = (domain: DomainItem) => {
confirm.require({
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
header: t('settings.domainsDns.confirm.removeHeader'),
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
accept: async () => {
removingId.value = domain.id;
try {
await rpcClient.deleteDomain({ id: domain.id });
await refetchDomains();
toast.add({
severity: 'info',
summary: t('settings.domainsDns.toast.removedSummary'),
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
life: 3000,
});
} catch (e: any) {
console.error(e);
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.failedSummary'),
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
life: 5000,
});
} finally {
removingId.value = null;
}
},
});
confirm.require({
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
header: t('settings.domainsDns.confirm.removeHeader'),
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
accept: async () => {
removingId.value = domain.id;
try {
await rpcClient.deleteDomain({ id: domain.id });
await refetch();
toast.add({
severity: 'info',
summary: t('settings.domainsDns.toast.removedSummary'),
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
life: 3000,
});
} catch (e: any) {
console.error(e);
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.failedSummary'),
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
life: 5000,
});
} finally {
removingId.value = null;
}
},
});
};
const copyIframeCode = async () => {
try {
await navigator.clipboard.writeText(iframeCode.value);
} catch {
const textArea = document.createElement('textarea');
textArea.value = iframeCode.value;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
try {
await navigator.clipboard.writeText(iframeCode.value);
} catch {
const textArea = document.createElement('textarea');
textArea.value = iframeCode.value;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
toast.add({
severity: 'success',
summary: t('settings.domainsDns.toast.copiedSummary'),
detail: t('settings.domainsDns.toast.copiedDetail'),
life: 2000,
});
toast.add({
severity: 'success',
summary: t('settings.domainsDns.toast.copiedSummary'),
detail: t('settings.domainsDns.toast.copiedDetail'),
life: 2000,
});
};
const columns = computed<ColumnDef<DomainItem>[]>(() => [
{
id: 'domain',
header: t('settings.domainsDns.table.domain'),
accessorFn: row => row.name,
cell: ({ row }) => h('div', { class: 'flex items-center gap-2' }, [
h(LinkIcon, { class: 'h-4 w-4 text-foreground/40' }),
h('span', { class: 'text-sm font-medium text-foreground' }, row.original.name),
]),
meta: {
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
cellClass: 'px-6 py-3',
},
},
{
id: 'addedAt',
header: t('settings.domainsDns.table.addedDate'),
accessorFn: row => row.addedAt,
cell: ({ row }) => h('span', { class: 'text-sm text-foreground/60' }, row.original.addedAt),
meta: {
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
cellClass: 'px-6 py-3',
},
},
{
id: 'actions',
header: t('common.actions'),
enableSorting: false,
cell: ({ row }) => h(AppButton, {
variant: 'ghost',
size: 'sm',
disabled: adding.value || removingId.value !== null,
onClick: () => handleRemoveDomain(row.original),
}, {
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
}),
meta: {
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
cellClass: 'px-6 py-3 text-right',
},
},
]);
</script>
<template>
<SettingsSectionCard
:title="t('settings.content.domains.title')"
:description="t('settings.content.domains.subtitle')"
bodyClass=""
>
<template #header-actions>
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.addDomain') }}
</AppButton>
</template>
<SettingsSectionCard
:title="t('settings.content.domains.title')"
:description="t('settings.content.domains.subtitle')"
bodyClass=""
>
<template #header-actions>
<DomainsDnsToolbar
:loading="adding"
:disabled="isInitialLoading || removingId !== null"
@create="openAddDialog"
/>
</template>
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
{{ t('settings.domainsDns.infoBanner') }}
</SettingsNotice>
<DomainsDnsNotices />
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
<DomainsDnsTable
:domains="domains"
:is-initial-loading="isInitialLoading"
:adding="adding"
:removing-id="removingId"
@remove="handleRemoveDomain"
/>
<BaseTable
v-else
:data="domains"
:columns="columns"
:get-row-id="(row) => row.id"
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
tableClass="w-full"
headerRowClass="bg-muted/30"
bodyRowClass="border-b border-border hover:bg-muted/30"
>
<template #empty>
<div class="px-6 py-12 text-center">
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.domainsDns.emptyTitle') }}</p>
<p class="text-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
</div>
</template>
</BaseTable>
<DomainsDnsEmbedCode :code="iframeCode" @copy="copyIframeCode" />
<div class="px-6 py-4 bg-muted/30">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
<AppButton variant="secondary" size="sm" @click="copyIframeCode">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.copyCode') }}
</AppButton>
</div>
<p class="text-xs text-foreground/60 mb-2">
{{ t('settings.domainsDns.embedCodeHint') }}
</p>
<pre class="bg-header border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ iframeCode }}</code></pre>
</div>
<AppDialog
:visible="showAddDialog"
:title="t('settings.domainsDns.dialog.title')"
maxWidthClass="max-w-md"
@update:visible="showAddDialog = $event"
@close="closeAddDialog"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
<AppInput
id="domain"
v-model="newDomain"
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
@enter="handleAddDomain"
/>
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
</div>
<SettingsNotice
tone="warning"
:title="t('settings.domainsDns.dialog.importantTitle')"
class="p-3"
>
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
</SettingsNotice>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" :loading="adding" @click="handleAddDomain">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.domainsDns.addDomain') }}
</AppButton>
</div>
</template>
</AppDialog>
</SettingsSectionCard>
<DomainsDnsDialog
:visible="showAddDialog"
:domain="newDomain"
:adding="adding"
@update:visible="showAddDialog = $event"
@update:domain="newDomain = $event"
@submit="handleAddDomain"
@close="closeAddDialog"
/>
</SettingsSectionCard>
</template>