Files
nexus/Project/fonrey/UI_DESIGN/新增客源_UI.html
2026-04-28 16:39:52 +08:00

706 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 新增客源 · 静态原型</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#F0FDFA',
100: '#CCFBF1',
200: '#99F6E4',
500: '#14B8A6',
600: '#0F766E',
700: '#115E59',
800: '#134E4A'
},
neutral: {
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A'
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
},
boxShadow: {
xs: '0 1px 2px rgba(15,23,42,0.04)'
},
fontFamily: {
sans: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif']
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
html { scroll-behavior: smooth; }
[x-cloak] { display: none !important; }
.tabular-nums { font-variant-numeric: tabular-nums; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
.chip { border:1px solid #E2E8F0; background:#FFFFFF; color:#64748B; }
.chip.active { border-color:#0F766E; color:#0F766E; background:#F0FDFA; }
.subtab { color:#64748B; border-bottom:2px solid transparent; }
.subtab.active { color:#0F766E; border-bottom-color:#0F766E; font-weight:600; }
</style>
</head>
<body class="bg-neutral-50 text-sm text-neutral-700 antialiased" x-data="createClientPage()">
<header class="fixed top-0 left-0 right-0 h-14 z-20 bg-primary-800 flex items-center justify-between">
<div class="flex items-center gap-2 px-4 w-60 shrink-0">
<div class="w-7 h-7 rounded-md bg-primary-500 flex items-center justify-center text-white text-sm font-semibold">F</div>
<span class="text-base font-semibold text-white">Fonrey</span>
</div>
<nav class="flex items-center gap-1 flex-1 px-2" aria-label="主导航">
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">工作台</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">房源</a>
<a class="px-3 py-1.5 text-sm rounded-md bg-primary-600 text-white font-medium">客源</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">营销</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">交易</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">数据</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">人事</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">系统</a>
</nav>
<div class="flex items-center gap-1 px-4 shrink-0">
<button class="p-1.5 text-primary-200 hover:bg-primary-700 hover:text-white rounded-md" aria-label="消息">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022 23.848 23.848 0 0 0 5.454 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
</button>
<div class="flex items-center gap-2 pl-3 ml-1 border-l border-primary-700">
<div class="w-8 h-8 rounded-full bg-primary-600 text-white flex items-center justify-center text-sm font-semibold"></div>
<span class="text-sm font-medium text-primary-100">魏深</span>
</div>
</div>
</header>
<aside class="fixed left-0 top-14 h-[calc(100vh-56px)] w-60 z-20 border-r border-neutral-200 bg-white">
<nav class="p-3 space-y-0.5">
<div class="px-2 pt-2 pb-1 text-xs font-medium text-neutral-500 uppercase tracking-wide">客源管理</div>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-primary-50 text-primary-700 font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>
私客列表
</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">公客池</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">成交客</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">已删客源</a>
</nav>
</aside>
<main class="ml-60 pt-[72px] min-h-screen bg-neutral-50 px-6 py-5">
<div class="mx-auto max-w-5xl">
<div class="mb-6">
<nav class="flex items-center gap-1 text-xs text-neutral-500 mb-2" aria-label="面包屑">
<a href="/clients/" class="hover:text-neutral-700">客源</a>
<span>/</span>
<span class="text-neutral-700">录入私客</span>
</nav>
<h1 class="text-xl font-semibold text-neutral-800">录入私客</h1>
</div>
<form id="create-client-form" @submit.prevent="submitForm" class="space-y-4 pb-28">
<section class="bg-white rounded-lg border border-neutral-200 p-4">
<div>
<div class="text-xs text-neutral-500 mb-2">需求状态</div>
<div class="flex items-center gap-2" id="demandStatusGroup" data-field-key="status">
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" :class="{ 'active': form.status==='buying' }" @click="form.status='buying'">求购</button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" :class="{ 'active': form.status==='renting' }" @click="form.status='renting'">求租</button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" :class="{ 'active': form.status==='buy_or_rent' }" @click="form.status='buy_or_rent'">租购</button>
</div>
<p class="text-xs text-danger-600 mt-1" x-show="errors.status" x-text="errors.status"></p>
</div>
</section>
<nav class="bg-white rounded-lg border border-neutral-200 px-4 sticky top-[74px] z-10">
<div class="flex items-center gap-5 overflow-x-auto text-sm">
<a href="#section-client-info" class="subtab py-3" :class="{ 'active': activeSection==='client-info' }">房源信息</a>
<a href="#section-contact" class="subtab py-3" :class="{ 'active': activeSection==='contact' }">联系人</a>
<a href="#section-basic" class="subtab py-3" :class="{ 'active': activeSection==='basic' }">基础信息</a>
<a href="#section-staff" class="subtab py-3" :class="{ 'active': activeSection==='staff' }">相关员工</a>
</div>
</nav>
<section id="section-client-info" class="bg-white rounded-lg border border-neutral-200 p-6" data-section-key="client-info">
<h2 class="text-base font-semibold text-neutral-800 mb-4">房源信息</h2>
<div class="space-y-4">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">用途 <span class="text-danger-600">*</span></label>
<div data-field-key="property_usage" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('property_usage')">
<template x-for="item in usageOptions" :key="item.value">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" x-model="form.propertyUsage" :value="item.value" class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700" x-text="item.label"></span>
</label>
</template>
</div>
<p class="text-xs text-danger-600" x-show="errors.property_usage" x-text="errors.property_usage"></p>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">等级 <span class="text-danger-600">*</span></label>
<div data-field-key="grade" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('grade')">
<template x-for="item in gradeOptions" :key="item.value">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" x-model="form.grade" :value="item.value" class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700" x-text="item.label"></span>
</label>
</template>
</div>
<p class="text-xs text-danger-600" x-show="errors.grade" x-text="errors.grade"></p>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">来源 <span class="text-danger-600">*</span></label>
<select
data-field-key="source"
x-model="form.source"
:disabled="sourceLoading"
class="block w-full max-w-xs px-3 py-2 text-sm rounded-md border bg-white focus:outline-none focus:ring-2 disabled:bg-neutral-100 disabled:text-neutral-400"
:class="inputClass('source')"
>
<option value="" x-text="sourceLoading ? '加载中...' : '请选择'"></option>
<template x-for="item in sourceOptions" :key="item.value">
<option :value="item.value" x-text="item.label"></option>
</template>
</select>
<p class="text-xs text-danger-600" x-show="errors.source" x-text="errors.source"></p>
</div>
</div>
</section>
<section id="section-contact" class="bg-white rounded-lg border border-neutral-200 p-6" data-section-key="contact">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-neutral-800">联系人</h2>
<span class="text-xs text-neutral-500">最多添加 5 位联系人</span>
</div>
<div class="space-y-5">
<template x-for="(contact, idx) in form.contacts" :key="contact.id">
<div class="border border-neutral-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-neutral-700" x-text="'联系人' + (idx + 1)"></h3>
<button
type="button"
x-show="idx > 0"
@click="removeContact(idx)"
class="text-xs text-danger-600 hover:text-danger-600 hover:underline"
>删除</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">
姓名 <span class="text-danger-600">*</span>
</label>
<input
type="text"
x-model.trim="contact.name"
:data-field-key="fieldKey('contacts', idx, 'name')"
placeholder="请输入"
class="block w-full px-3 py-2 text-sm rounded-md border placeholder:text-neutral-400 focus:outline-none focus:ring-2"
:class="inputClass(fieldKey('contacts', idx, 'name'))"
>
<p class="text-xs text-danger-600" x-show="errors[fieldKey('contacts', idx, 'name')]" x-text="errors[fieldKey('contacts', idx, 'name')]"></p>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">
性别 <span class="text-danger-600">*</span>
</label>
<div :data-field-key="fieldKey('contacts', idx, 'gender')" class="flex items-center gap-6 rounded-md border px-3 py-2 min-h-[42px]"
:class="groupClass(fieldKey('contacts', idx, 'gender'))">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" x-model="contact.gender" value="male" class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">先生</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="radio" x-model="contact.gender" value="female" class="w-4 h-4 accent-primary-600">
<span class="text-sm text-neutral-700">女士</span>
</label>
</div>
<p class="text-xs text-danger-600" x-show="errors[fieldKey('contacts', idx, 'gender')]" x-text="errors[fieldKey('contacts', idx, 'gender')]"></p>
</div>
<div class="md:col-span-2 space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">
电话1 <span class="text-danger-600">*</span>
</label>
<div
:data-field-key="fieldKey('contacts', idx, 'phone')"
class="flex rounded-md border overflow-hidden focus-within:ring-2"
:class="phoneWrapClass(fieldKey('contacts', idx, 'phone'))"
>
<select x-model="contact.phoneCountryCode" class="w-24 px-2 py-2 text-sm bg-neutral-50 border-r border-neutral-200 text-neutral-700 focus:outline-none">
<template x-for="code in countryCodes" :key="code">
<option :value="code" x-text="code"></option>
</template>
</select>
<input
type="tel"
x-model.trim="contact.phone"
@blur="idx === 0 ? checkDuplicatePhone() : null"
placeholder="输入手机号"
class="flex-1 px-3 py-2 text-sm bg-white focus:outline-none"
>
</div>
<p class="text-xs text-danger-600" x-show="errors[fieldKey('contacts', idx, 'phone')]" x-text="errors[fieldKey('contacts', idx, 'phone')]"></p>
<template x-if="idx === 0">
<div class="mt-1 min-h-[20px]">
<template x-if="checkingDuplicate">
<div class="animate-pulse h-4 bg-neutral-200 rounded w-56"></div>
</template>
<p
x-show="duplicateHint"
x-text="duplicateHint"
class="text-xs text-warning-600"
></p>
</div>
</template>
</div>
</div>
<div class="mt-3">
<button
type="button"
@click="contact.expanded = !contact.expanded"
class="flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700"
>
<span>电话2、微信、QQ等</span>
<svg class="w-4 h-4 transition-transform" :class="contact.expanded ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6"/></svg>
</button>
<div x-show="contact.expanded" x-transition class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">电话2</label>
<div class="flex rounded-md border border-neutral-300 overflow-hidden focus-within:border-primary-600 focus-within:ring-2 focus-within:ring-primary-600/20">
<select x-model="contact.phone2CountryCode" class="w-24 px-2 py-2 text-sm bg-neutral-50 border-r border-neutral-200 text-neutral-700 focus:outline-none">
<template x-for="code in countryCodes" :key="'p2-' + code">
<option :value="code" x-text="code"></option>
</template>
</select>
<input type="tel" x-model.trim="contact.phone2" placeholder="输入手机号" class="flex-1 px-3 py-2 text-sm bg-white focus:outline-none">
</div>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">微信</label>
<input type="text" x-model.trim="contact.wechat" placeholder="请输入" class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">QQ</label>
<input type="text" x-model.trim="contact.qq" placeholder="请输入" class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
</div>
</div>
</div>
</div>
</template>
<button
type="button"
x-show="form.contacts.length < 5"
@click="addContact"
class="flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
新增联系人
</button>
</div>
</section>
<section id="section-basic" class="bg-white rounded-lg border border-neutral-200 p-6" data-section-key="basic">
<h2 class="text-base font-semibold text-neutral-800 mb-4">基础信息</h2>
<div class="space-y-4">
<div>
<button type="button" @click="infoExpanded = !infoExpanded" class="flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700">
<span>证件类型、证件号码、意向学校等</span>
<svg class="w-4 h-4 transition-transform" :class="infoExpanded ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6"/></svg>
</button>
<div x-show="infoExpanded" x-transition class="mt-3 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">证件类型</label>
<select x-model="form.idType" class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 bg-white focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
<option value="">请选择</option>
<option value="id_card">身份证</option>
<option value="passport">护照</option>
<option value="hk_macao">港澳通行证</option>
<option value="other">其他</option>
</select>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">证件号码</label>
<input type="text" x-model.trim="form.idNumber" placeholder="请输入证件号码" class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
</div>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-neutral-700">意向学校</label>
<template x-for="(school, sIdx) in form.schools" :key="'school-' + sIdx">
<div class="flex items-center gap-2 max-w-md">
<input type="text" x-model.trim="form.schools[sIdx]" placeholder="请输入学校名称" class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20">
<button type="button" @click="removeSchool(sIdx)" x-show="form.schools.length > 1" class="px-2 py-2 text-xs text-danger-600 hover:bg-danger-50 rounded-md">删除</button>
</div>
</template>
<button type="button" @click="addSchool" class="flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
添加学校
</button>
</div>
</div>
</div>
</div>
</section>
<section id="section-staff" class="bg-white rounded-lg border border-neutral-200 p-6" data-section-key="staff">
<h2 class="text-base font-semibold text-neutral-800 mb-4">相关员工</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">首录人</label>
<input type="text" value="魏深 - 都市港湾店一组" disabled class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-200 bg-neutral-100 text-neutral-500 cursor-not-allowed">
<input type="hidden" name="first_recorder_id" x-model="form.firstRecorderId">
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-neutral-700">归属人</label>
<input type="text" value="魏深 - 都市港湾店一组" disabled class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-200 bg-neutral-100 text-neutral-500 cursor-not-allowed">
<input type="hidden" name="owner_id" x-model="form.ownerId">
</div>
</div>
</section>
<div class="fixed bottom-4 left-[calc(15rem+1.5rem)] right-6 z-30">
<div class="mx-auto max-w-5xl bg-white rounded-lg border border-neutral-200 shadow-xs p-4">
<div class="flex items-center justify-end gap-3">
<button
type="button"
@click="cancelForm"
:disabled="submitting"
class="inline-flex items-center px-4 py-2 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400 disabled:opacity-60"
>
取消
</button>
<button
type="button"
@click="submitAndContinue"
:disabled="submitting"
class="inline-flex items-center px-4 py-2 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400 disabled:opacity-60"
>
保存并继续新增
</button>
<button
type="submit"
:disabled="submitting"
class="inline-flex items-center gap-1.5 px-6 py-2 text-sm font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-70 disabled:cursor-wait"
>
<svg x-show="submitting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity=".25" stroke-width="4"></circle>
<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" stroke-width="4"></path>
</svg>
<span x-text="submitting ? '保存中...' : '保存'"></span>
</button>
</div>
<p x-show="redirectHint" x-text="redirectHint" class="text-xs text-success-600 text-right mt-2"></p>
</div>
</div>
</form>
</div>
</main>
<div x-show="toast.show" x-transition.opacity class="fixed bottom-6 right-6 z-[70]">
<div class="w-80 bg-white rounded-lg shadow-lg border border-neutral-200 flex items-start gap-3 p-3">
<svg class="w-5 h-5 text-success-600 shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
<div class="flex-1 text-sm">
<p class="font-medium text-neutral-800" x-text="toast.message"></p>
<p class="text-xs text-neutral-500">客源信息已保存</p>
</div>
</div>
</div>
<script>
function createClientPage() {
return {
countryCodes: ['+86', '+852', '+853', '+886', '+1'],
usageOptions: [
{ value: 'residential', label: '住宅' },
{ value: 'villa', label: '别墅' },
{ value: 'commercial_residential', label: '商住' },
{ value: 'shop', label: '商铺' },
{ value: 'other', label: '其他' }
],
gradeOptions: [
{ value: 'A_urgent', label: 'A(急迫)' },
{ value: 'A', label: 'A' },
{ value: 'B', label: 'B(较强)' },
{ value: 'C', label: 'C(一般)' },
{ value: 'D', label: 'D(较弱)' },
{ value: 'E', label: 'E(暂不关注)' }
],
sourceLoading: true,
sourceOptions: [],
infoExpanded: false,
checkingDuplicate: false,
duplicateHint: '',
activeSection: 'client-info',
submitting: false,
redirectHint: '',
errors: {},
toast: {
show: false,
message: ''
},
form: {
contacts: [],
status: '',
propertyUsage: 'residential',
grade: '',
source: '',
idType: '',
idNumber: '',
schools: [''],
firstRecorderId: 'staff-1001',
ownerId: 'staff-1001'
},
init() {
this.form.contacts = [this.newContact()];
this.simulateSourceLoading();
this.bindSectionObserver();
},
newContact() {
return {
id: 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
name: '',
gender: '',
phoneCountryCode: '+86',
phone: '',
expanded: false,
phone2CountryCode: '+86',
phone2: '',
wechat: '',
qq: ''
};
},
fieldKey(group, idx, field) {
return group + '_' + idx + '_' + field;
},
inputClass(key) {
return this.errors[key]
? 'border-danger-600 ring-danger-600/20 focus:border-danger-600 focus:ring-danger-600/20'
: 'border-neutral-300 focus:border-primary-600 focus:ring-primary-600/20';
},
groupClass(key) {
return this.errors[key]
? 'border-danger-600 ring-2 ring-danger-600/20'
: 'border-neutral-300';
},
phoneWrapClass(key) {
return this.errors[key]
? 'border-danger-600 ring-danger-600/20 focus-within:border-danger-600 focus-within:ring-danger-600/20'
: 'border-neutral-300 focus-within:border-primary-600 focus-within:ring-primary-600/20';
},
addContact() {
if (this.form.contacts.length >= 5) return;
this.form.contacts.push(this.newContact());
},
removeContact(idx) {
if (idx === 0) return;
this.form.contacts.splice(idx, 1);
},
addSchool() {
this.form.schools.push('');
},
removeSchool(idx) {
if (this.form.schools.length <= 1) return;
this.form.schools.splice(idx, 1);
},
setPropertyUsage(type) {
this.form.propertyUsage = type;
},
bindSectionObserver() {
this.$nextTick(() => {
const sections = Array.from(document.querySelectorAll('[data-section-key]'));
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.activeSection = entry.target.getAttribute('data-section-key');
}
});
}, { rootMargin: '-35% 0px -55% 0px', threshold: 0 });
sections.forEach((s) => observer.observe(s));
});
},
simulateSourceLoading() {
setTimeout(() => {
this.sourceOptions = [
{ value: 'store_visit', label: '线下丨门店接待' },
{ value: 'old_client_referral', label: '老客户转介绍' },
{ value: 'online_form', label: '线上丨留资表单' },
{ value: 'community_push', label: '社群活动' }
];
this.sourceLoading = false;
}, 900);
},
validatePhone(phone, countryCode) {
if (!phone) return false;
if (countryCode === '+86') return /^1\d{10}$/.test(phone);
return /^[0-9]{5,20}$/.test(phone);
},
validateForm() {
this.errors = {};
let firstErrorKey = '';
this.form.contacts.forEach((contact, idx) => {
const nameKey = this.fieldKey('contacts', idx, 'name');
const genderKey = this.fieldKey('contacts', idx, 'gender');
const phoneKey = this.fieldKey('contacts', idx, 'phone');
if (!contact.name) {
this.errors[nameKey] = '姓名不能为空';
if (!firstErrorKey) firstErrorKey = nameKey;
}
if (!contact.gender) {
this.errors[genderKey] = '请选择性别';
if (!firstErrorKey) firstErrorKey = genderKey;
}
if (!contact.phone) {
this.errors[phoneKey] = '请输入手机号';
if (!firstErrorKey) firstErrorKey = phoneKey;
} else if (!this.validatePhone(contact.phone, contact.phoneCountryCode)) {
this.errors[phoneKey] = contact.phoneCountryCode === '+86' ? '请输入有效的手机号码' : '请输入有效的电话号码';
if (!firstErrorKey) firstErrorKey = phoneKey;
}
});
if (!this.form.status) {
this.errors.status = '请选择客源状态';
if (!firstErrorKey) firstErrorKey = 'status';
}
if (!this.form.propertyUsage) {
this.errors.property_usage = '请选择用途';
if (!firstErrorKey) firstErrorKey = 'property_usage';
}
if (!this.form.grade) {
this.errors.grade = '请选择客源等级';
if (!firstErrorKey) firstErrorKey = 'grade';
}
if (!this.form.source) {
this.errors.source = '请选择客户来源';
if (!firstErrorKey) firstErrorKey = 'source';
}
if (firstErrorKey) {
this.$nextTick(() => {
const target = document.querySelector('[data-field-key="' + firstErrorKey + '"]');
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (typeof target.focus === 'function') target.focus({ preventScroll: true });
}
});
return false;
}
return true;
},
checkDuplicatePhone() {
const c = this.form.contacts[0];
this.duplicateHint = '';
if (!c.phone || !this.validatePhone(c.phone, c.phoneCountryCode)) return;
this.checkingDuplicate = true;
setTimeout(() => {
const duplicatedPhones = ['13800138000', '13911112222', '13700009999'];
if (c.phoneCountryCode === '+86' && duplicatedPhones.includes(c.phone)) {
this.duplicateHint = '存在重复客源:张三(可前往详情查看),可继续提交但建议先核对。';
}
this.checkingDuplicate = false;
}, 600);
},
showToast(message) {
this.toast.message = message;
this.toast.show = true;
setTimeout(() => {
this.toast.show = false;
}, 2200);
},
submitForm(continueMode = false) {
if (this.submitting) return;
if (!this.validateForm()) return;
this.submitting = true;
this.redirectHint = '';
setTimeout(() => {
this.submitting = false;
this.showToast('保存成功');
if (continueMode) {
this.form.contacts = [this.newContact()];
this.form.status = '';
this.form.grade = '';
this.form.source = '';
this.form.idType = '';
this.form.idNumber = '';
this.form.schools = [''];
this.infoExpanded = false;
this.errors = {};
this.redirectHint = '已保存当前客源,表单已重置,可继续新增。';
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
this.redirectHint = '模拟跳转中:即将进入客源详情页 /clients/CL20260426001/';
}
}, 1200);
},
submitAndContinue() {
this.submitForm(true);
},
cancelForm() {
this.redirectHint = '已取消录入,模拟返回客源列表 /clients/';
}
};
}
</script>
</body>
</html>