Files
nexus/Project/fonrey/UI_DESIGN/新增房源_UI.html
2026-04-29 15:43:49 +08:00

759 lines
45 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1280" />
<title>Fonrey 房源管理 · 新增房源任务02</title>
<script src="https://cdn.tailwindcss.com"></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' }
}
}
}
}
</script>
<style>
:root {
--bg-page:#F8FAFC; --bg-panel:#FFF; --bg-subtle:#F1F5F9; --text-main:#1E293B; --text-sub:#64748B;
--border:#E2E8F0; --input-bg:#FFF; --input-text:#1E293B; --header-bg:#FFF;
}
[data-theme="dark"] {
--bg-page:#0F172A; --bg-panel:#1E293B; --bg-subtle:#334155; --text-main:#E2E8F0; --text-sub:#94A3B8;
--border:#334155; --input-bg:#0F172A; --input-text:#E2E8F0; --header-bg:#0F172A;
}
[data-theme="system"] {
--bg-page:#F8FAFC; --bg-panel:#FFF; --bg-subtle:#F1F5F9; --text-main:#1E293B; --text-sub:#64748B;
--border:#E2E8F0; --input-bg:#FFF; --input-text:#1E293B; --header-bg:#FFF;
}
@media (prefers-color-scheme: dark) {
[data-theme="system"] {
--bg-page:#0F172A; --bg-panel:#1E293B; --bg-subtle:#334155; --text-main:#E2E8F0; --text-sub:#94A3B8;
--border:#334155; --input-bg:#0F172A; --input-text:#E2E8F0; --header-bg:#0F172A;
}
}
body { background:var(--bg-page); color:var(--text-main); transition:all .2s ease; }
.page-header { background:var(--header-bg); border-bottom:1px solid var(--border); backdrop-filter:blur(8px); }
.panel { background:var(--bg-panel); border:1px solid var(--border); }
.subtle { background:var(--bg-subtle); border:1px solid var(--border); }
.text-main { color:var(--text-main); }
.text-sub { color:var(--text-sub); }
.input { background:var(--input-bg); color:var(--input-text); border:1px solid var(--border); }
.input::placeholder { color:#94A3B8; }
.input:focus { outline:none; border-color:#0F766E; box-shadow:0 0 0 2px rgba(15,118,110,.2); }
.input.error { border-color:#DC2626!important; box-shadow:0 0 0 2px rgba(220,38,38,.14)!important; }
.seg { border:1px solid var(--border); background:var(--bg-panel); }
.seg-btn { color:var(--text-sub); border:1px solid transparent; }
.seg-btn.active { background:#0F766E; border-color:#0F766E; color:#FFF; }
.chip { border:1px solid var(--border); background:var(--bg-panel); color:var(--text-sub); }
.chip.active { border-color:#0F766E; color:#0F766E; background:#F0FDFA; }
[data-theme="dark"] .chip.active, [data-theme="system"] .chip.active { background:rgba(20,184,166,.12); }
.anchor-link { color:var(--text-sub); border-bottom:2px solid transparent; }
.anchor-link.active { color:#0F766E; border-bottom-color:#0F766E; font-weight:600; }
.error-msg { min-height:18px; font-size:12px; color:#DC2626; }
.toast-in { animation:toastIn .2s ease-out; }
@keyframes toastIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
</style>
</head>
<body class="antialiased">
<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 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>
<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">全部房源</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>
<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 px-6 py-5">
<div class="mx-auto max-w-[1240px] space-y-4">
<div class="panel rounded-lg p-4">
<div class="flex items-start justify-between gap-4 flex-wrap">
<div>
<nav class="flex items-center gap-1 text-xs text-sub mb-2" aria-label="面包屑">
<a href="javascript:void(0)" class="hover:text-neutral-700">房源</a><span>/</span>
<a href="./房源列表_UI.html" class="hover:text-neutral-700">二手/租赁</a><span>/</span>
<span class="text-main font-medium">新增房源</span>
</nav>
<h1 class="text-xl font-semibold text-main">新增房源</h1>
</div>
<div class="flex items-center gap-3">
<a href="./房源列表_UI.html" 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">返回列表</a>
</div>
</div>
</div>
<section class="panel rounded-lg p-4">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="text-xs text-sub mb-2">房源类型</div>
<div id="propertyTypeGroup" class="flex items-center gap-2 flex-wrap">
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-type="residential">住宅 <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-500">P0</span></button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-type="villa">别墅 <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-500">P1</span></button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-type="commercial_residential">商住 <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-500">P2</span></button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-type="shop">商铺 <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-500">P2</span></button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-type="office">写字楼 <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-500">P2</span></button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-type="other">其他 <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-500">P2</span></button>
</div>
<p class="mt-2 text-xs text-sub">当前类型:<span id="currentTypeLabel" class="font-medium text-main">住宅</span> <span id="p2Badge" class="ml-2 hidden inline-flex items-center px-2 py-0.5 rounded bg-warning-50 text-warning-600 border border-warning-600/20">v2 预留类型</span></p>
</div>
<div class="subtle rounded-lg px-3 py-2 text-xs text-sub max-w-xl">
<div class="font-medium text-main mb-1">验收重点US-PROPERTY-001</div>
<ul class="list-disc pl-4 space-y-1">
<li>必填缺失时红色提示并定位</li>
<li>状态与价格字段联动(出售/出租/租售/暂缓)</li>
<li>保存成功给出反馈(原型中模拟详情跳转)</li>
</ul>
</div>
</div>
</section>
<nav class="panel rounded-lg px-4 sticky top-[74px] z-30">
<div class="flex items-center gap-5 overflow-x-auto text-sm">
<a href="#section-core" class="anchor-link py-3" data-anchor-link="core">房源核心信息</a>
<a href="#section-contact" class="anchor-link py-3" data-anchor-link="contact">业主/联系人</a>
<a href="#section-basic" class="anchor-link py-3" data-anchor-link="basic">基础信息</a>
<a href="#section-trade" class="anchor-link py-3" data-anchor-link="trade" id="tradeAnchor">交易信息</a>
<a href="#section-related" class="anchor-link py-3" data-anchor-link="related">相关方</a>
</div>
</nav>
<form id="propertyForm" class="space-y-4">
<section id="section-core" class="panel rounded-lg p-5 space-y-4" data-section-key="core">
<h2 class="text-base font-semibold text-main">房源核心信息</h2>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-6" data-field="status">
<label class="block text-sm font-medium text-main mb-1.5">状态 <span class="text-danger-600">*</span></label>
<div class="flex items-center gap-2 flex-wrap" id="statusGroup">
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-status="for_sale">出售</button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-status="for_rent">出租</button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-status="for_sale_rent">租售</button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-status="suspended">暂缓</button>
</div>
<p class="error-msg" data-error="status"></p>
</div>
<div class="col-span-6" data-field="attribute">
<label class="block text-sm font-medium text-main mb-1.5">房源属性 <span class="text-danger-600">*</span></label>
<div class="flex items-center gap-2" id="attributeGroup">
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-attr="public">公盘</button>
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-attr="private">私盘</button>
</div>
<p class="error-msg" data-error="attribute"></p>
</div>
<div class="col-span-6" data-field="usage">
<label class="block text-sm font-medium text-main mb-1.5">用途</label>
<div class="flex items-center gap-2 flex-wrap" id="usageGroup"></div>
<p id="usageHint" class="text-xs text-sub rounded-md subtle px-2 py-1.5 inline-block hidden">当前类型用途选项待补充(按 PRD 预留)</p>
</div>
<div class="col-span-6" data-field="complexName">
<label class="block text-sm font-medium text-main mb-1.5">小区名称 <span class="text-danger-600">*</span></label>
<input id="complexName" type="text" class="input w-full px-3 py-2 rounded-md text-sm" placeholder="请输入小区名称(联想搜索)" data-error-input="complexName" />
<p class="error-msg" data-error="complexName"></p>
</div>
<div class="col-span-12">
<label class="block text-sm font-medium text-main mb-1.5">户室号</label>
<div class="grid grid-cols-3 gap-3">
<input id="blockNo" type="text" class="input w-full px-3 py-2 rounded-md text-sm" placeholder="栋/幢/弄/胡同" />
<input id="unitNo" type="text" class="input w-full px-3 py-2 rounded-md text-sm" placeholder="单元/号" />
<input id="roomNo" type="text" class="input w-full px-3 py-2 rounded-md text-sm" placeholder="门牌/室号" />
</div>
</div>
<div class="col-span-6" data-field="floors">
<label class="block text-sm font-medium text-main mb-1.5">所在楼层 <span class="text-danger-600">*</span></label>
<div class="flex items-center gap-2">
<input id="floor" type="number" min="1" class="input w-28 px-3 py-2 rounded-md text-sm" placeholder="请输入" data-error-input="floors" />
<span class="text-sub">楼,共</span>
<input id="totalFloors" type="number" min="1" class="input w-28 px-3 py-2 rounded-md text-sm" placeholder="请输入" data-error-input="floors" />
<span class="text-sub"></span>
</div>
<p class="error-msg" data-error="floors"></p>
</div>
<div class="col-span-6" data-field="area">
<label class="block text-sm font-medium text-main mb-1.5">建筑面积 <span class="text-danger-600">*</span></label>
<div class="relative">
<input id="area" type="number" min="0" step="0.01" class="input w-full px-3 py-2 rounded-md text-sm pr-12" placeholder="请输入" data-error-input="area" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-sub text-sm"></span>
</div>
<p class="error-msg" data-error="area"></p>
</div>
<div class="col-span-12" id="layoutWrap" data-field="layout">
<label class="block text-sm font-medium text-main mb-1.5">户型 <span class="text-danger-600">*</span></label>
<div class="flex items-center flex-wrap gap-2">
<select id="bedroom" class="input px-3 py-2 rounded-md text-sm" data-error-input="layout"><option value=""></option></select>
<select id="living" class="input px-3 py-2 rounded-md text-sm" data-error-input="layout"><option value=""></option></select>
<select id="bathroom" class="input px-3 py-2 rounded-md text-sm" data-error-input="layout"><option value=""></option></select>
<select id="kitchen" class="input px-3 py-2 rounded-md text-sm" data-error-input="layout"><option value=""></option></select>
<select id="balcony" class="input px-3 py-2 rounded-md text-sm" data-error-input="layout"><option value="">阳台</option></select>
</div>
<p class="error-msg" data-error="layout"></p>
</div>
<div id="shopFields" class="col-span-12 hidden">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-4" data-field="shopFrontage">
<label class="block text-sm font-medium text-main mb-1.5">开间 <span class="text-danger-600">*</span></label>
<div class="relative"><input id="shopFrontage" type="number" min="0" step="0.01" class="input w-full px-3 py-2 rounded-md text-sm pr-10" placeholder="请输入" data-error-input="shopFrontage" /><span class="absolute right-3 top-1/2 -translate-y-1/2 text-sub text-sm"></span></div>
<p class="error-msg" data-error="shopFrontage"></p>
</div>
<div class="col-span-4" data-field="shopDepth">
<label class="block text-sm font-medium text-main mb-1.5">进深 <span class="text-danger-600">*</span></label>
<div class="relative"><input id="shopDepth" type="number" min="0" step="0.01" class="input w-full px-3 py-2 rounded-md text-sm pr-10" placeholder="请输入" data-error-input="shopDepth" /><span class="absolute right-3 top-1/2 -translate-y-1/2 text-sub text-sm"></span></div>
<p class="error-msg" data-error="shopDepth"></p>
</div>
<div class="col-span-4" data-field="shopHeight">
<label class="block text-sm font-medium text-main mb-1.5">层高 <span class="text-danger-600">*</span></label>
<div class="relative"><input id="shopHeight" type="number" min="0" step="0.01" class="input w-full px-3 py-2 rounded-md text-sm pr-10" placeholder="请输入" data-error-input="shopHeight" /><span class="absolute right-3 top-1/2 -translate-y-1/2 text-sub text-sm"></span></div>
<p class="error-msg" data-error="shopHeight"></p>
</div>
<div class="col-span-12" data-field="shopLocation">
<label class="block text-sm font-medium text-main mb-1.5">位置 <span class="text-danger-600">*</span></label>
<div class="flex items-center gap-2 flex-wrap" id="shopLocationGroup"></div>
<p class="error-msg" data-error="shopLocation"></p>
</div>
</div>
</div>
<div class="col-span-6" id="salePriceWrap" data-field="salePrice">
<label class="block text-sm font-medium text-main mb-1.5">售价 <span class="text-danger-600">*</span></label>
<div class="relative"><input id="salePrice" type="number" min="0" step="0.01" class="input w-full px-3 py-2 rounded-md text-sm pr-10" placeholder="请输入" data-error-input="salePrice" /><span class="absolute right-3 top-1/2 -translate-y-1/2 text-sub text-sm"></span></div>
<p class="error-msg" data-error="salePrice"></p>
</div>
<div class="col-span-6 hidden" id="rentPriceWrap" data-field="rentPrice">
<label class="block text-sm font-medium text-main mb-1.5">租价 <span class="text-danger-600">*</span></label>
<div class="relative"><input id="rentPrice" type="number" min="0" step="1" class="input w-full px-3 py-2 rounded-md text-sm pr-16" placeholder="请输入" data-error-input="rentPrice" /><span class="absolute right-3 top-1/2 -translate-y-1/2 text-sub text-sm">元/月</span></div>
<p class="error-msg" data-error="rentPrice"></p>
</div>
<div id="suspendHint" class="col-span-12 hidden"><p class="text-xs text-warning-600 bg-warning-50 border border-warning-600/20 rounded-md px-3 py-2">当前状态为“暂缓”,售价/租价字段已隐藏。</p></div>
</div>
</section>
<section id="section-contact" class="panel rounded-lg p-5 space-y-4" data-section-key="contact">
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold text-main">业主/联系人</h2>
<button id="addContactBtn" type="button" class="px-3 py-1.5 rounded-md text-sm bg-primary-600 text-white hover:bg-primary-700">+ 添加联系人</button>
</div>
<div id="contactsContainer"></div>
</section>
<section id="section-basic" class="panel rounded-lg p-5 space-y-4" data-section-key="basic">
<h2 class="text-base font-semibold text-main">基础信息</h2>
<div>
<label class="block text-sm font-medium text-main mb-1.5">朝向 <span class="text-danger-600">*</span></label>
<div class="flex items-center gap-2 flex-wrap" id="orientationGroup"></div>
<p class="error-msg" data-error="orientation"></p>
</div>
<div>
<label class="block text-sm font-medium text-main mb-1.5">装修 <span class="text-danger-600">*</span></label>
<div class="flex items-center gap-2 flex-wrap" id="decorationGroup"></div>
<p class="error-msg" data-error="decoration"></p>
</div>
</section>
<section id="section-trade" class="panel rounded-lg p-5 space-y-4" data-section-key="trade">
<h2 class="text-base font-semibold text-main">交易信息</h2>
<div class="grid grid-cols-12 gap-3" data-field="ownershipYears">
<div class="col-span-3">
<label class="block text-sm font-medium text-main mb-1.5">房本年限 <span class="text-danger-600">*</span></label>
<select id="ownershipYears" class="input w-full px-3 py-2 rounded-md text-sm" data-error-input="ownershipYears">
<option value="">具体年限</option><option value="lt2">不满2年</option><option value="gte2">满2年</option><option value="gte5">满5年</option>
</select>
</div>
<div class="col-span-3 pt-6">
<select id="ownershipYearsDetail" class="input w-full px-3 py-2 rounded-md text-sm" data-error-input="ownershipYears">
<option value="">请选择</option><option value="full5">满五</option><option value="not5">不满五</option>
</select>
</div>
<div class="col-span-6 flex items-end"><p class="text-xs text-sub">商住类型仅保留房本年限;商铺/写字楼不展示该区块。</p></div>
</div>
<p class="error-msg" data-error="ownershipYears"></p>
</section>
<section id="section-related" class="panel rounded-lg p-5 space-y-4" data-section-key="related">
<h2 class="text-base font-semibold text-main">相关方</h2>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4">
<label class="block text-sm font-medium text-main mb-1">首录方</label>
<input id="firstRecorder" type="text" readonly value="杜利强 - 系统管理组" class="input w-full px-3 py-2 rounded-md text-sm bg-neutral-100 cursor-not-allowed" />
</div>
<div class="col-span-4" data-field="numberHolder">
<label class="block text-sm font-medium text-main mb-1">号码方 <span class="text-danger-600">*</span></label>
<div class="flex gap-2"><select id="numberHolder" class="input flex-1 px-3 py-2 rounded-md text-sm" data-error-input="numberHolder"></select><button type="button" id="clearNumberHolder" class="px-2 rounded-md border border-neutral-300 text-sub hover:bg-neutral-50"></button></div>
<p class="error-msg" data-error="numberHolder"></p>
</div>
<div class="col-span-4" data-field="sellerAgent">
<label class="block text-sm font-medium text-main mb-1">出售方 <span class="text-danger-600">*</span></label>
<div class="flex gap-2"><select id="sellerAgent" class="input flex-1 px-3 py-2 rounded-md text-sm" data-error-input="sellerAgent"></select><button type="button" id="clearSellerAgent" class="px-2 rounded-md border border-neutral-300 text-sub hover:bg-neutral-50"></button></div>
<p class="error-msg" data-error="sellerAgent"></p>
</div>
</div>
</section>
<section class="panel rounded-lg p-4 sticky bottom-4 z-20">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div id="dirtyText" class="text-xs text-sub">当前内容已保存或未变更</div>
<div class="flex items-center gap-3">
<button type="button" id="cancelBtn" 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" id="saveContinueBtn" 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" id="saveBtn" 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">保存</button>
</div>
</div>
</section>
</form>
</div>
</main>
<div id="toastContainer" class="fixed top-4 right-4 z-[70] space-y-2 w-[340px]" aria-live="polite"></div>
<template id="contactTemplate">
<div class="subtle rounded-lg p-4 space-y-3 contact-item">
<div class="flex items-center justify-between"><div class="text-sm font-medium text-main contact-title">业主/联系人1</div><button type="button" class="text-xs text-danger-600 hover:underline remove-contact hidden">删除</button></div>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-3" data-field="contactName"><label class="block text-sm font-medium text-main mb-1">姓名 <span class="text-danger-600">*</span></label><input type="text" class="input w-full px-3 py-2 rounded-md text-sm contact-name" placeholder="请输入" /><p class="error-msg contact-error-name"></p></div>
<div class="col-span-3"><label class="block text-sm font-medium text-main mb-1">性别 <span class="text-danger-600">*</span></label><div class="flex items-center gap-2 gender-group"><button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-gender="male">先生</button><button type="button" class="chip px-3 py-1.5 rounded-md text-sm" data-gender="female">女士</button></div></div>
<div class="col-span-2"><label class="block text-sm font-medium text-main mb-1">身份 <span class="text-danger-600">*</span></label><select class="input w-full px-3 py-2 rounded-md text-sm contact-identity"><option value="owner">业主</option><option value="contact">联系人</option><option value="agent">代理人</option><option value="tenant">租客</option><option value="subletter">二房东</option></select></div>
<div class="col-span-2"><label class="block text-sm font-medium text-main mb-1">电话1</label><input type="text" class="input w-full px-3 py-2 rounded-md text-sm contact-phone1" placeholder="手机号或座机号" /></div>
<div class="col-span-2"><label class="block text-sm font-medium text-main mb-1">电话2</label><input type="text" class="input w-full px-3 py-2 rounded-md text-sm contact-phone2" placeholder="手机号或座机号" /></div>
</div>
</div>
</template>
<script>
const state = {
propertyType: 'residential', status: 'for_sale', attribute: 'public', usage: '', shopLocation: '',
orientation: '', decoration: '', dirty: false, saving: false,
contacts: [{ name:'', gender:'male', identity:'owner', phone1:'', phone2:'' }],
errors: {},
options: {
usage: {
residential: [['normal_residential','普通住宅'], ['garden_house','花园洋房']],
villa: [['townhouse','联排别墅'], ['detached','独栋别墅'], ['semi_detached','双拼别墅'], ['stacked','叠加别墅']],
commercial_residential: [], shop: [], office: [],
other: [['garage','车库'], ['parking','车位'], ['bungalow','平房'], ['siheyuan','四合院'], ['warehouse','仓库'], ['factory','厂房'], ['land','地皮'], ['shop_factory','铺厂'], ['outlet','网点'], ['office_factory','写厂']]
},
shopLocation: [['street','临街'], ['mall','商场'], ['residential','小区'], ['ground_floor','底商'], ['complex','商业综合体']],
orientation: [['east','东'],['south','南'],['west','西'],['north','北'],['southeast','东南'],['northeast','东北'],['east_west','东西'],['south_north','南北'],['southwest','西南'],['northwest','西北']],
decoration: [['rough','毛坯'],['plain','清水'],['simple','简装'],['medium','中装'],['fine','精装'],['luxury','豪装']],
staff: [['','请选择'], ['du_liqiang','杜利强 - 系统管理组'], ['wei_shen','魏深 - 房源一组'], ['li_min','李敏 - 房源二组']]
}
}
const byId = id => document.getElementById(id)
const q = s => document.querySelector(s)
const qa = s => Array.from(document.querySelectorAll(s))
function setDirty(v = true) {
state.dirty = v
byId('dirtyText').textContent = state.dirty ? '你有未保存的更改' : '当前内容已保存或未变更'
}
function markActive(groupSelector, attr, value) {
qa(`${groupSelector} [${attr}]`).forEach(btn => btn.classList.toggle('active', btn.getAttribute(attr) === value))
}
function buildNumberSelects() {
;['bedroom','living','bathroom','kitchen','balcony'].forEach(id => {
const sel = byId(id)
for (let i = 0; i <= 9; i++) {
const op = document.createElement('option')
op.value = String(i)
op.textContent = `${i}${id==='bedroom'?'室':id==='living'?'厅':id==='bathroom'?'卫':id==='kitchen'?'厨':'阳台'}`
sel.appendChild(op)
}
})
}
function buildOptions() {
const usage = byId('usageGroup')
usage.innerHTML = ''
const arr = state.options.usage[state.propertyType] || []
if (!arr.length) {
byId('usageHint').classList.remove('hidden')
} else {
byId('usageHint').classList.add('hidden')
arr.forEach(([v, t]) => {
const b = document.createElement('button')
b.type = 'button'; b.className = 'chip px-3 py-1.5 rounded-md text-sm'; b.textContent = t; b.dataset.usage = v
if (state.usage === v) b.classList.add('active')
b.addEventListener('click', () => { state.usage = v; markActive('#usageGroup','data-usage',v); setDirty() })
usage.appendChild(b)
})
}
const shopLoc = byId('shopLocationGroup')
shopLoc.innerHTML = ''
state.options.shopLocation.forEach(([v, t]) => {
const b = document.createElement('button')
b.type = 'button'; b.className = 'chip px-3 py-1.5 rounded-md text-sm'; b.textContent = t; b.dataset.shoploc = v
if (state.shopLocation === v) b.classList.add('active')
b.addEventListener('click', () => { state.shopLocation = v; markActive('#shopLocationGroup','data-shoploc',v); clearError('shopLocation'); setDirty() })
shopLoc.appendChild(b)
})
const orientation = byId('orientationGroup')
orientation.innerHTML = ''
state.options.orientation.forEach(([v, t]) => {
const b = document.createElement('button')
b.type = 'button'; b.className = 'chip px-3 py-1.5 rounded-md text-sm'; b.textContent = t; b.dataset.orient = v
if (state.orientation === v) b.classList.add('active')
b.addEventListener('click', () => { state.orientation = v; markActive('#orientationGroup','data-orient',v); clearError('orientation'); setDirty() })
orientation.appendChild(b)
})
const decoration = byId('decorationGroup')
decoration.innerHTML = ''
state.options.decoration.forEach(([v, t]) => {
const b = document.createElement('button')
b.type = 'button'; b.className = 'chip px-3 py-1.5 rounded-md text-sm'; b.textContent = t; b.dataset.deco = v
if (state.decoration === v) b.classList.add('active')
b.addEventListener('click', () => { state.decoration = v; markActive('#decorationGroup','data-deco',v); clearError('decoration'); setDirty() })
decoration.appendChild(b)
})
;['numberHolder','sellerAgent'].forEach(id => {
const sel = byId(id)
const old = sel.value
sel.innerHTML = ''
state.options.staff.forEach(([v, t]) => {
const op = document.createElement('option'); op.value = v; op.textContent = id==='numberHolder' && v==='' ? '请选择号码方' : id==='sellerAgent' && v==='' ? '请选择出售方' : t
sel.appendChild(op)
})
sel.value = old || 'du_liqiang'
})
}
function renderContacts() {
const wrap = byId('contactsContainer')
wrap.innerHTML = ''
state.contacts.forEach((c, idx) => {
const el = byId('contactTemplate').content.firstElementChild.cloneNode(true)
el.querySelector('.contact-title').textContent = `业主/联系人${idx + 1}`
const removeBtn = el.querySelector('.remove-contact')
if (idx > 0) {
removeBtn.classList.remove('hidden')
removeBtn.addEventListener('click', () => { state.contacts.splice(idx, 1); renderContacts(); setDirty() })
}
const name = el.querySelector('.contact-name'); name.value = c.name
name.addEventListener('input', e => { state.contacts[idx].name = e.target.value; setDirty() })
const genderBtns = el.querySelectorAll('[data-gender]')
genderBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.gender === c.gender)
btn.addEventListener('click', () => {
state.contacts[idx].gender = btn.dataset.gender
genderBtns.forEach(b => b.classList.toggle('active', b === btn)); setDirty()
})
})
const identity = el.querySelector('.contact-identity'); identity.value = c.identity
identity.addEventListener('change', e => { state.contacts[idx].identity = e.target.value; setDirty() })
const p1 = el.querySelector('.contact-phone1'); p1.value = c.phone1; p1.addEventListener('input', e => { state.contacts[idx].phone1 = e.target.value; setDirty() })
const p2 = el.querySelector('.contact-phone2'); p2.value = c.phone2; p2.addEventListener('input', e => { state.contacts[idx].phone2 = e.target.value; setDirty() })
if (idx === 0 && state.errors['contacts.0.name']) {
name.classList.add('error')
el.querySelector('.contact-error-name').textContent = state.errors['contacts.0.name']
}
wrap.appendChild(el)
})
}
function isShop() { return state.propertyType === 'shop' }
function showLayout() { return !['shop','office'].includes(state.propertyType) }
function showTrade() { return !['shop','office'].includes(state.propertyType) }
function refreshTypeUI() {
markActive('#propertyTypeGroup', 'data-type', state.propertyType)
const map = { residential:'住宅', villa:'别墅', commercial_residential:'商住', shop:'商铺', office:'写字楼', other:'其他' }
byId('currentTypeLabel').textContent = map[state.propertyType]
byId('p2Badge').classList.toggle('hidden', !['commercial_residential','shop','office','other'].includes(state.propertyType))
byId('layoutWrap').classList.toggle('hidden', !showLayout())
byId('shopFields').classList.toggle('hidden', !isShop())
byId('section-trade').classList.toggle('hidden', !showTrade())
byId('tradeAnchor').classList.toggle('hidden', !showTrade())
buildOptions()
updateStatusUI()
}
function updateStatusUI() {
markActive('#statusGroup', 'data-status', state.status)
byId('salePriceWrap').classList.toggle('hidden', !['for_sale','for_sale_rent'].includes(state.status))
byId('rentPriceWrap').classList.toggle('hidden', !['for_rent','for_sale_rent'].includes(state.status))
byId('suspendHint').classList.toggle('hidden', state.status !== 'suspended')
}
function clearErrors() {
state.errors = {}
qa('.error-msg').forEach(e => e.textContent = '')
qa('.input.error').forEach(i => i.classList.remove('error'))
}
function clearError(key) {
state.errors[key] = ''
const msg = q(`[data-error="${CSS.escape(key)}"]`)
if (msg) msg.textContent = ''
qa(`[data-error-input="${CSS.escape(key)}"]`).forEach(i => i.classList.remove('error'))
}
function setError(key, msg) {
state.errors[key] = msg
const msgEl = q(`[data-error="${CSS.escape(key)}"]`)
if (msgEl) msgEl.textContent = msg
qa(`[data-error-input="${CSS.escape(key)}"]`).forEach(i => i.classList.add('error'))
}
function firstErrorKey() {
return Object.keys(state.errors).find(k => state.errors[k])
}
function validate() {
clearErrors()
const v = id => (byId(id)?.value || '').trim()
if (!state.status) setError('status', '请选择状态')
if (!state.attribute) setError('attribute', '请选择房源属性')
if (!v('complexName')) setError('complexName', '请输入小区名称')
const floor = Number(v('floor')), total = Number(v('totalFloors'))
if (!floor || !total) setError('floors', '请输入所在楼层与总楼层')
else if (floor > total) setError('floors', '所在楼层不能大于总楼层')
if (!Number(v('area')) || Number(v('area')) <= 0) setError('area', '请输入建筑面积')
if (showLayout()) {
const keys = ['bedroom','living','bathroom','kitchen','balcony']
if (keys.some(k => v(k) === '')) setError('layout', '请完整填写户型(室/厅/卫/厨/阳台)')
}
if (['for_sale','for_sale_rent'].includes(state.status) && (!Number(v('salePrice')) || Number(v('salePrice')) <= 0)) {
setError('salePrice', '请输入有效售价')
}
if (['for_rent','for_sale_rent'].includes(state.status) && (!Number(v('rentPrice')) || Number(v('rentPrice')) <= 0)) {
setError('rentPrice', '请输入有效租价')
}
if (isShop()) {
if (!Number(v('shopFrontage')) || Number(v('shopFrontage')) <= 0) setError('shopFrontage', '请输入开间')
if (!Number(v('shopDepth')) || Number(v('shopDepth')) <= 0) setError('shopDepth', '请输入进深')
if (!Number(v('shopHeight')) || Number(v('shopHeight')) <= 0) setError('shopHeight', '请输入层高')
if (!state.shopLocation) setError('shopLocation', '请选择位置')
}
if (!state.contacts[0]?.name?.trim()) setError('contacts.0.name', '联系人1姓名必填')
if (!state.orientation) setError('orientation', '请选择朝向')
if (!state.decoration) setError('decoration', '请选择装修')
if (showTrade()) {
if (!v('ownershipYears') || !v('ownershipYearsDetail')) setError('ownershipYears', '请选择完整房本年限')
}
if (!v('numberHolder')) setError('numberHolder', '请选择号码方')
if (!v('sellerAgent')) setError('sellerAgent', '请选择出售方')
renderContacts()
return !firstErrorKey()
}
function scrollToFirstError() {
const key = firstErrorKey()
if (!key) return
const selector = key === 'contacts.0.name' ? '#section-contact' : `[data-field="${CSS.escape(key)}"]`
const target = q(selector)
if (target) target.scrollIntoView({ behavior:'smooth', block:'center' })
}
function toast(type, title, message) {
const c = byId('toastContainer')
const el = document.createElement('div')
const cls = type==='success' ? 'border-success-600/30 bg-success-50' : type==='warning' ? 'border-warning-600/30 bg-warning-50' : type==='error' ? 'border-danger-600/30 bg-danger-50' : 'border-info-600/30 bg-info-50'
el.className = `panel rounded-lg px-3 py-2 shadow-lg toast-in ${cls}`
el.innerHTML = `<div class="flex items-start gap-2"><div class="text-sm font-medium">${title}</div><button class="ml-auto text-xs text-sub">✕</button></div><p class="text-xs mt-1 text-sub">${message}</p>`
el.querySelector('button').addEventListener('click', ()=> el.remove())
c.appendChild(el)
setTimeout(()=>el.remove(), 3200)
}
function setButtonsDisabled(disabled) {
;['saveBtn','saveContinueBtn','cancelBtn'].forEach(id => byId(id).disabled = disabled)
byId('saveBtn').textContent = disabled ? '保存中...' : '保存'
}
async function submit(mode) {
if (!validate()) {
toast('error','保存失败','请先修正必填项与格式错误')
scrollToFirstError()
return
}
setButtonsDisabled(true)
await new Promise(r => setTimeout(r, 650))
if (['commercial_residential','shop','office','other'].includes(state.propertyType)) {
toast('warning','类型预留提醒','该房源类型在 PRD 中为 v2 预留,当前原型仅用于评审。')
setButtonsDisabled(false)
setDirty(false)
return
}
if (mode === 'save_continue') {
byId('propertyForm').reset()
state.contacts = [{ name:'', gender:'male', identity:'owner', phone1:'', phone2:'' }]
state.orientation = ''; state.decoration = ''; state.usage = ''; state.shopLocation = ''
state.status = 'for_sale'; state.attribute = 'public'
buildOptions(); renderContacts(); updateStatusUI(); clearErrors()
toast('success','保存成功','已保存当前房源,表单已重置,可继续新增。')
window.scrollTo({ top:0, behavior:'smooth' })
} else {
toast('success','保存成功','已完成保存。原型阶段将于任务03接入详情页跳转。')
}
setButtonsDisabled(false)
setDirty(false)
}
function bindAnchors() {
const links = {
core:q('[data-anchor-link="core"]'), contact:q('[data-anchor-link="contact"]'), basic:q('[data-anchor-link="basic"]'),
trade:q('[data-anchor-link="trade"]'), related:q('[data-anchor-link="related"]')
}
const obs = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return
const key = entry.target.dataset.sectionKey
if (key==='trade' && byId('section-trade').classList.contains('hidden')) return
Object.values(links).forEach(a => a && a.classList.remove('active'))
if (links[key]) links[key].classList.add('active')
})
}, { rootMargin:'-35% 0px -55% 0px', threshold:0 })
;['section-core','section-contact','section-basic','section-trade','section-related'].forEach(id => {
const el = byId(id); if (el) obs.observe(el)
})
links.core.classList.add('active')
}
function initTheme() {
const saved = localStorage.getItem('fonrey_theme') || 'light'
document.documentElement.setAttribute('data-theme', saved)
qa('[data-theme-btn]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.themeBtn === saved)
btn.addEventListener('click', () => {
const t = btn.dataset.themeBtn
document.documentElement.setAttribute('data-theme', t)
localStorage.setItem('fonrey_theme', t)
qa('[data-theme-btn]').forEach(x => x.classList.toggle('active', x === btn))
})
})
}
function init() {
initTheme()
buildNumberSelects()
buildOptions()
renderContacts()
bindAnchors()
markActive('#statusGroup', 'data-status', state.status)
markActive('#attributeGroup', 'data-attr', state.attribute)
markActive('#propertyTypeGroup', 'data-type', state.propertyType)
refreshTypeUI()
qa('#propertyTypeGroup [data-type]').forEach(btn => {
btn.addEventListener('click', () => {
state.propertyType = btn.dataset.type
state.usage = ''; state.shopLocation = ''
clearErrors(); refreshTypeUI(); setDirty()
})
})
qa('#statusGroup [data-status]').forEach(btn => btn.addEventListener('click', () => { state.status = btn.dataset.status; updateStatusUI(); clearError('status'); setDirty() }))
qa('#attributeGroup [data-attr]').forEach(btn => btn.addEventListener('click', () => { state.attribute = btn.dataset.attr; markActive('#attributeGroup','data-attr',state.attribute); clearError('attribute'); setDirty() }))
byId('addContactBtn').addEventListener('click', () => { state.contacts.push({ name:'', gender:'male', identity:'contact', phone1:'', phone2:'' }); renderContacts(); setDirty() })
byId('numberHolder').addEventListener('change', () => { clearError('numberHolder'); setDirty() })
byId('sellerAgent').addEventListener('change', () => { clearError('sellerAgent'); setDirty() })
byId('clearNumberHolder').addEventListener('click', () => { byId('numberHolder').value=''; setDirty() })
byId('clearSellerAgent').addEventListener('click', () => { byId('sellerAgent').value=''; setDirty() })
qa('input,select,textarea').forEach(el => {
el.addEventListener('input', () => setDirty())
el.addEventListener('change', () => setDirty())
})
byId('propertyForm').addEventListener('submit', e => { e.preventDefault(); submit('save') })
byId('saveContinueBtn').addEventListener('click', () => submit('save_continue'))
byId('cancelBtn').addEventListener('click', () => {
if (!state.dirty) { window.location.href = './房源列表_UI.html'; return }
if (window.confirm('当前有未保存内容,确认放弃并离开吗?')) {
state.dirty = false
window.location.href = './房源列表_UI.html'
}
})
window.addEventListener('beforeunload', (e) => {
if (state.dirty) { e.preventDefault(); e.returnValue = '' }
})
setDirty(false)
}
init()
</script>
</body>
</html>