Files
nexus/Project/fonrey/UI_DESIGN/系统配置_UI.html
2026-04-29 15:43:49 +08:00

857 lines
43 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">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1366" />
<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' }
}
}
}
};
</script>
<style>
:root {
--bg-page: #F8FAFC;
--bg-card: #FFFFFF;
--bg-subtle: #F1F5F9;
--text-primary: #0F172A;
--text-secondary: #64748B;
--border: #E2E8F0;
}
body {
background: var(--bg-page);
color: var(--text-primary);
}
[x-cloak] { display: none !important; }
.main-tab {
color: #64748B;
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.main-tab.active {
color: #115E59;
border-bottom-color: #115E59;
font-weight: 600;
}
.action-link {
font-size: 12px;
color: #2563EB;
}
.action-link:hover { text-decoration: underline; }
.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; }
</style>
</head>
<body class="text-sm antialiased" x-data="settingPage()" x-init="init()" @keydown.escape.window="closeAllPanels()">
<!-- Top Bar -->
<header class="fixed top-0 left-0 right-0 h-14 z-30 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 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 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>
</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>
<!-- Side Bar -->
<aside class="fixed left-0 top-14 h-[calc(100vh-56px)] w-60 z-20 border-r border-neutral-200 bg-white overflow-y-auto">
<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 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>
<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">人事OA设置</a>
</nav>
</aside>
<!-- Main -->
<main class="ml-60 pt-[72px] min-h-screen px-6 py-5">
<div class="mx-auto max-w-[1760px] space-y-4">
<section class="bg-white border border-neutral-200 rounded-lg p-4">
<div class="flex items-start justify-between gap-4">
<div>
<nav class="flex items-center gap-1 text-xs text-neutral-500 mb-2" aria-label="面包屑">
<a href="#" class="hover:text-neutral-700">系统</a>
<span>/</span>
<a href="#" class="hover:text-neutral-700">设置</a>
<span>/</span>
<span class="text-neutral-900">系统配置</span>
</nav>
<h1 class="text-xl font-semibold text-neutral-900">系统配置</h1>
<p class="text-xs text-neutral-500 mt-1">覆盖参数配置Lookup、房源字段规则、客源录入规则</p>
</div>
<button class="px-3 py-1.5 rounded-md border border-info-600 text-info-600 bg-white hover:bg-info-50" @click="notify('查看系统配置帮助(原型)')">配置说明</button>
</div>
</section>
<section class="bg-white border border-neutral-200 rounded-lg px-4">
<nav class="flex items-center gap-6 overflow-x-auto" aria-label="系统配置主Tab">
<button class="main-tab py-3" :class="{ 'active': mainTab === 'lookup' }" @click="mainTab = 'lookup'">参数配置</button>
<button class="main-tab py-3" :class="{ 'active': mainTab === 'fieldRule' }" @click="mainTab = 'fieldRule'">房源字段规则</button>
<button class="main-tab py-3" :class="{ 'active': mainTab === 'clientRule' }" @click="mainTab = 'clientRule'">客源规则</button>
</nav>
</section>
<!-- 参数配置 -->
<template x-if="mainTab === 'lookup'">
<section class="space-y-4">
<section class="bg-white border border-neutral-200 rounded-lg p-4 space-y-3">
<div class="grid grid-cols-5 gap-3">
<input x-model.trim="lookupFilters.keyword" type="text" placeholder="请输入参数项/项目值" class="px-3 py-2 rounded-md border border-neutral-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/30" />
<select x-model="lookupFilters.module" class="px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">模块(全选)</option>
<option value="client">客源</option>
<option value="property">房源</option>
</select>
<div class="col-span-3 flex items-center justify-end gap-2">
<button class="px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700">查询</button>
<button class="px-3 py-2 rounded-md border border-neutral-300 hover:bg-neutral-50" @click="lookupFilters = { keyword: '', module: '' }">清空条件</button>
</div>
</div>
<div class="text-xs text-neutral-500">系统预制项不可删除,仅可停用;保存后触发 lookup 缓存失效。</div>
</section>
<template x-if="filteredLookupGroups.length === 0">
<section class="bg-white border border-neutral-200 rounded-lg p-10 text-center text-neutral-400">暂无匹配参数组</section>
</template>
<template x-for="group in filteredLookupGroups" :key="group.id">
<section class="bg-white border border-neutral-200 rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-neutral-200 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold" x-text="`${group.label}${group.moduleLabel}`"></h3>
<p class="text-xs text-neutral-500 mt-1" x-text="`Key${group.module}.${group.key}`"></p>
</div>
<button class="px-3 py-1.5 rounded-md border border-primary-600 text-primary-600 hover:bg-primary-50" @click="openLookupEditor(group)">编辑参数</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-neutral-50 border-b border-neutral-200 text-neutral-500">
<tr>
<th class="px-3 py-2 text-left">项目值</th>
<th class="px-3 py-2 text-left">排序</th>
<th class="px-3 py-2 text-left">状态</th>
<th class="px-3 py-2 text-left">来源</th>
<th class="px-3 py-2 text-left">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100 bg-white">
<template x-if="group.items.length === 0">
<tr><td colspan="5" class="px-3 py-8 text-center text-neutral-400">暂无项目值</td></tr>
</template>
<template x-for="item in group.items" :key="item.id">
<tr>
<td class="px-3 py-2" x-text="item.label"></td>
<td class="px-3 py-2 tabular-nums" x-text="item.sortOrder"></td>
<td class="px-3 py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px]" :class="item.active ? 'bg-success-50 text-success-600' : 'bg-neutral-100 text-neutral-500'" x-text="item.active ? '启用' : '停用'"></span>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px]" :class="item.isSystem ? 'bg-info-50 text-info-600' : 'bg-warning-50 text-warning-600'" x-text="item.isSystem ? '系统预制' : '自定义'"></span>
</td>
<td class="px-3 py-2 whitespace-nowrap">
<button class="action-link" @click="toggleLookupItemActive(group.id, item.id)" x-text="item.active ? '停用' : '启用'"></button>
<button class="action-link ml-2" :class="item.isSystem ? 'opacity-40 pointer-events-none' : ''" @click="deleteLookupItem(group.id, item.id)">删除</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</template>
</section>
</template>
<!-- 房源字段规则 -->
<template x-if="mainTab === 'fieldRule'">
<section class="space-y-4">
<section class="bg-white border border-neutral-200 rounded-lg p-4 space-y-3">
<div class="grid grid-cols-5 gap-3">
<input x-model.trim="fieldFilters.keyword" type="text" placeholder="请输入用途/交易状态" class="px-3 py-2 rounded-md border border-neutral-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/30" />
<select x-model="fieldFilters.entityType" class="px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">用途(全选)</option>
<option value="residential">住宅</option>
<option value="shop">商铺</option>
</select>
<select x-model="fieldFilters.tradeStatus" class="px-3 py-2 rounded-md border border-neutral-300 bg-white">
<option value="">交易状态(全选)</option>
<option value="sale">出售</option>
<option value="rent">出租</option>
</select>
<div class="col-span-2 flex items-center justify-end gap-2">
<button class="px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700">查询</button>
<button class="px-3 py-2 rounded-md border border-neutral-300 hover:bg-neutral-50" @click="fieldFilters = { keyword: '', entityType: '', tradeStatus: '' }">清空条件</button>
</div>
</div>
<p class="text-xs text-neutral-500">规则值映射required=必填optional=选填hidden=隐藏。隐藏字段不在录入页渲染。</p>
</section>
<section class="bg-white border border-neutral-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-neutral-50 border-b border-neutral-200 text-neutral-500">
<tr>
<th class="px-3 py-2 text-left">用途</th>
<th class="px-3 py-2 text-left">交易状态</th>
<th class="px-3 py-2 text-left">必填字段数</th>
<th class="px-3 py-2 text-left">隐藏字段数</th>
<th class="px-3 py-2 text-left">更新时间</th>
<th class="px-3 py-2 text-left">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100 bg-white">
<template x-if="filteredFieldCombos.length === 0">
<tr><td colspan="6" class="px-3 py-8 text-center text-neutral-400">暂无匹配规则</td></tr>
</template>
<template x-for="combo in filteredFieldCombos" :key="combo.id">
<tr>
<td class="px-3 py-2" x-text="combo.entityTypeLabel"></td>
<td class="px-3 py-2" x-text="combo.tradeStatusLabel"></td>
<td class="px-3 py-2 tabular-nums" x-text="countRequirement(combo, 'required')"></td>
<td class="px-3 py-2 tabular-nums" x-text="countRequirement(combo, 'hidden')"></td>
<td class="px-3 py-2 tabular-nums" x-text="combo.updatedAt"></td>
<td class="px-3 py-2"><button class="action-link" @click="openRuleDrawer(combo)">编辑规则</button></td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</section>
</template>
<!-- 客源规则 -->
<template x-if="mainTab === 'clientRule'">
<section class="space-y-4">
<section class="bg-white border border-neutral-200 rounded-lg p-4 space-y-3">
<h3 class="text-base font-semibold">新增私客查重范围</h3>
<div class="grid grid-cols-3 gap-3">
<template x-for="scope in duplicateScopeOptions" :key="scope.value">
<label class="border rounded-md p-3 cursor-pointer" :class="clientRules.duplicateScope === scope.value ? 'border-primary-600 bg-primary-50' : 'border-neutral-200 hover:border-neutral-300'">
<div class="flex items-center gap-2">
<input type="radio" name="duplicateScope" :value="scope.value" x-model="clientRules.duplicateScope" class="text-primary-600 border-neutral-300" />
<span class="font-medium text-sm" x-text="scope.label"></span>
</div>
<p class="text-xs text-neutral-500 mt-2" x-text="scope.desc"></p>
</label>
</template>
</div>
</section>
<section class="bg-white border border-neutral-200 rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">客源必填字段配置</h3>
<button class="px-3 py-1.5 rounded-md border border-neutral-300 hover:bg-neutral-50" @click="resetClientRules()">恢复默认</button>
</div>
<div class="grid grid-cols-2 gap-3">
<template x-for="field in clientRequiredFieldOptions" :key="field.key">
<label class="flex items-center justify-between border border-neutral-200 rounded-md p-3">
<div>
<div class="text-sm font-medium" x-text="field.label"></div>
<div class="text-xs text-neutral-500" x-text="field.desc"></div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs" :class="clientRules.requiredFields[field.key] ? 'text-success-600' : 'text-neutral-500'" x-text="clientRules.requiredFields[field.key] ? '必填' : '选填'"></span>
<button type="button" class="relative inline-flex h-6 w-11 items-center rounded-full transition"
:class="clientRules.requiredFields[field.key] ? 'bg-primary-600' : 'bg-neutral-300'"
@click="toggleClientRequired(field)">
<span class="inline-block h-5 w-5 transform rounded-full bg-white transition"
:class="clientRules.requiredFields[field.key] ? 'translate-x-5' : 'translate-x-1'"></span>
</button>
</div>
</label>
</template>
</div>
<div class="rounded-md bg-info-50 border border-info-600/20 px-3 py-2 text-xs text-info-600">
保存后触发缓存失效:`{tenant_schema}:setting:client_rules`,经纪人下次打开录入页即读取新规则。
</div>
<template x-if="clientRuleError">
<div class="rounded-md bg-danger-50 border border-danger-600/20 px-3 py-2 text-xs text-danger-600" x-text="clientRuleError"></div>
</template>
<div class="flex justify-end">
<button class="px-4 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="saveClientRules()">保存客源规则</button>
</div>
</section>
</section>
</template>
</div>
</main>
<!-- 参数编辑弹窗 -->
<div x-cloak x-show="lookupEditor.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeLookupEditor()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<section class="w-full max-w-5xl bg-white rounded-xl shadow-xl border border-neutral-200 pointer-events-auto flex flex-col max-h-[90vh]">
<header class="px-5 py-4 border-b border-neutral-200 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold" x-text="lookupEditor.title"></h3>
<p class="text-xs text-neutral-500 mt-1">支持新增、排序、停用;系统预制项不可删除</p>
</div>
<button class="p-1.5 rounded-md text-neutral-500 hover:bg-neutral-100" @click="closeLookupEditor()"></button>
</header>
<div class="px-5 py-4 overflow-y-auto space-y-3">
<template x-if="lookupEditor.error">
<div class="rounded-md bg-danger-50 border border-danger-600/20 px-3 py-2 text-xs text-danger-600" x-text="lookupEditor.error"></div>
</template>
<div class="border border-neutral-200 rounded-lg overflow-hidden">
<table class="min-w-full text-xs">
<thead class="bg-neutral-50 border-b border-neutral-200 text-neutral-500">
<tr>
<th class="px-3 py-2 text-left">项目值</th>
<th class="px-3 py-2 text-left">状态</th>
<th class="px-3 py-2 text-left">来源</th>
<th class="px-3 py-2 text-left">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100 bg-white">
<template x-for="(item, idx) in lookupEditor.items" :key="item.id">
<tr>
<td class="px-3 py-2">
<input type="text" x-model.trim="item.label" class="w-full px-2 py-1.5 rounded border border-neutral-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/30" placeholder="请输入项目值" />
</td>
<td class="px-3 py-2">
<button type="button" class="relative inline-flex h-6 w-11 items-center rounded-full transition"
:class="item.active ? 'bg-primary-600' : 'bg-neutral-300'"
@click="item.active = !item.active">
<span class="inline-block h-5 w-5 transform rounded-full bg-white transition" :class="item.active ? 'translate-x-5' : 'translate-x-1'"></span>
</button>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px]" :class="item.isSystem ? 'bg-info-50 text-info-600' : 'bg-warning-50 text-warning-600'" x-text="item.isSystem ? '系统预制' : '自定义'"></span>
</td>
<td class="px-3 py-2 whitespace-nowrap">
<button class="action-link" :class="idx===0 ? 'opacity-40 pointer-events-none' : ''" @click="moveLookupEditorItem(idx, -1)">上移</button>
<button class="action-link ml-2" :class="idx===lookupEditor.items.length-1 ? 'opacity-40 pointer-events-none' : ''" @click="moveLookupEditorItem(idx, 1)">下移</button>
<button class="action-link ml-2" :class="item.isSystem ? 'opacity-40 pointer-events-none' : ''" @click="removeLookupEditorItem(idx)">删除</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<button class="px-3 py-1.5 rounded-md border border-primary-600 text-primary-600 hover:bg-primary-50" @click="addLookupEditorItem()">+ 添加项目</button>
</div>
<footer class="px-5 py-3 border-t border-neutral-200 bg-neutral-50 flex items-center justify-end gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 hover:bg-white" @click="closeLookupEditor()">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="saveLookupEditor()">保存</button>
</footer>
</section>
</div>
</div>
<!-- 字段规则编辑抽屉 -->
<div x-cloak x-show="ruleDrawer.open" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeRuleDrawer()"></div>
<aside class="absolute right-0 top-0 h-full w-[860px] bg-white border-l border-neutral-200 shadow-2xl flex flex-col">
<header class="px-5 py-4 border-b border-neutral-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-semibold" x-text="ruleDrawer.title"></h3>
<p class="text-xs text-neutral-500 mt-1">三态规则:必填 / 选填 / 隐藏(隐藏=录入页不展示)</p>
</div>
<button class="p-1.5 rounded-md text-neutral-500 hover:bg-neutral-100" @click="closeRuleDrawer()"></button>
</div>
<div class="mt-3">
<input x-model.trim="ruleDrawer.search" type="text" placeholder="搜索字段名称" class="w-full px-3 py-2 rounded-md border border-neutral-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/30" />
</div>
</header>
<div class="flex-1 overflow-y-auto p-5 space-y-3">
<template x-for="(rule, idx) in filteredRuleDrawerItems" :key="rule.fieldKey">
<div class="border border-neutral-200 rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<div class="font-medium text-sm" x-text="rule.fieldName"></div>
<div class="text-xs text-neutral-500" x-text="rule.fieldKey"></div>
</div>
<div class="flex items-center gap-4 text-xs">
<label class="inline-flex items-center gap-1.5 cursor-pointer">
<input type="radio" :name="`req-${rule.fieldKey}`" :checked="rule.requirement === 'required'" @change="setDrawerRequirement(rule.fieldKey, 'required')" />
<span>必填</span>
</label>
<label class="inline-flex items-center gap-1.5 cursor-pointer">
<input type="radio" :name="`req-${rule.fieldKey}`" :checked="rule.requirement === 'optional'" @change="setDrawerRequirement(rule.fieldKey, 'optional')" />
<span>选填</span>
</label>
<label class="inline-flex items-center gap-1.5 cursor-pointer">
<input type="radio" :name="`req-${rule.fieldKey}`" :checked="rule.requirement === 'hidden'" @change="setDrawerRequirement(rule.fieldKey, 'hidden')" />
<span>隐藏</span>
</label>
<div class="ml-auto flex items-center gap-2">
<span class="text-neutral-500">录入页展示</span>
<button type="button" class="relative inline-flex h-6 w-11 items-center rounded-full transition"
:class="rule.requirement === 'hidden' ? 'bg-neutral-300' : 'bg-primary-600'"
@click="toggleDrawerFieldDisplay(rule.fieldKey)">
<span class="inline-block h-5 w-5 transform rounded-full bg-white transition"
:class="rule.requirement === 'hidden' ? 'translate-x-1' : 'translate-x-5'"></span>
</button>
</div>
</div>
</div>
</template>
<template x-if="filteredRuleDrawerItems.length === 0">
<div class="border border-dashed border-neutral-300 rounded-lg p-8 text-center text-neutral-400">暂无匹配字段</div>
</template>
</div>
<footer class="px-5 py-3 border-t border-neutral-200 bg-neutral-50 flex items-center justify-end gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 hover:bg-white" @click="closeRuleDrawer()">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="saveRuleDrawer()">保存规则</button>
</footer>
</aside>
</div>
<!-- Toast -->
<div x-cloak x-show="toast.show" x-transition class="fixed top-20 right-6 z-[60]">
<div class="rounded-lg border px-4 py-2 shadow-lg text-sm"
:class="toast.type === 'error' ? 'bg-danger-50 border-danger-600/30 text-danger-600' : 'bg-success-50 border-success-600/30 text-success-600'"
x-text="toast.message"></div>
</div>
<script>
function settingPage() {
return {
mainTab: 'lookup',
toast: { show: false, type: 'success', message: '' },
lookupFilters: { keyword: '', module: '' },
lookupGroups: [],
lookupEditor: { open: false, groupId: null, title: '', items: [], error: '' },
fieldFilters: { keyword: '', entityType: '', tradeStatus: '' },
fieldCombos: [],
ruleDrawer: { open: false, comboId: null, title: '', items: [], search: '' },
duplicateScopeOptions: [
{ value: 'self', label: '本人(默认)', desc: '同一经纪人不可重复录入同一手机号。' },
{ value: 'dept', label: '本部门', desc: '同部门范围内不可重复录入同一手机号。' },
{ value: 'company', label: '全公司', desc: '全公司范围内不可重复录入同一手机号。' }
],
clientRequiredFieldOptions: [
{ key: 'grade', label: '等级', desc: '客源等级字段,默认必填且不可关闭。', locked: true },
{ key: 'source', label: '来源', desc: '客源来源字段,默认必填且不可关闭。', locked: true },
{ key: 'budget_range', label: '求购/求租总价区间', desc: '控制预算区间是否必填。', locked: false },
{ key: 'room_requirement', label: '居室需求', desc: '控制户型需求是否必填。', locked: false },
{ key: 'buying_purpose', label: '购房目的', desc: '控制购房目的是否必填。', locked: false }
],
clientRules: {
duplicateScope: 'self',
requiredFields: {
grade: true,
source: true,
budget_range: false,
room_requirement: false,
buying_purpose: false
}
},
clientRuleError: '',
init() {
this.seedLookupGroups();
this.seedFieldCombos();
},
seedLookupGroups() {
this.lookupGroups = [
{
id: 'g-client-source',
module: 'client',
moduleLabel: '客源',
key: 'source',
label: '客源来源',
items: [
{ id: 'c-src-1', label: '门店接待', sortOrder: 1, active: true, isSystem: true },
{ id: 'c-src-2', label: '老客户转介绍', sortOrder: 2, active: true, isSystem: true },
{ id: 'c-src-3', label: '驻守派单', sortOrder: 3, active: true, isSystem: true },
{ id: 'c-src-4', label: '上门', sortOrder: 4, active: true, isSystem: true },
{ id: 'c-src-5', label: '网络-58同城', sortOrder: 5, active: true, isSystem: true },
{ id: 'c-src-6', label: '网络-安居客', sortOrder: 6, active: true, isSystem: true },
{ id: 'c-src-7', label: '微信', sortOrder: 7, active: true, isSystem: true },
{ id: 'c-src-8', label: '抖音线索', sortOrder: 8, active: false, isSystem: false }
]
},
{
id: 'g-client-purpose',
module: 'client',
moduleLabel: '客源',
key: 'follow_purpose',
label: '跟进目的',
items: [
{ id: 'c-pur-1', label: '回拨', sortOrder: 1, active: true, isSystem: true },
{ id: 'c-pur-2', label: '推房', sortOrder: 2, active: true, isSystem: true },
{ id: 'c-pur-3', label: '带看', sortOrder: 3, active: true, isSystem: true },
{ id: 'c-pur-4', label: '维护', sortOrder: 4, active: true, isSystem: true },
{ id: 'c-pur-5', label: '其他', sortOrder: 5, active: true, isSystem: true }
]
},
{
id: 'g-property-source',
module: 'property',
moduleLabel: '房源',
key: 'source',
label: '房源来源',
items: [
{ id: 'p-src-1', label: '主动开发', sortOrder: 1, active: true, isSystem: true },
{ id: 'p-src-2', label: '业主上门', sortOrder: 2, active: true, isSystem: true },
{ id: 'p-src-3', label: '老客户转介绍', sortOrder: 3, active: true, isSystem: true },
{ id: 'p-src-4', label: '网络来电', sortOrder: 4, active: true, isSystem: true }
]
}
];
},
seedFieldCombos() {
const baseRules = [
{ fieldKey: 'orientation', fieldName: '朝向', requirement: 'required' },
{ fieldKey: 'decoration', fieldName: '装修情况', requirement: 'required' },
{ fieldKey: 'floor', fieldName: '楼层', requirement: 'optional' },
{ fieldKey: 'building_area', fieldName: '建筑面积', requirement: 'required' },
{ fieldKey: 'inner_area', fieldName: '套内面积', requirement: 'optional' },
{ fieldKey: 'room_layout', fieldName: '房型(室/厅/卫)', requirement: 'required' },
{ fieldKey: 'ownership_years', fieldName: '产权年限', requirement: 'optional' },
{ fieldKey: 'parking_count', fieldName: '车位数', requirement: 'hidden' }
];
this.fieldCombos = [
{
id: 'res-sale',
entityType: 'residential',
entityTypeLabel: '住宅',
tradeStatus: 'sale',
tradeStatusLabel: '出售',
updatedAt: '2026-04-29 09:10',
rules: this.deepClone(baseRules)
},
{
id: 'res-rent',
entityType: 'residential',
entityTypeLabel: '住宅',
tradeStatus: 'rent',
tradeStatusLabel: '出租',
updatedAt: '2026-04-29 09:10',
rules: this.deepClone(baseRules).map(r => r.fieldKey === 'parking_count' ? { ...r, requirement: 'optional' } : r)
},
{
id: 'shop-sale',
entityType: 'shop',
entityTypeLabel: '商铺',
tradeStatus: 'sale',
tradeStatusLabel: '出售',
updatedAt: '2026-04-29 09:10',
rules: this.deepClone(baseRules).map(r => r.fieldKey === 'room_layout' ? { ...r, requirement: 'hidden' } : r)
},
{
id: 'shop-rent',
entityType: 'shop',
entityTypeLabel: '商铺',
tradeStatus: 'rent',
tradeStatusLabel: '出租',
updatedAt: '2026-04-29 09:10',
rules: this.deepClone(baseRules).map(r => r.fieldKey === 'ownership_years' ? { ...r, requirement: 'hidden' } : r)
}
];
},
get filteredLookupGroups() {
const keyword = this.lookupFilters.keyword.trim();
const module = this.lookupFilters.module;
return this.lookupGroups
.filter(group => !module || group.module === module)
.map(group => {
if (!keyword) return group;
const matchedItems = group.items.filter(item => item.label.includes(keyword));
const groupMatched = group.label.includes(keyword) || `${group.module}.${group.key}`.includes(keyword);
return {
...group,
items: groupMatched ? group.items : matchedItems
};
})
.filter(group => keyword ? (group.items.length > 0 || group.label.includes(keyword)) : true);
},
openLookupEditor(group) {
this.lookupEditor.open = true;
this.lookupEditor.groupId = group.id;
this.lookupEditor.title = `${group.label}${group.module}.${group.key}`;
this.lookupEditor.error = '';
this.lookupEditor.items = this.deepClone(group.items).sort((a, b) => a.sortOrder - b.sortOrder);
},
closeLookupEditor() {
this.lookupEditor = { open: false, groupId: null, title: '', items: [], error: '' };
},
addLookupEditorItem() {
this.lookupEditor.items.push({
id: `tmp-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
label: '',
sortOrder: this.lookupEditor.items.length + 1,
active: true,
isSystem: false
});
},
moveLookupEditorItem(index, direction) {
const target = index + direction;
if (target < 0 || target >= this.lookupEditor.items.length) return;
const cloned = this.lookupEditor.items;
[cloned[index], cloned[target]] = [cloned[target], cloned[index]];
this.lookupEditor.items = [...cloned];
},
removeLookupEditorItem(index) {
const item = this.lookupEditor.items[index];
if (item.isSystem) {
this.lookupEditor.error = '系统预制项不可删除,仅可停用';
return;
}
this.lookupEditor.items.splice(index, 1);
this.lookupEditor.items = [...this.lookupEditor.items];
},
saveLookupEditor() {
this.lookupEditor.error = '';
const labels = this.lookupEditor.items.map(item => (item.label || '').trim());
if (labels.some(v => !v)) {
this.lookupEditor.error = '项目值不能为空';
return;
}
const normalized = labels.map(v => v.toLowerCase());
if (new Set(normalized).size !== normalized.length) {
this.lookupEditor.error = '项目值不可重复';
return;
}
this.lookupEditor.items.forEach((item, idx) => {
item.label = item.label.trim();
item.sortOrder = idx + 1;
});
this.lookupGroups = this.lookupGroups.map(group => {
if (group.id !== this.lookupEditor.groupId) return group;
return { ...group, items: this.deepClone(this.lookupEditor.items) };
});
const group = this.lookupGroups.find(g => g.id === this.lookupEditor.groupId);
this.closeLookupEditor();
this.notify(`参数已保存,缓存失效:{tenant_schema}:setting:lookup:${group.module}.${group.key}`);
},
toggleLookupItemActive(groupId, itemId) {
this.lookupGroups = this.lookupGroups.map(group => {
if (group.id !== groupId) return group;
return {
...group,
items: group.items.map(item => item.id === itemId ? { ...item, active: !item.active } : item)
};
});
this.notify('项目状态已更新');
},
deleteLookupItem(groupId, itemId) {
const group = this.lookupGroups.find(g => g.id === groupId);
const item = group.items.find(i => i.id === itemId);
if (!item || item.isSystem) {
this.notify('系统预制项不可删除,仅可停用', 'error');
return;
}
this.lookupGroups = this.lookupGroups.map(g => {
if (g.id !== groupId) return g;
const nextItems = g.items.filter(i => i.id !== itemId).map((i, idx) => ({ ...i, sortOrder: idx + 1 }));
return { ...g, items: nextItems };
});
this.notify('项目已删除');
},
get filteredFieldCombos() {
const keyword = this.fieldFilters.keyword.trim();
return this.fieldCombos.filter(combo => {
const byEntity = !this.fieldFilters.entityType || combo.entityType === this.fieldFilters.entityType;
const byTrade = !this.fieldFilters.tradeStatus || combo.tradeStatus === this.fieldFilters.tradeStatus;
const byKeyword = !keyword || combo.entityTypeLabel.includes(keyword) || combo.tradeStatusLabel.includes(keyword);
return byEntity && byTrade && byKeyword;
});
},
countRequirement(combo, requirement) {
return combo.rules.filter(rule => rule.requirement === requirement).length;
},
openRuleDrawer(combo) {
this.ruleDrawer.open = true;
this.ruleDrawer.comboId = combo.id;
this.ruleDrawer.title = `${combo.entityTypeLabel} × ${combo.tradeStatusLabel} 字段规则`;
this.ruleDrawer.items = this.deepClone(combo.rules);
this.ruleDrawer.search = '';
},
closeRuleDrawer() {
this.ruleDrawer = { open: false, comboId: null, title: '', items: [], search: '' };
},
get filteredRuleDrawerItems() {
const q = this.ruleDrawer.search.trim();
if (!q) return this.ruleDrawer.items;
return this.ruleDrawer.items.filter(item => item.fieldName.includes(q) || item.fieldKey.includes(q));
},
setDrawerRequirement(fieldKey, requirement) {
this.ruleDrawer.items = this.ruleDrawer.items.map(item => item.fieldKey === fieldKey ? { ...item, requirement } : item);
},
toggleDrawerFieldDisplay(fieldKey) {
this.ruleDrawer.items = this.ruleDrawer.items.map(item => {
if (item.fieldKey !== fieldKey) return item;
if (item.requirement === 'hidden') return { ...item, requirement: 'optional' };
return { ...item, requirement: 'hidden' };
});
},
saveRuleDrawer() {
const now = this.formatNow();
this.fieldCombos = this.fieldCombos.map(combo => {
if (combo.id !== this.ruleDrawer.comboId) return combo;
return { ...combo, rules: this.deepClone(this.ruleDrawer.items), updatedAt: now };
});
const combo = this.fieldCombos.find(c => c.id === this.ruleDrawer.comboId);
this.closeRuleDrawer();
this.notify(`字段规则已保存,缓存失效:{tenant_schema}:setting:field_req:property.${combo.entityType}.${combo.tradeStatus}`);
},
toggleClientRequired(field) {
this.clientRuleError = '';
if (field.locked) {
this.clientRuleError = '等级、来源为默认必填字段,不可取消';
return;
}
this.clientRules.requiredFields[field.key] = !this.clientRules.requiredFields[field.key];
},
resetClientRules() {
this.clientRuleError = '';
this.clientRules = {
duplicateScope: 'self',
requiredFields: {
grade: true,
source: true,
budget_range: false,
room_requirement: false,
buying_purpose: false
}
};
this.notify('已恢复默认配置');
},
saveClientRules() {
this.clientRuleError = '';
if (!this.clientRules.requiredFields.grade || !this.clientRules.requiredFields.source) {
this.clientRuleError = '等级、来源为默认必填字段,不可取消';
return;
}
this.notify('客源规则已保存,缓存失效:{tenant_schema}:setting:client_rules');
},
closeAllPanels() {
if (this.ruleDrawer.open) return this.closeRuleDrawer();
if (this.lookupEditor.open) return this.closeLookupEditor();
},
notify(message, type = 'success') {
this.toast = { show: true, type, message };
setTimeout(() => { this.toast.show = false; }, 2200);
},
deepClone(v) {
return JSON.parse(JSON.stringify(v));
},
formatNow() {
const now = new Date();
const yy = now.getFullYear();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mi = String(now.getMinutes()).padStart(2, '0');
return `${yy}-${mm}-${dd} ${hh}:${mi}`;
}
}
}
</script>
</body>
</html>