This commit is contained in:
2026-03-11 02:43:33 +00:00
parent edc1a33547
commit dc06412f79
61 changed files with 7408 additions and 1606 deletions

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { client } from '@/api/client';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
@@ -10,23 +11,99 @@ 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 { computed, ref } from 'vue';
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
import { useQuery } from '@pinia/colada';
import { computed, ref, watch } from 'vue';
import { useTranslation } from 'i18next-vue';
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
const domains = ref([
{ id: '1', name: 'example.com', addedAt: '2024-01-15' },
{ id: '2', name: 'mysite.org', addedAt: '2024-02-20' },
]);
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 handleAddDomain = () => {
if (!newDomain.value.trim()) {
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 client.domains.domainsList({ baseUrl: '/r' });
return ((((response.data as any)?.data?.domains) || []) as DomainApiItem[]).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;
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;
};
const closeAddDialog = () => {
showAddDialog.value = false;
newDomain.value = '';
};
const handleAddDomain = async () => {
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'),
@@ -36,7 +113,7 @@ const handleAddDomain = () => {
return;
}
const exists = domains.value.some(d => d.name === newDomain.value.trim().toLowerCase());
const exists = domains.value.some(domain => domain.name === domainName);
if (exists) {
toast.add({
severity: 'error',
@@ -47,48 +124,95 @@ const handleAddDomain = () => {
return;
}
const domainName = newDomain.value.trim().toLowerCase();
domains.value.push({
id: Math.random().toString(36).substring(2, 9),
name: domainName,
addedAt: new Date().toISOString().split('T')[0],
});
adding.value = true;
try {
await client.domains.domainsCreate({
name: domainName,
}, { baseUrl: '/r' });
newDomain.value = '';
showAddDialog.value = false;
toast.add({
severity: 'success',
summary: t('settings.domainsDns.toast.addedSummary'),
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
life: 3000,
});
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 handleRemoveDomain = (domain: typeof domains.value[0]) => {
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: () => {
const index = domains.value.findIndex(d => d.id === domain.id);
if (index !== -1) {
domains.value.splice(index, 1);
accept: async () => {
removingId.value = domain.id;
try {
await client.domains.domainsDelete(domain.id, { baseUrl: '/r' });
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;
}
toast.add({
severity: 'info',
summary: t('settings.domainsDns.toast.removedSummary'),
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
life: 3000,
});
},
});
};
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
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);
}
const copyIframeCode = () => {
navigator.clipboard.writeText(iframeCode.value);
toast.add({
severity: 'success',
summary: t('settings.domainsDns.toast.copiedSummary'),
@@ -105,7 +229,7 @@ const copyIframeCode = () => {
bodyClass=""
>
<template #header-actions>
<AppButton size="sm" @click="showAddDialog = true">
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
@@ -117,7 +241,9 @@ const copyIframeCode = () => {
{{ t('settings.domainsDns.infoBanner') }}
</SettingsNotice>
<div class="border-b border-border mt-4">
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
<div v-else class="border-b border-border mt-4">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
@@ -127,27 +253,34 @@ const copyIframeCode = () => {
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr
v-for="domain in domains"
:key="domain.id"
class="hover:bg-muted/30 transition-all"
>
<td class="px-6 py-3">
<div class="flex items-center gap-2">
<LinkIcon class="w-4 h-4 text-foreground/40" />
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
</div>
</td>
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
<td class="px-6 py-3 text-right">
<AppButton variant="ghost" size="sm" @click="handleRemoveDomain(domain)">
<template #icon>
<TrashIcon class="w-4 h-4 text-danger" />
</template>
</AppButton>
</td>
</tr>
<tr v-if="domains.length === 0">
<template v-if="domains.length > 0">
<tr
v-for="domain in domains"
:key="domain.id"
class="hover:bg-muted/30 transition-all"
>
<td class="px-6 py-3">
<div class="flex items-center gap-2">
<LinkIcon class="w-4 h-4 text-foreground/40" />
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
</div>
</td>
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
<td class="px-6 py-3 text-right">
<AppButton
variant="ghost"
size="sm"
:disabled="adding || removingId !== null"
@click="handleRemoveDomain(domain)"
>
<template #icon>
<TrashIcon class="w-4 h-4 text-danger" />
</template>
</AppButton>
</td>
</tr>
</template>
<tr v-else>
<td colspan="3" class="px-6 py-12 text-center">
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.domainsDns.emptyTitle') }}</p>
@@ -176,9 +309,10 @@ const copyIframeCode = () => {
<AppDialog
:visible="showAddDialog"
@update:visible="showAddDialog = $event"
: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">
@@ -202,15 +336,17 @@ const copyIframeCode = () => {
</div>
<template #footer>
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" @click="handleAddDomain">
<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>