Files
admin/www/app/pages/contact.vue
T

194 lines
6.7 KiB
Vue
Raw Normal View History

2026-08-03 19:09:03 +08:00
<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>