857 lines
43 KiB
HTML
857 lines
43 KiB
HTML
<!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>
|