feat(frontend): 添加邮件模板编辑器
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
8cef9a7ab1
commit
11462a3e9f
@ -850,6 +850,105 @@ export async function sendTestEmail(
|
||||
return data;
|
||||
}
|
||||
|
||||
// ==================== Email Template Settings ====================
|
||||
|
||||
export interface EmailTemplateOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type EmailTemplateEventOption = string | EmailTemplateOption;
|
||||
|
||||
export interface EmailTemplateSummary {
|
||||
event: string;
|
||||
locale: string;
|
||||
subject: string;
|
||||
is_custom?: boolean;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplateListResponse {
|
||||
events: EmailTemplateEventOption[];
|
||||
locales: string[];
|
||||
templates?: EmailTemplateSummary[];
|
||||
placeholders?: string[];
|
||||
}
|
||||
|
||||
export interface EmailTemplateDetail {
|
||||
event: string;
|
||||
locale: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
is_custom?: boolean;
|
||||
updated_at?: string;
|
||||
placeholders?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateEmailTemplateRequest {
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface PreviewEmailTemplateRequest extends UpdateEmailTemplateRequest {
|
||||
event: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplatePreviewResponse {
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export async function getEmailTemplates(): Promise<EmailTemplateListResponse> {
|
||||
const { data } = await apiClient.get<EmailTemplateListResponse>(
|
||||
"/admin/settings/email-templates",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getEmailTemplate(
|
||||
event: string,
|
||||
locale: string,
|
||||
): Promise<EmailTemplateDetail> {
|
||||
const { data } = await apiClient.get<EmailTemplateDetail>(
|
||||
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(
|
||||
event: string,
|
||||
locale: string,
|
||||
request: UpdateEmailTemplateRequest,
|
||||
): Promise<EmailTemplateDetail> {
|
||||
const { data } = await apiClient.put<EmailTemplateDetail>(
|
||||
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function restoreOfficialEmailTemplate(
|
||||
event: string,
|
||||
locale: string,
|
||||
): Promise<EmailTemplateDetail> {
|
||||
const { data } = await apiClient.post<EmailTemplateDetail>(
|
||||
`/admin/settings/email-templates/${encodeURIComponent(event)}/${encodeURIComponent(locale)}/restore-official`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function previewEmailTemplate(
|
||||
request: PreviewEmailTemplateRequest,
|
||||
): Promise<EmailTemplatePreviewResponse> {
|
||||
const { data } = await apiClient.post<EmailTemplatePreviewResponse>(
|
||||
"/admin/settings/email-template-preview",
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin API Key status response
|
||||
*/
|
||||
@ -1156,6 +1255,11 @@ export const settingsAPI = {
|
||||
updateSettings,
|
||||
testSmtpConnection,
|
||||
sendTestEmail,
|
||||
getEmailTemplates,
|
||||
getEmailTemplate,
|
||||
updateEmailTemplate,
|
||||
restoreOfficialEmailTemplate,
|
||||
previewEmailTemplate,
|
||||
getAdminApiKey,
|
||||
regenerateAdminApiKey,
|
||||
deleteAdminApiKey,
|
||||
|
||||
@ -6193,6 +6193,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmailTemplateEditor />
|
||||
|
||||
<!-- Balance Low Notification -->
|
||||
<div class="card">
|
||||
<div
|
||||
@ -6450,6 +6453,7 @@ import Toggle from "@/components/common/Toggle.vue";
|
||||
import ProxySelector from "@/components/common/ProxySelector.vue";
|
||||
import ImageUpload from "@/components/common/ImageUpload.vue";
|
||||
import BackupSettings from "@/views/admin/BackupView.vue";
|
||||
import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue";
|
||||
import { useClipboard } from "@/composables/useClipboard";
|
||||
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
|
||||
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
|
||||
|
||||
438
frontend/src/views/admin/settings/EmailTemplateEditor.vue
Normal file
438
frontend/src/views/admin/settings/EmailTemplateEditor.vue
Normal file
@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div
|
||||
class="flex flex-col gap-3 border-b border-gray-100 px-6 py-4 dark:border-dark-700 lg:flex-row lg:items-start lg:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.emailTemplates.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.emailTemplates.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="loadingTemplate || previewing || !canPreview"
|
||||
@click="refreshPreview"
|
||||
>
|
||||
{{ previewing ? t("admin.settings.emailTemplates.previewing") : t("admin.settings.emailTemplates.preview") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="loadingTemplate || restoring || !selectedEvent || !selectedLocale"
|
||||
@click="restoreOfficial"
|
||||
>
|
||||
{{ restoring ? t("admin.settings.emailTemplates.restoring") : t("admin.settings.emailTemplates.restoreOfficial") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="loadingTemplate || saving || !canSave"
|
||||
@click="saveTemplate"
|
||||
>
|
||||
{{ saving ? t("admin.settings.emailTemplates.saving") : t("admin.settings.emailTemplates.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 p-6">
|
||||
<div
|
||||
v-if="loadingList"
|
||||
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"
|
||||
></span>
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label" for="email-template-event">
|
||||
{{ t("admin.settings.emailTemplates.event") }}
|
||||
</label>
|
||||
<select
|
||||
id="email-template-event"
|
||||
v-model="selectedEvent"
|
||||
class="input"
|
||||
:disabled="loadingTemplate || eventOptions.length === 0"
|
||||
>
|
||||
<option
|
||||
v-for="option in eventOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label || option.value }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="selectedEventDescription" class="input-hint">
|
||||
{{ selectedEventDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label" for="email-template-locale">
|
||||
{{ t("admin.settings.emailTemplates.locale") }}
|
||||
</label>
|
||||
<select
|
||||
id="email-template-locale"
|
||||
v-model="selectedLocale"
|
||||
class="input"
|
||||
:disabled="loadingTemplate || localeOptions.length === 0"
|
||||
>
|
||||
<option
|
||||
v-for="localeOption in localeOptions"
|
||||
:key="localeOption"
|
||||
:value="localeOption"
|
||||
>
|
||||
{{ formatLocale(localeOption) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!eventOptions.length || !localeOptions.length"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
>
|
||||
{{ t("admin.settings.emailTemplates.empty") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label" for="email-template-subject">
|
||||
{{ t("admin.settings.emailTemplates.subject") }}
|
||||
</label>
|
||||
<input
|
||||
id="email-template-subject"
|
||||
v-model="subject"
|
||||
type="text"
|
||||
class="input"
|
||||
:disabled="loadingTemplate"
|
||||
:placeholder="t('admin.settings.emailTemplates.subjectPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label" for="email-template-html">
|
||||
{{ t("admin.settings.emailTemplates.html") }}
|
||||
</label>
|
||||
<textarea
|
||||
id="email-template-html"
|
||||
v-model="html"
|
||||
rows="18"
|
||||
class="input min-h-[28rem] resize-y font-mono text-sm leading-6"
|
||||
:disabled="loadingTemplate"
|
||||
:placeholder="t('admin.settings.emailTemplates.htmlPlaceholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/60"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.emailTemplates.placeholders") }}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.emailTemplates.placeholdersHelp") }}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="placeholder in placeholderList"
|
||||
:key="placeholder"
|
||||
type="button"
|
||||
class="rounded-full border border-gray-200 bg-white px-3 py-1 font-mono text-xs text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-600 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:border-primary-500 dark:hover:text-primary-300"
|
||||
@click="copyPlaceholder(placeholder)"
|
||||
>
|
||||
{{ placeholder }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.emailTemplates.livePreview") }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ previewSubject || t("admin.settings.emailTemplates.noPreview") }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="isCustomTemplate"
|
||||
class="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
|
||||
>
|
||||
{{ t("admin.settings.emailTemplates.customized") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bg-gray-100 p-3 dark:bg-dark-900">
|
||||
<iframe
|
||||
class="h-[36rem] w-full rounded-md border border-gray-200 bg-white dark:border-dark-700"
|
||||
sandbox=""
|
||||
:srcdoc="previewHtml"
|
||||
:title="t('admin.settings.emailTemplates.livePreview')"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.emailTemplates.previewSecurityHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { adminAPI } from "@/api";
|
||||
import type {
|
||||
EmailTemplateEventOption,
|
||||
EmailTemplateOption,
|
||||
} from "@/api/admin/settings";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { extractApiErrorMessage } from "@/utils/apiError";
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const fallbackPlaceholders = [
|
||||
"{{site_name}}",
|
||||
"{{recipient_name}}",
|
||||
"{{recipient_email}}",
|
||||
"{{subscription_group}}",
|
||||
"{{expiry_time}}",
|
||||
"{{days_remaining}}",
|
||||
"{{current_balance}}",
|
||||
"{{threshold}}",
|
||||
"{{recharge_url}}",
|
||||
"{{recharge_amount}}",
|
||||
"{{order_id}}",
|
||||
"{{unsubscribe_url}}",
|
||||
];
|
||||
|
||||
const loadingList = ref(true);
|
||||
const loadingTemplate = ref(false);
|
||||
const saving = ref(false);
|
||||
const previewing = ref(false);
|
||||
const restoring = ref(false);
|
||||
const eventOptions = ref<EmailTemplateOption[]>([]);
|
||||
const localeOptions = ref<string[]>([]);
|
||||
const selectedEvent = ref("");
|
||||
const selectedLocale = ref("");
|
||||
const subject = ref("");
|
||||
const html = ref("");
|
||||
const isCustomTemplate = ref(false);
|
||||
const placeholders = ref<string[]>([]);
|
||||
const previewSubject = ref("");
|
||||
const previewHtml = ref("");
|
||||
const initializingSelection = ref(false);
|
||||
|
||||
function normalizeEventOption(option: EmailTemplateEventOption): EmailTemplateOption {
|
||||
if (typeof option === "string") {
|
||||
return { value: option };
|
||||
}
|
||||
return option;
|
||||
}
|
||||
|
||||
const selectedEventDescription = computed(() => {
|
||||
return (
|
||||
eventOptions.value.find((option) => option.value === selectedEvent.value)
|
||||
?.description || ""
|
||||
);
|
||||
});
|
||||
|
||||
const placeholderList = computed(() => {
|
||||
const combined = [...placeholders.value, ...fallbackPlaceholders];
|
||||
return Array.from(
|
||||
new Set(
|
||||
combined
|
||||
.map((item) => formatPlaceholder(item))
|
||||
.filter((item) => item.length > 0),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function formatPlaceholder(placeholder: string): string {
|
||||
const trimmed = placeholder.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.startsWith("{{") && trimmed.endsWith("}}")) return trimmed;
|
||||
return `{{${trimmed}}}`;
|
||||
}
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
Boolean(selectedEvent.value && selectedLocale.value) &&
|
||||
subject.value.trim().length > 0 &&
|
||||
html.value.trim().length > 0,
|
||||
);
|
||||
|
||||
const canPreview = computed(
|
||||
() => Boolean(selectedEvent.value && selectedLocale.value) && html.value.trim().length > 0,
|
||||
);
|
||||
|
||||
function formatLocale(locale: string): string {
|
||||
const lower = locale.toLowerCase();
|
||||
if (lower === "zh" || lower.startsWith("zh-")) {
|
||||
return t("admin.settings.emailTemplates.localeZh");
|
||||
}
|
||||
if (lower === "en" || lower.startsWith("en-")) {
|
||||
return t("admin.settings.emailTemplates.localeEn");
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
function applyTemplate(template: {
|
||||
subject: string;
|
||||
html: string;
|
||||
is_custom?: boolean;
|
||||
placeholders?: string[];
|
||||
}) {
|
||||
subject.value = template.subject;
|
||||
html.value = template.html;
|
||||
isCustomTemplate.value = template.is_custom === true;
|
||||
if (template.placeholders?.length) {
|
||||
placeholders.value = template.placeholders;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplate() {
|
||||
if (!selectedEvent.value || !selectedLocale.value) return;
|
||||
loadingTemplate.value = true;
|
||||
try {
|
||||
const template = await adminAPI.settings.getEmailTemplate(
|
||||
selectedEvent.value,
|
||||
selectedLocale.value,
|
||||
);
|
||||
applyTemplate(template);
|
||||
await refreshPreview();
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||
} finally {
|
||||
loadingTemplate.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplateList() {
|
||||
loadingList.value = true;
|
||||
try {
|
||||
const response = await adminAPI.settings.getEmailTemplates();
|
||||
eventOptions.value = response.events.map(normalizeEventOption);
|
||||
localeOptions.value = response.locales;
|
||||
placeholders.value = response.placeholders || [];
|
||||
initializingSelection.value = true;
|
||||
selectedEvent.value = eventOptions.value[0]?.value || "";
|
||||
selectedLocale.value = response.locales[0] || "";
|
||||
await loadTemplate();
|
||||
initializingSelection.value = false;
|
||||
} catch (err: unknown) {
|
||||
initializingSelection.value = false;
|
||||
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||
} finally {
|
||||
loadingList.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
if (!canSave.value) {
|
||||
appStore.showError(t("admin.settings.emailTemplates.validationRequired"));
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const template = await adminAPI.settings.updateEmailTemplate(
|
||||
selectedEvent.value,
|
||||
selectedLocale.value,
|
||||
{
|
||||
subject: subject.value,
|
||||
html: html.value,
|
||||
},
|
||||
);
|
||||
applyTemplate(template);
|
||||
await refreshPreview();
|
||||
appStore.showSuccess(t("admin.settings.emailTemplates.saveSuccess"));
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
if (!canPreview.value) {
|
||||
previewSubject.value = "";
|
||||
previewHtml.value = "";
|
||||
return;
|
||||
}
|
||||
previewing.value = true;
|
||||
try {
|
||||
const preview = await adminAPI.settings.previewEmailTemplate({
|
||||
event: selectedEvent.value,
|
||||
locale: selectedLocale.value,
|
||||
subject: subject.value,
|
||||
html: html.value,
|
||||
});
|
||||
previewSubject.value = preview.subject;
|
||||
previewHtml.value = preview.html;
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOfficial() {
|
||||
if (!selectedEvent.value || !selectedLocale.value) return;
|
||||
if (!window.confirm(t("admin.settings.emailTemplates.restoreConfirm"))) return;
|
||||
|
||||
restoring.value = true;
|
||||
try {
|
||||
const template = await adminAPI.settings.restoreOfficialEmailTemplate(
|
||||
selectedEvent.value,
|
||||
selectedLocale.value,
|
||||
);
|
||||
applyTemplate(template);
|
||||
await refreshPreview();
|
||||
appStore.showSuccess(t("admin.settings.emailTemplates.restoreSuccess"));
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t("common.error")));
|
||||
} finally {
|
||||
restoring.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPlaceholder(placeholder: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(placeholder);
|
||||
appStore.showSuccess(t("admin.settings.emailTemplates.placeholderCopied"));
|
||||
} catch {
|
||||
appStore.showError(t("common.error"));
|
||||
}
|
||||
}
|
||||
|
||||
watch([selectedEvent, selectedLocale], ([eventValue, localeValue], [oldEvent, oldLocale]) => {
|
||||
if (initializingSelection.value) return;
|
||||
if (!eventValue || !localeValue) return;
|
||||
if (eventValue === oldEvent && localeValue === oldLocale) return;
|
||||
void loadTemplate();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadTemplateList();
|
||||
});
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user