官网开发中 0803
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user