194 lines
6.7 KiB
Vue
194 lines
6.7 KiB
Vue
<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>
|