Files
nexus/Project/fonrey/UI_DESIGN/首页设置_UI.html
2026-04-29 15:43:49 +08:00

536 lines
27 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>
body { background: #F8FAFC; color: #0F172A; }
[x-cloak] { display: none !important; }
.role-tab { color: #64748B; border: 1px solid #E2E8F0; background: #fff; }
.role-tab.active { color: #115E59; border-color: #115E59; background: #F0FDFA; font-weight: 600; }
.switch {
position: relative;
display: inline-flex;
width: 38px;
height: 22px;
border-radius: 999px;
background: #CBD5E1;
transition: background .2s;
}
.switch::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 999px;
top: 2px;
left: 2px;
background: #fff;
transition: transform .2s;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.switch.on { background: #0F766E; }
.switch.on::after { transform: translateX(16px); }
</style>
</head>
<body class="text-sm antialiased" x-data="homeSettingPage()" x-init="init()">
<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">主页</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700">房源</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700">客源</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700">营销</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700">交易</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700">数据</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700">人事</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">三网</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 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 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 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>
<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 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">管理员可按角色配置首页展示卡片、排行榜及成交战报视图</p>
</div>
<div class="w-[320px]">
<input type="text" x-model.trim="searchKeyword" 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>
</div>
</section>
<section class="grid grid-cols-12 gap-4 items-start">
<section class="col-span-8 space-y-4">
<section class="bg-white border border-neutral-200 rounded-lg p-4 space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2" role="tablist" aria-label="角色视图切换">
<template x-for="role in roleOptions" :key="role.key">
<button
class="role-tab px-3 py-1.5 rounded-md"
:class="{ 'active': currentRole === role.key }"
@click="switchRole(role.key)"
x-text="role.label"
></button>
</template>
</div>
<div class="flex items-center gap-2">
<template x-if="!editMode">
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="startEdit">编辑</button>
</template>
<template x-if="editMode">
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 rounded-md border border-neutral-300 hover:bg-neutral-50" @click="cancelEdit">取消</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="saveConfig">保存配置</button>
</div>
</template>
</div>
</div>
<div x-show="formError" class="rounded-md border border-danger-600 bg-danger-50 text-danger-600 px-3 py-2 text-xs" x-text="formError"></div>
<section class="rounded-lg border border-neutral-200" x-show="matchSection('员工信息')">
<header class="px-4 py-3 border-b border-neutral-200 bg-neutral-50 font-semibold">员工信息模块</header>
<div class="p-4 flex items-center justify-between">
<div>
<p class="font-medium">是否展示员工司龄</p>
<p class="text-xs text-neutral-500 mt-1">若开启则首页展示员工司龄</p>
</div>
<button class="switch" :class="draftConfig.show_seniority ? 'on' : ''" @click="toggleBoolean('show_seniority')" :disabled="!editMode"></button>
</div>
</section>
<section class="rounded-lg border border-neutral-200" x-show="matchSection('行程')">
<header class="px-4 py-3 border-b border-neutral-200 bg-neutral-50 font-semibold">行程模块显示指标</header>
<div class="p-4 space-y-2">
<label class="inline-flex items-center gap-2 mr-4">
<input type="checkbox" class="rounded border-neutral-300" :checked="draftConfig.itinerary_metrics.includes('sale')" @change="toggleMetric('sale')" :disabled="!editMode" />
<span>买卖</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" class="rounded border-neutral-300" :checked="draftConfig.itinerary_metrics.includes('rent')" @change="toggleMetric('rent')" :disabled="!editMode" />
<span>租赁</span>
</label>
</div>
</section>
<section class="rounded-lg border border-neutral-200" x-show="matchSection('业绩')">
<header class="px-4 py-3 border-b border-neutral-200 bg-neutral-50 font-semibold">首页业绩显示设置</header>
<div class="p-4 space-y-3">
<label class="flex items-start gap-2">
<input type="radio" name="kpi_mode" value="pending_and_approved" :checked="draftConfig.kpi_mode==='pending_and_approved'" @change="draftConfig.kpi_mode='pending_and_approved'" :disabled="!editMode" class="mt-0.5" />
<span class="text-xs leading-5">统计审批中和审批通过的转定业绩:录转定/认购后统计审批中、审批驳回和审批通过的业绩,若无转定/认购,直接签约则统计录签约后审批中、审批驳回和审批通过的分成后业绩</span>
</label>
<label class="flex items-start gap-2">
<input type="radio" name="kpi_mode" value="approved_only" :checked="draftConfig.kpi_mode==='approved_only'" @change="draftConfig.kpi_mode='approved_only'" :disabled="!editMode" class="mt-0.5" />
<span class="text-xs leading-5">仅统计审批通过业绩(严格口径)</span>
</label>
</div>
</section>
<section class="rounded-lg border border-neutral-200" x-show="matchSection('统计卡片')">
<header class="px-4 py-3 border-b border-neutral-200 bg-neutral-50 font-semibold">首页统计卡片配置</header>
<div class="p-4 space-y-2">
<template x-for="card in sortedCards" :key="card.key">
<div class="border border-neutral-200 rounded-md px-3 py-2 flex items-center justify-between gap-3 bg-white">
<div class="flex items-center gap-2">
<button class="switch" :class="card.enabled ? 'on' : ''" @click="toggleCard(card.key)" :disabled="!editMode"></button>
<div>
<p class="font-medium" x-text="card.label"></p>
<p class="text-xs text-neutral-500" x-text="card.desc"></p>
</div>
</div>
<div class="flex items-center gap-1">
<button class="px-2 py-1 rounded border border-neutral-300 text-xs" @click="moveCard(card.key,-1)" :disabled="!editMode">上移</button>
<button class="px-2 py-1 rounded border border-neutral-300 text-xs" @click="moveCard(card.key,1)" :disabled="!editMode">下移</button>
</div>
</div>
</template>
<p class="text-xs text-neutral-500 mt-1">至少保留1个启用卡片卡片顺序将影响首页展示顺序。</p>
</div>
</section>
<section class="rounded-lg border border-neutral-200" x-show="matchSection('排行榜')">
<header class="px-4 py-3 border-b border-neutral-200 bg-neutral-50 font-semibold">排行榜设置</header>
<div class="p-4 grid grid-cols-2 gap-4">
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">是否显示业绩和单数</p>
<p class="text-xs text-neutral-500">若开启则排行榜中显示业绩和单数</p>
</div>
<button class="switch" :class="draftConfig.ranking.show_performance_and_deal ? 'on' : ''" @click="toggleNested('ranking','show_performance_and_deal')" :disabled="!editMode"></button>
</div>
<label class="block">
<span class="text-xs text-neutral-500">业绩计算方式</span>
<select class="mt-1 w-full px-3 py-2 rounded-md border border-neutral-300 bg-white" x-model="draftConfig.ranking.calc_mode" :disabled="!editMode">
<option value="turn_order">统计审批中和审批通过的转定单量和业绩</option>
<option value="approved_only">仅统计审批通过单量和业绩</option>
</select>
</label>
<label class="block">
<span class="text-xs text-neutral-500">默认按店或组排名</span>
<select class="mt-1 w-full px-3 py-2 rounded-md border border-neutral-300 bg-white" x-model="draftConfig.ranking.default_rank_level" :disabled="!editMode">
<option value="store">门店</option>
<option value="group">组别</option>
</select>
</label>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="font-medium">默认展示全公司前10排名数据</p>
<p class="text-xs text-neutral-500">权限为本人/无时仅展示默认排行</p>
</div>
<button class="switch" :class="draftConfig.ranking.show_company_top10 ? 'on' : ''" @click="toggleNested('ranking','show_company_top10')" :disabled="!editMode"></button>
</div>
<label class="block">
<span class="text-xs text-neutral-500">过滤账号</span>
<input type="text" class="mt-1 w-full px-3 py-2 rounded-md border border-neutral-300" placeholder="无限制" x-model="draftConfig.ranking.filter_accounts" :disabled="!editMode" />
</label>
<label class="block">
<span class="text-xs text-neutral-500">过滤部门</span>
<input type="text" class="mt-1 w-full px-3 py-2 rounded-md border border-neutral-300" placeholder="无限制" x-model="draftConfig.ranking.filter_departments" :disabled="!editMode" />
</label>
</div>
</div>
</section>
<section class="rounded-lg border border-neutral-200" x-show="matchSection('成交战报')">
<header class="px-4 py-3 border-b border-neutral-200 bg-neutral-50 font-semibold">成交战报设置</header>
<div class="p-4 grid grid-cols-2 gap-4">
<div class="space-y-3">
<label class="block">
<span class="text-xs text-neutral-500">成交时间范围</span>
<input type="text" class="mt-1 w-full px-3 py-2 rounded-md border border-neutral-300" placeholder="不限制" x-model="draftConfig.battle_report.days_range" :disabled="!editMode" />
</label>
<label class="block">
<span class="text-xs text-neutral-500">成交业绩范围</span>
<input type="text" class="mt-1 w-full px-3 py-2 rounded-md border border-neutral-300" placeholder="不限制" x-model="draftConfig.battle_report.amount_range" :disabled="!editMode" />
</label>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div><p class="font-medium">是否显示业绩</p><p class="text-xs text-neutral-500">若开启则成交战报中显示业绩</p></div>
<button class="switch" :class="draftConfig.battle_report.show_performance ? 'on' : ''" @click="toggleNested('battle_report','show_performance')" :disabled="!editMode"></button>
</div>
<div class="flex items-center justify-between">
<div><p class="font-medium">是否显示房源</p><p class="text-xs text-neutral-500">若开启则成交战报中显示房源名称</p></div>
<button class="switch" :class="draftConfig.battle_report.show_property_name ? 'on' : ''" @click="toggleNested('battle_report','show_property_name')" :disabled="!editMode"></button>
</div>
<div class="flex items-center justify-between">
<div><p class="font-medium">是否显示房源总价</p><p class="text-xs text-neutral-500">若开启则成交战报中显示房源总价</p></div>
<button class="switch" :class="draftConfig.battle_report.show_property_total_price ? 'on' : ''" @click="toggleNested('battle_report','show_property_total_price')" :disabled="!editMode"></button>
</div>
</div>
</div>
</section>
</section>
</section>
<aside class="col-span-4 space-y-4 sticky top-[84px]">
<section class="bg-white border border-neutral-200 rounded-lg p-4">
<h3 class="text-base font-semibold mb-3">首页预览(<span x-text="currentRoleLabel"></span></h3>
<div class="grid grid-cols-2 gap-2">
<template x-for="card in enabledCards" :key="card.key">
<div class="rounded-md border border-neutral-200 p-3 bg-neutral-50">
<p class="text-xs text-neutral-500" x-text="card.label"></p>
<p class="text-lg font-semibold mt-1" x-text="metricValue(card.key)"></p>
</div>
</template>
</div>
</section>
<section class="bg-white border border-neutral-200 rounded-lg p-4 text-xs text-neutral-600 space-y-2">
<p><span class="font-medium text-neutral-800">员工司龄:</span><span x-text="draftConfig.show_seniority ? '显示' : '隐藏'"></span></p>
<p><span class="font-medium text-neutral-800">行程指标:</span><span x-text="draftConfig.itinerary_metrics.length ? draftConfig.itinerary_metrics.map(v => v==='sale'?'买卖':'租赁').join('、') : '无' "></span></p>
<p><span class="font-medium text-neutral-800">排行榜显示业绩/单数:</span><span x-text="draftConfig.ranking.show_performance_and_deal ? '开启' : '关闭'"></span></p>
<p><span class="font-medium text-neutral-800">成交战报展示房源:</span><span x-text="draftConfig.battle_report.show_property_name ? '开启' : '关闭'"></span></p>
</section>
</aside>
</section>
</div>
</main>
<div x-cloak x-show="toast.open" x-transition class="fixed right-6 bottom-6 px-4 py-2 rounded-md shadow-lg text-sm bg-neutral-900 text-white" x-text="toast.message"></div>
<script>
function homeSettingPage() {
return {
searchKeyword: '',
editMode: false,
formError: '',
currentRole: 'agent',
roleOptions: [
{ key: 'agent', label: '经纪人视图' },
{ key: 'manager', label: '店长视图' },
{ key: 'admin', label: '管理员视图' }
],
roleConfigs: {},
draftConfig: null,
backupConfig: null,
toast: { open: false, message: '' },
cardCatalog: {
new_property_today: { label: '今日新增房源', desc: '统计今日新增房源数量' },
new_client_today: { label: '今日新增客源', desc: '统计今日新增客源数量' },
new_showing_today: { label: '今日新增带看', desc: '统计今日新增带看数量' },
new_followup_today: { label: '今日新增跟进', desc: '统计今日新增跟进数量' },
signed_deals_today: { label: '今日签约单量', desc: '统计今日签约套数' },
deal_amount_today: { label: '今日成交业绩', desc: '统计今日成交业绩金额' }
},
metricValues: {
new_property_today: '18',
new_client_today: '12',
new_showing_today: '9',
new_followup_today: '36',
signed_deals_today: '4',
deal_amount_today: '126.8万'
},
init() {
this.roleConfigs = {
agent: this.defaultRoleConfig('agent'),
manager: this.defaultRoleConfig('manager'),
admin: this.defaultRoleConfig('admin')
};
this.loadRole('agent');
},
defaultRoleConfig(role) {
const baseCards = [
{ key: 'new_property_today', enabled: true, sort: 1 },
{ key: 'new_client_today', enabled: true, sort: 2 },
{ key: 'new_followup_today', enabled: true, sort: 3 },
{ key: 'new_showing_today', enabled: role !== 'agent', sort: 4 },
{ key: 'signed_deals_today', enabled: role === 'admin' || role === 'manager', sort: 5 },
{ key: 'deal_amount_today', enabled: role === 'admin', sort: 6 }
];
return {
show_seniority: role !== 'agent',
itinerary_metrics: ['sale', 'rent'],
kpi_mode: 'pending_and_approved',
home_cards: baseCards,
ranking: {
show_performance_and_deal: true,
calc_mode: 'turn_order',
default_rank_level: role === 'agent' ? 'group' : 'store',
show_company_top10: role === 'admin',
filter_accounts: '',
filter_departments: ''
},
battle_report: {
days_range: '',
amount_range: '',
show_performance: role === 'admin',
show_property_name: true,
show_property_total_price: role === 'admin'
}
};
},
get currentRoleLabel() {
return this.roleOptions.find(r => r.key === this.currentRole)?.label || '';
},
get sortedCards() {
if (!this.draftConfig) return [];
return this.draftConfig.home_cards
.slice()
.sort((a, b) => a.sort - b.sort)
.map(c => ({ ...c, label: this.cardCatalog[c.key].label, desc: this.cardCatalog[c.key].desc }));
},
get enabledCards() {
return this.sortedCards.filter(c => c.enabled);
},
metricValue(key) {
return this.metricValues[key] || '-';
},
matchSection(label) {
if (!this.searchKeyword) return true;
return label.includes(this.searchKeyword);
},
loadRole(role) {
this.currentRole = role;
this.draftConfig = this.deepClone(this.roleConfigs[role]);
this.backupConfig = this.deepClone(this.roleConfigs[role]);
this.formError = '';
this.editMode = false;
},
switchRole(role) {
if (this.editMode) {
this.formError = '请先保存或取消当前角色编辑内容';
return;
}
this.loadRole(role);
},
startEdit() {
this.editMode = true;
this.formError = '';
this.backupConfig = this.deepClone(this.draftConfig);
},
cancelEdit() {
this.draftConfig = this.deepClone(this.backupConfig);
this.editMode = false;
this.formError = '';
this.notify('已取消编辑,恢复到上次保存状态');
},
saveConfig() {
this.formError = '';
const enabledCount = this.draftConfig.home_cards.filter(c => c.enabled).length;
if (enabledCount < 1) {
this.formError = '至少保留 1 个首页统计卡片';
return;
}
if ((this.draftConfig.ranking.filter_accounts || '').length > 100) {
this.formError = '过滤账号输入过长请控制在100字符以内';
return;
}
if ((this.draftConfig.ranking.filter_departments || '').length > 100) {
this.formError = '过滤部门输入过长请控制在100字符以内';
return;
}
this.roleConfigs[this.currentRole] = this.deepClone(this.draftConfig);
this.backupConfig = this.deepClone(this.draftConfig);
this.editMode = false;
this.notify('首页设置已保存,当前角色视图即时生效');
},
toggleBoolean(field) {
if (!this.editMode) return;
this.draftConfig[field] = !this.draftConfig[field];
},
toggleNested(group, field) {
if (!this.editMode) return;
this.draftConfig[group][field] = !this.draftConfig[group][field];
},
toggleMetric(metric) {
if (!this.editMode) return;
const list = this.draftConfig.itinerary_metrics;
const idx = list.indexOf(metric);
if (idx >= 0) list.splice(idx, 1);
else list.push(metric);
},
toggleCard(key) {
if (!this.editMode) return;
const card = this.draftConfig.home_cards.find(c => c.key === key);
if (!card) return;
card.enabled = !card.enabled;
},
moveCard(key, delta) {
if (!this.editMode) return;
const cards = this.draftConfig.home_cards.slice().sort((a,b) => a.sort-b.sort);
const index = cards.findIndex(c => c.key === key);
if (index < 0) return;
const target = index + delta;
if (target < 0 || target >= cards.length) return;
const tmp = cards[index].sort;
cards[index].sort = cards[target].sort;
cards[target].sort = tmp;
},
notify(message) {
this.toast.message = message;
this.toast.open = true;
setTimeout(() => {
this.toast.open = false;
}, 1800);
},
deepClone(v) { return JSON.parse(JSON.stringify(v)); }
}
}
</script>
</body>
</html>