Files
admin/www/app/pages/contact.vue
T
2026-08-03 19:09:03 +08:00

194 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { ContactConfig, FormField } from '~/types/site';
import { parseJson } from '~/utils/parseJson';
import { ApiError } from '~/composables/useRequest';
const { t, locale } = useI18n();
const { getContact, submitContact } = useSiteApi();
// SEO:联系页(dashboard 未提供 contact 类型,用默认标题)
useHead({
title: () => `${t('app.name')} - ${t('nav.contact')}`
});
const config = ref<ContactConfig | null>(null);
const loading = ref(true);
// ===== 表单状态 =====
const values = reactive<Record<string, string>>({});
const errors = ref<Record<string, string>>({});
const submitting = ref(false);
const submitted = ref(false);
const notice = ref('');
onMounted(async () => {
try {
config.value = await getContact();
// 初始化字段值
for (const f of config.value?.fields || []) {
values[fieldKey(f)] = '';
}
} catch {
config.value = null;
} finally {
loading.value = false;
}
});
const fields = computed<FormField[]>(() => config.value?.fields || []);
const settings = computed(() => config.value?.settings);
/** 字段在提交数据中的 key:使用当前语言的字段名(与后端必填校验匹配) */
function fieldKey(f: FormField): string {
return locale.value === 'en' ? f.name_en : f.name_zh;
}
function fieldLabel(f: FormField): string {
return locale.value === 'en' ? f.name_en : f.name_zh;
}
function fieldPlaceholder(f: FormField): string {
return locale.value === 'en' ? f.placeholder_en : f.placeholder_zh;
}
function fieldOptions(f: FormField): { label: string; value: string }[] {
return parseJson<{ label: string; value: string }[]>(f.options_json, []);
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[+0-9()\-\s]{6,20}$/;
function validate(): boolean {
const errs: Record<string, string> = {};
for (const f of fields.value) {
const v = (values[fieldKey(f)] || '').trim();
if (f.is_required && !v) {
errs[fieldKey(f)] = t('form.required');
continue;
}
if (!v) continue;
if (f.field_type === 'email' && !EMAIL_RE.test(v)) {
errs[fieldKey(f)] = t('form.invalidEmail');
}
if (f.field_type === 'phone' && !PHONE_RE.test(v)) {
errs[fieldKey(f)] = t('form.invalidPhone');
}
}
errors.value = errs;
return Object.keys(errs).length === 0;
}
async function handleSubmit() {
if (submitting.value) return;
if (!validate()) return;
submitting.value = true;
submitted.value = false;
notice.value = '';
try {
await submitContact({ ...values });
submitted.value = true;
notice.value = locale.value === 'en'
? settings.value?.success_message_en || t('form.success')
: settings.value?.success_message_zh || t('form.success');
// 清空表单
for (const k of Object.keys(values)) values[k] = '';
} catch (e) {
notice.value = e instanceof ApiError
? e.message
: (locale.value === 'en'
? settings.value?.error_message_en || t('form.failed')
: settings.value?.error_message_zh || t('form.failed'));
} finally {
submitting.value = false;
}
}
</script>
<template>
<div>
<PageHero
:title="t('nav.contact')"
:subtitle="t('form.subtitle')"
/>
<div v-if="loading" class="py-80px text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else class="section-container py-64px md:py-80px max-w-720px">
<!-- 提交成功/失败提示 -->
<div
v-if="notice"
class="mb-24px px-20px py-14px rounded-8px text-sm leading-relaxed"
:class="submitted
? 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800'
: 'bg-red-50 text-red-600 border border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800'"
>
{{ notice }}
</div>
<!-- 表单字段 CMS 动态配置 -->
<form v-if="fields.length" class="flex flex-col gap-20px" @submit.prevent="handleSubmit">
<div v-for="f in fields" :key="f.id" class="flex flex-col gap-8px">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ fieldLabel(f) }}
<span v-if="f.is_required" class="text-red-500 ml-4px">*</span>
</label>
<!-- 下拉选择 -->
<select
v-if="f.field_type === 'select'"
v-model="values[fieldKey(f)]"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors"
:class="errors[fieldKey(f)]
? 'border-red-400 focus:border-red-400'
: 'border-gray-200 dark:border-dark-600 focus:border-primary'"
>
<option value="">{{ fieldPlaceholder(f) }}</option>
<option v-for="opt in fieldOptions(f)" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<!-- 多行文本 -->
<textarea
v-else-if="f.field_type === 'textarea'"
v-model="values[fieldKey(f)]"
:placeholder="fieldPlaceholder(f)"
rows="5"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors resize-y"
:class="errors[fieldKey(f)]
? 'border-red-400 focus:border-red-400'
: 'border-gray-200 dark:border-dark-600 focus:border-primary'"
></textarea>
<!-- 单行文本 / 邮箱 / 电话 -->
<input
v-else
v-model="values[fieldKey(f)]"
:type="f.field_type === 'email' ? 'email' : f.field_type === 'phone' ? 'tel' : 'text'"
:placeholder="fieldPlaceholder(f)"
class="px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors"
:class="errors[fieldKey(f)]
? 'border-red-400 focus:border-red-400'
: 'border-gray-200 dark:border-dark-600 focus:border-primary'"
/>
<p v-if="errors[fieldKey(f)]" class="text-xs text-red-500">{{ errors[fieldKey(f)] }}</p>
</div>
<button
type="submit"
:disabled="submitting"
class="mt-8px px-32px py-12px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed self-start"
>
{{ submitting ? t('common.loading') : t('form.submit') }}
</button>
</form>
<!-- 未配置字段 -->
<div v-else class="py-80px text-center">
<p class="text-gray-400">{{ t('common.empty') }}</p>
</div>
</div>
</div>
</template>