chore: sync local project changes
This commit is contained in:
@@ -499,20 +499,20 @@ CREATE INDEX idx_enum_labels_domain ON enum_labels(domain, sort_order);
|
||||
|
||||
| domain | 说明 | 对应表字段 |
|
||||
|--------|------|-----------|
|
||||
| `client.status` | 客源状态(7 态) | `clients.status` |
|
||||
| `client.status` | 客源状态(8 态) | `clients.status` |
|
||||
| `client.grade` | 客源等级(5 档 + E) | `clients.grade` |
|
||||
| `client.purpose_type` | 需求类型 | `client_requirements.purpose_type` |
|
||||
| `client.usage` | 房源用途偏好 | `client_requirements.usage` |
|
||||
| `client.requirement_type` | 需求类型(旧:`client.purpose_type`) | `client_requirements.requirement_type` |
|
||||
| `client.property_usage` | 房源用途偏好(旧:`client.usage`) | `client_requirements.property_usage` |
|
||||
| `client.orientation` | 朝向偏好 | `client_requirements.orientation` |
|
||||
| `client.payment_method` | 付款方式 | `clients.payment_method` |
|
||||
| `property.status` | 房源状态 | `properties.status` |
|
||||
| `property.attribute` | 房源属性(公/私/保护) | `properties.attribute` |
|
||||
| `property.usage` | 房源用途 | `properties.usage` |
|
||||
| `property.property_type` | 房源类型(旧:`property.usage`) | `properties.property_type` |
|
||||
| `property.grade` | 房源等级(5 档) | `properties.grade` |
|
||||
| `property.listing_type` | 挂牌类型 | `properties.listing_type` |
|
||||
| `property.listing_history.listing_type` | 挂牌类型(旧:`property.listing_type`) | `listing_histories.listing_type` |
|
||||
| `property.decoration` | 装修程度 | `properties.decoration` |
|
||||
| `property.orientation` | 朝向 | `properties.orientation` |
|
||||
| `commission.type` | 委托类型 | `commissions.commission_type` |
|
||||
| `property.commission.status` | 委托状态(旧:`commission.type`) | `commissions.status` |
|
||||
| `field_survey.status` | 实勘状态 | `field_surveys.status` |
|
||||
| `follow_log.log_type` | 跟进日志类型 | `follow_logs.log_type` |
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ Staff (员工)
|
||||
| client_no | VARCHAR(30) | UNIQUE, NOT NULL | 系统生成的客源编号,格式由运营配置(如 KY20260424001) |
|
||||
| client_type | VARCHAR(20) | NOT NULL DEFAULT 'private' | `private`=私客 / `public`=公客 / `transacted`=成交客 |
|
||||
| status | VARCHAR(20) | NOT NULL DEFAULT 'buying' | 见下方枚举 |
|
||||
| grade | VARCHAR(5) | NOT NULL DEFAULT 'C' | `A_urgent`=A急迫 / `A` / `B`=较强 / `C`=一般 / `D`=较弱 / `E`=暂不关注 |
|
||||
| grade | VARCHAR(5) | NOT NULL DEFAULT 'C' | `A`=A急迫 / `B`=较强 / `C`=一般 / `D`=较弱 / `E`=暂不关注 |
|
||||
| property_usage | VARCHAR(30) | NOT NULL DEFAULT 'residential' | `residential`=住宅 / `villa`=别墅 / `commercial_residential`=商住 / `shop`=商铺 / `office`=写字楼 / `other`=其他 |
|
||||
| buying_purpose | VARCHAR(20)[] | | 购房目的多选:`rigid`=刚需 / `investment`=投资 / `school_district`=学区 / `upgrade`=改善 / `commercial`=商用 / `other`=其他 |
|
||||
| payment_method | VARCHAR(30) | | `full`=全额 / `mortgage`=商业贷款 / `mortgage_fund`=商贷+公积金 / `fund`=公积金 |
|
||||
|
||||
@@ -155,11 +155,10 @@
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `A_urgent` | A(急迫) |
|
||||
| `A` | A |
|
||||
| `A` | A(急迫) |
|
||||
| `B` | B(较强) |
|
||||
| `C` | C(一般) |
|
||||
| `D` | D |
|
||||
| `D` | D(较弱) |
|
||||
|
||||
### follow_log.log_type(跟进日志类型)
|
||||
|
||||
@@ -263,7 +262,7 @@ CREATE TABLE properties (
|
||||
|
||||
-- ── 等级与标签 ──
|
||||
grade VARCHAR(10)
|
||||
CHECK (grade IN ('A_urgent','A','B','C','D')),
|
||||
CHECK (grade IN ('A','B','C','D')),
|
||||
|
||||
-- ── 交易属性 ──
|
||||
ownership_years VARCHAR(30), -- 房本年限:不满2年/满2年/满5年 等
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
# Fonrey — 统一枚举字典(ENUMS)
|
||||
|
||||
> **定位**:本文件是 Fonrey 全局枚举标准(Public + Tenant)的统一实现基线。
|
||||
> **版本**:v2.1
|
||||
> **日期**:2026-04-27
|
||||
> **版本**:v2.2
|
||||
> **日期**:2026-04-28
|
||||
> **适用范围**:`DATA_MODEL_PUBLIC.md`、`DATA_MODEL_LOGIN.md`、`DATA_MODEL_ORG.md`、`DATA_MODEL_COMPLEX.md`、`DATA_MODEL_PROPERTY.md`、`DATA_MODEL_CLIENT.md`、`DATA_MODEL_PERMISSION.md`、`DATA_MODEL_SETTING.md`
|
||||
|
||||
> **⚠️ 枚举值命名规范**:所有枚举值统一使用 **`lower_snake_case`**(全小写+下划线)。
|
||||
> 历史遗留大写值(`SUCCESS`/`FAILED`、`BOOLEAN`/`SCOPE`/`INTEGER`、`REPLACE`/`RESTRICT`/`GRANT`、`A_urgent`/`A_app`/`B_schema`/`C_feature`)在 v2.2 中已统一迁移为小写。
|
||||
> **DB `CHECK` 约束、`enum_labels` 种子数据、前端代码须同步更新为新值**;迁移脚本须包含历史数据 UPDATE。
|
||||
|
||||
---
|
||||
|
||||
## 一、枚举分层标准(必须遵守)
|
||||
@@ -58,8 +62,8 @@ Fonrey 采用两层枚举体系:
|
||||
### 2.3 平台审计与备份导出
|
||||
|
||||
**domain**: `public.platform_audit.result`
|
||||
- `SUCCESS`:成功
|
||||
- `FAILED`:失败
|
||||
- `success`:成功
|
||||
- `failed`:失败
|
||||
|
||||
**domain**: `public.backup_schedule.frequency`
|
||||
- `hourly`:每小时
|
||||
@@ -92,7 +96,7 @@ Fonrey 采用两层枚举体系:
|
||||
**domain**: `public.export_task.status`
|
||||
- `pending`:待执行
|
||||
- `in_progress`:执行中
|
||||
- `done`:已完成
|
||||
- `success`:成功(语义等价原 `done`,与 `backup_record.status` 对齐)
|
||||
- `failed`:失败
|
||||
|
||||
### 2.4 升级与发布(Public)
|
||||
@@ -102,9 +106,9 @@ Fonrey 采用两层枚举体系:
|
||||
- `rollback`:回滚
|
||||
|
||||
**domain**: `public.upgrade_event.upgrade_type`
|
||||
- `A_app`:A类应用升级
|
||||
- `B_schema`:B类数据库结构升级
|
||||
- `C_feature`:C类功能开关升级
|
||||
- `app`:A类-应用升级(原 `A_app`)
|
||||
- `schema`:B类-数据库结构升级(原 `B_schema`)
|
||||
- `feature`:C类-功能开关升级(原 `C_feature`)
|
||||
|
||||
**domain**: `public.upgrade_event.strategy`
|
||||
- `full`:全量发布
|
||||
@@ -241,9 +245,9 @@ Fonrey 采用两层枚举体系:
|
||||
- `recharge`:在线充值
|
||||
|
||||
**domain**: `permission.value_type`
|
||||
- `BOOLEAN`:开关型
|
||||
- `SCOPE`:范围型
|
||||
- `INTEGER`:数值型
|
||||
- `boolean`:开关型(原 `BOOLEAN`)
|
||||
- `scope`:范围型(原 `SCOPE`)
|
||||
- `integer`:数值型(原 `INTEGER`)
|
||||
|
||||
**domain**: `permission.role_category`
|
||||
- `agent`:置业顾问
|
||||
@@ -262,9 +266,9 @@ Fonrey 采用两层枚举体系:
|
||||
- `company`:全公司
|
||||
|
||||
**domain**: `permission.override_mode`
|
||||
- `REPLACE`:覆盖
|
||||
- `RESTRICT`:限制
|
||||
- `GRANT`:授予
|
||||
- `replace`:覆盖(原 `REPLACE`)
|
||||
- `restrict`:限制(原 `RESTRICT`)
|
||||
- `grant`:授予(原 `GRANT`)
|
||||
|
||||
**domain**: `permission.data_scope_type`
|
||||
- `self`:本人
|
||||
@@ -394,11 +398,10 @@ Fonrey 采用两层枚举体系:
|
||||
- `inconvenient`:不便看房
|
||||
|
||||
**domain**: `property.grade`
|
||||
- `A_urgent`:A(急迫)
|
||||
- `A`:A
|
||||
- `B`:B(较强)
|
||||
- `C`:C(一般)
|
||||
- `D`:D(较弱)
|
||||
- `a`:A(急迫)
|
||||
- `b`:B(较强)
|
||||
- `c`:C(一般)
|
||||
- `d`:D(较弱)
|
||||
|
||||
**domain**: `property.contact.gender`
|
||||
- `male`:先生
|
||||
@@ -519,6 +522,21 @@ Fonrey 采用两层枚举体系:
|
||||
- `public`:公客
|
||||
- `invalid`:无效
|
||||
|
||||
> **⚠️ 合法组合约束**(`client_type` × `client.status`)
|
||||
> `client_type` 表示客源的"身份类别",`status` 表示客源的"当前业务状态",两者是不同维度。
|
||||
> 合法组合矩阵如下:
|
||||
>
|
||||
> | `client_type` | 允许的 `status` 值 | 禁止的 `status` 值 |
|
||||
> |---|---|---|
|
||||
> | `private`(私客) | `buying` / `renting` / `buy_or_rent` / `suspended` / `invalid` | `public`、`bought`、`rented_done`(未成交不可用终态) |
|
||||
> | `public`(公客) | `public` / `buying` / `renting` / `buy_or_rent` / `suspended` / `invalid` | `bought`、`rented_done` |
|
||||
> | `transacted`(成交客) | `bought` / `rented_done` | 其他所有值(终态不可逆) |
|
||||
>
|
||||
> **实施要求**:
|
||||
> - 服务层(`ClientService`)在状态变更时必须校验组合合法性
|
||||
> - DB 侧可用触发器或 `CHECK` 约束覆盖最关键禁止项(如 `transacted` + 非终态)
|
||||
> - `private → public` 转换须调用专用方法 `transfer_to_public()`,同时修改 `client_type` 和 `status`
|
||||
|
||||
**domain**: `client.grade`
|
||||
- `A`:A(急迫)
|
||||
- `B`:B(较强)
|
||||
@@ -697,7 +715,7 @@ Fonrey 采用两层枚举体系:
|
||||
- `sale`:出售
|
||||
- `rent`:出租
|
||||
- `sale_rent`:租售
|
||||
- `*`:全部
|
||||
- `all`:全部(原设计为 `*`,因 SQL/URL/权限表达式通配误解风险已统一改为 `all`;DB CHECK 约束、前端筛选器须同步)
|
||||
|
||||
**domain**: `setting.field_rule.requirement`
|
||||
- `required`:必填
|
||||
|
||||
@@ -133,10 +133,10 @@
|
||||
| 房源设置(字段必填/自定义字段/标签) | **P0** | 影响录入表单 |
|
||||
| 相关方设置 | **P1** | |
|
||||
| 客源设置(基本配置/参数配置) | **P1** | |
|
||||
| 人事OA设置 | **P2** | |
|
||||
| 交易设置 | **P2** | |
|
||||
| 财务设置 | **P2** | |
|
||||
| 合同设置 | **P2** | |
|
||||
| 人事OA设置 | **P2** | 依赖人事OA模块完整规划,本期不实现 |
|
||||
| 交易设置 | **P2** | 依赖交易模块完整规划,本期不实现 |
|
||||
| 财务设置 | **Out of Scope** | 消费方「财务管理模块」为 Out of Scope,配置面板无意义,随模块一并排除 |
|
||||
| 合同设置 | **Out of Scope** | 消费方「合同管理模块」为 Out of Scope,配置面板无意义,随模块一并排除 |
|
||||
|
||||
#### 🖥️ 系统管理(运营后台)
|
||||
|
||||
|
||||
@@ -108,8 +108,8 @@
|
||||
| [US-ORG-022](#US-ORG-022-管理员查看门店分布地图) | 组织人事 | 管理员查看门店分布地图 | [ ] |
|
||||
| [US-SETTING-020](#US-SETTING-020-管理员配置人事OA相关参数) | 系统配置 | 管理员配置人事OA相关参数 | [ ] |
|
||||
| [US-SETTING-021](#US-SETTING-021-管理员配置交易规则) | 系统配置 | 管理员配置交易规则 | [ ] |
|
||||
| [US-SETTING-022](#US-SETTING-022-管理员配置财务规则) | 系统配置 | 管理员配置财务规则 | [ ] |
|
||||
| [US-SETTING-023](#US-SETTING-023-管理员配置合同模板) | 系统配置 | 管理员配置合同模板 | [ ] |
|
||||
| ~~US-SETTING-022~~ | ~~系统配置~~ | ~~管理员配置财务规则~~ — **已移出,财务模块 Out of Scope** | ~~[ ]~~ |
|
||||
| ~~US-SETTING-023~~ | ~~系统配置~~ | ~~管理员配置合同模板~~ — **已移出,合同模块 Out of Scope** | ~~[ ]~~ |
|
||||
| [US-SYSTEM-020](#US-SYSTEM-020-平台管理员查看操作审计日志) | 系统管理 | 平台管理员查看操作审计日志 | [ ] |
|
||||
| [US-SYSTEM-021](#US-SYSTEM-021-平台管理员管理灰度发布滚动升级) | 系统管理 | 平台管理员管理灰度发布/滚动升级 | [ ] |
|
||||
|
||||
@@ -806,17 +806,13 @@
|
||||
- 状态:[ ]
|
||||
- 验收标准:(规划中,详细验收标准待PRD细化后补充)
|
||||
|
||||
##### US-SETTING-022 管理员配置财务规则
|
||||
##### ~~US-SETTING-022 管理员配置财务规则~~
|
||||
|
||||
- 参考PRD文档:`Project/fonrey/PRD/系统配置/系统配置.md` - 财务设置
|
||||
- 状态:[ ]
|
||||
- 验收标准:(规划中,详细验收标准待PRD细化后补充)
|
||||
> ⛔ **已移出路线图**:消费方「财务管理/提成结算模块」在 MVP 及近期版本中明确为 Out of Scope(见 PRD_MVP.md §3),对应的系统配置面板无实际消费方,随模块一并排除。若后续财务模块立项,本 US 随之恢复。
|
||||
|
||||
##### US-SETTING-023 管理员配置合同模板
|
||||
##### ~~US-SETTING-023 管理员配置合同模板~~
|
||||
|
||||
- 参考PRD文档:`Project/fonrey/PRD/系统配置/系统配置.md` - 合同设置
|
||||
- 状态:[ ]
|
||||
- 验收标准:(规划中,详细验收标准待PRD细化后补充)
|
||||
> ⛔ **已移出路线图**:消费方「合同管理模块」在 MVP 及近期版本中明确为 Out of Scope(见 PRD_MVP.md §3),对应的系统配置面板无实际消费方,随模块一并排除。若后续合同模块立项,本 US 随之恢复。
|
||||
|
||||
---
|
||||
|
||||
|
||||
5899
Project/fonrey/PRD/TASK_AGENT_READY.md
Normal file
5899
Project/fonrey/PRD/TASK_AGENT_READY.md
Normal file
File diff suppressed because it is too large
Load Diff
3104
Project/fonrey/PRD/TASK_AGENT_READY_P0.md
Normal file
3104
Project/fonrey/PRD/TASK_AGENT_READY_P0.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,407 +1,407 @@
|
||||
# PRD: 客户端发布管理模块
|
||||
**状态**: Draft
|
||||
**作者**: 产品经理
|
||||
**最后更新**: 2026-04-24(v1.0 初稿)
|
||||
**版本**: 1.0
|
||||
**所属系统**: Fonrey 房产经纪管理系统
|
||||
**关联模块**: 系统管理、权限管理
|
||||
**干系人**: 工程负责人、运维负责人、系统管理员
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题陈述
|
||||
|
||||
### 背景
|
||||
|
||||
Fonrey 房产经纪管理系统当前为纯 Web 应用,依赖用户自行通过浏览器访问。然而在实际部署场景中,经纪公司的终端设备环境高度复杂:
|
||||
|
||||
- **浏览器版本参差不齐**:经纪人使用的 Windows 设备可能运行 IE11、旧版 Edge、或未更新的 Chrome,导致 HTMX + Alpine.js 等现代前端技术出现兼容性问题,系统体验碎片化
|
||||
- **交付和部署门槛高**:IT 能力薄弱的经纪公司无法独立配置浏览器访问方式,URL 记忆成本高,容易访问错误版本
|
||||
- **版本管理缺失**:后端服务升级后,用户仍可能使用旧版缓存页面操作,导致接口不兼容和功能异常
|
||||
- **无官方入口**:用户通过私发链接访问系统,存在钓鱼仿冒风险,且无法统一品牌形象
|
||||
|
||||
### 目标用户
|
||||
|
||||
| 角色 | 使用场景 | 使用频率 |
|
||||
|------|---------|----------|
|
||||
| 一线经纪人 | 下载安装客户端、日常登录使用系统、接受自动更新 | 每日 |
|
||||
| 店长/经理 | 同上 | 每日 |
|
||||
| 系统管理员 | 发布新版本、管理安装包下载地址、监控客户端版本分布 | 按需 |
|
||||
| IT 运维人员 | 维护更新服务器、签名证书、构建发布流水线 | 按发布周期 |
|
||||
|
||||
### 核心痛点
|
||||
|
||||
1. **无法控制用户使用的浏览器环境**,兼容性问题无法从根源解决
|
||||
2. **升级依赖用户主动刷新浏览器**,后端 API 变更时旧客户端可能造成数据错误
|
||||
3. **缺乏官方分发渠道**,无法向终端用户传递信任感和版本一致性保障
|
||||
4. **SaaS 多租户管理系统需要统一、可控的客户端入口**,避免因客户端环境差异导致的支持成本上升
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与成功指标
|
||||
|
||||
| 目标 | 指标 | 当前基准 | 目标值 | 衡量周期 |
|
||||
|------|------|---------|--------|---------|
|
||||
| 消除浏览器兼容性问题 | 因浏览器兼容产生的支持工单数 | 待统计 | 降低 ≥ 90% | 上线后 60 天 |
|
||||
| 提升版本一致性 | 在线用户中使用最新版本客户端的比例 | 0%(无客户端) | ≥ 95% | 版本发布后 7 天 |
|
||||
| 降低部署门槛 | 新客户从获取安装包到完成首次登录的时间 | 无基准 | ≤ 10 分钟 | 上线后首批客户反馈 |
|
||||
| 自动更新成功率 | 客户端自动更新完成率(收到更新通知 → 升级完成) | 无基准 | ≥ 98% | 每次版本发布后 48 小时 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 非目标(本期不做)
|
||||
|
||||
- **不支持 macOS / Linux 客户端**:目标用户群体 99% 使用 Windows,macOS 版本为后续规划
|
||||
- **不支持移动端 App(iOS / Android)**:移动端为 v2 规划,本期不涉及
|
||||
- **不开发私有化部署的离线安装方案**:本期聚焦 SaaS 在线版,私有化部署另行规划
|
||||
- **不包含客户端内置的离线模式**:系统需联网使用,客户端不缓存业务数据供离线访问
|
||||
- **不包含客户端层面的安全加固(如代码混淆、反逆向)**:本期以功能交付为优先,安全加固列入后续迭代
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户故事与验收标准
|
||||
|
||||
---
|
||||
|
||||
### Story 1:经纪人下载并安装客户端
|
||||
|
||||
**As** 一线经纪人,**I want** 通过公司提供的网址下载一个安装程序并完成安装,**So that** 我可以立即打开登录界面使用 Fonrey 系统,无需手动配置浏览器。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 官方下载页面可通过指定 URL 访问,页面展示最新版本号、发布日期及下载按钮
|
||||
- [ ] 下载产物为单一 `.exe` 安装包(或免安装便携版 `.zip`),文件大小控制在合理范围内
|
||||
- [ ] 双击安装包后,安装向导步骤不超过 3 步(下一步 → 选择安装路径 → 安装),无需勾选额外组件
|
||||
- [ ] 安装完成后,桌面自动生成快捷方式(图标为 Fonrey 品牌 Logo)
|
||||
- [ ] 首次启动后直接显示登录界面,无需用户手动输入任何 URL
|
||||
- [ ] 安装包经过代码签名,Windows SmartScreen 不弹出"无法识别的应用"警告
|
||||
- [ ] 安装过程无需管理员权限(支持用户级安装到 `%APPDATA%` 目录),降低企业 IT 审批障碍
|
||||
|
||||
---
|
||||
|
||||
### Story 2:经纪人使用客户端正常登录并使用系统
|
||||
|
||||
**As** 一线经纪人,**I want** 打开客户端后直接访问 Fonrey 系统的完整功能,**So that** 我的日常使用体验与使用 Chrome 浏览器无差异,且不受本机安装的浏览器版本影响。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 客户端内嵌现代 Chromium 内核(如基于 Electron 或 WebView2),版本不低于 Chromium 100,支持现代 Web 标准(ES2020、CSS Grid、Fetch API 等)
|
||||
- [ ] HTMX 局部刷新、Alpine.js 状态交互、Tailwind CSS 样式在客户端中渲染效果与 Chrome 最新版一致
|
||||
- [ ] 支持 Cookie / Session 存储,登录状态在客户端关闭后保留(复用 Django Session 机制)
|
||||
- [ ] 文件上传(图片、附件)、文件下载(Excel 导出)在客户端中正常工作
|
||||
- [ ] 客户端窗口支持最大化、最小化、拖拽调整大小,支持多显示器
|
||||
- [ ] 客户端标题栏显示应用名称和当前版本号(如:`Fonrey 房睿 v1.2.3`)
|
||||
- [ ] 客户端不显示浏览器默认的地址栏、书签栏、扩展工具栏,保持沉浸式应用体验
|
||||
|
||||
---
|
||||
|
||||
### Story 3:客户端感知新版本并自动升级
|
||||
|
||||
**As** 一线经纪人,**I want** 客户端在有新版本时自动提示并完成升级,**So that** 我无需手动下载安装,始终使用最新版本,不会因版本落后导致功能异常。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 客户端启动时及运行期间(每隔 4 小时)自动向更新服务器检查最新版本
|
||||
- [ ] 有新版本时,客户端右下角弹出非阻断式通知:"发现新版本 vX.X.X,点击立即更新",用户可选择"立即更新"或"稍后提醒"
|
||||
- [ ] 点击"立即更新"后,客户端在后台静默下载更新包,进度条显示下载进度
|
||||
- [ ] 下载完成后提示用户"更新已就绪,重启客户端完成安装",用户选择"立即重启"或"下次启动时安装"
|
||||
- [ ] 重启后,新版本生效,标题栏版本号更新,历史会话自动恢复(用户无需重新登录)
|
||||
- [ ] 支持强制更新模式:服务端可标记某版本为"强制升级",客户端不展示"稍后提醒"选项,必须升级后方可继续使用(用于重大 API 兼容性变更场景)
|
||||
- [ ] 更新失败时(网络中断、磁盘空间不足等),客户端显示错误提示并保持当前版本正常运行,不影响用户当前操作
|
||||
|
||||
---
|
||||
|
||||
### Story 4:系统管理员发布新版本
|
||||
|
||||
**As** 系统管理员,**I want** 通过管理后台上传新版客户端安装包并配置版本信息,**So that** 客户端能感知到更新并引导用户升级。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 系统管理后台提供"客户端版本管理"页面(位于系统管理模块下)
|
||||
- [ ] 支持上传 `.exe` 安装包,并填写版本号(遵循 SemVer:`X.Y.Z`)、版本说明(更新日志,支持 Markdown)、发布日期
|
||||
- [ ] 支持设置版本类型:普通更新 / 强制更新
|
||||
- [ ] 支持设置版本状态:草稿(不对外生效)/ 已发布 / 已下线
|
||||
- [ ] 发布后,更新服务器 API 即时返回最新版本信息,客户端下次检测时可感知
|
||||
- [ ] 支持版本回滚:将指定历史版本重新设为"已发布",自动将当前版本标记为已下线
|
||||
- [ ] 支持查看各版本的下载量和活跃客户端版本分布统计
|
||||
|
||||
---
|
||||
|
||||
### Story 5:管理员监控客户端版本分布
|
||||
|
||||
**As** 系统管理员,**I want** 查看当前所有在线客户端的版本分布情况,**So that** 了解升级覆盖率,对仍在使用旧版本的客户端发出提醒或强制升级。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 客户端版本管理页面展示版本分布统计:各版本在线客户端数量及占比(饼图或条形图)
|
||||
- [ ] 支持按租户维度查看版本分布(多租户场景下,区分不同经纪公司的版本使用情况)
|
||||
- [ ] 支持对指定版本范围的用户推送"强制更新"通知(如:将所有低于 v1.5.0 的客户端标记为强制更新)
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能详细说明
|
||||
|
||||
### 5.1 技术架构选型
|
||||
|
||||
#### 5.1.1 客户端技术方案
|
||||
|
||||
基于 Fonrey 现有技术栈(Django + HTMX + Alpine.js + Tailwind CSS,后端已采用 Docker Compose 部署),客户端本质是一个**内嵌现代 Chromium 内核的原生 Windows 应用外壳(Shell)**,其核心职责是:
|
||||
|
||||
1. 提供操作系统级原生窗口(标题栏、任务栏图标、托盘)
|
||||
2. 内嵌高版本 Chromium 内核加载 Fonrey Web 应用 URL
|
||||
3. 实现版本检测与自动更新逻辑
|
||||
4. 处理文件下载、本地存储等 OS 级能力
|
||||
|
||||
**推荐方案:Electron(主选)**
|
||||
|
||||
| 维度 | Electron | Tauri | WebView2 封装 |
|
||||
|------|---------|-------|--------------|
|
||||
| 内核控制 | ✅ 捆绑 Chromium,100% 可控 | ❌ 依赖系统 WebView,版本不可控 | ⚠️ 依赖 Windows 内置 WebView2 Runtime |
|
||||
| 包体大小 | ~150MB(可接受) | ~5MB | ~5MB |
|
||||
| 生态成熟度 | ✅ 最成熟,社区最大 | ✅ 较新但活跃 | ⚠️ 微软官方但文档偏少 |
|
||||
| 自动更新支持 | ✅ `electron-updater` 成熟方案 | ✅ 内置更新器 | ⚠️ 需自行实现 |
|
||||
| 跨平台 | ✅ Win/Mac/Linux | ✅ | ❌ 仅 Windows |
|
||||
| 团队技术匹配 | ✅ 主进程用 Node.js,渲染层纯 Web | ⚠️ 主进程需 Rust | ✅ 主进程用 C# |
|
||||
| **推荐度** | **✅ 主选** | 次选 | 备选 |
|
||||
|
||||
**选型决策**:采用 **Electron + electron-updater**。理由:
|
||||
|
||||
- 内嵌 Chromium 内核是本需求的核心约束,Electron 是唯一能 100% 保证内核版本可控的主流方案
|
||||
- `electron-updater` 配合 GitHub Releases 或自建 S3/R2 存储可实现完整的版本管理与自动更新流程,开发成本最低
|
||||
- 渲染层完全复用 Fonrey 现有 Web 技术栈,无需新增前端框架学习成本
|
||||
- 团队具备 JavaScript/Node.js 能力,主进程开发门槛可控
|
||||
|
||||
**技术决策**:客户端不内置任何业务逻辑,所有业务功能由服务端 Fonrey Web 应用提供。客户端仅负责加载 Web 应用、更新管理和 OS 级能力(窗口、托盘、文件下载路径)。
|
||||
|
||||
---
|
||||
|
||||
#### 5.1.2 更新服务架构
|
||||
|
||||
更新机制采用**差量检测 + 全量包下载**模式:
|
||||
|
||||
```
|
||||
客户端启动 / 定时检测(每4小时)
|
||||
│
|
||||
▼
|
||||
GET /api/client/updates/latest?platform=win32&arch=x64¤t_version=1.2.0
|
||||
│
|
||||
▼
|
||||
更新服务器(Fonrey 后端 Django API)
|
||||
返回:{ latest_version, download_url, release_notes, force_update, checksum }
|
||||
│
|
||||
├── 无更新 → 继续正常运行
|
||||
│
|
||||
└── 有更新 → 弹出通知
|
||||
│
|
||||
├── 用户点击"立即更新" → 后台下载 .exe / NSIS 更新包
|
||||
│ │
|
||||
│ └── 下载完成 → 校验 SHA256 → 提示重启安装
|
||||
│
|
||||
└── 用户选择"稍后" → 下次启动再提示
|
||||
```
|
||||
|
||||
**更新包存储**:上传至 Cloudflare R2(与现有对象存储一致),通过 Cloudflare CDN 加速下载,全国用户均可获得稳定下载速度。
|
||||
|
||||
**版本 API 端点**(新增至 Django 后端):
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/client/updates/latest/` | GET | 客户端查询最新版本,返回版本信息和下载 URL |
|
||||
| `/api/client/updates/` | GET | 管理端查询版本列表(需认证) |
|
||||
| `/api/client/updates/` | POST | 管理端发布新版本(需管理员权限) |
|
||||
| `/api/client/updates/<id>/` | PATCH | 管理端修改版本状态(发布/下线/强制) |
|
||||
|
||||
---
|
||||
|
||||
#### 5.1.3 安装包签名与分发
|
||||
|
||||
**代码签名**:
|
||||
- 使用 EV 代码签名证书(推荐购买 DigiCert 或 Sectigo EV 证书)
|
||||
- 通过 `electron-builder` 在 CI/CD 构建时自动签名
|
||||
- 签名后安装包经 Windows SmartScreen 审核,用户安装时不触发安全警告
|
||||
|
||||
**安装包分发**:
|
||||
- 官方下载页:独立 HTML 页面托管于 Cloudflare Pages 或 Nginx 静态站
|
||||
- 页面展示:最新版本号 + 发布日期 + 更新日志 + 下载按钮
|
||||
- 下载 URL 格式:`https://download.fonrey.com/releases/v1.2.3/fonrey-setup-1.2.3-win.exe`
|
||||
- 同时提供便携版(Portable):`fonrey-portable-1.2.3-win.zip`,供无安装权限的企业环境使用
|
||||
|
||||
---
|
||||
|
||||
### 5.2 客户端功能规格
|
||||
|
||||
#### 5.2.1 主窗口
|
||||
|
||||
| 属性 | 规格 |
|
||||
|------|------|
|
||||
| 默认窗口尺寸 | 1280 × 800(最小:1024 × 600) |
|
||||
| 标题栏 | 显示 `Fonrey 房睿 v{version}`,含原生最小化/最大化/关闭按钮 |
|
||||
| 内嵌 URL | 启动时加载 `https://{tenant}.fonrey.com`(或私有化部署地址,可配置) |
|
||||
| 地址栏 | 不显示(沉浸式应用模式) |
|
||||
| 右键菜单 | 仅保留"复制"/"粘贴"/"检查元素(仅开发模式)",移除"查看源代码"等浏览器默认项 |
|
||||
| 外部链接 | 点击 `target="_blank"` 链接时,在系统默认浏览器中打开,不在客户端内新窗口打开 |
|
||||
|
||||
#### 5.2.2 系统托盘
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 托盘图标 | Fonrey Logo,鼠标悬停显示 `Fonrey 房睿 - 已连接` / `- 离线` |
|
||||
| 右键菜单 | 打开主窗口 / 检查更新 / 关于 / 退出 |
|
||||
| 最小化行为 | 点击关闭按钮时最小化至托盘(不退出程序),用户通过托盘图标恢复窗口 |
|
||||
|
||||
#### 5.2.3 网络状态感知
|
||||
|
||||
| 状态 | 客户端行为 |
|
||||
|------|-----------|
|
||||
| 正常联网 | 加载 Fonrey Web 应用,状态栏显示"已连接" |
|
||||
| 网络断开 | 显示全屏提示页:"网络连接已断开,请检查您的网络后重试",提供"重新连接"按钮 |
|
||||
| 服务器维护 | 服务器返回 503 时,展示维护提示页(内容由服务端控制) |
|
||||
|
||||
#### 5.2.4 文件下载处理
|
||||
|
||||
- Excel 导出等文件下载触发时,客户端调用系统原生"另存为"对话框,用户选择保存路径
|
||||
- 下载完成后,状态栏显示"下载完成,点击打开"提示,点击可直接打开文件
|
||||
|
||||
---
|
||||
|
||||
### 5.3 版本管理后台(系统管理模块新增页面)
|
||||
|
||||
**页面路径**:系统管理 → 客户端发布管理
|
||||
|
||||
#### 5.3.1 版本列表
|
||||
|
||||
| 列 | 说明 |
|
||||
|----|------|
|
||||
| 版本号 | SemVer 格式,如 `v1.2.3` |
|
||||
| 版本类型 | 普通更新 / 强制更新(红色标签) |
|
||||
| 状态 | 草稿 / 已发布(绿色)/ 已下线(灰色) |
|
||||
| 发布时间 | 版本设为已发布的时间 |
|
||||
| 下载量 | 该版本安装包被下载次数 |
|
||||
| 操作 | 发布 / 下线 / 编辑 / 复制下载链接 |
|
||||
|
||||
#### 5.3.2 新增/编辑版本表单
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 版本号 | 文本输入 | 是 | 格式:`X.Y.Z`,自动校验 SemVer 格式 |
|
||||
| 版本类型 | 单选 | 是 | 普通更新 / 强制更新 |
|
||||
| 最低兼容版本 | 文本输入 | 否 | 低于该版本的客户端将被强制更新(如填写 `1.0.0`,则低于此版本的客户端强制升级) |
|
||||
| 安装包(EXE) | 文件上传 | 是 | 上传至 Cloudflare R2,最大 500MB |
|
||||
| 便携版(ZIP) | 文件上传 | 否 | 同上 |
|
||||
| SHA256 校验值 | 文本输入(自动填充) | 是 | 上传后系统自动计算并填充,用于客户端下载完成后校验完整性 |
|
||||
| 更新日志 | Markdown 文本区域 | 是 | 展示给用户看的版本说明,最多 2000 字 |
|
||||
| 发布说明(内部) | 文本区域 | 否 | 仅内部查看的技术说明,不对外展示 |
|
||||
| 状态 | 单选 | 是 | 草稿 / 立即发布 |
|
||||
|
||||
#### 5.3.3 版本分布统计
|
||||
|
||||
| 图表 | 说明 |
|
||||
|------|------|
|
||||
| 版本分布饼图 | 按客户端版本号统计当前活跃用户数量及占比 |
|
||||
| 升级进度趋势图 | 新版本发布后,各天累计升级完成的用户比例(折线图) |
|
||||
| 租户版本明细 | 按租户(经纪公司)展示其员工的客户端版本分布 |
|
||||
|
||||
---
|
||||
|
||||
### 5.4 更新 API 规格
|
||||
|
||||
#### GET `/api/client/updates/latest/`
|
||||
|
||||
**请求参数(Query String)**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `platform` | string | 是 | 平台标识,如 `win32` |
|
||||
| `arch` | string | 是 | CPU 架构,如 `x64` / `arm64` |
|
||||
| `current_version` | string | 是 | 客户端当前版本号,如 `1.2.0` |
|
||||
|
||||
**响应示例(有新版本)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_update": true,
|
||||
"latest_version": "1.3.0",
|
||||
"force_update": false,
|
||||
"download_url": "https://download.fonrey.com/releases/v1.3.0/fonrey-setup-1.3.0-win.exe",
|
||||
"portable_url": "https://download.fonrey.com/releases/v1.3.0/fonrey-portable-1.3.0-win.zip",
|
||||
"checksum_sha256": "a1b2c3d4...",
|
||||
"release_notes": "## v1.3.0 更新内容\n- 新增客源智能配房功能\n- 修复房源列表筛选条件保存异常",
|
||||
"release_date": "2026-05-01"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(已是最新)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_update": false,
|
||||
"latest_version": "1.3.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术实现注意事项
|
||||
|
||||
### 6.1 依赖关系
|
||||
|
||||
| 依赖项 | 说明 | 负责方 | 风险等级 |
|
||||
|--------|------|--------|---------|
|
||||
| Electron 框架 | 客户端技术基础,需评估 License(MIT,商业可用) | 前端/客户端工程师 | 低 |
|
||||
| EV 代码签名证书 | 需提前申请,EV 证书审核周期 1-2 周 | IT/运维 | 中(需提前排期) |
|
||||
| Cloudflare R2 存储桶 | 存放安装包,利用现有账号新增 bucket | 运维 | 低 |
|
||||
| `electron-updater` | 自动更新库,需配合更新 API 端点实现 | 客户端工程师 | 低 |
|
||||
| Django 更新 API | 新增 `/api/client/updates/` 相关接口 | 后端工程师 | 低 |
|
||||
| CI/CD 构建流水线 | 自动构建、签名、上传安装包 | 运维/DevOps | 中 |
|
||||
|
||||
### 6.2 已知风险
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|--------|------|---------|
|
||||
| EV 证书申请延迟 | 中 | 高(无签名包无法正常分发) | MVP 阶段可使用普通 OV 证书临时过渡,但需向用户说明安全警告原因 |
|
||||
| Electron 包体过大导致下载放弃 | 低 | 中 | 使用 `electron-builder` 的 `asar` 压缩 + 分片下载;首包控制在 150MB 以内 |
|
||||
| 企业网络拦截 CDN 下载 | 中 | 中 | 提供备用下载 URL(直连服务器),支持客户手动下载后本地安装 |
|
||||
| 自动更新期间用户强制关闭 | 低 | 低 | 更新包下载完成后才替换原文件,下载中断不影响现有版本正常运行 |
|
||||
| 多租户场景下 URL 配置问题 | 低 | 高 | 客户端启动时加载的 URL 通过配置文件指定,支持定制化部署;SaaS 版统一指向主域名 |
|
||||
|
||||
### 6.3 开放问题(开发启动前必须解决)
|
||||
|
||||
- [ ] **租户 URL 如何分发到客户端?** 选项 A:客户端硬编码主域名,由服务端重定向到租户子域(`fonrey.com` → `{tenant}.fonrey.com`);选项 B:安装包内置配置文件,由销售/运维在分发给客户前填写租户子域。——**Owner**: 产品 + 工程 **Deadline**: 开发启动前
|
||||
- [ ] **代码签名证书采购主体和预算是否确认?** — **Owner**: IT 负责人 **Deadline**: 立项后 1 周
|
||||
- [ ] **CI/CD 平台选型是否确定?**(GitHub Actions / Jenkins / 其他)— **Owner**: 运维负责人 **Deadline**: 开发启动前
|
||||
- [ ] **便携版(Portable ZIP)是否纳入 v1 范围?** 便携版可解决企业无安装权限场景,但增加测试成本。— **Owner**: PM **Deadline**: 立项后 1 周
|
||||
|
||||
---
|
||||
|
||||
## 7. 发布计划
|
||||
|
||||
| 阶段 | 时间 | 受众 | 成功门槛 |
|
||||
|------|------|------|---------|
|
||||
| 内部 Alpha | 开发完成后 1 周 | 内部团队 + 1 家种子客户 | 核心流程无 P0 Bug,自动更新机制验证通过 |
|
||||
| 封闭 Beta | Alpha + 2 周 | 3-5 家头部客户 | 安装成功率 ≥ 95%,自动更新成功率 ≥ 95%,无 P0/P1 Bug |
|
||||
| 正式发布(GA) | Beta + 1 周 | 全部客户 | Beta 阶段目标达成 |
|
||||
|
||||
**回滚标准**:若正式发布后 24 小时内出现以下情况,立即下线该版本并恢复上一稳定版本为"已发布":
|
||||
- 自动更新失败率 > 5%
|
||||
- 客户端白屏/崩溃率 > 2%
|
||||
- 收到 P0 级安全漏洞报告
|
||||
|
||||
---
|
||||
|
||||
## 8. 附录
|
||||
|
||||
### 8.1 竞品参考
|
||||
|
||||
| 产品 | 客户端方案 | 更新机制 |
|
||||
|------|-----------|---------|
|
||||
| 企业微信 | Electron + 自研内核 | 强制更新,启动时自动下载 |
|
||||
| 飞书 | Electron | 后台静默更新,重启生效 |
|
||||
| 钉钉 | Electron | 同上 |
|
||||
|
||||
> 房产经纪行业的竞品(如房客多、云客优)均采用 Electron 方案,验证了技术路线的合理性。
|
||||
|
||||
### 8.2 术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
|------|------|
|
||||
| SemVer | 语义化版本控制(Semantic Versioning):`主版本号.次版本号.补丁号`,如 `1.2.3` |
|
||||
| Electron | 由 GitHub 开发的开源框架,允许使用 Web 技术(HTML/CSS/JS)构建跨平台桌面应用,内嵌 Chromium 和 Node.js |
|
||||
| electron-updater | Electron 生态中成熟的自动更新库,支持增量更新和全量更新 |
|
||||
| EV 证书 | Extended Validation 代码签名证书,由 CA 机构颁发,可消除 Windows SmartScreen 安全警告 |
|
||||
| SHA256 | 安全散列算法,用于验证下载文件的完整性,防止篡改或下载损坏 |
|
||||
| Portable | 便携版,无需安装,解压即用,适合无管理员权限的企业环境 |
|
||||
# PRD: 客户端发布管理模块
|
||||
**状态**: Draft
|
||||
**作者**: 产品经理
|
||||
**最后更新**: 2026-04-24(v1.0 初稿)
|
||||
**版本**: 1.0
|
||||
**所属系统**: Fonrey 房产经纪管理系统
|
||||
**关联模块**: 系统管理、权限管理
|
||||
**干系人**: 工程负责人、运维负责人、系统管理员
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题陈述
|
||||
|
||||
### 背景
|
||||
|
||||
Fonrey 房产经纪管理系统当前为纯 Web 应用,依赖用户自行通过浏览器访问。然而在实际部署场景中,经纪公司的终端设备环境高度复杂:
|
||||
|
||||
- **浏览器版本参差不齐**:经纪人使用的 Windows 设备可能运行 IE11、旧版 Edge、或未更新的 Chrome,导致 HTMX + Alpine.js 等现代前端技术出现兼容性问题,系统体验碎片化
|
||||
- **交付和部署门槛高**:IT 能力薄弱的经纪公司无法独立配置浏览器访问方式,URL 记忆成本高,容易访问错误版本
|
||||
- **版本管理缺失**:后端服务升级后,用户仍可能使用旧版缓存页面操作,导致接口不兼容和功能异常
|
||||
- **无官方入口**:用户通过私发链接访问系统,存在钓鱼仿冒风险,且无法统一品牌形象
|
||||
|
||||
### 目标用户
|
||||
|
||||
| 角色 | 使用场景 | 使用频率 |
|
||||
|------|---------|----------|
|
||||
| 一线经纪人 | 下载安装客户端、日常登录使用系统、接受自动更新 | 每日 |
|
||||
| 店长/经理 | 同上 | 每日 |
|
||||
| 系统管理员 | 发布新版本、管理安装包下载地址、监控客户端版本分布 | 按需 |
|
||||
| IT 运维人员 | 维护更新服务器、签名证书、构建发布流水线 | 按发布周期 |
|
||||
|
||||
### 核心痛点
|
||||
|
||||
1. **无法控制用户使用的浏览器环境**,兼容性问题无法从根源解决
|
||||
2. **升级依赖用户主动刷新浏览器**,后端 API 变更时旧客户端可能造成数据错误
|
||||
3. **缺乏官方分发渠道**,无法向终端用户传递信任感和版本一致性保障
|
||||
4. **SaaS 多租户管理系统需要统一、可控的客户端入口**,避免因客户端环境差异导致的支持成本上升
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与成功指标
|
||||
|
||||
| 目标 | 指标 | 当前基准 | 目标值 | 衡量周期 |
|
||||
|------|------|---------|--------|---------|
|
||||
| 消除浏览器兼容性问题 | 因浏览器兼容产生的支持工单数 | 待统计 | 降低 ≥ 90% | 上线后 60 天 |
|
||||
| 提升版本一致性 | 在线用户中使用最新版本客户端的比例 | 0%(无客户端) | ≥ 95% | 版本发布后 7 天 |
|
||||
| 降低部署门槛 | 新客户从获取安装包到完成首次登录的时间 | 无基准 | ≤ 10 分钟 | 上线后首批客户反馈 |
|
||||
| 自动更新成功率 | 客户端自动更新完成率(收到更新通知 → 升级完成) | 无基准 | ≥ 98% | 每次版本发布后 48 小时 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 非目标(本期不做)
|
||||
|
||||
- **不支持 macOS / Linux 客户端**:目标用户群体 99% 使用 Windows,macOS 版本为后续规划
|
||||
- **不支持移动端 App(iOS / Android)**:移动端为 v2 规划,本期不涉及
|
||||
- **不开发私有化部署的离线安装方案**:本期聚焦 SaaS 在线版,私有化部署另行规划
|
||||
- **不包含客户端内置的离线模式**:系统需联网使用,客户端不缓存业务数据供离线访问
|
||||
- **不包含客户端层面的安全加固(如代码混淆、反逆向)**:本期以功能交付为优先,安全加固列入后续迭代
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户故事与验收标准
|
||||
|
||||
---
|
||||
|
||||
### Story 1:经纪人下载并安装客户端
|
||||
|
||||
**As** 一线经纪人,**I want** 通过公司提供的网址下载一个安装程序并完成安装,**So that** 我可以立即打开登录界面使用 Fonrey 系统,无需手动配置浏览器。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 官方下载页面可通过指定 URL 访问,页面展示最新版本号、发布日期及下载按钮
|
||||
- [ ] 下载产物为单一 `.exe` 安装包(或免安装便携版 `.zip`),文件大小控制在合理范围内
|
||||
- [ ] 双击安装包后,安装向导步骤不超过 3 步(下一步 → 选择安装路径 → 安装),无需勾选额外组件
|
||||
- [ ] 安装完成后,桌面自动生成快捷方式(图标为 Fonrey 品牌 Logo)
|
||||
- [ ] 首次启动后直接显示登录界面,无需用户手动输入任何 URL
|
||||
- [ ] 安装包经过代码签名,Windows SmartScreen 不弹出"无法识别的应用"警告
|
||||
- [ ] 安装过程无需管理员权限(支持用户级安装到 `%APPDATA%` 目录),降低企业 IT 审批障碍
|
||||
|
||||
---
|
||||
|
||||
### Story 2:经纪人使用客户端正常登录并使用系统
|
||||
|
||||
**As** 一线经纪人,**I want** 打开客户端后直接访问 Fonrey 系统的完整功能,**So that** 我的日常使用体验与使用 Chrome 浏览器无差异,且不受本机安装的浏览器版本影响。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 客户端内嵌现代 Chromium 内核(如基于 Electron 或 WebView2),版本不低于 Chromium 100,支持现代 Web 标准(ES2020、CSS Grid、Fetch API 等)
|
||||
- [ ] HTMX 局部刷新、Alpine.js 状态交互、Tailwind CSS 样式在客户端中渲染效果与 Chrome 最新版一致
|
||||
- [ ] 支持 Cookie / Session 存储,登录状态在客户端关闭后保留(复用 Django Session 机制)
|
||||
- [ ] 文件上传(图片、附件)、文件下载(Excel 导出)在客户端中正常工作
|
||||
- [ ] 客户端窗口支持最大化、最小化、拖拽调整大小,支持多显示器
|
||||
- [ ] 客户端标题栏显示应用名称和当前版本号(如:`Fonrey 房睿 v1.2.3`)
|
||||
- [ ] 客户端不显示浏览器默认的地址栏、书签栏、扩展工具栏,保持沉浸式应用体验
|
||||
|
||||
---
|
||||
|
||||
### Story 3:客户端感知新版本并自动升级
|
||||
|
||||
**As** 一线经纪人,**I want** 客户端在有新版本时自动提示并完成升级,**So that** 我无需手动下载安装,始终使用最新版本,不会因版本落后导致功能异常。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 客户端启动时及运行期间(每隔 4 小时)自动向更新服务器检查最新版本
|
||||
- [ ] 有新版本时,客户端右下角弹出非阻断式通知:"发现新版本 vX.X.X,点击立即更新",用户可选择"立即更新"或"稍后提醒"
|
||||
- [ ] 点击"立即更新"后,客户端在后台静默下载更新包,进度条显示下载进度
|
||||
- [ ] 下载完成后提示用户"更新已就绪,重启客户端完成安装",用户选择"立即重启"或"下次启动时安装"
|
||||
- [ ] 重启后,新版本生效,标题栏版本号更新,历史会话自动恢复(用户无需重新登录)
|
||||
- [ ] 支持强制更新模式:服务端可标记某版本为"强制升级",客户端不展示"稍后提醒"选项,必须升级后方可继续使用(用于重大 API 兼容性变更场景)
|
||||
- [ ] 更新失败时(网络中断、磁盘空间不足等),客户端显示错误提示并保持当前版本正常运行,不影响用户当前操作
|
||||
|
||||
---
|
||||
|
||||
### Story 4:系统管理员发布新版本
|
||||
|
||||
**As** 系统管理员,**I want** 通过管理后台上传新版客户端安装包并配置版本信息,**So that** 客户端能感知到更新并引导用户升级。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 系统管理后台提供"客户端版本管理"页面(位于系统管理模块下)
|
||||
- [ ] 支持上传 `.exe` 安装包,并填写版本号(遵循 SemVer:`X.Y.Z`)、版本说明(更新日志,支持 Markdown)、发布日期
|
||||
- [ ] 支持设置版本类型:普通更新 / 强制更新
|
||||
- [ ] 支持设置版本状态:草稿(不对外生效)/ 已发布 / 已下线
|
||||
- [ ] 发布后,更新服务器 API 即时返回最新版本信息,客户端下次检测时可感知
|
||||
- [ ] 支持版本回滚:将指定历史版本重新设为"已发布",自动将当前版本标记为已下线
|
||||
- [ ] 支持查看各版本的下载量和活跃客户端版本分布统计
|
||||
|
||||
---
|
||||
|
||||
### Story 5:管理员监控客户端版本分布
|
||||
|
||||
**As** 系统管理员,**I want** 查看当前所有在线客户端的版本分布情况,**So that** 了解升级覆盖率,对仍在使用旧版本的客户端发出提醒或强制升级。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 客户端版本管理页面展示版本分布统计:各版本在线客户端数量及占比(饼图或条形图)
|
||||
- [ ] 支持按租户维度查看版本分布(多租户场景下,区分不同经纪公司的版本使用情况)
|
||||
- [ ] 支持对指定版本范围的用户推送"强制更新"通知(如:将所有低于 v1.5.0 的客户端标记为强制更新)
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能详细说明
|
||||
|
||||
### 5.1 技术架构选型
|
||||
|
||||
#### 5.1.1 客户端技术方案
|
||||
|
||||
基于 Fonrey 现有技术栈(Django + HTMX + Alpine.js + Tailwind CSS,后端已采用 Docker Compose 部署),客户端本质是一个**内嵌现代 Chromium 内核的原生 Windows 应用外壳(Shell)**,其核心职责是:
|
||||
|
||||
1. 提供操作系统级原生窗口(标题栏、任务栏图标、托盘)
|
||||
2. 内嵌高版本 Chromium 内核加载 Fonrey Web 应用 URL
|
||||
3. 实现版本检测与自动更新逻辑
|
||||
4. 处理文件下载、本地存储等 OS 级能力
|
||||
|
||||
**推荐方案:Electron(主选)**
|
||||
|
||||
| 维度 | Electron | Tauri | WebView2 封装 |
|
||||
|------|---------|-------|--------------|
|
||||
| 内核控制 | ✅ 捆绑 Chromium,100% 可控 | ❌ 依赖系统 WebView,版本不可控 | ⚠️ 依赖 Windows 内置 WebView2 Runtime |
|
||||
| 包体大小 | ~150MB(可接受) | ~5MB | ~5MB |
|
||||
| 生态成熟度 | ✅ 最成熟,社区最大 | ✅ 较新但活跃 | ⚠️ 微软官方但文档偏少 |
|
||||
| 自动更新支持 | ✅ `electron-updater` 成熟方案 | ✅ 内置更新器 | ⚠️ 需自行实现 |
|
||||
| 跨平台 | ✅ Win/Mac/Linux | ✅ | ❌ 仅 Windows |
|
||||
| 团队技术匹配 | ✅ 主进程用 Node.js,渲染层纯 Web | ⚠️ 主进程需 Rust | ✅ 主进程用 C# |
|
||||
| **推荐度** | **✅ 主选** | 次选 | 备选 |
|
||||
|
||||
**选型决策**:采用 **Electron + electron-updater**。理由:
|
||||
|
||||
- 内嵌 Chromium 内核是本需求的核心约束,Electron 是唯一能 100% 保证内核版本可控的主流方案
|
||||
- `electron-updater` 配合 GitHub Releases 或自建 S3/R2 存储可实现完整的版本管理与自动更新流程,开发成本最低
|
||||
- 渲染层完全复用 Fonrey 现有 Web 技术栈,无需新增前端框架学习成本
|
||||
- 团队具备 JavaScript/Node.js 能力,主进程开发门槛可控
|
||||
|
||||
**技术决策**:客户端不内置任何业务逻辑,所有业务功能由服务端 Fonrey Web 应用提供。客户端仅负责加载 Web 应用、更新管理和 OS 级能力(窗口、托盘、文件下载路径)。
|
||||
|
||||
---
|
||||
|
||||
#### 5.1.2 更新服务架构
|
||||
|
||||
更新机制采用**差量检测 + 全量包下载**模式:
|
||||
|
||||
```
|
||||
客户端启动 / 定时检测(每4小时)
|
||||
│
|
||||
▼
|
||||
GET /api/client/updates/latest?platform=win32&arch=x64¤t_version=1.2.0
|
||||
│
|
||||
▼
|
||||
更新服务器(Fonrey 后端 Django API)
|
||||
返回:{ latest_version, download_url, release_notes, force_update, checksum }
|
||||
│
|
||||
├── 无更新 → 继续正常运行
|
||||
│
|
||||
└── 有更新 → 弹出通知
|
||||
│
|
||||
├── 用户点击"立即更新" → 后台下载 .exe / NSIS 更新包
|
||||
│ │
|
||||
│ └── 下载完成 → 校验 SHA256 → 提示重启安装
|
||||
│
|
||||
└── 用户选择"稍后" → 下次启动再提示
|
||||
```
|
||||
|
||||
**更新包存储**:上传至 Cloudflare R2(与现有对象存储一致),通过 Cloudflare CDN 加速下载,全国用户均可获得稳定下载速度。
|
||||
|
||||
**版本 API 端点**(新增至 Django 后端):
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/client/updates/latest/` | GET | 客户端查询最新版本,返回版本信息和下载 URL |
|
||||
| `/api/client/updates/` | GET | 管理端查询版本列表(需认证) |
|
||||
| `/api/client/updates/` | POST | 管理端发布新版本(需管理员权限) |
|
||||
| `/api/client/updates/<id>/` | PATCH | 管理端修改版本状态(发布/下线/强制) |
|
||||
|
||||
---
|
||||
|
||||
#### 5.1.3 安装包签名与分发
|
||||
|
||||
**代码签名**:
|
||||
- 使用 EV 代码签名证书(推荐购买 DigiCert 或 Sectigo EV 证书)
|
||||
- 通过 `electron-builder` 在 CI/CD 构建时自动签名
|
||||
- 签名后安装包经 Windows SmartScreen 审核,用户安装时不触发安全警告
|
||||
|
||||
**安装包分发**:
|
||||
- 官方下载页:独立 HTML 页面托管于 Cloudflare Pages 或 Nginx 静态站
|
||||
- 页面展示:最新版本号 + 发布日期 + 更新日志 + 下载按钮
|
||||
- 下载 URL 格式:`https://download.fonrey.com/releases/v1.2.3/fonrey-setup-1.2.3-win.exe`
|
||||
- 同时提供便携版(Portable):`fonrey-portable-1.2.3-win.zip`,供无安装权限的企业环境使用
|
||||
|
||||
---
|
||||
|
||||
### 5.2 客户端功能规格
|
||||
|
||||
#### 5.2.1 主窗口
|
||||
|
||||
| 属性 | 规格 |
|
||||
|------|------|
|
||||
| 默认窗口尺寸 | 1280 × 800(最小:1024 × 600) |
|
||||
| 标题栏 | 显示 `Fonrey 房睿 v{version}`,含原生最小化/最大化/关闭按钮 |
|
||||
| 内嵌 URL | 启动时加载 `https://{tenant}.fonrey.com`(或私有化部署地址,可配置) |
|
||||
| 地址栏 | 不显示(沉浸式应用模式) |
|
||||
| 右键菜单 | 仅保留"复制"/"粘贴"/"检查元素(仅开发模式)",移除"查看源代码"等浏览器默认项 |
|
||||
| 外部链接 | 点击 `target="_blank"` 链接时,在系统默认浏览器中打开,不在客户端内新窗口打开 |
|
||||
|
||||
#### 5.2.2 系统托盘
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 托盘图标 | Fonrey Logo,鼠标悬停显示 `Fonrey 房睿 - 已连接` / `- 离线` |
|
||||
| 右键菜单 | 打开主窗口 / 检查更新 / 关于 / 退出 |
|
||||
| 最小化行为 | 点击关闭按钮时最小化至托盘(不退出程序),用户通过托盘图标恢复窗口 |
|
||||
|
||||
#### 5.2.3 网络状态感知
|
||||
|
||||
| 状态 | 客户端行为 |
|
||||
|------|-----------|
|
||||
| 正常联网 | 加载 Fonrey Web 应用,状态栏显示"已连接" |
|
||||
| 网络断开 | 显示全屏提示页:"网络连接已断开,请检查您的网络后重试",提供"重新连接"按钮 |
|
||||
| 服务器维护 | 服务器返回 503 时,展示维护提示页(内容由服务端控制) |
|
||||
|
||||
#### 5.2.4 文件下载处理
|
||||
|
||||
- Excel 导出等文件下载触发时,客户端调用系统原生"另存为"对话框,用户选择保存路径
|
||||
- 下载完成后,状态栏显示"下载完成,点击打开"提示,点击可直接打开文件
|
||||
|
||||
---
|
||||
|
||||
### 5.3 版本管理后台(系统管理模块新增页面)
|
||||
|
||||
**页面路径**:系统管理 → 客户端发布管理
|
||||
|
||||
#### 5.3.1 版本列表
|
||||
|
||||
| 列 | 说明 |
|
||||
|----|------|
|
||||
| 版本号 | SemVer 格式,如 `v1.2.3` |
|
||||
| 版本类型 | 普通更新 / 强制更新(红色标签) |
|
||||
| 状态 | 草稿 / 已发布(绿色)/ 已下线(灰色) |
|
||||
| 发布时间 | 版本设为已发布的时间 |
|
||||
| 下载量 | 该版本安装包被下载次数 |
|
||||
| 操作 | 发布 / 下线 / 编辑 / 复制下载链接 |
|
||||
|
||||
#### 5.3.2 新增/编辑版本表单
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 版本号 | 文本输入 | 是 | 格式:`X.Y.Z`,自动校验 SemVer 格式 |
|
||||
| 版本类型 | 单选 | 是 | 普通更新 / 强制更新 |
|
||||
| 最低兼容版本 | 文本输入 | 否 | 低于该版本的客户端将被强制更新(如填写 `1.0.0`,则低于此版本的客户端强制升级) |
|
||||
| 安装包(EXE) | 文件上传 | 是 | 上传至 Cloudflare R2,最大 500MB |
|
||||
| 便携版(ZIP) | 文件上传 | 否 | 同上 |
|
||||
| SHA256 校验值 | 文本输入(自动填充) | 是 | 上传后系统自动计算并填充,用于客户端下载完成后校验完整性 |
|
||||
| 更新日志 | Markdown 文本区域 | 是 | 展示给用户看的版本说明,最多 2000 字 |
|
||||
| 发布说明(内部) | 文本区域 | 否 | 仅内部查看的技术说明,不对外展示 |
|
||||
| 状态 | 单选 | 是 | 草稿 / 立即发布 |
|
||||
|
||||
#### 5.3.3 版本分布统计
|
||||
|
||||
| 图表 | 说明 |
|
||||
|------|------|
|
||||
| 版本分布饼图 | 按客户端版本号统计当前活跃用户数量及占比 |
|
||||
| 升级进度趋势图 | 新版本发布后,各天累计升级完成的用户比例(折线图) |
|
||||
| 租户版本明细 | 按租户(经纪公司)展示其员工的客户端版本分布 |
|
||||
|
||||
---
|
||||
|
||||
### 5.4 更新 API 规格
|
||||
|
||||
#### GET `/api/client/updates/latest/`
|
||||
|
||||
**请求参数(Query String)**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `platform` | string | 是 | 平台标识,如 `win32` |
|
||||
| `arch` | string | 是 | CPU 架构,如 `x64` / `arm64` |
|
||||
| `current_version` | string | 是 | 客户端当前版本号,如 `1.2.0` |
|
||||
|
||||
**响应示例(有新版本)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_update": true,
|
||||
"latest_version": "1.3.0",
|
||||
"force_update": false,
|
||||
"download_url": "https://download.fonrey.com/releases/v1.3.0/fonrey-setup-1.3.0-win.exe",
|
||||
"portable_url": "https://download.fonrey.com/releases/v1.3.0/fonrey-portable-1.3.0-win.zip",
|
||||
"checksum_sha256": "a1b2c3d4...",
|
||||
"release_notes": "## v1.3.0 更新内容\n- 新增客源智能配房功能\n- 修复房源列表筛选条件保存异常",
|
||||
"release_date": "2026-05-01"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(已是最新)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_update": false,
|
||||
"latest_version": "1.3.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术实现注意事项
|
||||
|
||||
### 6.1 依赖关系
|
||||
|
||||
| 依赖项 | 说明 | 负责方 | 风险等级 |
|
||||
|--------|------|--------|---------|
|
||||
| Electron 框架 | 客户端技术基础,需评估 License(MIT,商业可用) | 前端/客户端工程师 | 低 |
|
||||
| EV 代码签名证书 | 需提前申请,EV 证书审核周期 1-2 周 | IT/运维 | 中(需提前排期) |
|
||||
| Cloudflare R2 存储桶 | 存放安装包,利用现有账号新增 bucket | 运维 | 低 |
|
||||
| `electron-updater` | 自动更新库,需配合更新 API 端点实现 | 客户端工程师 | 低 |
|
||||
| Django 更新 API | 新增 `/api/client/updates/` 相关接口 | 后端工程师 | 低 |
|
||||
| CI/CD 构建流水线 | 自动构建、签名、上传安装包 | 运维/DevOps | 中 |
|
||||
|
||||
### 6.2 已知风险
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|--------|------|---------|
|
||||
| EV 证书申请延迟 | 中 | 高(无签名包无法正常分发) | MVP 阶段可使用普通 OV 证书临时过渡,但需向用户说明安全警告原因 |
|
||||
| Electron 包体过大导致下载放弃 | 低 | 中 | 使用 `electron-builder` 的 `asar` 压缩 + 分片下载;首包控制在 150MB 以内 |
|
||||
| 企业网络拦截 CDN 下载 | 中 | 中 | 提供备用下载 URL(直连服务器),支持客户手动下载后本地安装 |
|
||||
| 自动更新期间用户强制关闭 | 低 | 低 | 更新包下载完成后才替换原文件,下载中断不影响现有版本正常运行 |
|
||||
| 多租户场景下 URL 配置问题 | 低 | 高 | 客户端启动时加载的 URL 通过配置文件指定,支持定制化部署;SaaS 版统一指向主域名 |
|
||||
|
||||
### 6.3 开放问题(开发启动前必须解决)
|
||||
|
||||
- [ ] **租户 URL 如何分发到客户端?** 选项 A:客户端硬编码主域名,由服务端重定向到租户子域(`fonrey.com` → `{tenant}.fonrey.com`);选项 B:安装包内置配置文件,由销售/运维在分发给客户前填写租户子域。——**Owner**: 产品 + 工程 **Deadline**: 开发启动前
|
||||
- [ ] **代码签名证书采购主体和预算是否确认?** — **Owner**: IT 负责人 **Deadline**: 立项后 1 周
|
||||
- [ ] **CI/CD 平台选型是否确定?**(GitHub Actions / Jenkins / 其他)— **Owner**: 运维负责人 **Deadline**: 开发启动前
|
||||
- [ ] **便携版(Portable ZIP)是否纳入 v1 范围?** 便携版可解决企业无安装权限场景,但增加测试成本。— **Owner**: PM **Deadline**: 立项后 1 周
|
||||
|
||||
---
|
||||
|
||||
## 7. 发布计划
|
||||
|
||||
| 阶段 | 时间 | 受众 | 成功门槛 |
|
||||
|------|------|------|---------|
|
||||
| 内部 Alpha | 开发完成后 1 周 | 内部团队 + 1 家种子客户 | 核心流程无 P0 Bug,自动更新机制验证通过 |
|
||||
| 封闭 Beta | Alpha + 2 周 | 3-5 家头部客户 | 安装成功率 ≥ 95%,自动更新成功率 ≥ 95%,无 P0/P1 Bug |
|
||||
| 正式发布(GA) | Beta + 1 周 | 全部客户 | Beta 阶段目标达成 |
|
||||
|
||||
**回滚标准**:若正式发布后 24 小时内出现以下情况,立即下线该版本并恢复上一稳定版本为"已发布":
|
||||
- 自动更新失败率 > 5%
|
||||
- 客户端白屏/崩溃率 > 2%
|
||||
- 收到 P0 级安全漏洞报告
|
||||
|
||||
---
|
||||
|
||||
## 8. 附录
|
||||
|
||||
### 8.1 竞品参考
|
||||
|
||||
| 产品 | 客户端方案 | 更新机制 |
|
||||
|------|-----------|---------|
|
||||
| 企业微信 | Electron + 自研内核 | 强制更新,启动时自动下载 |
|
||||
| 飞书 | Electron | 后台静默更新,重启生效 |
|
||||
| 钉钉 | Electron | 同上 |
|
||||
|
||||
> 房产经纪行业的竞品(如房客多、云客优)均采用 Electron 方案,验证了技术路线的合理性。
|
||||
|
||||
### 8.2 术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
|------|------|
|
||||
| SemVer | 语义化版本控制(Semantic Versioning):`主版本号.次版本号.补丁号`,如 `1.2.3` |
|
||||
| Electron | 由 GitHub 开发的开源框架,允许使用 Web 技术(HTML/CSS/JS)构建跨平台桌面应用,内嵌 Chromium 和 Node.js |
|
||||
| electron-updater | Electron 生态中成熟的自动更新库,支持增量更新和全量更新 |
|
||||
| EV 证书 | Extended Validation 代码签名证书,由 CA 机构颁发,可消除 Windows SmartScreen 安全警告 |
|
||||
| SHA256 | 安全散列算法,用于验证下载文件的完整性,防止篡改或下载损坏 |
|
||||
| Portable | 便携版,无需安装,解压即用,适合无管理员权限的企业环境 |
|
||||
|
||||
@@ -1942,6 +1942,134 @@
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术考量
|
||||
|
||||
### 6.0 核心技术设计
|
||||
|
||||
#### 6.0.1 客源状态机
|
||||
|
||||
客源在生命周期内的状态流转是系统的核心业务逻辑,必须在后端通过状态机严格控制,禁止跨状态直接跳转。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ▼
|
||||
[录入] ──► 私客(求购/求租/暂缓)──► 公客 ──► 成交客
|
||||
│ │
|
||||
│ 手动转公/自动掉公 │ 转私客(若有权限)
|
||||
│ │
|
||||
▼ ▼
|
||||
无效客 无效客
|
||||
```
|
||||
|
||||
| 状态 | 允许流转目标 | 触发方式 |
|
||||
|------|------------|---------|
|
||||
| 私客(求购) | 求租、暂缓、公客、成交客、无效 | 手动操作 / 掉公规则 |
|
||||
| 私客(求租) | 求购、暂缓、公客、成交客、无效 | 手动操作 / 掉公规则 |
|
||||
| 私客(暂缓) | 求购、求租、公客、无效 | 手动操作 |
|
||||
| 公客 | 无效 | 手动操作(暂不支持公转私) |
|
||||
| 成交客 | — | 终态,不可流转 |
|
||||
| 无效客 | — | 终态,不可流转 |
|
||||
|
||||
**实现要求**:
|
||||
- 所有状态变更须通过后端 API 的状态机校验,前端不直接修改状态字段
|
||||
- 每次状态变更自动写入 `ClientStatusChangeLog` 审计表(变更人、变更前后状态、时间、理由)
|
||||
- 状态变更失败须返回明确错误码,前端展示友好提示
|
||||
|
||||
---
|
||||
|
||||
#### 6.0.2 重复客源检测算法
|
||||
|
||||
重复检测是客源管理的核心数据质量保障。采用**异步检测 + 前端提示**策略,不阻塞录入流程。
|
||||
|
||||
**检测触发时机**:
|
||||
1. 录入时:提交表单后,后端异步触发检测,结果以橙色提示横幅展示
|
||||
2. 编辑手机号时:号码变更保存后重新触发
|
||||
3. 批量导入后:全量扫描,生成重复报告
|
||||
|
||||
**去重匹配规则(按优先级)**:
|
||||
|
||||
| 优先级 | 匹配维度 | 匹配逻辑 | 说明 |
|
||||
|--------|---------|---------|------|
|
||||
| P0 | 手机号精确匹配 | `phone_normalized = normalize(input_phone)` | 去除空格、+86、国际区号后精确比对 |
|
||||
| P1 | 手机号模糊匹配 | 去除格式差异后匹配 | 处理 `135****8888` 与 `13500008888` 等录入差异 |
|
||||
| P2 | 姓名 + 意向商圈组合 | 同名 + 相同意向区域 | 辅助人工判断,不自动合并 |
|
||||
|
||||
**去重处理规则**:
|
||||
- **私客与私客重复**:提示「该手机号已存在于 [员工姓名] 名下私客,请确认是否为同一客户」,由操作人决定是否继续录入或转为该员工协作方
|
||||
- **私客与公客重复**:提示「该手机号已在公客池中,请确认业务归属后操作」
|
||||
- **合并客户**:合并以**首录时间更早**的记录为主数据,跟进记录、带看记录全部合并至主记录,被合并记录保留为「已合并」历史副本,不可恢复
|
||||
|
||||
**数据模型**:
|
||||
```python
|
||||
class ClientDuplicateLog(TenantModel):
|
||||
source_client = ForeignKey(Client, related_name='duplicate_source')
|
||||
target_client = ForeignKey(Client, related_name='duplicate_target')
|
||||
match_type = CharField(choices=['phone_exact', 'phone_fuzzy', 'name_area'])
|
||||
detected_at = DateTimeField(auto_now_add=True)
|
||||
resolved_by = ForeignKey(User, null=True)
|
||||
resolution = CharField(choices=['merged', 'ignored', 'pending'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6.0.3 私客掉公(自动转公)机制
|
||||
|
||||
掉公规则由运营配置,初期建议默认值如下(待业务确认后写入系统配置模块):
|
||||
|
||||
| 触发条件 | 默认值 | 是否可配置 |
|
||||
|---------|--------|----------|
|
||||
| 私客无跟进天数 | 30 天 | 是(系统配置模块) |
|
||||
| 触发检测频率 | 每日凌晨 02:00 Celery 定时任务 | 否 |
|
||||
| 提前预警天数 | 到期前 3 天,系统消息通知归属人 | 是 |
|
||||
|
||||
**掉公流程**:
|
||||
```
|
||||
Celery Beat(每日 02:00)
|
||||
└─► 扫描:last_follow_up_at < NOW() - 掉公天数 AND status='private'
|
||||
└─► 批量更新状态为 'public'
|
||||
└─► 写入 ClientStatusChangeLog(operator='system')
|
||||
└─► 触发消息推送:「您的 N 个客源已自动转入公客池」
|
||||
```
|
||||
|
||||
**边界情况**:
|
||||
- 暂缓状态的私客**不参与**掉公计算
|
||||
- 成交客、无效客**不参与**掉公计算
|
||||
- 掉公后归属人变更为 `NULL`,首录人保留
|
||||
|
||||
---
|
||||
|
||||
#### 6.0.4 敏感号码权限与留痕
|
||||
|
||||
手机号码为核心敏感数据,访问须满足以下要求:
|
||||
|
||||
| 场景 | 展示方式 | 查看权限 | 留痕要求 |
|
||||
|------|---------|---------|---------|
|
||||
| 列表页 | `+86 135****8888` 打码 | 无需额外权限 | 不留痕 |
|
||||
| 详情页联系人面板 | `+86 135****8888` 打码 | 无需额外权限 | 不留痕 |
|
||||
| 点击「查看号码」 | 展示完整号码 10 秒后重新打码 | 需「查看客源完整号码」权限 | **必须留痕**:写入操作日志 |
|
||||
| 编辑联系人 | 打码显示 + 「查看号码」链接 | 查看需权限,编辑需「编辑客源联系人」权限 | 查看留痕 |
|
||||
|
||||
**实现要求**:
|
||||
- 号码存储使用字段级加密(AES-256),与房源模块同等安全标准
|
||||
- 查看完整号码的 API 须独立端点(不含在标准详情接口内),方便审计
|
||||
- 操作日志异步写入,不影响查看响应时间
|
||||
|
||||
---
|
||||
|
||||
#### 6.0.5 列表性能策略
|
||||
|
||||
当前截图显示公客池已有 28,878 条,私客总量持续增长,列表性能须在设计阶段预防:
|
||||
|
||||
| 策略 | 说明 |
|
||||
|------|------|
|
||||
| 服务端分页 | 所有列表接口默认 20 条/页,禁止前端全量加载 |
|
||||
| 筛选条件下推 | 所有筛选条件传至 Django ORM,生成 SQL WHERE 子句,不做内存过滤 |
|
||||
| 索引设计 | 联合索引:`(tenant_id, status,归属人, 最后跟进时间)`;手机号哈希索引(辅助去重查询) |
|
||||
| 慢查询监控 | 超过 500ms 的查询写入慢日志,上线前需通过 EXPLAIN ANALYZE 验证核心列表查询 |
|
||||
| 智能配房批量计算 | 不在请求链路内实时计算,通过 Celery 定时或客源更新时异步触发,结果缓存至 Redis |
|
||||
|
||||
---
|
||||
|
||||
### 6.1 依赖关系
|
||||
|
||||
| 系统/模块 | 依赖原因 | 优先级风险 |
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
## 3. 非目标(本期不做)
|
||||
|
||||
- 不包含细化到行级(Row-level)的数据权限(如仅允许查看特定小区的房源),本期数据范围控制以「本人 / 本部门 / 全公司」三档为主
|
||||
- 不包含细化到行级(Row-level)的数据权限(如仅允许查看特定小区的房源);本期数据范围控制采用**五档模型**:本人 / 本组 / 本门店 / 本区域 / 全公司。注意:并非所有权限项均开放五档,各项的实际可选范围以权限编辑页的下拉配置为准(例如某些权限项仅提供「本人 / 本门店 / 全公司」三个选项)
|
||||
- 不包含权限申请审批工作流(员工自助申请权限需上级审批),本期由管理员直接操作
|
||||
- 不包含操作日志的可视化看板(日志写入,查询能力在后续版本规划)
|
||||
- 不包含 IP 白名单、登录时段等安全策略配置(安全策略模块另行规划)
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
# PRD:系统管理模块(Admin & System Management)
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: Alex (Product Manager)
|
||||
**Last Updated**: 2026-04-24
|
||||
**Version**: 1.0
|
||||
**Stakeholders**: 工程负责人、运营团队、安全合规、客户成功团队
|
||||
|
||||
---
|
||||
|
||||
## 0. 模块定位与背景
|
||||
|
||||
Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-tenants` 实现 PostgreSQL Schema 级别的多租户隔离。随着平台商业化推进,运营团队需要一套独立的管理后台(Admin Console)来管理租户生命周期、系统升级、备份恢复及合规审计。
|
||||
|
||||
**核心问题**:平台运营团队当前缺乏统一的工具来:
|
||||
|
||||
1. 管理数百家经纪公司(租户)的开通、暂停、注销流程
|
||||
2. 在不中断服务的前提下对平台进行版本升级与灰度发布
|
||||
3. 应对数据灾难场景(数据误删、升级失败)时快速恢复
|
||||
4. 满足合规要求,对所有高危操作留存完整审计轨迹
|
||||
|
||||
**本模块不解决**:
|
||||
|
||||
- 租户内部的业务功能(房源、客源、楼盘管理)——已在各自 PRD 中覆盖
|
||||
- 移动端管理能力——v2 规划
|
||||
- 财务收费与发票系统——独立财务模块
|
||||
- 自动化客服与工单系统——独立支持模块
|
||||
**状态**:Draft
|
||||
**作者**:产品经理
|
||||
**最后更新**:2026-04-24
|
||||
**版本**:v1.0
|
||||
**利益相关方**:工程负责人、运营团队、安全合规、客户成功团队
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题陈述
|
||||
|
||||
### 1.1 核心痛点
|
||||
### 1.1 背景
|
||||
|
||||
Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-tenants` 实现 PostgreSQL Schema 级别的多租户隔离。随着平台商业化推进,运营团队需要一套独立的管理后台(Admin Console)来管理租户生命周期、系统升级、备份恢复及合规审计。
|
||||
|
||||
**本模块解决的核心问题**:平台运营团队当前缺乏统一的工具来:
|
||||
1. 管理数百家经纪公司(租户)的开通、暂停、注销流程
|
||||
2. 在不中断服务的前提下对平台进行版本升级与灰度发布
|
||||
3. 应对数据灾难场景(数据误删、升级失败)时快速恢复
|
||||
4. 满足合规要求,对所有高危操作留存完整审计轨迹
|
||||
|
||||
### 1.2 核心痛点
|
||||
|
||||
| 痛点 | 影响方 | 当前代价 |
|
||||
|------|--------|---------|
|
||||
@@ -39,7 +29,7 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
|
||||
| 数据备份无策略,灾难恢复依赖人工 | 平台稳定性 | 数据丢失风险 |
|
||||
| 高危操作无审计日志,合规风险暴露 | 管理层/合规 | 法律与客户信任风险 |
|
||||
|
||||
### 1.2 目标用户
|
||||
### 1.3 目标用户
|
||||
|
||||
| 角色 | 使用场景 | 频率 |
|
||||
| --------------------------- | ----------- | ------ |
|
||||
|
||||
@@ -465,7 +465,127 @@
|
||||
|
||||
---
|
||||
|
||||
## 6. 上线计划
|
||||
## 6. 技术考量
|
||||
|
||||
### 6.1 依赖关系
|
||||
|
||||
| 系统/模块 | 依赖原因 | 优先级风险 |
|
||||
|-----------|---------|-----------|
|
||||
| 登录管理模块 | 员工账号的创建、冻结、密码重置由登录模块提供接口 | 高 |
|
||||
| 权限管理模块 | 员工的角色/权限分配在人事模块发起,权限模块执行 | 高 |
|
||||
| 系统配置模块 | 职务类别、部门级别、奖惩类型等枚举值从系统配置读取 | 中 |
|
||||
|
||||
### 6.2 核心技术设计
|
||||
|
||||
#### 6.2.1 组织树存储方案
|
||||
|
||||
采用 **Closure Table(闭包表)** 存储多层级部门树,最大支持 8 层嵌套:
|
||||
|
||||
| 方案 | 优点 | 缺点 | 选用理由 |
|
||||
|------|------|------|---------|
|
||||
| Adjacency List | 写入简单 | 递归查询慢,需多次 SQL | ❌ 不适合 8 层嵌套 |
|
||||
| Nested Set | 读取极快 | 写入时需重排序,高并发写冲突大 | ❌ 人事模块写入频繁 |
|
||||
| **Closure Table** | 读写均衡,查询任意层级 O(1) | 存储量略多 | ✅ **选用** |
|
||||
|
||||
**数据结构**:
|
||||
```python
|
||||
class OrgUnit(TenantModel): # 部门节点
|
||||
id = UUIDField(primary_key=True)
|
||||
name = CharField(max_length=100)
|
||||
level = CharField(choices=['事业部','大区','区域','片区','门店','店组','职能'])
|
||||
parent = ForeignKey('self', null=True, on_delete=PROTECT) # 直接父节点
|
||||
is_direct = BooleanField(default=True) # True=直营, False=加盟
|
||||
deleted_at = DateTimeField(null=True)
|
||||
|
||||
class OrgUnitClosure(TenantModel): # 闭包表
|
||||
ancestor = ForeignKey(OrgUnit, related_name='descendants')
|
||||
descendant = ForeignKey(OrgUnit, related_name='ancestors')
|
||||
depth = IntegerField() # 0=自身, 1=子, 2=孙...
|
||||
```
|
||||
|
||||
**查询示例**:
|
||||
- 查某节点所有子孙:`WHERE ancestor_id = X AND depth > 0`
|
||||
- 查某节点完整路径:`WHERE descendant_id = X ORDER BY depth DESC`
|
||||
- 移动节点:删除旧闭包记录 + 插入新闭包记录(事务内执行)
|
||||
|
||||
#### 6.2.2 员工状态机
|
||||
|
||||
员工在系统内的状态须通过后端状态机严格控制:
|
||||
|
||||
```
|
||||
[录入] ──► 试用期 ──► 正式 ──► 离职
|
||||
│ │
|
||||
└──────────────┘
|
||||
复职
|
||||
│
|
||||
▼
|
||||
试用期 / 正式(恢复上次状态)
|
||||
```
|
||||
|
||||
| 状态 | 允许流转目标 | 触发方式 |
|
||||
|------|------------|---------|
|
||||
| 试用期 | 正式、离职 | 管理员手动操作 |
|
||||
| 正式 | 离职 | 管理员手动操作 |
|
||||
| 离职 | 复职 | 管理员手动操作 |
|
||||
| 冻结 | 启用(需先恢复员工状态) | 账号冻结操作 |
|
||||
|
||||
**实现要求**:
|
||||
- 每次状态变更写入 `StaffChangeLog`(异动记录表),字段包含:变更人、变更类型、变更前后值、变更时间、备注
|
||||
- 员工离职时,系统自动:① 冻结关联登录账号 ② 解除权限角色 ③ 归属客源触发掉公检查
|
||||
- 员工状态变更失败返回明确错误码,前端展示友好提示
|
||||
|
||||
#### 6.2.3 异动记录审计策略
|
||||
|
||||
异动记录为**只追加(append-only)**设计,不支持修改或删除:
|
||||
|
||||
| 异动类型枚举 | 触发场景 |
|
||||
|------------|---------|
|
||||
| 入职 | 员工首次录入系统 |
|
||||
| 转正 | 试用期转为正式员工 |
|
||||
| 调岗 | 员工所属部门变更 |
|
||||
| 上级变动 | 直接上级变更 |
|
||||
| 离职 | 办理离职手续 |
|
||||
| 复职 | 离职员工重新入职 |
|
||||
| 账号冻结 | 账号被冻结 |
|
||||
| 账号启用 | 账号从冻结恢复 |
|
||||
|
||||
```python
|
||||
class StaffChangeLog(TenantModel):
|
||||
staff = ForeignKey(Staff)
|
||||
change_type = CharField(choices=[...]) # 见枚举表
|
||||
before_value = JSONField(null=True) # 变更前状态快照
|
||||
after_value = JSONField(null=True) # 变更后状态快照
|
||||
operator = ForeignKey(User)
|
||||
remark = TextField(blank=True)
|
||||
created_at = DateTimeField(auto_now_add=True)
|
||||
# 无 updated_at / deleted_at — append-only
|
||||
```
|
||||
|
||||
#### 6.2.4 敏感数据脱敏
|
||||
|
||||
| 数据 | 存储方式 | 展示方式 | 查看权限 |
|
||||
|------|---------|---------|---------|
|
||||
| 手机号 | AES-256-GCM 加密 + SHA-256 哈希索引 | `159****9696` | 需「查看员工手机号」权限,留操作日志 |
|
||||
| 证件号码 | AES-256-GCM 加密 | `410***********3037` | 需「查看员工证件」权限,留操作日志 |
|
||||
| 通讯录号码 | AES-256-GCM 加密 | 脱敏显示 | 点击「查看号码」后临时展示,留痕 |
|
||||
|
||||
### 6.3 已知风险
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|--------|------|---------|
|
||||
| 大型机构部门树深度超过 8 层 | 低 | 中 | 前端录入时限制最大深度,超出提示联系运营调整 |
|
||||
| 员工大批量导入时账号创建并发冲突 | 中 | 中 | 批量导入走 Celery 异步任务,序列化账号创建 |
|
||||
| 组织调整时历史客源/房源归属关系错乱 | 中 | 高 | 员工调岗不改变现有资源归属,仅更新组织关系;业务重新分配由管理员手动操作 |
|
||||
|
||||
### 6.4 待确认问题
|
||||
|
||||
- [ ] 员工工号规则:系统自动生成还是管理员手动填写?格式约定是什么? — 待业务确认 — 开发前必须明确
|
||||
- [ ] 「复职」时是否复用原有账号?还是重新创建新账号? — 待产品/法务确认
|
||||
- [ ] 部门移动操作(将某节点及其子树挂到新父节点)是否在 MVP 范围内? — 待产品确认
|
||||
|
||||
---
|
||||
|
||||
## 7. 上线计划
|
||||
|
||||
| 阶段 | 时间 | 受众 | 验收门槛 |
|
||||
|------|------|------|----------|
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
# Fonrey API 契约规范(API_CONTRACT)
|
||||
|
||||
**版本**: 1.0
|
||||
**版本**: 1.1
|
||||
**适用范围**: 全模块(account / permission / property / client / complex / org / setting)
|
||||
**关联总纲**: `TECH_STACK/TECH_STACK.md`
|
||||
**最后更新**: 2026-04-27
|
||||
**最后更新**: 2026-04-28
|
||||
|
||||
---
|
||||
|
||||
@@ -285,3 +285,183 @@ Fonrey 优先采用“预签名上传 + 回执提交(commit)”两段式。
|
||||
- [ ] 下载是否采用 job 流程并处理未就绪 409
|
||||
- [ ] 权限拒绝是否遵循 200/403/302(401) 三态
|
||||
- [ ] 测试是否覆盖契约关键路径
|
||||
- [ ] 所有视图是否附加 `@extend_schema`(或 `@extend_schema_view`)注解
|
||||
- [ ] 枚举字段是否通过 `OpenApiTypes` 或 `ChoiceField` 在 Schema 中完整暴露所有值
|
||||
- [ ] 生成的 `openapi.json` 是否已提交 / 与代码同步更新
|
||||
- [ ] `schemathesis` 契约测试是否纳入 CI(至少覆盖 Positive 用例)
|
||||
|
||||
---
|
||||
|
||||
## 11. OpenAPI 落地规范(机器可读契约)
|
||||
|
||||
> **For AI assistants**: §11 是实现层强约定。生成视图代码时必须同步写 @extend_schema;生成测试代码时必须包含契约断言。
|
||||
|
||||
### 11.1 工具链(MUST)
|
||||
|
||||
| 角色 | 工具 | 说明 |
|
||||
|---|---|---|
|
||||
| Schema 生成 | `drf-spectacular` | 唯一授权的 OpenAPI 生成库;禁止 drf-yasg |
|
||||
| Schema 文件 | `openapi.json`(根目录) | 每次 CI 必须重新生成并 diff 检查 |
|
||||
| 契约测试 | `schemathesis` | 基于生成 Schema 做 Positive + Negative 测试 |
|
||||
| 文档 UI | Swagger UI(`/api/docs/`) | 开发环境默认开启,生产环境按需 |
|
||||
|
||||
安装:
|
||||
```bash
|
||||
pip install drf-spectacular schemathesis
|
||||
```
|
||||
|
||||
`settings.py` 最低配置:
|
||||
```python
|
||||
INSTALLED_APPS += ["drf_spectacular"]
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Fonrey API",
|
||||
"VERSION": "1.1.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"SCHEMA_PATH_PREFIX": r"/api/",
|
||||
# 枚举值直接展开(不折叠成 $ref),便于 AI agent 直接读值
|
||||
"ENUM_GENERATE_CHOICE_DESCRIPTION": True,
|
||||
"COMPONENT_SPLIT_REQUEST": True,
|
||||
}
|
||||
```
|
||||
|
||||
`urls.py`:
|
||||
```python
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
|
||||
urlpatterns += [
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
||||
]
|
||||
```
|
||||
|
||||
### 11.2 视图注解规范(MUST)
|
||||
|
||||
每个 `APIView` / `ViewSet` 动作 MUST 携带 `@extend_schema`,最低包含:
|
||||
|
||||
```python
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
@extend_schema(
|
||||
summary="获取房源详情", # 简短操作名(中文 OK)
|
||||
tags=["property"], # 模块 tag,与路由前缀一致
|
||||
responses={200: PropertyDetailSerializer},
|
||||
# 失败响应也要声明,给 AI agent 提供完整错误路径
|
||||
responses={
|
||||
200: PropertyDetailSerializer,
|
||||
403: OpenApiTypes.OBJECT, # 统一 error envelope
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
)
|
||||
def retrieve(self, request, pk=None):
|
||||
...
|
||||
```
|
||||
|
||||
枚举字段 MUST 在 Serializer 中使用 `ChoiceField` 并指定 `choices`,`drf-spectacular` 会自动生成 `enum` 约束:
|
||||
|
||||
```python
|
||||
from ENUMS import PropertyType # 取自项目枚举常量
|
||||
|
||||
class PropertySerializer(serializers.Serializer):
|
||||
property_type = serializers.ChoiceField(
|
||||
choices=PropertyType.choices,
|
||||
help_text="房源类型(详见 DATA_MODEL/ENUMS.md § property.property_type)"
|
||||
)
|
||||
```
|
||||
|
||||
分页/筛选端点额外声明 `parameters`:
|
||||
|
||||
```python
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("cursor", OpenApiTypes.STR, description="Keyset 游标,首页传 null"),
|
||||
OpenApiParameter("limit", OpenApiTypes.INT, description="每页条数,最大 100"),
|
||||
OpenApiParameter("status", OpenApiTypes.STR, description="状态筛选,多选用逗号分隔"),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### 11.3 Schema 文件管理(MUST)
|
||||
|
||||
CI pipeline MUST 包含以下步骤,防止 Schema 与代码漂移:
|
||||
|
||||
```bash
|
||||
# 生成最新 Schema
|
||||
python manage.py spectacular --color --file openapi.json
|
||||
|
||||
# diff 检查(有变更时 CI 提醒,但不阻断 — 由开发者 review 后提交)
|
||||
git diff --exit-code openapi.json || echo "⚠️ openapi.json has changed — please review and commit"
|
||||
```
|
||||
|
||||
- `openapi.json` MUST 纳入版本控制(不加入 `.gitignore`)
|
||||
- 合并 PR 时若 `openapi.json` 有非预期变更,MUST 作为 Review 阻断项
|
||||
|
||||
### 11.4 契约测试规范(MUST)
|
||||
|
||||
使用 `schemathesis` 对每个模块做 Positive 路径覆盖,最低要求:
|
||||
|
||||
```bash
|
||||
# 对本地运行的 dev server 跑契约测试
|
||||
schemathesis run openapi.json \
|
||||
--base-url http://localhost:8000 \
|
||||
--auth-header "Authorization: Bearer $TEST_TOKEN" \
|
||||
--checks status_code_conformance response_schema_conformance \
|
||||
--tag property # 可按模块 tag 分批跑
|
||||
```
|
||||
|
||||
CI 集成示例(GitHub Actions):
|
||||
|
||||
```yaml
|
||||
- name: Contract Tests
|
||||
run: |
|
||||
python manage.py spectacular --file openapi.json
|
||||
schemathesis run openapi.json \
|
||||
--base-url http://localhost:8000 \
|
||||
--checks status_code_conformance response_schema_conformance \
|
||||
--exitfirst # 首个失败即停止
|
||||
```
|
||||
|
||||
**AI Agent 验收词(Acceptance Criteria)**:实现任意 API 端点后,须能通过以下验证:
|
||||
|
||||
```
|
||||
GIVEN openapi.json 已重新生成
|
||||
WHEN schemathesis 对该端点执行 Positive 测试
|
||||
THEN status_code_conformance PASS(响应码与 Schema 声明一致)
|
||||
AND response_schema_conformance PASS(响应体结构与 Serializer 一致)
|
||||
AND 所有枚举字段值落在 ENUMS.md 定义的合法值集合内
|
||||
```
|
||||
|
||||
### 11.5 枚举值契约一致性(MUST)
|
||||
|
||||
`ENUMS.md` 是枚举的单一事实来源(Source of Truth)。项目 MUST 维护一个 `enums.py`(或按模块拆分),与 `ENUMS.md` 保持同步:
|
||||
|
||||
```python
|
||||
# fonrey/core/enums.py — 机器可读枚举常量,与 ENUMS.md 严格对齐
|
||||
from django.db import models
|
||||
|
||||
class PropertyType(models.TextChoices):
|
||||
RESIDENTIAL = "residential", "住宅"
|
||||
COMMERCIAL = "commercial", "商业"
|
||||
OFFICE = "office", "办公"
|
||||
INDUSTRIAL = "industrial", "工业"
|
||||
LAND = "land", "土地"
|
||||
OTHER = "other", "其他"
|
||||
```
|
||||
|
||||
AI agent 实现时验证词:
|
||||
|
||||
```
|
||||
GIVEN enums.py 中某枚举类的所有 value
|
||||
WHEN 与 ENUMS.md 对应域的值列表对比
|
||||
THEN 两侧完全一致(无多余值,无缺失值,大小写相同)
|
||||
```
|
||||
|
||||
### 11.6 分阶段落地路线(参考)
|
||||
|
||||
| 阶段 | 目标 | 完成标志 |
|
||||
|---|---|---|
|
||||
| **P0 接入** | 安装 drf-spectacular,生成首版 `openapi.json`,Swagger UI 可访问 | `/api/docs/` 正常渲染,无 import 报错 |
|
||||
| **P1 注解补全** | 所有现有视图加 `@extend_schema`,枚举字段用 ChoiceField | `openapi.json` 无 `{}` 空 Schema;所有端点有 `summary` 和 `tags` |
|
||||
| **P2 契约测试** | schemathesis 纳入 CI,Positive 用例全绿 | CI status_code + response_schema 两项检查全 PASS |
|
||||
| **P3 持续守护** | openapi.json diff 纳入 PR Review;枚举值变更同步 enums.py | PR checklist 自动提醒 Schema 变更 |
|
||||
|
||||
@@ -24,35 +24,33 @@
|
||||
5. 我按反馈修改静态页 + 回写 UI.md
|
||||
6. 该任务标记为 `已完成`
|
||||
|
||||
> 设计基线:所有新页面默认包含 **Light / Dark / System** 主题切换方案与状态说明。
|
||||
|
||||
---
|
||||
|
||||
## 3) P0 缺口任务(按优先级执行)
|
||||
|
||||
| 序号 | 优先级 | 模块 | 覆盖 US | UI.md 目标文件 | HTML 目标文件 | 当前状态 | 下一步 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 01 | P0-A | 登录管理 | US-ACCOUNT-001~003 | `UI_DESIGN/登录管理/登录_UI.md` | `UI_DESIGN/登录_UI.html` | 待评审 | 你评审登录 UI.md + 静态页,给我反馈我再迭代 |
|
||||
| 02 | P0-A | 房源管理(新增) | US-PROPERTY-001 | `UI_DESIGN/房源管理/新增房源_UI.md` | `UI_DESIGN/新增房源_UI.html` | 待设计 | 完成任务01后开始 |
|
||||
| 03 | P0-A | 房源管理(详情) | US-PROPERTY-003~008 | `UI_DESIGN/房源管理/房源详情_UI.md` | `UI_DESIGN/房源详情_UI.html` | 待设计 | 完成任务02后开始 |
|
||||
| 04 | P0-B | 楼盘管理(列表) | US-COMPLEX-002 | `UI_DESIGN/楼盘管理/楼盘列表_UI.md` | `UI_DESIGN/楼盘列表_UI.html` | 待设计 | 完成任务03后开始 |
|
||||
| 05 | P0-B | 楼盘管理(详情/维护) | US-COMPLEX-001 | `UI_DESIGN/楼盘管理/楼盘详情_UI.md` | `UI_DESIGN/楼盘详情_UI.html` | 待设计 | 完成任务04后开始 |
|
||||
| 06 | P0-B | 楼盘管理(区域) | US-COMPLEX-003 | `UI_DESIGN/楼盘管理/区域管理_UI.md` | `UI_DESIGN/区域管理_UI.html` | 待设计 | 完成任务05后开始 |
|
||||
| 07 | P0-C | 组织人事 | US-ORG-001~003 | `UI_DESIGN/组织人事管理/组织人事_UI.md` | `UI_DESIGN/组织人事_UI.html` | 待设计 | 完成任务06后开始 |
|
||||
| 08 | P0-C | 权限管理 | US-PERMISSION-001~005 | `UI_DESIGN/权限管理/权限管理_UI.md` | `UI_DESIGN/权限管理_UI.html` | 待设计 | 完成任务07后开始 |
|
||||
| 09 | P0-C | 系统配置 | US-SETTING-001-A/B/C | `UI_DESIGN/系统配置/系统配置_UI.md` | `UI_DESIGN/系统配置_UI.html` | 待设计 | 完成任务08后开始 |
|
||||
| 序号 | 优先级 | 模块 | 覆盖 US | UI.md 目标文件 | HTML 目标文件 | 当前状态 | 下一步 |
|
||||
| --- | ---- | ----------- | --------------------- | ----------------------------- | ------------------------ | ---- | --------------------------- |
|
||||
| 01 | P0-A | 登录管理 | US-ACCOUNT-001~003 | `UI_DESIGN/登录管理/登录_UI.md` | `UI_DESIGN/登录_UI.html` | 待评审 | 你评审登录 UI.md + 静态页,给我反馈我再迭代 |
|
||||
| 02 | P0-A | 房源管理(新增) | US-PROPERTY-001 | `UI_DESIGN/房源管理/新增房源_UI.md` | `UI_DESIGN/新增房源_UI.html` | 已完成 | 已完成评审迭代(壳层/按钮/结构一致化),进入任务03 |
|
||||
| 03 | P0-A | 房源管理(详情) | US-PROPERTY-003~008 | `UI_DESIGN/房源管理/房源详情_UI.md` | `UI_DESIGN/房源详情_UI.html` | 待评审 | 你评审房源详情 UI.md + 静态页,给我反馈我再迭代 |
|
||||
| 04 | P0-B | 楼盘管理(列表) | US-COMPLEX-002 | `UI_DESIGN/楼盘管理/楼盘列表_UI.md` | `UI_DESIGN/楼盘列表_UI.html` | 待评审 | 你评审楼盘列表 UI.md + 静态页,给我反馈我再迭代 |
|
||||
| 05 | P0-B | 楼盘管理(详情/维护) | US-COMPLEX-001 | `UI_DESIGN/楼盘管理/楼盘详情_UI.md` | `UI_DESIGN/楼盘详情_UI.html` | 待设计 | 完成任务04后开始 |
|
||||
| 06 | P0-B | 楼盘管理(区域) | US-COMPLEX-003 | `UI_DESIGN/楼盘管理/区域管理_UI.md` | `UI_DESIGN/区域管理_UI.html` | 待设计 | 完成任务05后开始 |
|
||||
| 07 | P0-C | 组织人事 | US-ORG-001~003 | `UI_DESIGN/组织人事管理/组织人事_UI.md` | `UI_DESIGN/组织人事_UI.html` | 待设计 | 完成任务06后开始 |
|
||||
| 08 | P0-C | 权限管理 | US-PERMISSION-001~005 | `UI_DESIGN/权限管理/权限管理_UI.md` | `UI_DESIGN/权限管理_UI.html` | 待设计 | 完成任务07后开始 |
|
||||
| 09 | P0-C | 系统配置 | US-SETTING-001-A/B/C | `UI_DESIGN/系统配置/系统配置_UI.md` | `UI_DESIGN/系统配置_UI.html` | 待设计 | 完成任务08后开始 |
|
||||
|
||||
---
|
||||
|
||||
## 4) 已有 UI(保留并在后续做回归校对)
|
||||
|
||||
| 模块 | 现有 UI.md | 当前状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 客源管理 | `UI_DESIGN/客源管理/新增客源_UI.md` | 已有 | 后续按你反馈可增量调整 |
|
||||
| 客源管理 | `UI_DESIGN/客源管理/客源列表_UI.md` | 已有 | 后续按你反馈可增量调整 |
|
||||
| 客源管理 | `UI_DESIGN/客源管理/客源详情_UI.md` | 已有 | 后续按你反馈可增量调整 |
|
||||
| 客源管理 | `UI_DESIGN/客源管理/编辑客源_UI.md` | 已有 | `US-CLIENT-014` 的新房/租房 Tab 边界待确认 |
|
||||
| 房源管理 | `UI_DESIGN/房源管理/房源列表_UI.md` | 已有 | 后续按你反馈可增量调整 |
|
||||
| 模块 | 现有 UI.md | 当前状态 | 说明 |
|
||||
| ---- | --------------------------- | ---- | -------------------------------- |
|
||||
| 客源管理 | `UI_DESIGN/客源管理/新增客源_UI.md` | 已有 | 后续按你反馈可增量调整 |
|
||||
| 客源管理 | `UI_DESIGN/客源管理/客源列表_UI.md` | 已有 | 后续按你反馈可增量调整 |
|
||||
| 客源管理 | `UI_DESIGN/客源管理/客源详情_UI.md` | 已有 | 后续按你反馈可增量调整 |
|
||||
| 客源管理 | `UI_DESIGN/客源管理/编辑客源_UI.md` | 已有 | `US-CLIENT-014` 的新房/租房 Tab 边界待确认 |
|
||||
| 房源管理 | `UI_DESIGN/房源管理/房源列表_UI.md` | 已有 | 后续按你反馈可增量调整 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
286
Project/fonrey/UI_DESIGN/房源管理/房源详情_UI.md
Normal file
286
Project/fonrey/UI_DESIGN/房源管理/房源详情_UI.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# 房源详情 UI 设计文档
|
||||
|
||||
> **版本**:v1.0 · **日期**:2026-04-28
|
||||
> **依赖规范**:`UI_SYSTEM/UI_SYSTEM.md v1.2`、`UI_SYSTEM/UI_SYSTEM文档要求.md`
|
||||
> **PRD 来源**:`PRD/房源管理/房源管理模块PRD.md` §5.2(房源详情)+ Story 3~8
|
||||
> **TASK 来源**:`PRD/TASK.md` · `US-PROPERTY-003 ~ US-PROPERTY-008`
|
||||
> **数据模型来源**:`DATA_MODEL/DATA_MODEL_PROPERTY.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块概述
|
||||
|
||||
### 1.1 页面目标
|
||||
|
||||
本任务对应任务03(P0-A),目标是交付可评审的房源详情主页面原型,覆盖以下关键能力:
|
||||
|
||||
- 完整展示房源详情信息,按分区连续展示并通过二级锚点导航定位(US-PROPERTY-003)
|
||||
- 在详情页内支持写入/筛选跟进日志(US-PROPERTY-004)
|
||||
- 支持房源状态变更(改状态)弹层入口(US-PROPERTY-008)
|
||||
- 支持写跟进弹层入口(US-PROPERTY-004)
|
||||
- 提供图片与附件管理入口结构(US-PROPERTY-005)
|
||||
- 提供业主联系人与相关员工信息区(US-PROPERTY-006)
|
||||
- 提供调价/调级/改属性等快捷编辑入口位(US-PROPERTY-007)
|
||||
|
||||
### 1.2 页面清单
|
||||
|
||||
| 页面 | 文件 | 优先级 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 房源详情页 | `UI_DESIGN/房源详情_UI.html` | P0 🔴 | 主详情壳层 + 二级锚点导航 + 跟进子Tab + 右侧信息面板 + 关键弹层 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 信息架构与页面骨架
|
||||
|
||||
### 2.1 壳层结构(与新增客源页对齐)
|
||||
|
||||
```
|
||||
Top Bar(固定 56px,bg-primary-800)
|
||||
├─ 左:品牌区(Fonrey)
|
||||
├─ 中:主导航(工作台/房源/客源/营销/交易/数据/人事/系统)
|
||||
└─ 右:消息 + 用户
|
||||
|
||||
Sidebar(固定左侧 240px,位于 top-14 下)
|
||||
└─ 房源管理导航(全部房源 / 我的房源 / 公盘池 / 成交房源 / 已删房源)
|
||||
|
||||
Main Content(ml-60 + pt-[72px])
|
||||
├─ 面包屑 + 标题区 + 顶部操作区
|
||||
├─ 二级锚点导航(8个分区)
|
||||
└─ 双栏布局
|
||||
├─ 左侧:分区主内容(连续展示)
|
||||
└─ 右侧:固定信息面板(业主联系人 / 维护完成度 / 房源动态)
|
||||
```
|
||||
|
||||
### 2.2 二级锚点分区定义(PRD §5.2 对齐)
|
||||
|
||||
1. 核心信息
|
||||
2. 跟进日志
|
||||
3. 业务信息
|
||||
4. 房源信息
|
||||
5. 营销信息
|
||||
6. 相关员工
|
||||
7. 相册信息
|
||||
8. 附件信息
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心区块设计
|
||||
|
||||
### 3.1 顶部标题操作区
|
||||
|
||||
- 标题:`交易标签 + 房源名称`(例:出售 · 都市港湾055-0301)
|
||||
- 快捷操作:
|
||||
- `改状态`(弹层)
|
||||
- `编辑房源`(入口)
|
||||
- `分享`(入口)
|
||||
- `写跟进`(弹层)
|
||||
- `更多`(下拉入口位)
|
||||
|
||||
### 3.2 核心信息分区
|
||||
|
||||
由三层信息组成:
|
||||
|
||||
1. **价格与图片层**:主图、总价、单价、近30天变化、调价入口
|
||||
2. **房源概要层**:户型、面积、楼层、电梯、装修、朝向
|
||||
3. **详情字段层**:基础信息 + 交易信息(键值对)
|
||||
|
||||
快捷编辑入口位(文本链):
|
||||
|
||||
- 调价
|
||||
- 改等级
|
||||
- 改属性
|
||||
- 改用途
|
||||
- 看房时间
|
||||
- 挂牌历史
|
||||
|
||||
### 3.3 跟进日志分区
|
||||
|
||||
#### 3.3.1 子Tab结构
|
||||
|
||||
- 全部
|
||||
- 写入跟进
|
||||
- 敏感信息跟进
|
||||
- 敏感信息查看
|
||||
- 修改跟进
|
||||
- 其他跟进
|
||||
|
||||
#### 3.3.2 筛选区
|
||||
|
||||
- 日期范围(开始/结束)
|
||||
- 复选项:有录音 / 有附件 / 仅看我的
|
||||
- 关键字输入(记录内容检索)
|
||||
|
||||
#### 3.3.3 时间线
|
||||
|
||||
每条记录包含:
|
||||
|
||||
- 类型标签(颜色区分)
|
||||
- 内容摘要
|
||||
- 操作人 + 门店组别
|
||||
- 时间戳
|
||||
- 公开/隐藏状态位
|
||||
|
||||
### 3.4 业务信息分区
|
||||
|
||||
卡片化展示三个模块:
|
||||
|
||||
- 钥匙管理(新增钥匙 / 钥匙在他司)
|
||||
- 委托管理(新增委托 / 查看全部)
|
||||
- 实勘管理(新增实勘)
|
||||
|
||||
### 3.5 房源信息分区
|
||||
|
||||
四块可编辑信息:
|
||||
|
||||
- 基本信息
|
||||
- 产证信息
|
||||
- 房屋介绍
|
||||
- 楼盘信息
|
||||
|
||||
每块右上角保留 `编辑` 入口。
|
||||
|
||||
### 3.6 相关员工分区
|
||||
|
||||
以列表卡片展示角色与归属:
|
||||
|
||||
- 首录方
|
||||
- 号码方
|
||||
- 出售方
|
||||
- 实买方
|
||||
|
||||
### 3.7 相册信息分区
|
||||
|
||||
- 分类 Tab(全部/封面/客厅/卧室/卫生间/厨房…)
|
||||
- 操作行(上传图片/批量改类/批量删除/批量下载)
|
||||
- 图片网格(封面标记 + 分类 + 时间)
|
||||
|
||||
### 3.8 附件信息分区
|
||||
|
||||
- 分类 Tab(全部/身份证/房产证/委托书/其他)
|
||||
- 操作行(上传附件/批量改类/批量下载)
|
||||
- 空状态(暂无附件)
|
||||
|
||||
---
|
||||
|
||||
## 4. 右侧固定信息面板
|
||||
|
||||
> 布局:`w-80`,`sticky top-16`
|
||||
|
||||
### 4.1 业主联系人
|
||||
|
||||
字段与交互:
|
||||
|
||||
- 姓名 + 身份
|
||||
- 电话(默认打码)
|
||||
- `查看号码`(示例交互)
|
||||
- `新增联系人`(入口位)
|
||||
- `更多`(入口位)
|
||||
|
||||
### 4.2 房源维护完成度
|
||||
|
||||
- 总分(0~100)
|
||||
- 分项进度(重点信息/附件/实勘/VR/钥匙/委托)
|
||||
- 视觉:进度条 + 百分比
|
||||
|
||||
### 4.3 房源动态(近30天)
|
||||
|
||||
指标卡:
|
||||
|
||||
- 带看次数
|
||||
- 复看次数
|
||||
- 面访次数
|
||||
- 收藏人数
|
||||
- 分享次数
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键弹层与交互
|
||||
|
||||
### 5.1 改状态弹层(US-PROPERTY-008)
|
||||
|
||||
字段:
|
||||
|
||||
- 原状态(只读)
|
||||
- 原交易类型(只读)
|
||||
- 新状态(必填)
|
||||
- 更改理由(必填,≤50字)
|
||||
|
||||
交互规则:
|
||||
|
||||
- 未选新状态或理由为空不可提交
|
||||
- 提交成功后提示“状态已更新(原型模拟)”
|
||||
|
||||
### 5.2 写跟进弹层(US-PROPERTY-004)
|
||||
|
||||
字段:
|
||||
|
||||
- 跟进目的(必填)
|
||||
- 跟进内容(必填,6~500字)
|
||||
- 附件上传入口(原型位)
|
||||
|
||||
交互规则:
|
||||
|
||||
- 内容不足 6 字时给出错误提示
|
||||
- 提交后关闭弹层并提示“跟进已写入(原型模拟)”
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据模型映射(关键字段)
|
||||
|
||||
| UI字段 | 数据模型字段 | 备注 |
|
||||
|---|---|---|
|
||||
| 房源状态 | `properties.status` | 支持状态流转 |
|
||||
| 房源属性 | `properties.attribute` | 公盘/私盘 |
|
||||
| 售价 | `properties.sale_price` | 调价入口 |
|
||||
| 租价 | `properties.rent_price` | 状态联动显示 |
|
||||
| 单价 | `properties.sale_unit_price`(计算) | 展示字段 |
|
||||
| 户型(室厅卫厨阳) | `bedroom_count/living_room_count/bathroom_count/kitchen_count/balcony_count` | 概要展示 |
|
||||
| 建筑面积 | `properties.area` | m² |
|
||||
| 房本年限 | `properties.ownership_years` | 交易信息 |
|
||||
| 等级 | `properties.grade` | 改等级入口 |
|
||||
| 看房时间 | `properties.viewing_time` | 改看房时间入口 |
|
||||
| 最后跟进时间 | `properties.last_followed_at` | 动态区可关联 |
|
||||
| 联系人姓名/身份 | `property_contacts.name/identity` | 右侧面板 |
|
||||
| 联系人号码 | `property_contacts.phone_enc/phone_hash` | 默认打码展示 |
|
||||
| 跟进记录 | `follow_logs` | 跟进子Tab来源 |
|
||||
| 跟进附件 | `follow_log_attachments` | 有附件筛选 |
|
||||
| 调价记录 | `price_changes` | 历史追踪 |
|
||||
| 相册 | `property_photos` | 分类、封面、排序 |
|
||||
| 附件 | `property_attachments` | 分类、批量下载 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 状态矩阵
|
||||
|
||||
| 状态 | 触发 | UI反馈 |
|
||||
|---|---|---|
|
||||
| 默认 | 首次进入 | 页面滚动到“核心信息”分区,右侧面板可见,锚点高亮同步 |
|
||||
| 分区导航点击 | 点击二级锚点 | 平滑滚动至对应分区并更新高亮 |
|
||||
| 跟进子Tab切换 | 点击跟进类型子Tab | 时间线按类型过滤 |
|
||||
| 改状态弹层打开 | 点击改状态 | Modal 打开 |
|
||||
| 写跟进弹层打开 | 点击写跟进 | Modal 打开 |
|
||||
| 校验失败 | 必填未填/字数不达标 | 字段下方错误文案 |
|
||||
| 提交成功(模拟) | 校验通过 | Toast 成功提示 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 可访问性
|
||||
|
||||
### 8.1 可访问性
|
||||
|
||||
- 所有按钮具备可见文本或 `aria-label`
|
||||
- Modal 支持 ESC 关闭
|
||||
- 关键操作保留焦点态(focus ring)
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收清单
|
||||
|
||||
- [x] 产出 `UI_DESIGN/房源管理/房源详情_UI.md`
|
||||
- [x] 产出 `UI_DESIGN/房源详情_UI.html`
|
||||
- [x] 覆盖 `US-PROPERTY-003 ~ US-PROPERTY-008` 的 UI 入口与结构
|
||||
- [x] 二级锚点分区导航 + 跟进子Tab + 时间线结构齐备
|
||||
- [x] 右侧信息面板(业主联系人/维护完成度/房源动态)齐备
|
||||
- [x] 改状态 / 写跟进 弹层原型齐备
|
||||
- [x] 页面内不包含 Light/Dark/System 主题切换控件
|
||||
- [ ] 待你评审后迭代(先改 HTML,再回写本 UI 文档)
|
||||
198
Project/fonrey/UI_DESIGN/房源管理/新增房源_UI.md
Normal file
198
Project/fonrey/UI_DESIGN/房源管理/新增房源_UI.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# 新增房源 UI 设计文档
|
||||
|
||||
> **版本**:v1.0 · **日期**:2026-04-28
|
||||
> **依赖规范**:`UI_SYSTEM/UI_SYSTEM.md v1.2`、`UI_SYSTEM/UI_SYSTEM文档要求.md`
|
||||
> **PRD 来源**:`PRD/房源管理/房源管理模块PRD.md` §5.3(新增房源)+ §5.3.1 / §5.3.2(类型差异)
|
||||
> **TASK 来源**:`PRD/TASK.md` · `US-PROPERTY-001`
|
||||
> **数据模型来源**:`DATA_MODEL/DATA_MODEL_PROPERTY.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块概述
|
||||
|
||||
### 1.1 设计目标
|
||||
|
||||
本任务对应 `UI_设计任务总表.md` 任务 02(P0-A)与 `US-PROPERTY-001`:
|
||||
|
||||
- 支持经纪人录入二手房源核心信息,并完成必填校验
|
||||
- 以「新增住宅」为 P0 主链路
|
||||
- 同页支持切换房源类型并展示差异字段(便于评审与后续迭代)
|
||||
- 保存成功后给出“保存成功”反馈并进入详情页(静态原型中为模拟跳转)
|
||||
|
||||
### 1.2 页面清单
|
||||
|
||||
| 页面 | 文件 | 优先级 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 新增房源页 | `UI_DESIGN/新增房源_UI.html` | P0 🔴 | 主表单页,含类型切换、锚点导航、校验与保存反馈 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 信息架构与布局
|
||||
|
||||
### 2.1 页面骨架
|
||||
|
||||
```
|
||||
Top Bar(固定 56px,高优先主导航)
|
||||
├─ 左侧品牌区(Fonrey)
|
||||
├─ 中部主导航(工作台/房源/客源/营销/交易/数据/人事/系统)
|
||||
└─ 右侧消息+用户信息
|
||||
|
||||
Sidebar(固定左侧 240px,位于 Top Bar 下方)
|
||||
└─ 房源管理二级导航(全部房源/我的房源/公海池/成交房源/已删房源)
|
||||
|
||||
Main Content(ml-60 + pt-[72px])
|
||||
├─ 页面头卡(面包屑 + 页面标题 + Theme Toggle + 返回列表)
|
||||
├─ 房源类型选择器(住宅/别墅/商住/商铺/写字楼/其他)
|
||||
├─ 表单锚点导航(房源核心信息 / 业主联系人 / 基础信息 / 交易信息 / 相关方)
|
||||
├─ 五个纵向区块卡片(按锚点顺序)
|
||||
└─ 底部操作条(取消 / 保存并继续新增 / 保存)
|
||||
```
|
||||
|
||||
### 2.2 设计说明
|
||||
|
||||
- 页面壳层**对齐 `UI_DESIGN/新增客源_UI.html`**:同款 top bar + left sidebar + content 区域栅格
|
||||
- 表单采用“分区块竖向布局 + 顶部锚点”方案,对齐 PRD §5.3
|
||||
- 锚点导航 `sticky`,滚动时高亮当前区块
|
||||
- 必填未填时:字段红框 + 错误文案 + 自动滚动到首个错误字段
|
||||
|
||||
---
|
||||
|
||||
## 3. 字段设计(通用)
|
||||
|
||||
## 3.1 房源核心信息
|
||||
|
||||
| 字段 | 控件 | 必填 | 规则 / 说明 | 数据模型映射 |
|
||||
|---|---|---|---|---|
|
||||
| 状态 | Radio | 是 | 出售 / 出租 / 租售 / 暂缓 | `properties.status` |
|
||||
| 房源属性 | Radio | 是 | 公盘 / 私盘 | `properties.attribute` |
|
||||
| 用途 | Radio | 否 | 选项随房源类型变化 | `properties.usage_type` |
|
||||
| 小区名称 | 搜索输入 | 是 | 支持联想,原型中为文本输入 | `properties.complex_id`(落地后) |
|
||||
| 户室号 | 3 段输入 | 否 | 栋/幢/弄/胡同 + 单元/号 + 门牌/室号 | `block_no/unit_no/room_no` |
|
||||
| 所在楼层 | 数字输入×2 | 是 | 当前楼层 ≤ 总楼层 | `floor/total_floors` |
|
||||
| 户型 | 室厅卫厨阳台 | 条件必填 | 商铺/写字楼不显示 | `bedroom_count...balcony_count` |
|
||||
| 建筑面积 | 数字输入 | 是 | 单位 m² | `area` |
|
||||
| 售价 | 数字输入 | 条件必填 | 状态含出售/租售时显示 | `sale_price` |
|
||||
| 租价 | 数字输入 | 条件必填 | 状态含出租/租售时显示 | `rent_price` |
|
||||
| 开间 | 数字输入 | 条件必填 | 仅商铺显示,单位米 | `shop_frontage` |
|
||||
| 进深 | 数字输入 | 条件必填 | 仅商铺显示,单位米 | `shop_depth` |
|
||||
| 层高 | 数字输入 | 条件必填 | 仅商铺显示,单位米 | `shop_height` |
|
||||
| 位置 | Radio | 条件必填 | 仅商铺:临街/商场/小区/底商/商业综合体 | `shop_location` |
|
||||
|
||||
## 3.2 业主/联系人
|
||||
|
||||
| 字段 | 控件 | 必填 | 规则 | 数据模型映射 |
|
||||
|---|---|---|---|---|
|
||||
| 姓名 | Input | 是 | 联系人1必填 | `property_contacts.name` |
|
||||
| 性别 | Radio | 是 | 先生 / 女士,默认先生 | `property_contacts.gender` |
|
||||
| 身份 | Select | 是 | 默认业主 | `property_contacts.identity` |
|
||||
| 电话1 | Input | 否 | 手机/座机格式提示 | `phone_enc/phone_hash`(后端加密) |
|
||||
| 电话2 | Input | 否 | 备用号码 | `phone2_enc/phone2_hash` |
|
||||
|
||||
交互:支持“+ 添加联系人”,第 1 个联系人不可删除。
|
||||
|
||||
## 3.3 基础信息
|
||||
|
||||
| 字段 | 控件 | 必填 | 规则 | 数据模型映射 |
|
||||
|---|---|---|---|---|
|
||||
| 朝向 | Radio | 是 | 10个枚举值 | `properties.orientation` |
|
||||
| 装修 | Radio | 是 | 毛坯~豪装 | `properties.decoration` |
|
||||
|
||||
## 3.4 交易信息
|
||||
|
||||
| 字段 | 控件 | 必填 | 规则 | 数据模型映射 |
|
||||
|---|---|---|---|---|
|
||||
| 房本年限 | 双下拉 | 是(显示时) | 左侧年限 + 右侧补充 | `ownership_years / ownership_years_detail` |
|
||||
|
||||
显示规则:
|
||||
- 住宅 / 别墅 / 其他:显示完整交易信息入口(本版先落地房本年限)
|
||||
- 商住:仅显示房本年限
|
||||
- 商铺 / 写字楼:不显示该区块
|
||||
|
||||
## 3.5 相关方
|
||||
|
||||
| 字段 | 控件 | 必填 | 规则 | 数据模型映射 |
|
||||
|---|---|---|---|---|
|
||||
| 首录方 | 只读输入 | 自动 | 默认当前登录人 | `first_recorder_id` |
|
||||
| 号码方 | 员工选择 | 是 | 默认当前登录人,可清除重选 | `number_holder_id` |
|
||||
| 出售方 | 员工选择 | 是 | 默认当前登录人,可清除重选 | `seller_agent_id` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 房源类型差异(对齐 PRD §5.3.1)
|
||||
|
||||
| 类型 | 区块结构 | 与住宅差异 |
|
||||
|---|---|---|
|
||||
| 住宅(P0) | 5区块完整 | 用途:普通住宅/花园洋房 |
|
||||
| 别墅(P1) | 5区块完整 | 用途:联排/独栋/双拼/叠加 |
|
||||
| 商住(P2) | 5区块 | 用途字段保留但无选项;交易信息仅房本年限 |
|
||||
| 商铺(P2) | 4区块(无交易信息) | 无户型;新增开间/进深/层高/位置 |
|
||||
| 写字楼(P2) | 4区块(无交易信息) | 无户型;其余与住宅主干一致 |
|
||||
| 其他(P2) | 5区块完整 | 用途为10项杂类 |
|
||||
|
||||
> 说明:根据 PRD,P2 类型在 v1 为“即将上线”。原型保留展示与交互,提交时给出“即将上线”提示,避免与开发范围冲突。
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键交互与状态
|
||||
|
||||
## 5.1 联动规则
|
||||
|
||||
1. **状态-价格联动**
|
||||
- 出售:仅显示售价
|
||||
- 出租:仅显示租价
|
||||
- 租售:售价+租价均显示
|
||||
- 暂缓:隐藏售价/租价并给出提示文案
|
||||
|
||||
2. **类型-字段联动**
|
||||
- 商铺/写字楼:隐藏户型
|
||||
- 商铺:显示开间/进深/层高/位置
|
||||
- 商铺/写字楼:隐藏交易信息
|
||||
|
||||
3. **校验失败定位**
|
||||
- 点击保存后,自动滚动到首个错误字段
|
||||
- 字段红框 + 错误文案
|
||||
|
||||
## 5.2 页面状态
|
||||
|
||||
| 状态 | 触发 | 反馈 |
|
||||
|---|---|---|
|
||||
| 默认 | 首次进入 | 住宅类型 + 空白表单 |
|
||||
| 编辑中 | 任意字段修改 | 页面 `dirty` 标记 |
|
||||
| 保存中 | 点击保存 | 按钮 loading + 禁用 |
|
||||
| 保存成功 | 校验通过 | Success Toast:保存成功,正在跳转 |
|
||||
| 保存失败 | 校验不通过 | 错误提示 + 自动定位 |
|
||||
| 类型未开放 | 提交 P2 类型 | Warning Toast:该类型即将上线 |
|
||||
|
||||
## 5.3 离开确认
|
||||
|
||||
- 页面有未保存更改时,离开触发浏览器确认
|
||||
- 对齐 PRD“离开页面需提示是否放弃填写内容”
|
||||
|
||||
---
|
||||
|
||||
## 6. 主题与可访问性
|
||||
|
||||
## 6.1 主题(本任务新增)
|
||||
|
||||
页面内置 Theme Toggle:`Light / Dark / System`
|
||||
|
||||
- 存储键:`fonrey_theme`
|
||||
- `system` 跟随 `prefers-color-scheme`
|
||||
- 颜色通过 CSS 变量驱动,避免硬编码
|
||||
|
||||
## 6.2 可访问性
|
||||
|
||||
- 所有输入项均有可见 Label
|
||||
- 错误提示与字段 `aria-describedby` 关联
|
||||
- 锚点导航和按钮均支持键盘焦点态
|
||||
- 颜色之外辅以文字提示(如必填、错误、状态文案)
|
||||
|
||||
---
|
||||
|
||||
## 7. 实现与评审清单
|
||||
|
||||
- [x] 产出 `UI_DESIGN/房源管理/新增房源_UI.md`
|
||||
- [x] 产出 `UI_DESIGN/新增房源_UI.html`
|
||||
- [x] 覆盖 `US-PROPERTY-001` 的主流程与验收关键点
|
||||
- [x] 包含 Light / Dark / System 主题切换
|
||||
- [ ] 待你评审后按反馈迭代(先改 HTML,再回写本 UI 文档)
|
||||
662
Project/fonrey/UI_DESIGN/房源详情_UI.html
Normal file
662
Project/fonrey/UI_DESIGN/房源详情_UI.html
Normal file
@@ -0,0 +1,662 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1280" />
|
||||
<title>Fonrey 房源详情 · 静态原型</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#F0FDFA',
|
||||
100: '#CCFBF1',
|
||||
200: '#99F6E4',
|
||||
500: '#14B8A6',
|
||||
600: '#0F766E',
|
||||
700: '#115E59',
|
||||
800: '#134E4A'
|
||||
},
|
||||
neutral: {
|
||||
50: '#F8FAFC',
|
||||
100: '#F1F5F9',
|
||||
200: '#E2E8F0',
|
||||
300: '#CBD5E1',
|
||||
400: '#94A3B8',
|
||||
500: '#64748B',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1E293B',
|
||||
900: '#0F172A'
|
||||
},
|
||||
success: { 50: '#F0FDF4', 600: '#16A34A' },
|
||||
warning: { 50: '#FFFBEB', 600: '#D97706' },
|
||||
danger: { 50: '#FEF2F2', 600: '#DC2626' },
|
||||
info: { 50: '#EFF6FF', 600: '#2563EB' }
|
||||
},
|
||||
boxShadow: {
|
||||
xs: '0 1px 2px rgba(15,23,42,0.04)'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-page: #F8FAFC;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-subtle: #F1F5F9;
|
||||
--text-primary: #0F172A;
|
||||
--text-secondary: #64748B;
|
||||
--border: #E2E8F0;
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
|
||||
|
||||
.bg-surface { background: var(--bg-card); }
|
||||
.bg-subtle { background: var(--bg-subtle); }
|
||||
.border-surface { border-color: var(--border); }
|
||||
.text-surface { color: var(--text-primary); }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
|
||||
.section-nav-btn { color: #64748B; background: transparent; }
|
||||
.section-nav-btn.active {
|
||||
color: #0F766E;
|
||||
background: #F0FDFA;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtab-pill {
|
||||
border: 1px solid #E2E8F0;
|
||||
background: #FFFFFF;
|
||||
color: #64748B;
|
||||
}
|
||||
.subtab-pill.active {
|
||||
border-color: #0F766E;
|
||||
color: #0F766E;
|
||||
background: #F0FDFA;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-sm antialiased" x-data="propertyDetailPage()" x-init="init()">
|
||||
<!-- Topbar -->
|
||||
<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>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="fixed left-0 top-14 h-[calc(100vh-56px)] w-60 z-20 border-r border-surface bg-surface overflow-y-auto">
|
||||
<nav class="p-3 space-y-0.5">
|
||||
<div class="px-2 pt-2 pb-1 text-xs font-medium text-muted 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 -->
|
||||
<main class="ml-60 pt-[72px] min-h-screen px-6 py-5">
|
||||
<div class="mx-auto max-w-[1600px] space-y-4">
|
||||
<div class="bg-surface border border-surface rounded-lg p-4">
|
||||
<nav class="flex items-center gap-1 text-xs text-muted 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-surface">房源详情</span>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-surface">出售 · 都市港湾055-0301</h1>
|
||||
<p class="text-xs text-muted mt-1">嘉定 · 丰庄 · 109.77㎡ · 2室2厅 · 房源编号 01DS016848</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<button class="px-3 py-1.5 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-50" @click="openStatusModal = true">改状态</button>
|
||||
<button class="px-3 py-1.5 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-50">编辑房源</button>
|
||||
<button class="px-3 py-1.5 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-50">分享</button>
|
||||
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="openFollowModal = true">写跟进</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二级导航:锚点 -->
|
||||
<section class="bg-surface border border-surface rounded-lg px-3 py-2 sticky top-16 z-30 shadow-xs">
|
||||
<nav class="flex items-center gap-1 overflow-x-auto whitespace-nowrap" aria-label="房源详情分区导航">
|
||||
<template x-for="item in navItems" :key="item.id">
|
||||
<a :href="'#' + item.id"
|
||||
@click.prevent="scrollToSection(item.id)"
|
||||
:aria-current="activeSection === item.id ? 'true' : 'false'"
|
||||
:class="activeSection === item.id ? 'section-nav-btn active' : 'section-nav-btn hover:bg-neutral-100 hover:text-neutral-800'"
|
||||
class="px-3 py-1.5 text-sm rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 transition-colors"
|
||||
x-text="item.label"></a>
|
||||
</template>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-12 gap-6 items-start">
|
||||
<section class="col-span-8 space-y-4">
|
||||
<!-- 核心信息 -->
|
||||
<div id="section-core" class="section-anchor scroll-mt-24 space-y-4">
|
||||
<article class="bg-surface border border-surface rounded-lg p-4">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-4">
|
||||
<div class="aspect-[4/3] rounded-lg bg-neutral-200 flex items-center justify-center text-neutral-500">房源主图</div>
|
||||
<p class="text-xs text-muted mt-2">全部 16 张 · 封面 1 张</p>
|
||||
</div>
|
||||
<div class="col-span-8 space-y-3">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-danger-600 tabular-nums">650 万</div>
|
||||
<div class="text-sm text-muted tabular-nums">59215 元/㎡ · 近30天无价格变动</div>
|
||||
</div>
|
||||
<button class="text-sm text-primary-600 hover:underline">调价</button>
|
||||
</div>
|
||||
<dl class="grid grid-cols-3 gap-3 text-sm">
|
||||
<div><dt class="text-xs text-muted">户型</dt><dd class="text-surface">2室2厅1卫1厨2阳</dd></div>
|
||||
<div><dt class="text-xs text-muted">面积</dt><dd class="text-surface tabular-nums">109.77㎡</dd></div>
|
||||
<div><dt class="text-xs text-muted">楼层</dt><dd class="text-surface">3 / 14</dd></div>
|
||||
<div><dt class="text-xs text-muted">电梯</dt><dd class="text-surface">有电梯</dd></div>
|
||||
<div><dt class="text-xs text-muted">装修</dt><dd class="text-surface">精装</dd></div>
|
||||
<div><dt class="text-xs text-muted">朝向</dt><dd class="text-surface">南北</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="bg-surface border border-surface rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-base font-semibold">基础信息</h2>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<a href="#" class="text-primary-600 hover:underline">改等级</a>
|
||||
<a href="#" class="text-primary-600 hover:underline">改属性</a>
|
||||
<a href="#" class="text-primary-600 hover:underline">改用途</a>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="grid grid-cols-3 gap-y-3 gap-x-6 text-sm">
|
||||
<div><dt class="text-xs text-muted">等级</dt><dd>C(一般)</dd></div>
|
||||
<div><dt class="text-xs text-muted">属性</dt><dd>公盘</dd></div>
|
||||
<div><dt class="text-xs text-muted">房屋现状</dt><dd>未知</dd></div>
|
||||
<div><dt class="text-xs text-muted">看房时间</dt><dd>随时可看</dd></div>
|
||||
<div><dt class="text-xs text-muted">用途</dt><dd>住宅</dd></div>
|
||||
<div><dt class="text-xs text-muted">录入时间</dt><dd class="tabular-nums">2015-05-01 19:05:53</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 跟进日志 -->
|
||||
<div id="section-follow" class="section-anchor scroll-mt-24">
|
||||
<article class="bg-surface border border-surface rounded-lg p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold">跟进日志</h2>
|
||||
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="openFollowModal = true">写跟进</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<template x-for="sub in followTabs" :key="sub.key">
|
||||
<button class="subtab-pill px-3 py-1 rounded-full text-xs" :class="{ 'active': activeFollowTab === sub.key }" @click="activeFollowTab = sub.key" x-text="sub.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="bg-subtle border border-surface rounded-md p-3 flex flex-wrap items-center gap-3 text-xs">
|
||||
<label class="flex items-center gap-1.5">开始 <input type="date" class="px-2 py-1 rounded border border-surface bg-surface"></label>
|
||||
<label class="flex items-center gap-1.5">结束 <input type="date" class="px-2 py-1 rounded border border-surface bg-surface"></label>
|
||||
<label class="flex items-center gap-1.5"><input type="checkbox" class="rounded border-surface"> 有录音</label>
|
||||
<label class="flex items-center gap-1.5"><input type="checkbox" class="rounded border-surface"> 有附件</label>
|
||||
<label class="flex items-center gap-1.5"><input type="checkbox" class="rounded border-surface"> 仅看我的</label>
|
||||
<input type="text" placeholder="搜索跟进内容" class="px-2 py-1 rounded border border-surface bg-surface min-w-[180px]" />
|
||||
</div>
|
||||
|
||||
<ol class="relative border-l-2 border-surface ml-3 pl-5 space-y-4">
|
||||
<template x-for="item in filteredTimeline" :key="item.id">
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[29px] top-1.5 w-3 h-3 rounded-full" :class="item.dot"></span>
|
||||
<div class="border border-surface rounded-md p-3 bg-surface space-y-1.5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium" :class="item.tagClass" x-text="item.tag"></span>
|
||||
<button class="text-xs text-muted hover:text-neutral-700">公开</button>
|
||||
</div>
|
||||
<p class="text-sm" x-text="item.content"></p>
|
||||
<p class="text-xs text-muted" x-text="item.meta"></p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 业务信息 -->
|
||||
<div id="section-biz" class="section-anchor scroll-mt-24">
|
||||
<article class="bg-surface border border-surface rounded-lg p-4">
|
||||
<h2 class="text-base font-semibold mb-3">业务信息</h2>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="border border-surface rounded-md p-3 bg-subtle">
|
||||
<p class="text-sm font-medium">钥匙管理</p>
|
||||
<p class="text-xs text-muted mt-1">当前:无钥匙</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button class="px-2 py-1 text-xs rounded border border-surface bg-surface">钥匙在他司</button>
|
||||
<button class="px-2 py-1 text-xs rounded border border-surface bg-surface">新增钥匙</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-surface rounded-md p-3 bg-subtle">
|
||||
<p class="text-sm font-medium">委托管理</p>
|
||||
<p class="text-xs text-muted mt-1">当前:无委托</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button class="px-2 py-1 text-xs rounded border border-surface bg-surface">新增委托</button>
|
||||
<button class="px-2 py-1 text-xs rounded border border-surface bg-surface">全部(0)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-surface rounded-md p-3 bg-subtle">
|
||||
<p class="text-sm font-medium">实勘管理</p>
|
||||
<p class="text-xs text-muted mt-1">当前:无实勘</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button class="px-2 py-1 text-xs rounded border border-surface bg-surface">新增实勘</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 房源信息 -->
|
||||
<div id="section-info" class="section-anchor scroll-mt-24">
|
||||
<article class="bg-surface border border-surface rounded-lg p-4">
|
||||
<h2 class="text-base font-semibold mb-3">房源信息</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="border border-surface rounded-md p-3">
|
||||
<div class="flex justify-between"><p class="font-medium">基本信息</p><a href="#" class="text-primary-600 text-xs">编辑</a></div>
|
||||
<p class="text-xs text-muted mt-2">购房付款方式、原购价、包税费、来源</p>
|
||||
</div>
|
||||
<div class="border border-surface rounded-md p-3">
|
||||
<div class="flex justify-between"><p class="font-medium">产证信息</p><a href="#" class="text-primary-600 text-xs">编辑</a></div>
|
||||
<p class="text-xs text-muted mt-2">权属方名称、证件号、房屋坐落</p>
|
||||
</div>
|
||||
<div class="border border-surface rounded-md p-3">
|
||||
<div class="flex justify-between"><p class="font-medium">房屋介绍</p><a href="#" class="text-primary-600 text-xs">编辑</a></div>
|
||||
<p class="text-xs text-muted mt-2">营销标题、核心卖点、业主心态</p>
|
||||
</div>
|
||||
<div class="border border-surface rounded-md p-3">
|
||||
<div class="flex justify-between"><p class="font-medium">楼盘信息</p><a href="#" class="text-primary-600 text-xs">编辑</a></div>
|
||||
<p class="text-xs text-muted mt-2">建成年代、物业公司、物业费、绿化率</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="section-marketing" class="section-anchor scroll-mt-24">
|
||||
<article class="bg-surface border border-surface rounded-lg p-4">
|
||||
<h2 class="text-base font-semibold mb-3">营销信息</h2>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="rounded-md p-3 bg-info-50 border border-info-600/20">
|
||||
<p class="font-medium text-info-600">AI 视频</p>
|
||||
<p class="text-xs text-muted mt-1">生成讲房短视频素材</p>
|
||||
</div>
|
||||
<div class="rounded-md p-3 bg-warning-50 border border-warning-600/20">
|
||||
<p class="font-medium text-warning-600">抖音营销</p>
|
||||
<p class="text-xs text-muted mt-1">一键生成发布文案</p>
|
||||
</div>
|
||||
<div class="rounded-md p-3 bg-success-50 border border-success-600/20">
|
||||
<p class="font-medium text-success-600">小红书营销</p>
|
||||
<p class="text-xs text-muted mt-1">多模板图文内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="section-staff" class="section-anchor scroll-mt-24">
|
||||
<article class="bg-surface border border-surface rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-base font-semibold">相关员工</h2>
|
||||
<a href="#" class="text-sm text-primary-600 hover:underline">编辑</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<template x-for="staff in staffList" :key="staff.role">
|
||||
<div class="border border-surface rounded-md p-3 flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center font-semibold" x-text="staff.name.slice(0,1)"></div>
|
||||
<div>
|
||||
<p class="text-sm font-medium" x-text="staff.name"></p>
|
||||
<p class="text-xs text-muted" x-text="staff.role + ' · ' + staff.team"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="section-album" class="section-anchor scroll-mt-24">
|
||||
<article class="bg-surface border border-surface rounded-lg p-4 space-y-3">
|
||||
<h2 class="text-base font-semibold">相册信息</h2>
|
||||
<div class="flex items-center gap-2 flex-wrap text-xs">
|
||||
<span class="subtab-pill active px-3 py-1 rounded-full">全部(16)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">封面(1)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">客厅(2)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">卧室(4)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">厨房(1)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">卫生间(1)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button class="px-2 py-1 rounded border border-surface bg-surface">上传图片</button>
|
||||
<button class="px-2 py-1 rounded border border-surface bg-surface">批量改类</button>
|
||||
<button class="px-2 py-1 rounded border border-surface bg-surface">批量删除</button>
|
||||
<button class="px-2 py-1 rounded border border-surface bg-surface">批量下载</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<template x-for="idx in 8" :key="idx">
|
||||
<div class="aspect-[4/3] rounded-md border border-surface bg-neutral-100 flex items-center justify-center text-xs text-neutral-500">图片{{idx}}</div>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="section-attachment" class="section-anchor scroll-mt-24">
|
||||
<article class="bg-surface border border-surface rounded-lg p-4 space-y-3">
|
||||
<h2 class="text-base font-semibold">附件信息</h2>
|
||||
<div class="flex items-center gap-2 text-xs flex-wrap">
|
||||
<span class="subtab-pill active px-3 py-1 rounded-full">全部(0)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">身份证(0)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">房产证(0)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">委托书(0)</span>
|
||||
<span class="subtab-pill px-3 py-1 rounded-full">其他(0)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button class="px-2 py-1 rounded border border-surface bg-surface">上传附件</button>
|
||||
<button class="px-2 py-1 rounded border border-surface bg-surface">批量改类</button>
|
||||
<button class="px-2 py-1 rounded border border-surface bg-surface">批量下载</button>
|
||||
</div>
|
||||
<div class="border border-dashed border-surface rounded-lg p-10 text-center text-sm text-muted">暂无附件</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="col-span-4 space-y-3 sticky top-16 max-h-[calc(100vh-80px)] overflow-y-auto">
|
||||
<section class="bg-surface border border-surface rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold">业主/联系人</h3>
|
||||
<button class="text-xs text-primary-600 hover:underline">新增联系人</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="rounded-md border border-surface p-2.5 bg-subtle">
|
||||
<p class="text-sm font-medium">方叔叔 <span class="text-xs text-muted">业主</span></p>
|
||||
<p class="text-xs text-muted mt-1">电话:135****2201</p>
|
||||
<button class="mt-2 text-xs text-primary-600 hover:underline">查看号码</button>
|
||||
</div>
|
||||
<p class="text-xs text-warning-600">TIPS:该业主名下还有 2 套房源,建议一起跟进。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-surface border border-surface rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold mb-3">房源维护完成度</h3>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-muted">总分</span>
|
||||
<span class="text-base font-semibold tabular-nums">69%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 rounded-full bg-neutral-200 overflow-hidden mb-3">
|
||||
<div class="h-full bg-primary-600" style="width:69%"></div>
|
||||
</div>
|
||||
<div class="space-y-2 text-xs">
|
||||
<template x-for="item in completeness" :key="item.name">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted" x-text="item.name"></span>
|
||||
<span class="tabular-nums" x-text="item.score + '%' "></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-surface border border-surface rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold mb-3">房源动态(近30天)</h3>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<template x-for="m in metrics" :key="m.name">
|
||||
<div class="rounded-md border border-surface bg-subtle p-2.5">
|
||||
<p class="text-muted" x-text="m.name"></p>
|
||||
<p class="text-base font-semibold mt-1 tabular-nums" x-text="m.value"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 改状态弹层 -->
|
||||
<div x-show="openStatusModal" x-cloak class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-md bg-surface border border-surface rounded-lg shadow-lg">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-surface">
|
||||
<h3 class="text-base font-semibold">改状态</h3>
|
||||
<button @click="closeStatusModal()" class="text-muted hover:text-surface">✕</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-muted">新状态 <span class="text-danger-600">*</span></label>
|
||||
<select x-model="statusForm.nextStatus" class="mt-1 w-full px-3 py-2 rounded-md border" :class="statusErrors.nextStatus ? 'border-danger-600' : 'border-surface'">
|
||||
<option value="">请选择</option>
|
||||
<option value="suspended">暂缓</option>
|
||||
<option value="sold_elsewhere">他售</option>
|
||||
<option value="sold">成交</option>
|
||||
</select>
|
||||
<p class="text-xs text-danger-600 mt-1" x-show="statusErrors.nextStatus">请选择新状态</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-muted">更改理由 <span class="text-danger-600">*</span></label>
|
||||
<textarea x-model.trim="statusForm.reason" rows="3" maxlength="50" placeholder="最多50字" class="mt-1 w-full px-3 py-2 rounded-md border" :class="statusErrors.reason ? 'border-danger-600' : 'border-surface'"></textarea>
|
||||
<p class="text-xs text-danger-600 mt-1" x-show="statusErrors.reason">请填写更改理由</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-surface flex justify-end gap-2">
|
||||
<button class="px-3 py-1.5 rounded-md border border-surface" @click="closeStatusModal()">取消</button>
|
||||
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white" @click="submitStatusChange()">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 写跟进弹层 -->
|
||||
<div x-show="openFollowModal" x-cloak class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-lg bg-surface border border-surface rounded-lg shadow-lg">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-surface">
|
||||
<h3 class="text-base font-semibold">写跟进</h3>
|
||||
<button @click="closeFollowModal()" class="text-muted hover:text-surface">✕</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-muted">跟进目的 <span class="text-danger-600">*</span></label>
|
||||
<select x-model="followForm.purpose" class="mt-1 w-full px-3 py-2 rounded-md border" :class="followErrors.purpose ? 'border-danger-600' : 'border-surface'">
|
||||
<option value="">请选择</option>
|
||||
<option value="manual">写入跟进</option>
|
||||
<option value="sensitive">敏感信息跟进</option>
|
||||
<option value="modified">修改跟进</option>
|
||||
<option value="other">其他跟进</option>
|
||||
</select>
|
||||
<p class="text-xs text-danger-600 mt-1" x-show="followErrors.purpose">请选择跟进目的</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-muted">跟进内容 <span class="text-danger-600">*</span></label>
|
||||
<textarea x-model.trim="followForm.content" rows="4" maxlength="500" placeholder="最少6字,最多500字" class="mt-1 w-full px-3 py-2 rounded-md border" :class="followErrors.content ? 'border-danger-600' : 'border-surface'"></textarea>
|
||||
<p class="text-xs text-danger-600 mt-1" x-show="followErrors.content">跟进内容至少 6 字</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-surface flex justify-end gap-2">
|
||||
<button class="px-3 py-1.5 rounded-md border border-surface" @click="closeFollowModal()">取消</button>
|
||||
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white" @click="submitFollow()">提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="toast.show" x-cloak class="fixed bottom-5 right-5 z-50 px-4 py-2 rounded-md text-sm text-white shadow-lg" :class="toast.type==='success' ? 'bg-success-600' : 'bg-danger-600'" x-text="toast.message"></div>
|
||||
|
||||
<script>
|
||||
function propertyDetailPage() {
|
||||
return {
|
||||
navItems: [
|
||||
{ id: 'section-core', label: '核心信息' },
|
||||
{ id: 'section-follow', label: '跟进日志' },
|
||||
{ id: 'section-biz', label: '业务信息' },
|
||||
{ id: 'section-info', label: '房源信息' },
|
||||
{ id: 'section-marketing', label: '营销信息' },
|
||||
{ id: 'section-staff', label: '相关员工' },
|
||||
{ id: 'section-album', label: '相册信息' },
|
||||
{ id: 'section-attachment', label: '附件信息' }
|
||||
],
|
||||
activeSection: 'section-core',
|
||||
observer: null,
|
||||
|
||||
followTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'manual', label: '写入跟进' },
|
||||
{ key: 'sensitive', label: '敏感信息跟进' },
|
||||
{ key: 'sensitive_view', label: '敏感信息查看' },
|
||||
{ key: 'modified', label: '修改跟进' },
|
||||
{ key: 'other', label: '其他跟进' }
|
||||
],
|
||||
activeFollowTab: 'all',
|
||||
|
||||
timeline: [
|
||||
{ id: 1, type: 'manual', tag: '跟进-业主跟进', tagClass: 'bg-info-50 text-info-600', dot: 'bg-info-600', content: '业主可接受 630-640 万区间,周末可安排第二次带看。', meta: '[经纪人] 魏深 · 都市港湾店一组 · 2026-04-28 10:23:12' },
|
||||
{ id: 2, type: 'modified', tag: '改价格', tagClass: 'bg-warning-50 text-warning-600', dot: 'bg-warning-600', content: '售价:660 => 650(万);备注:业主接受小幅让价。', meta: '[系统] system · 2026-04-27 18:11:05' },
|
||||
{ id: 3, type: 'sensitive_view', tag: '查看号码', tagClass: 'bg-danger-50 text-danger-600', dot: 'bg-danger-600', content: '经纪人查看业主隐号信息。', meta: '[经纪人] 魏深 · 都市港湾店一组 · 2026-04-26 13:04:47' },
|
||||
{ id: 4, type: 'other', tag: '图片下载', tagClass: 'bg-neutral-100 text-neutral-700', dot: 'bg-neutral-400', content: '下载了房源相册(8张)。', meta: '[经纪人] 魏深 · 都市港湾店一组 · 2026-04-25 21:30:10' }
|
||||
],
|
||||
|
||||
staffList: [
|
||||
{ role: '首录方', name: '魏深', team: '都市港湾店一组' },
|
||||
{ role: '号码方', name: '雷威', team: '都市港湾店一组' },
|
||||
{ role: '出售方', name: '史彬彬', team: '都市港湾店一组' },
|
||||
{ role: '实买方', name: '待分配', team: '—' }
|
||||
],
|
||||
|
||||
completeness: [
|
||||
{ name: '重点信息', score: 90 },
|
||||
{ name: '附件信息', score: 40 },
|
||||
{ name: '实勘', score: 0 },
|
||||
{ name: 'VR', score: 0 },
|
||||
{ name: '钥匙', score: 20 },
|
||||
{ name: '委托', score: 10 }
|
||||
],
|
||||
|
||||
metrics: [
|
||||
{ name: '带看次数', value: 8 },
|
||||
{ name: '复看次数', value: 2 },
|
||||
{ name: '面访次数', value: 5 },
|
||||
{ name: '收藏人数', value: 13 },
|
||||
{ name: '分享次数', value: 21 },
|
||||
{ name: '空看人次', value: 3 }
|
||||
],
|
||||
|
||||
openStatusModal: false,
|
||||
statusForm: { nextStatus: '', reason: '' },
|
||||
statusErrors: {},
|
||||
|
||||
openFollowModal: false,
|
||||
followForm: { purpose: '', content: '' },
|
||||
followErrors: {},
|
||||
|
||||
toast: { show: false, message: '', type: 'success' },
|
||||
|
||||
get filteredTimeline() {
|
||||
if (this.activeFollowTab === 'all') return this.timeline
|
||||
return this.timeline.filter((x) => x.type === this.activeFollowTab)
|
||||
},
|
||||
|
||||
scrollToSection(id) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
},
|
||||
|
||||
init() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (this.openStatusModal) this.closeStatusModal()
|
||||
if (this.openFollowModal) this.closeFollowModal()
|
||||
}
|
||||
})
|
||||
|
||||
const sections = Array.from(document.querySelectorAll('.section-anchor'))
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) this.activeSection = entry.target.id
|
||||
})
|
||||
}, {
|
||||
root: null,
|
||||
rootMargin: '-140px 0px -55% 0px',
|
||||
threshold: 0.01
|
||||
})
|
||||
sections.forEach((s) => this.observer.observe(s))
|
||||
},
|
||||
|
||||
closeStatusModal() {
|
||||
this.openStatusModal = false
|
||||
this.statusErrors = {}
|
||||
},
|
||||
submitStatusChange() {
|
||||
this.statusErrors = {}
|
||||
if (!this.statusForm.nextStatus) this.statusErrors.nextStatus = true
|
||||
if (!this.statusForm.reason.trim()) this.statusErrors.reason = true
|
||||
if (Object.keys(this.statusErrors).length) return
|
||||
this.openStatusModal = false
|
||||
this.statusForm = { nextStatus: '', reason: '' }
|
||||
this.notify('状态已更新(原型模拟)', 'success')
|
||||
},
|
||||
|
||||
closeFollowModal() {
|
||||
this.openFollowModal = false
|
||||
this.followErrors = {}
|
||||
},
|
||||
submitFollow() {
|
||||
this.followErrors = {}
|
||||
if (!this.followForm.purpose) this.followErrors.purpose = true
|
||||
if (!this.followForm.content || this.followForm.content.length < 6) this.followErrors.content = true
|
||||
if (Object.keys(this.followErrors).length) return
|
||||
this.openFollowModal = false
|
||||
this.followForm = { purpose: '', content: '' }
|
||||
this.notify('跟进已写入(原型模拟)', 'success')
|
||||
},
|
||||
|
||||
notify(message, type = 'success') {
|
||||
this.toast = { show: true, message, type }
|
||||
window.setTimeout(() => { this.toast.show = false }, 1800)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -55,6 +55,11 @@
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
|
||||
|
||||
.chip { border:1px solid #E2E8F0; background:#FFFFFF; color:#64748B; }
|
||||
.chip.active { border-color:#0F766E; color:#0F766E; background:#F0FDFA; }
|
||||
.subtab { color:#64748B; border-bottom:2px solid transparent; }
|
||||
.subtab.active { color:#0F766E; border-bottom-color:#0F766E; font-weight:600; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 text-sm text-neutral-700 antialiased" x-data="createClientPage()">
|
||||
@@ -108,8 +113,78 @@
|
||||
<h1 class="text-xl font-semibold text-neutral-800">录入私客</h1>
|
||||
</div>
|
||||
|
||||
<form id="create-client-form" @submit.prevent="submitForm" class="space-y-4">
|
||||
<section class="bg-white rounded-lg border border-neutral-200 p-6">
|
||||
<form id="create-client-form" @submit.prevent="submitForm" class="space-y-4 pb-28">
|
||||
<section class="bg-white rounded-lg border border-neutral-200 p-4">
|
||||
<div>
|
||||
<div class="text-xs text-neutral-500 mb-2">需求状态</div>
|
||||
<div class="flex items-center gap-2" id="demandStatusGroup" data-field-key="status">
|
||||
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" :class="{ 'active': form.status==='buying' }" @click="form.status='buying'">求购</button>
|
||||
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" :class="{ 'active': form.status==='renting' }" @click="form.status='renting'">求租</button>
|
||||
<button type="button" class="chip px-3 py-1.5 rounded-md text-sm" :class="{ 'active': form.status==='buy_or_rent' }" @click="form.status='buy_or_rent'">租购</button>
|
||||
</div>
|
||||
<p class="text-xs text-danger-600 mt-1" x-show="errors.status" x-text="errors.status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="bg-white rounded-lg border border-neutral-200 px-4 sticky top-[74px] z-10">
|
||||
<div class="flex items-center gap-5 overflow-x-auto text-sm">
|
||||
<a href="#section-client-info" class="subtab py-3" :class="{ 'active': activeSection==='client-info' }">房源信息</a>
|
||||
<a href="#section-contact" class="subtab py-3" :class="{ 'active': activeSection==='contact' }">联系人</a>
|
||||
<a href="#section-basic" class="subtab py-3" :class="{ 'active': activeSection==='basic' }">基础信息</a>
|
||||
<a href="#section-staff" class="subtab py-3" :class="{ 'active': activeSection==='staff' }">相关员工</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section id="section-client-info" class="bg-white rounded-lg border border-neutral-200 p-6" data-section-key="client-info">
|
||||
<h2 class="text-base font-semibold text-neutral-800 mb-4">房源信息</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-neutral-700">用途 <span class="text-danger-600">*</span></label>
|
||||
<div data-field-key="property_usage" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('property_usage')">
|
||||
<template x-for="item in usageOptions" :key="item.value">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" x-model="form.propertyUsage" :value="item.value" class="w-4 h-4 accent-primary-600">
|
||||
<span class="text-sm text-neutral-700" x-text="item.label"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-danger-600" x-show="errors.property_usage" x-text="errors.property_usage"></p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-neutral-700">等级 <span class="text-danger-600">*</span></label>
|
||||
<div data-field-key="grade" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('grade')">
|
||||
<template x-for="item in gradeOptions" :key="item.value">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" x-model="form.grade" :value="item.value" class="w-4 h-4 accent-primary-600">
|
||||
<span class="text-sm text-neutral-700" x-text="item.label"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-danger-600" x-show="errors.grade" x-text="errors.grade"></p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-neutral-700">来源 <span class="text-danger-600">*</span></label>
|
||||
<select
|
||||
data-field-key="source"
|
||||
x-model="form.source"
|
||||
:disabled="sourceLoading"
|
||||
class="block w-full max-w-xs px-3 py-2 text-sm rounded-md border bg-white focus:outline-none focus:ring-2 disabled:bg-neutral-100 disabled:text-neutral-400"
|
||||
:class="inputClass('source')"
|
||||
>
|
||||
<option value="" x-text="sourceLoading ? '加载中...' : '请选择'"></option>
|
||||
<template x-for="item in sourceOptions" :key="item.value">
|
||||
<option :value="item.value" x-text="item.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="text-xs text-danger-600" x-show="errors.source" x-text="errors.source"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="section-contact" class="bg-white rounded-lg border border-neutral-200 p-6" data-section-key="contact">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-neutral-800">联系人</h2>
|
||||
<span class="text-xs text-neutral-500">最多添加 5 位联系人</span>
|
||||
@@ -250,72 +325,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-lg border border-neutral-200 p-6">
|
||||
<section id="section-basic" class="bg-white rounded-lg border border-neutral-200 p-6" data-section-key="basic">
|
||||
<h2 class="text-base font-semibold text-neutral-800 mb-4">基础信息</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-neutral-700">状态 <span class="text-danger-600">*</span></label>
|
||||
<div data-field-key="status" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('status')">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" x-model="form.status" value="buying" class="w-4 h-4 accent-primary-600">
|
||||
<span class="text-sm text-neutral-700">求购</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" x-model="form.status" value="renting" class="w-4 h-4 accent-primary-600">
|
||||
<span class="text-sm text-neutral-700">求租</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" x-model="form.status" value="buy_or_rent" class="w-4 h-4 accent-primary-600">
|
||||
<span class="text-sm text-neutral-700">租购</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-danger-600" x-show="errors.status" x-text="errors.status"></p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-neutral-700">用途 <span class="text-danger-600">*</span></label>
|
||||
<div data-field-key="property_usage" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('property_usage')">
|
||||
<template x-for="item in usageOptions" :key="item.value">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" x-model="form.propertyUsage" :value="item.value" class="w-4 h-4 accent-primary-600">
|
||||
<span class="text-sm text-neutral-700" x-text="item.label"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-danger-600" x-show="errors.property_usage" x-text="errors.property_usage"></p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-neutral-700">等级 <span class="text-danger-600">*</span></label>
|
||||
<div data-field-key="grade" class="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border px-3 py-2 min-h-[42px]" :class="groupClass('grade')">
|
||||
<template x-for="item in gradeOptions" :key="item.value">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" x-model="form.grade" :value="item.value" class="w-4 h-4 accent-primary-600">
|
||||
<span class="text-sm text-neutral-700" x-text="item.label"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-danger-600" x-show="errors.grade" x-text="errors.grade"></p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-neutral-700">来源 <span class="text-danger-600">*</span></label>
|
||||
<select
|
||||
data-field-key="source"
|
||||
x-model="form.source"
|
||||
:disabled="sourceLoading"
|
||||
class="block w-full max-w-xs px-3 py-2 text-sm rounded-md border bg-white focus:outline-none focus:ring-2 disabled:bg-neutral-100 disabled:text-neutral-400"
|
||||
:class="inputClass('source')"
|
||||
>
|
||||
<option value="" x-text="sourceLoading ? '加载中...' : '请选择'"></option>
|
||||
<template x-for="item in sourceOptions" :key="item.value">
|
||||
<option :value="item.value" x-text="item.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="text-xs text-danger-600" x-show="errors.source" x-text="errors.source"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="button" @click="infoExpanded = !infoExpanded" class="flex items-center gap-1 text-sm text-neutral-500 hover:text-neutral-700">
|
||||
<span>证件类型、证件号码、意向学校等</span>
|
||||
@@ -358,7 +371,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-lg border border-neutral-200 p-6">
|
||||
<section id="section-staff" class="bg-white rounded-lg border border-neutral-200 p-6" data-section-key="staff">
|
||||
<h2 class="text-base font-semibold text-neutral-800 mb-4">相关员工</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-1.5">
|
||||
@@ -374,27 +387,39 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-3 mt-2 pb-8">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="inline-flex items-center gap-1.5 px-6 py-2 text-sm font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-70 disabled:cursor-wait"
|
||||
>
|
||||
<svg x-show="submitting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity=".25" stroke-width="4"></circle>
|
||||
<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" stroke-width="4"></path>
|
||||
</svg>
|
||||
<span x-text="submitting ? '保存中...' : '确定'"></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelForm"
|
||||
:disabled="submitting"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400 disabled:opacity-60"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<p x-show="redirectHint" x-text="redirectHint" class="text-xs text-success-600"></p>
|
||||
<div class="fixed bottom-4 left-[calc(15rem+1.5rem)] right-6 z-30">
|
||||
<div class="mx-auto max-w-5xl bg-white rounded-lg border border-neutral-200 shadow-xs p-4">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelForm"
|
||||
:disabled="submitting"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400 disabled:opacity-60"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="submitAndContinue"
|
||||
:disabled="submitting"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400 disabled:opacity-60"
|
||||
>
|
||||
保存并继续新增
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="inline-flex items-center gap-1.5 px-6 py-2 text-sm font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-70 disabled:cursor-wait"
|
||||
>
|
||||
<svg x-show="submitting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity=".25" stroke-width="4"></circle>
|
||||
<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" stroke-width="4"></path>
|
||||
</svg>
|
||||
<span x-text="submitting ? '保存中...' : '保存'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="redirectHint" x-text="redirectHint" class="text-xs text-success-600 text-right mt-2"></p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -419,7 +444,6 @@
|
||||
{ value: 'villa', label: '别墅' },
|
||||
{ value: 'commercial_residential', label: '商住' },
|
||||
{ value: 'shop', label: '商铺' },
|
||||
{ value: 'office', label: '写字楼' },
|
||||
{ value: 'other', label: '其他' }
|
||||
],
|
||||
gradeOptions: [
|
||||
@@ -435,6 +459,7 @@
|
||||
infoExpanded: false,
|
||||
checkingDuplicate: false,
|
||||
duplicateHint: '',
|
||||
activeSection: 'client-info',
|
||||
submitting: false,
|
||||
redirectHint: '',
|
||||
errors: {},
|
||||
@@ -458,6 +483,7 @@
|
||||
init() {
|
||||
this.form.contacts = [this.newContact()];
|
||||
this.simulateSourceLoading();
|
||||
this.bindSectionObserver();
|
||||
},
|
||||
|
||||
newContact() {
|
||||
@@ -516,6 +542,24 @@
|
||||
this.form.schools.splice(idx, 1);
|
||||
},
|
||||
|
||||
setPropertyUsage(type) {
|
||||
this.form.propertyUsage = type;
|
||||
},
|
||||
|
||||
bindSectionObserver() {
|
||||
this.$nextTick(() => {
|
||||
const sections = Array.from(document.querySelectorAll('[data-section-key]'));
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
this.activeSection = entry.target.getAttribute('data-section-key');
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '-35% 0px -55% 0px', threshold: 0 });
|
||||
sections.forEach((s) => observer.observe(s));
|
||||
});
|
||||
},
|
||||
|
||||
simulateSourceLoading() {
|
||||
setTimeout(() => {
|
||||
this.sourceOptions = [
|
||||
@@ -619,7 +663,7 @@
|
||||
}, 2200);
|
||||
},
|
||||
|
||||
submitForm() {
|
||||
submitForm(continueMode = false) {
|
||||
if (this.submitting) return;
|
||||
if (!this.validateForm()) return;
|
||||
|
||||
@@ -629,10 +673,28 @@
|
||||
setTimeout(() => {
|
||||
this.submitting = false;
|
||||
this.showToast('保存成功');
|
||||
this.redirectHint = '模拟跳转中:即将进入客源详情页 /clients/CL20260426001/';
|
||||
if (continueMode) {
|
||||
this.form.contacts = [this.newContact()];
|
||||
this.form.status = '';
|
||||
this.form.grade = '';
|
||||
this.form.source = '';
|
||||
this.form.idType = '';
|
||||
this.form.idNumber = '';
|
||||
this.form.schools = [''];
|
||||
this.infoExpanded = false;
|
||||
this.errors = {};
|
||||
this.redirectHint = '已保存当前客源,表单已重置,可继续新增。';
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
this.redirectHint = '模拟跳转中:即将进入客源详情页 /clients/CL20260426001/';
|
||||
}
|
||||
}, 1200);
|
||||
},
|
||||
|
||||
submitAndContinue() {
|
||||
this.submitForm(true);
|
||||
},
|
||||
|
||||
cancelForm() {
|
||||
this.redirectHint = '已取消录入,模拟返回客源列表 /clients/';
|
||||
}
|
||||
|
||||
758
Project/fonrey/UI_DESIGN/新增房源_UI.html
Normal file
758
Project/fonrey/UI_DESIGN/新增房源_UI.html
Normal file
@@ -0,0 +1,758 @@
|
||||
<!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:#FFFFFFD9;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg-page:#0B1220; --bg-panel:#111A2E; --bg-subtle:#1A253C; --text-main:#E2E8F0; --text-sub:#94A3B8;
|
||||
--border:#334155; --input-bg:#0F172A; --input-text:#E2E8F0; --header-bg:#0F172AD9;
|
||||
}
|
||||
[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:#FFFFFFD9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-theme="system"] {
|
||||
--bg-page:#0B1220; --bg-panel:#111A2E; --bg-subtle:#1A253C; --text-main:#E2E8F0; --text-sub:#94A3B8;
|
||||
--border:#334155; --input-bg:#0F172A; --input-text:#E2E8F0; --header-bg:#0F172AD9;
|
||||
}
|
||||
}
|
||||
|
||||
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">m²</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>
|
||||
513
Project/fonrey/UI_DESIGN/楼盘列表_UI.html
Normal file
513
Project/fonrey/UI_DESIGN/楼盘列表_UI.html
Normal file
@@ -0,0 +1,513 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1280" />
|
||||
<title>Fonrey 楼盘列表 · 静态原型</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#F0FDFA',
|
||||
100: '#CCFBF1',
|
||||
200: '#99F6E4',
|
||||
500: '#14B8A6',
|
||||
600: '#0F766E',
|
||||
700: '#115E59',
|
||||
800: '#134E4A'
|
||||
},
|
||||
neutral: {
|
||||
50: '#F8FAFC',
|
||||
100: '#F1F5F9',
|
||||
200: '#E2E8F0',
|
||||
300: '#CBD5E1',
|
||||
400: '#94A3B8',
|
||||
500: '#64748B',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1E293B',
|
||||
900: '#0F172A'
|
||||
},
|
||||
success: { 50: '#F0FDF4', 600: '#16A34A' },
|
||||
warning: { 50: '#FFFBEB', 600: '#D97706' },
|
||||
danger: { 50: '#FEF2F2', 600: '#DC2626' },
|
||||
info: { 50: '#EFF6FF', 600: '#2563EB' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-page: #F8FAFC;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-subtle: #F1F5F9;
|
||||
--text-primary: #0F172A;
|
||||
--text-secondary: #64748B;
|
||||
--border: #E2E8F0;
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
transition: background-color .2s ease, color .2s ease;
|
||||
}
|
||||
[x-cloak] { display: none !important; }
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.bg-surface { background: var(--bg-card); }
|
||||
.bg-subtle { background: var(--bg-subtle); }
|
||||
.border-surface { border-color: var(--border); }
|
||||
.text-surface { color: var(--text-primary); }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
|
||||
.module-tab {
|
||||
color: #64748B;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.module-tab.active {
|
||||
color: #0F766E;
|
||||
border-bottom-color: #0F766E;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid #E2E8F0;
|
||||
background: #FFFFFF;
|
||||
color: #64748B;
|
||||
}
|
||||
.chip.active {
|
||||
border-color: #0F766E;
|
||||
color: #0F766E;
|
||||
background: #F0FDFA;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::-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="complexListPage()" x-init="init()">
|
||||
<!-- Top Bar -->
|
||||
<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>
|
||||
|
||||
<!-- Side Bar -->
|
||||
<aside class="fixed left-0 top-14 h-[calc(100vh-56px)] w-60 z-20 border-r border-surface bg-surface overflow-y-auto">
|
||||
<nav class="p-3 space-y-0.5">
|
||||
<div class="px-2 pt-2 pb-1 text-xs font-medium text-muted 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 -->
|
||||
<main class="ml-60 pt-[72px] min-h-screen px-6 py-5">
|
||||
<div class="mx-auto max-w-[1680px] space-y-4">
|
||||
<!-- Breadcrumb + title -->
|
||||
<section class="bg-surface border border-surface rounded-lg p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<nav class="flex items-center gap-1 text-xs text-muted 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-surface">楼盘管理系统-楼盘管理</span>
|
||||
</nav>
|
||||
<h1 class="text-xl font-semibold text-surface">楼盘管理</h1>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Module Tabs -->
|
||||
<section class="bg-surface border border-surface rounded-lg px-4">
|
||||
<nav class="flex items-center gap-6 overflow-x-auto" aria-label="楼盘管理模块导航">
|
||||
<button class="module-tab py-3 whitespace-nowrap active">楼盘</button>
|
||||
<button class="module-tab py-3 whitespace-nowrap">区域管理</button>
|
||||
<button class="module-tab py-3 whitespace-nowrap">学校管理</button>
|
||||
<button class="module-tab py-3 whitespace-nowrap">应用标准数据</button>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<!-- Completeness metrics -->
|
||||
<section class="bg-info-50 border border-info-600/20 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-2">
|
||||
<h2 class="text-sm font-semibold text-info-600">数据完整度指标</h2>
|
||||
<button class="text-xs px-2 py-1 rounded border border-info-600/30 text-info-600 bg-white hover:bg-info-50" @click="notify('已触发重新计算(原型)')">重新计算</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-2 text-xs">
|
||||
<template x-for="item in metrics" :key="item.name">
|
||||
<div class="bg-white rounded-md border border-info-600/10 p-2">
|
||||
<p class="text-muted" x-text="item.name"></p>
|
||||
<p class="font-semibold tabular-nums mt-0.5" x-text="item.value"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Search + Filter -->
|
||||
<section class="bg-surface border border-surface rounded-lg p-4 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input x-model.trim="filters.keyword" type="text" placeholder="楼盘名/别名/拼音/详细地址" class="w-[380px] px-3 py-2 rounded-md border border-surface bg-white focus:outline-none focus:ring-2 focus:ring-primary-600/40" />
|
||||
<button class="px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="applyFilters()">查询</button>
|
||||
<button class="px-3 py-2 rounded-md border border-surface hover:bg-neutral-50" @click="resetFilters()">清除</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs text-muted">区域:</span>
|
||||
<template x-for="item in regionOptions" :key="item">
|
||||
<button class="chip px-2.5 py-1 rounded-md text-xs" :class="{ 'active': filters.region === item }" @click="filters.region = item" x-text="item"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs text-muted">用途:</span>
|
||||
<template x-for="item in usageOptions" :key="item">
|
||||
<button class="chip px-2.5 py-1 rounded-md text-xs" :class="{ 'active': filters.usage === item }" @click="filters.usage = item" x-text="item"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
<template x-for="cfg in extraFilters" :key="cfg.key">
|
||||
<select x-model="filters[cfg.key]" class="px-2.5 py-2 rounded-md border border-surface bg-white text-xs focus:outline-none focus:ring-2 focus:ring-primary-600/40">
|
||||
<option value="" x-text="cfg.label + ':不限'"></option>
|
||||
<template x-for="opt in cfg.options" :key="opt">
|
||||
<option :value="opt" x-text="opt"></option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Action bar -->
|
||||
<section class="bg-surface border border-surface rounded-lg p-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="notify('新增楼盘(原型入口)')">+ 新增楼盘</button>
|
||||
<button class="px-3 py-1.5 rounded-md border border-surface" :disabled="selectedCount===0" :class="selectedCount===0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'">批量新增楼栋</button>
|
||||
<button class="px-3 py-1.5 rounded-md border border-surface" :disabled="selectedCount===0" :class="selectedCount===0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'">批改区域商圈</button>
|
||||
<button class="px-3 py-1.5 rounded-md border border-surface" :disabled="selectedCount===0" :class="selectedCount===0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'">删除</button>
|
||||
<button class="px-3 py-1.5 rounded-md border border-surface" :disabled="selectedCount===0" :class="selectedCount===0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'">合并楼盘</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted">
|
||||
<span x-show="selectedCount===0">未选中楼盘</span>
|
||||
<span x-show="selectedCount>0" x-text="'已选 ' + selectedCount + ' 条'"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Table -->
|
||||
<section class="bg-surface border border-surface rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-subtle border-b border-surface">
|
||||
<tr class="text-left">
|
||||
<th scope="col" class="px-3 py-2 w-10">
|
||||
<input type="checkbox" class="rounded border-surface" :checked="allOnPageSelected" @click.prevent="toggleSelectPage(!allOnPageSelected)" aria-label="全选当前页" />
|
||||
</th>
|
||||
<th scope="col" class="px-3 py-2">楼盘名称</th>
|
||||
<th scope="col" class="px-3 py-2">楼盘类型</th>
|
||||
<th scope="col" class="px-3 py-2">详细地址</th>
|
||||
<th scope="col" class="px-3 py-2">城区商圈</th>
|
||||
<th scope="col" class="px-3 py-2">当月挂牌均价(元/㎡)</th>
|
||||
<th scope="col" class="px-3 py-2">楼栋数</th>
|
||||
<th scope="col" class="px-3 py-2">产品数</th>
|
||||
<th scope="col" class="px-3 py-2">房源数</th>
|
||||
<th scope="col" class="px-3 py-2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="paginatedRows.length === 0">
|
||||
<tr>
|
||||
<td colspan="10" class="px-4 py-12 text-center">
|
||||
<p class="text-base font-medium text-surface">暂无匹配楼盘</p>
|
||||
<p class="text-xs text-muted mt-1">请尝试调整筛选条件</p>
|
||||
<button class="mt-3 px-3 py-1.5 rounded-md border border-surface hover:bg-neutral-50" @click="resetFilters()">清除筛选</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template x-for="row in paginatedRows" :key="row.id">
|
||||
<tr class="border-b border-surface hover:bg-neutral-50/50">
|
||||
<td class="px-3 py-2 align-top">
|
||||
<input type="checkbox" class="rounded border-surface" :checked="isSelected(row.id)" @click.prevent="toggleSelect(row.id, !isSelected(row.id))" aria-label="选择楼盘" />
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top">
|
||||
<button class="text-left text-info-600 hover:underline font-medium" @click="openDetail(row)" x-text="row.name"></button>
|
||||
<div class="mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-info-50 text-info-600">信息</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-warning-50 text-warning-600">标准楼盘</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-danger-50 text-danger-600">标准房号</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top" x-text="row.usage"></td>
|
||||
<td class="px-3 py-2 align-top" x-text="row.address"></td>
|
||||
<td class="px-3 py-2 align-top" x-text="row.region + '-' + row.biz"></td>
|
||||
<td class="px-3 py-2 align-top tabular-nums" x-text="row.avgPrice"></td>
|
||||
<td class="px-3 py-2 align-top tabular-nums" x-text="row.buildingCount"></td>
|
||||
<td class="px-3 py-2 align-top tabular-nums" x-text="row.productCount"></td>
|
||||
<td class="px-3 py-2 align-top">
|
||||
<span class="text-xs text-muted">出售</span>
|
||||
<span class="tabular-nums" x-text="row.saleCount"></span>
|
||||
<span class="text-xs text-muted">/出租</span>
|
||||
<span class="tabular-nums" x-text="row.rentCount"></span>
|
||||
<span class="text-xs text-muted">/共</span>
|
||||
<span class="tabular-nums" x-text="row.saleCount + row.rentCount"></span>
|
||||
</td>
|
||||
<td class="px-3 py-2 align-top">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="text-info-600 hover:underline text-xs" @click="notify('编辑楼盘(原型)')">编辑</button>
|
||||
<button class="text-danger-600 hover:underline text-xs" @click="notify('删除楼盘(原型)')">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pagination -->
|
||||
<section class="bg-surface border border-surface rounded-lg px-4 py-3 flex items-center justify-between">
|
||||
<p class="text-xs text-muted" x-text="'共 ' + filteredRows.length + ' 条'" aria-live="polite"></p>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button class="px-2 py-1 rounded border border-surface" :disabled="currentPage===1" :class="currentPage===1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'" @click="goPrev()">上一页</button>
|
||||
<span class="tabular-nums" x-text="currentPage + ' / ' + totalPages"></span>
|
||||
<button class="px-2 py-1 rounded border border-surface" :disabled="currentPage===totalPages || totalPages===0" :class="(currentPage===totalPages || totalPages===0) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'" @click="goNext()">下一页</button>
|
||||
|
||||
<span class="ml-2">20条/页</span>
|
||||
<span class="ml-2">跳至</span>
|
||||
<input type="number" min="1" :max="totalPages || 1" x-model.number="jumpPage" class="w-16 px-2 py-1 rounded border border-surface" />
|
||||
<button class="px-2 py-1 rounded border border-surface hover:bg-neutral-50" @click="goJump()">确定</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast -->
|
||||
<div x-show="toast.show" x-cloak class="fixed bottom-5 right-5 z-50 px-4 py-2 rounded-md text-sm text-white shadow-lg bg-success-600" x-text="toast.message"></div>
|
||||
|
||||
<script>
|
||||
function complexListPage() {
|
||||
return {
|
||||
rows: [],
|
||||
filters: {
|
||||
keyword: '',
|
||||
region: '不限',
|
||||
usage: '不限',
|
||||
fixedStatus: '',
|
||||
completion: '',
|
||||
complexType: '',
|
||||
hasProperty: '',
|
||||
buildingType: '',
|
||||
ownership: '',
|
||||
hasCoord: ''
|
||||
},
|
||||
regionOptions: ['不限', '静安', '闵行', '普陀', '松江', '长宁', '嘉定'],
|
||||
usageOptions: ['不限', '住宅', '别墅', '商住', '商业', '写字楼', '其他'],
|
||||
extraFilters: [
|
||||
{ key: 'fixedStatus', label: '固定情况', options: ['已固定', '未固定'] },
|
||||
{ key: 'completion', label: '完善情况', options: ['高', '中', '低'] },
|
||||
{ key: 'complexType', label: '楼盘类型', options: ['标准', '非标'] },
|
||||
{ key: 'hasProperty', label: '有无房源', options: ['有', '无'] },
|
||||
{ key: 'buildingType', label: '楼栋类型', options: ['板楼', '塔楼', '板塔结合'] },
|
||||
{ key: 'ownership', label: '权属关系', options: ['商品房', '房改房', '经济适用房'] },
|
||||
{ key: 'hasCoord', label: '有无坐标', options: ['有坐标', '无坐标'] }
|
||||
],
|
||||
metrics: [
|
||||
{ name: '楼盘关联率', value: '47.61%' },
|
||||
{ name: '楼栋及单元完整率', value: '100.38%' },
|
||||
{ name: '房号匹配率', value: '100%' },
|
||||
{ name: '处置率', value: '12.05%' },
|
||||
{ name: '入住人结构数据', value: '58 / 3000' },
|
||||
{ name: '有效结构数量', value: '523 / 523' },
|
||||
{ name: '房源对标', value: '1.83%' }
|
||||
],
|
||||
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
jumpPage: 1,
|
||||
selectedIds: [],
|
||||
|
||||
toast: { show: false, message: '' },
|
||||
|
||||
init() {
|
||||
this.rows = this.mockRows();
|
||||
},
|
||||
|
||||
mockRows() {
|
||||
const seeds = [
|
||||
['都市港湾', '住宅', '嘉定', '丰庄', '上海 嘉定 海波路1000弄'],
|
||||
['阳光威尼斯四期', '别墅', '普陀', '真光', '上海 普陀 金鼎路1600弄'],
|
||||
['嘉城名都', '住宅', '嘉定', '江桥', '上海 嘉定 嘉城路188弄'],
|
||||
['中海臻如府', '商住', '普陀', '真如', '上海 普陀 真如路88弄'],
|
||||
['凯旋华庭', '写字楼', '长宁', '中山公园', '上海 长宁 凯旋路888号'],
|
||||
['虹桥商务中心', '商业', '闵行', '虹桥', '上海 闵行 申长路699号'],
|
||||
['静安云邸', '住宅', '静安', '大宁', '上海 静安 万荣路66弄'],
|
||||
['松江壹号院', '别墅', '松江', '大学城', '上海 松江 文汇路188号']
|
||||
];
|
||||
|
||||
const list = [];
|
||||
for (let i = 1; i <= 46; i++) {
|
||||
const s = seeds[(i - 1) % seeds.length];
|
||||
list.push({
|
||||
id: i,
|
||||
name: `${s[0]}${i}`,
|
||||
usage: s[1],
|
||||
region: s[2],
|
||||
biz: s[3],
|
||||
address: s[4],
|
||||
avgPrice: (32000 + i * 137).toFixed(2),
|
||||
buildingCount: (6 + (i % 16)),
|
||||
productCount: (50 + (i % 90)),
|
||||
saleCount: (i % 12),
|
||||
rentCount: (i % 8)
|
||||
});
|
||||
}
|
||||
return list;
|
||||
},
|
||||
|
||||
applyFilters() {
|
||||
this.currentPage = 1;
|
||||
this.jumpPage = 1;
|
||||
this.selectedIds = [];
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.filters = {
|
||||
keyword: '',
|
||||
region: '不限',
|
||||
usage: '不限',
|
||||
fixedStatus: '',
|
||||
completion: '',
|
||||
complexType: '',
|
||||
hasProperty: '',
|
||||
buildingType: '',
|
||||
ownership: '',
|
||||
hasCoord: ''
|
||||
};
|
||||
this.currentPage = 1;
|
||||
this.jumpPage = 1;
|
||||
this.selectedIds = [];
|
||||
},
|
||||
|
||||
get filteredRows() {
|
||||
const keyword = (this.filters.keyword || '').toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
const hitKeyword = !keyword || [r.name, r.address, `${r.region}${r.biz}`].some(v => String(v).toLowerCase().includes(keyword));
|
||||
const hitRegion = this.filters.region === '不限' || r.region === this.filters.region;
|
||||
const hitUsage = this.filters.usage === '不限' || r.usage === this.filters.usage;
|
||||
return hitKeyword && hitRegion && hitUsage;
|
||||
});
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.filteredRows.length / this.pageSize));
|
||||
},
|
||||
|
||||
get paginatedRows() {
|
||||
const page = Math.min(this.currentPage, this.totalPages);
|
||||
const start = (page - 1) * this.pageSize;
|
||||
return this.filteredRows.slice(start, start + this.pageSize);
|
||||
},
|
||||
|
||||
get selectedCount() {
|
||||
return this.selectedIds.length;
|
||||
},
|
||||
|
||||
isSelected(id) {
|
||||
return this.selectedIds.includes(id);
|
||||
},
|
||||
|
||||
toggleSelect(id, checked) {
|
||||
if (checked) {
|
||||
if (!this.selectedIds.includes(id)) this.selectedIds.push(id);
|
||||
} else {
|
||||
this.selectedIds = this.selectedIds.filter(x => x !== id);
|
||||
}
|
||||
},
|
||||
|
||||
get allOnPageSelected() {
|
||||
if (this.paginatedRows.length === 0) return false;
|
||||
return this.paginatedRows.every(r => this.selectedIds.includes(r.id));
|
||||
},
|
||||
|
||||
toggleSelectPage(checked) {
|
||||
const pageIds = this.paginatedRows.map(r => r.id);
|
||||
if (checked) {
|
||||
const set = new Set([...this.selectedIds, ...pageIds]);
|
||||
this.selectedIds = [...set];
|
||||
} else {
|
||||
this.selectedIds = this.selectedIds.filter(id => !pageIds.includes(id));
|
||||
}
|
||||
},
|
||||
|
||||
goPrev() {
|
||||
if (this.currentPage > 1) this.currentPage -= 1;
|
||||
this.jumpPage = this.currentPage;
|
||||
this.selectedIds = [];
|
||||
},
|
||||
|
||||
goNext() {
|
||||
if (this.currentPage < this.totalPages) this.currentPage += 1;
|
||||
this.jumpPage = this.currentPage;
|
||||
this.selectedIds = [];
|
||||
},
|
||||
|
||||
goJump() {
|
||||
let p = Number(this.jumpPage || 1);
|
||||
if (!Number.isFinite(p)) p = 1;
|
||||
p = Math.max(1, Math.min(this.totalPages, Math.floor(p)));
|
||||
this.currentPage = p;
|
||||
this.jumpPage = p;
|
||||
this.selectedIds = [];
|
||||
},
|
||||
|
||||
openDetail(row) {
|
||||
window.location.hash = `complex/${row.id}`;
|
||||
this.notify(`进入楼盘详情(原型):${row.name}`);
|
||||
},
|
||||
|
||||
notify(message) {
|
||||
this.toast = { show: true, message };
|
||||
window.setTimeout(() => { this.toast.show = false; }, 1800);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
174
Project/fonrey/UI_DESIGN/楼盘管理/楼盘列表_UI.md
Normal file
174
Project/fonrey/UI_DESIGN/楼盘管理/楼盘列表_UI.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 楼盘列表 UI 设计文档
|
||||
|
||||
> **任务编号**:04(P0-B)
|
||||
> **覆盖范围**:`US-COMPLEX-002`(经纪人查看楼盘列表与详情入口)
|
||||
> **输出文件**:`UI_DESIGN/楼盘列表_UI.html`
|
||||
> **设计基线**:`UI_SYSTEM/UI_SYSTEM.md`(列表页模板、Top Bar + Sidebar 壳层、分页规范)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
### 1.1 页面目标
|
||||
|
||||
楼盘列表页用于经纪人和运营人员进行楼盘检索与筛选,并作为楼盘详情页的统一入口。
|
||||
|
||||
核心目标:
|
||||
|
||||
1. 支持关键词搜索(楼盘名称 / 别名 / 概要地址)
|
||||
2. 支持区域、用途等维度筛选
|
||||
3. 支持分页(默认 20 条/页 + 跳页)
|
||||
4. 列表中楼盘名称可点击进入楼盘详情
|
||||
5. 提供完整度统计面板与批量操作入口位(P0 原型占位)
|
||||
|
||||
### 1.2 本任务边界
|
||||
|
||||
本任务只交付“楼盘列表页”的静态高保真原型,不实现真实后端查询。
|
||||
|
||||
- ✅ 包含:筛选交互、分页演示、详情跳转入口位
|
||||
- ⛔ 不包含:真实接口联动、真实批量操作执行、真实权限校验
|
||||
|
||||
---
|
||||
|
||||
## 2. 信息架构
|
||||
|
||||
## 2.1 壳层结构(与新增客源/房源详情一致)
|
||||
|
||||
- **Top Bar(固定 56px)**:品牌 + 一级导航 + 用户区
|
||||
- **Sidebar(固定 240px)**:房源管理二级导航
|
||||
- **Main Content(`ml-60 pt-[72px]`)**:
|
||||
1. 面包屑 + 页面标题
|
||||
2. 模块 Tab(楼盘 / 区域管理 / 学校管理 / 应用标准数据)
|
||||
3. 完整度统计面板
|
||||
4. 搜索筛选区
|
||||
5. 批量操作条
|
||||
6. 列表表格
|
||||
7. 分页条
|
||||
|
||||
## 2.2 楼盘列表表格字段
|
||||
|
||||
| 列字段 | 说明 |
|
||||
|---|---|
|
||||
| 勾选 | 批量操作选择 |
|
||||
| 楼盘名称 | 蓝色可点击,进入详情页 |
|
||||
| 楼盘类型 | 住宅 / 别墅 / 商住 / 商业 / 写字楼 / 其他 |
|
||||
| 详细地址 | 城市 + 街道 / 弄号 |
|
||||
| 城区商圈 | 形如「嘉定-江桥新城」 |
|
||||
| 当月挂牌均价(元/㎡) | 数值,支持排序入口位 |
|
||||
| 楼栋数 | 数值 |
|
||||
| 产品数 | 数值 |
|
||||
| 房源数 | 出售N / 出租N / 共N |
|
||||
| 操作 | 编辑 / 删除 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 交互设计
|
||||
|
||||
## 3.1 搜索与筛选
|
||||
|
||||
### 3.1.1 关键词搜索
|
||||
|
||||
- 输入框占位:`楼盘名/别名/拼音/详细地址`
|
||||
- 点击「查询」执行前端过滤演示
|
||||
- 点击「清除」重置全部条件
|
||||
|
||||
### 3.1.2 快捷筛选
|
||||
|
||||
- 区域:不限、静安、闵行、普陀、松江、长宁、嘉定
|
||||
- 用途:不限、住宅、别墅、商住、商业、写字楼、其他
|
||||
|
||||
### 3.1.3 下拉筛选(占位)
|
||||
|
||||
- 固定情况
|
||||
- 完善情况
|
||||
- 楼盘类型
|
||||
- 有无房源
|
||||
- 楼栋类型
|
||||
- 权属关系
|
||||
- 有无坐标
|
||||
|
||||
> 以上下拉项在本原型为可见控件,不做真实复杂筛选逻辑。
|
||||
|
||||
## 3.2 批量操作
|
||||
|
||||
按钮区:
|
||||
|
||||
- + 新增楼盘(主 CTA)
|
||||
- 批量新增楼栋
|
||||
- 批改区域商圈
|
||||
- 删除
|
||||
- 合并楼盘
|
||||
|
||||
规则(原型态):
|
||||
|
||||
- 当未选择行时,批量按钮显示禁用态
|
||||
- 选中 1 条及以上时,禁用态解除(仅视觉)
|
||||
|
||||
## 3.3 列表与跳转
|
||||
|
||||
- 点击楼盘名称:跳转 `#/complex/{id}`(原型 hash)并 Toast 提示“进入楼盘详情(原型)”
|
||||
- 点击编辑 / 删除:仅触发提示,不执行真实动作
|
||||
|
||||
## 3.4 分页
|
||||
|
||||
- 默认每页 20 条
|
||||
- 支持上一页 / 下一页
|
||||
- 支持输入页码跳转(超范围自动纠正)
|
||||
- 展示 `共 X 条` + 当前页码
|
||||
|
||||
---
|
||||
|
||||
## 4. 状态矩阵
|
||||
|
||||
| 状态 | 触发 | 页面反馈 |
|
||||
|---|---|---|
|
||||
| 默认 | 首次加载 | 展示全部 mock 数据第一页 |
|
||||
| 搜索结果 | 输入关键词并查询 | 表格按关键词过滤 |
|
||||
| 空结果 | 条件过严 | 空状态(暂无匹配楼盘)+ 清除筛选 |
|
||||
| 行选中 | 勾选一行或全选 | 批量按钮解除禁用 |
|
||||
| 分页切换 | 点击页码/上一页/下一页 | 列表刷新为对应页 |
|
||||
| 页面主题 | 统一浅色管理后台主题 | 不在页面内提供 Light/Dark 切换控件 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据模型映射(DATA_MODEL_COMPLEX)
|
||||
|
||||
| UI 字段 | 数据模型字段 |
|
||||
|---|---|
|
||||
| 楼盘名称 | `complexes.name` |
|
||||
| 详细地址 | `complexes.address` |
|
||||
| 概要地址(搜索) | `complexes.address_summary` |
|
||||
| 城区 | `complexes.district_id -> districts.name` |
|
||||
| 主商圈 | `complex_business_areas.is_primary -> business_areas.name` |
|
||||
| 楼栋数 | `COUNT(buildings.id)` |
|
||||
| 出售/出租数量 | `properties.status` 聚合 |
|
||||
| 搜索向量 | `complexes.search_vector` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 可访问性与规范
|
||||
|
||||
- 表头使用语义化 `<th scope="col">`
|
||||
- 纯图标按钮添加 `aria-label`
|
||||
- 所有输入项含可见 label 或占位 + 分组标题
|
||||
- 焦点态统一 `focus-visible:ring-2`
|
||||
- 空状态提供主动作按钮(清除筛选)
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收清单
|
||||
|
||||
- [x] 壳层结构:Top Bar + Sidebar + Main
|
||||
- [x] 楼盘列表页模块 Tab 完整
|
||||
- [x] 统计面板、筛选区、批量区、表格区、分页区完整
|
||||
- [x] 默认 20 条/页的分页演示
|
||||
- [x] 楼盘名称可点击触发“进入详情”原型行为
|
||||
- [x] 页面内不包含主题切换控件(遵循统一后台视觉)
|
||||
- [x] 控制台 0 报错(本地预览验证阶段)
|
||||
|
||||
---
|
||||
|
||||
## 8. 后续衔接
|
||||
|
||||
- 本页评审通过后,进入任务05:楼盘详情/维护(`US-COMPLEX-001`)
|
||||
- 任务05将复用本页的详情入口与模块 Tab 风格,保证楼盘模块前后页体验一致
|
||||
@@ -1,4 +1,6 @@
|
||||
# Fonrey 项目骨架搭建 — 工程执行提示词
|
||||
> **版本**:v2.2(2026-04-28)|v2.0 修复 P0×5+P1×4;v2.1 修复 P0×9(交叉比对 AGENTS.md / 测试规范.md / 系统管理技术文档.md);v2.2 收口剩余一致性问题(URL 分离、Admin 弃用、密钥变量统一、测试 settings 一致性、环境变量占位修复)
|
||||
> **v2.2 主要变更**:统一 `config/urls.py` / `config/urls_public.py` 职责并修正执行清单;移除 Django Admin 路由引用(对齐系统管理技术文档);PII 密钥统一为 `PHONE_ENCRYPTION_KEY`;`pyproject.toml` 测试 settings 对齐 `config.settings.testing` 并新增 `testing.py` 生成要求;修复 AWS/R2 示例占位符与 `.env.example` 断行问题;修正 docker-compose 服务数量描述
|
||||
## 你的角色与约束
|
||||
你是一名资深 Django 后端工程师。你的任务是**严格按照规范**搭建 Fonrey 项目骨架,不得自行发明技术方案,不得引入文档未授权的第三方库。每一步操作后必须验证结果。
|
||||
**项目工作目录**:`/mnt/c/Project/`(在此目录下创建 `fonrey/` 子目录)
|
||||
@@ -33,12 +35,13 @@ fonrey/
|
||||
│ ├── property/ # 房源核心(in TENANT_APPS)
|
||||
│ ├── client/ # 客源管理(in TENANT_APPS)
|
||||
│ ├── setting/ # 系统设置(in TENANT_APPS)
|
||||
│ └── release/ # 客户端发布管理(in SHARED_APPS)
|
||||
│ └── release/ # 客户端发布管理(in SHARED_APPS,⚠️ 无 services/,不做多租户隔离)
|
||||
├── core/
|
||||
│ ├── __init__.py
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── base.py # 抽象基类(见第四节规范)
|
||||
│ ├── enums.py # 全局枚举类(与 ENUMS.md 严格对齐,供 models/serializers 导入)
|
||||
│ ├── encryption.py # PII 加密(AES-256-GCM)
|
||||
│ ├── cache.py # Redis 工具
|
||||
│ ├── templatetags/
|
||||
@@ -56,8 +59,10 @@ fonrey/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # 基础配置
|
||||
│ │ ├── development.py
|
||||
│ │ ├── testing.py # 测试配置(pytest)
|
||||
│ │ └── production.py
|
||||
│ ├── urls.py
|
||||
│ ├── urls.py # tenant schema 路由入口
|
||||
│ ├── urls_public.py # public schema 路由入口
|
||||
│ ├── asgi.py # ASGI 入口
|
||||
│ └── wsgi.py
|
||||
├── templates/
|
||||
@@ -111,7 +116,7 @@ apps/property/
|
||||
├── services/
|
||||
│ ├── __init__.py
|
||||
│ └── .gitkeep
|
||||
├── tasks.py # Celery 任务骨架
|
||||
├── tasks.py # Celery 任务骨架(见第九节模板)
|
||||
├── views.py # HTMX/JSON 视图骨架
|
||||
├── urls.py
|
||||
├── templates/
|
||||
@@ -120,8 +125,85 @@ apps/property/
|
||||
├── __init__.py
|
||||
└── .gitkeep
|
||||
```
|
||||
**⚠️ `apps/release/` 的内部结构特殊**(in SHARED_APPS,不做多租户数据隔离):
|
||||
```
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
└── .gitkeep # App 内单元测试(纯 model/service 逻辑,无 HTTP)
|
||||
```
|
||||
**整体 tests/ 分层规范**(P1-10):
|
||||
- `apps/<mod>/tests/` — 单元测试(该 App 内部逻辑,不跨 App)
|
||||
- 项目根 `tests/` — 集成测试 / E2E(跨 App 或依赖外部服务)
|
||||
```
|
||||
tests/ # 项目根集成/E2E 测试
|
||||
├── __init__.py
|
||||
├── conftest.py # 全局 fixture(DB、租户、认证)
|
||||
├── integration/
|
||||
│ ├── __init__.py
|
||||
│ ├── property/
|
||||
│ │ └── .gitkeep
|
||||
│ ├── client/
|
||||
│ │ └── .gitkeep
|
||||
│ └── release/
|
||||
│ └── test_client_update_api.py # schemathesis 契约测试骨架
|
||||
└── e2e/
|
||||
├── __init__.py
|
||||
└── .gitkeep # playwright E2E(pytest-playwright)
|
||||
```
|
||||
⚠️ `release/` **无** `services/`、`tasks.py`;不引用 `schema_context`。
|
||||
```
|
||||
apps/release/
|
||||
├── __init__.py
|
||||
├── apps.py
|
||||
├── admin.py
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ └── .gitkeep # ClientRelease 模型
|
||||
├── views.py # 公开 API(无需登录),返回 JSON
|
||||
├── urls.py # 被 config/urls_public.py include(public schema)
|
||||
└── serializers.py # DRF Serializer(配合 drf-spectacular)
|
||||
```
|
||||
---
|
||||
## 三、Django 配置规范
|
||||
### 3.0 URL 路由文件职责(强制分离,禁止合并)
|
||||
```python
|
||||
# config/urls.py — Tenant schema 路由入口(仅 tenant)
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path("", include("apps.account.urls")),
|
||||
path("", include("apps.property.urls")),
|
||||
path("", include("apps.client.urls")),
|
||||
path("", include("apps.complex.urls")),
|
||||
path("", include("apps.org.urls")),
|
||||
path("", include("apps.permission.urls")),
|
||||
path("", include("apps.setting.urls")),
|
||||
]
|
||||
```
|
||||
|
||||
```python
|
||||
# config/urls_public.py — Public schema 专用路由(管理后台、release API、OpenAPI)
|
||||
# ⚠️ 对齐 TECH_STACK/系统管理技术文档.md:Django Admin 全环境弃用,不注册任何管理后台路由
|
||||
from django.urls import path, include
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
|
||||
urlpatterns = [
|
||||
path("api/client/updates/latest/", include("apps.release.urls")),
|
||||
# OpenAPI — 仅 DEBUG 暴露,production 通过 nginx ACL 限制
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
||||
]
|
||||
```
|
||||
⚠️ django-tenants URL routing 强制规范(系统管理技术文档.md:207):
|
||||
|
||||
```python
|
||||
# config/settings/base.py — 多租户 URL 分离配置(必须显式声明)
|
||||
ROOT_URLCONF = "config.urls" # tenant schema 路由入口
|
||||
PUBLIC_SCHEMA_URLCONF = "config.urls_public" # public schema 路由入口
|
||||
```
|
||||
|
||||
> `config/urls.py` 仅包含 `urlpatterns`(tenant 路由),`config/urls_public.py` 包含 `urlpatterns`(public 路由)。两个文件分开维护,**不得合并**。
|
||||
|
||||
### 3.1 INSTALLED_APPS 分区
|
||||
```python
|
||||
# config/settings/base.py
|
||||
@@ -139,6 +221,11 @@ SHARED_APPS = [
|
||||
# 第三方(shared)
|
||||
"django_celery_beat",
|
||||
"django_celery_results",
|
||||
"rest_framework", # DRF(drf-spectacular 依赖)
|
||||
"drf_spectacular", # OpenAPI schema(API_CONTRACT.md §11 MUST)
|
||||
"core", # 基础工具层(非业务 App,放 shared 确保迁移可见)
|
||||
"django_htmx", # HTMX 中间件(request.htmx 语义支持,系统管理技术文档.md:431)
|
||||
"django_extensions", # shell_plus 等开发辅助命令
|
||||
]
|
||||
TENANT_APPS = [
|
||||
"apps.account",
|
||||
@@ -149,7 +236,6 @@ TENANT_APPS = [
|
||||
"apps.property",
|
||||
"apps.client",
|
||||
"apps.setting",
|
||||
"core",
|
||||
]
|
||||
INSTALLED_APPS = list(SHARED_APPS) + list(TENANT_APPS)
|
||||
```
|
||||
@@ -158,7 +244,7 @@ INSTALLED_APPS = list(SHARED_APPS) + list(TENANT_APPS)
|
||||
# 多租户
|
||||
TENANT_MODEL = "tenant.Tenant"
|
||||
TENANT_DOMAIN_MODEL = "tenant.Domain"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.UUIDField" # 全局 UUID PK
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # 非 UUID 表的默认 PK;业务表 UUID PK 靠 UUIDPrimaryKeyModel
|
||||
# 数据库(从环境变量读取)
|
||||
DATABASES = {
|
||||
"default": {
|
||||
@@ -206,7 +292,7 @@ ASGI_APPLICATION = "config.asgi.application"
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
CSRF_COOKIE_HTTPONLY = False # HTMX 需要读取
|
||||
CSRF_COOKIE_HTTPONLY = False # ⚠️ HTMX 需要 JS 读取 CSRF token,故意设为 False,禁止"修复"此项
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
# 模板
|
||||
TEMPLATES = [{
|
||||
@@ -224,6 +310,15 @@ TEMPLATES = [{
|
||||
}]
|
||||
# HTMX
|
||||
HTMX_GLOBAL_CSRF = True # 全局 CSRF 注入
|
||||
# drf-spectacular(OpenAPI,API_CONTRACT.md §11 MUST)
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Fonrey API",
|
||||
"DESCRIPTION": "Fonrey 房产经纪管理系统 OpenAPI 3.1",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"COMPONENT_SPLIT_REQUEST": True,
|
||||
"ENUM_GENERATE_CHOICE_DESCRIPTION": False, # 枚举说明由 ENUMS.md 权威维护
|
||||
}
|
||||
# 日志(骨架,production 扩展)
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
@@ -244,6 +339,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_htmx.middleware.HtmxMiddleware", # HTMX:request.htmx(系统管理技术文档.md:431)
|
||||
"core.middleware.audit.AuditMiddleware", # 自定义审计(骨架)
|
||||
]
|
||||
```
|
||||
@@ -266,6 +362,10 @@ class TimeStampedModel(UUIDPrimaryKeyModel):
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ["-created_at"]
|
||||
class ActiveManager(models.Manager):
|
||||
"""默认只返回未软删除的记录"""
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(deleted_at__isnull=True)
|
||||
class SoftDeleteModel(TimeStampedModel):
|
||||
"""软删除:deleted_at=NULL 表示未删除"""
|
||||
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
@@ -301,37 +401,125 @@ class AuditedModel(SoftDeleteModel):
|
||||
)
|
||||
class Meta:
|
||||
abstract = True
|
||||
class ActiveManager(models.Manager):
|
||||
"""默认只返回未软删除的记录"""
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(deleted_at__isnull=True)
|
||||
```
|
||||
---
|
||||
## 五、PII 加密(core/encryption.py)
|
||||
骨架实现,接口固定(后续补充实现体),确保接口签名正确:
|
||||
## 五、全局枚举骨架(core/enums.py)
|
||||
**权威来源**:`DATA_MODEL/ENUMS.md v2.2`。本文件是 ENUMS.md 的 Python 镜像,每次 ENUMS.md 更新后必须同步。
|
||||
**规范**:所有枚举值 `lower_snake_case`,禁止在 models/serializers 中硬编码字符串枚举值,必须从此文件导入。
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# public schema 枚举
|
||||
# ──────────────────────────────────────────────
|
||||
class PlatformAuditResult(models.TextChoices):
|
||||
SUCCESS = "success", "通过"
|
||||
FAILED = "failed", "未通过"
|
||||
|
||||
class UpgradeType(models.TextChoices):
|
||||
APP = "app", "应用升级"
|
||||
SCHEMA = "schema", "数据库 Schema 升级"
|
||||
FEATURE = "feature", "功能开关升级"
|
||||
|
||||
class ExportTaskStatus(models.TextChoices):
|
||||
PENDING = "pending", "排队中"
|
||||
PROCESSING = "processing", "处理中"
|
||||
SUCCESS = "success", "成功"
|
||||
FAILED = "failed", "失败"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# property 枚举(骨架,值见 ENUMS.md §property)
|
||||
# ──────────────────────────────────────────────
|
||||
class PropertyGrade(models.TextChoices):
|
||||
A = "a", "A(急迫)"
|
||||
B = "b", "B(优先)"
|
||||
C = "c", "C(普通)"
|
||||
D = "d", "D(搁置)"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# client 枚举(骨架,值见 ENUMS.md §client)
|
||||
# ──────────────────────────────────────────────
|
||||
class ClientGrade(models.TextChoices):
|
||||
A = "a", "A(急迫)"
|
||||
B = "b", "B(优先)"
|
||||
C = "c", "C(普通)"
|
||||
D = "d", "D(搁置)"
|
||||
E = "e", "E(冷冻)"
|
||||
|
||||
class ClientType(models.TextChoices):
|
||||
PRIVATE = "private", "私客"
|
||||
PUBLIC = "public", "公客"
|
||||
TRANSACTED = "transacted", "成交客"
|
||||
INVALID = "invalid", "无效客"
|
||||
|
||||
class ClientStatus(models.TextChoices):
|
||||
BUYING = "buying", "买房中"
|
||||
RENTING = "renting", "租房中"
|
||||
BUY_OR_RENT = "buy_or_rent", "买租均可"
|
||||
SUSPENDED = "suspended", "暂停"
|
||||
BOUGHT = "bought", "已购"
|
||||
RENTED_DONE = "rented_done", "已租"
|
||||
PUBLIC = "public", "已转公客"
|
||||
INVALID = "invalid", "已无效"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# permission 枚举(骨架,值见 ENUMS.md §permission)
|
||||
# ──────────────────────────────────────────────
|
||||
class PermissionValueType(models.TextChoices):
|
||||
BOOLEAN = "boolean", "布尔"
|
||||
SCOPE = "scope", "数据范围"
|
||||
INTEGER = "integer", "整数"
|
||||
|
||||
class PermissionOverrideMode(models.TextChoices):
|
||||
REPLACE = "replace", "替换"
|
||||
RESTRICT = "restrict", "限制(取更严格)"
|
||||
GRANT = "grant", "授权(取更宽松)"
|
||||
|
||||
# TODO:其余枚举按 ENUMS.md 顺序补全(setting、org、complex、account...)
|
||||
```
|
||||
---
|
||||
## 六、PII 加密(core/encryption.py)
|
||||
骨架实现,接口固定(后续补充实现体),确保接口签名正确:
|
||||
|
||||
> ⚠️ **算法强制规范(AGENTS.md §4.4):必须用 AES-256-GCM,禁止 Fernet(Fernet 是 AES-128-CBC)。**
|
||||
> `cryptography` 库使用 `cryptography.hazmat.primitives.ciphers.aead.AESGCM`。
|
||||
|
||||
```python
|
||||
from cryptography.fernet import Fernet
|
||||
import hashlib
|
||||
import os
|
||||
import base64
|
||||
from django.conf import settings
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
class PhoneEncryption:
|
||||
"""
|
||||
手机号 AES-256-GCM 加密存储 + SHA-256 哈希索引
|
||||
存储字段:phone_encrypted(加密密文)+ phone_hash(哈希,用于精确查询)
|
||||
显示:脱敏格式 138****1234
|
||||
- 加密算法:AES-256-GCM(AGENTS.md §4.4,对照 TECH_STACK/系统管理技术文档.md)
|
||||
- 存储字段:phone_encrypted(base64 密文)+ phone_hash(SHA-256,用于精确查询)
|
||||
- 显示:脱敏格式 138****1234
|
||||
密钥来源:settings.PHONE_ENCRYPTION_KEY(32字节,base64 encoded,从 .env 注入)
|
||||
"""
|
||||
@staticmethod
|
||||
def _get_key() -> bytes:
|
||||
key_b64 = settings.PHONE_ENCRYPTION_KEY
|
||||
return base64.b64decode(key_b64) # 必须 32 bytes(AES-256)
|
||||
|
||||
@staticmethod
|
||||
def encrypt(phone: str) -> str:
|
||||
"""加密手机号,返回 base64 密文"""
|
||||
... # TODO: 实现
|
||||
"""加密手机号,返回 base64(nonce + ciphertext + tag)"""
|
||||
... # TODO: AESGCM(key).encrypt(nonce, phone.encode(), None)
|
||||
|
||||
@staticmethod
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
"""解密返回明文"""
|
||||
... # TODO: 实现
|
||||
... # TODO: AESGCM(key).decrypt(nonce, ciphertext_bytes, None).decode()
|
||||
|
||||
@staticmethod
|
||||
def hash(phone: str) -> str:
|
||||
"""返回 SHA-256 哈希(用于 DB 索引查询)"""
|
||||
... # TODO: 实现
|
||||
... # TODO: hashlib.sha256(phone.encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def mask(phone: str) -> str:
|
||||
"""返回脱敏格式:138****1234"""
|
||||
@@ -340,7 +528,7 @@ class PhoneEncryption:
|
||||
return phone[:3] + "****" + phone[-4:]
|
||||
```
|
||||
---
|
||||
## 六、Heroicons Templatetag(core/templatetags/heroicons.py)
|
||||
## 七、Heroicons Templatetag(core/templatetags/heroicons.py)
|
||||
```python
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -356,11 +544,61 @@ def heroicon(name: str, size: str = "24", style: str = "outline", css_class: str
|
||||
# 骨架:实际从 heroicons vendor 文件读取 SVG
|
||||
# size 可选: 12, 16, 20, 24
|
||||
# style 可选: outline, solid, mini
|
||||
css = f'class="w-{size//4 if isinstance(size, int) else size} h-{size//4 if isinstance(size, int) else size} {css_class}"'
|
||||
size_int = int(size)
|
||||
css = f'class="w-{size_int // 4} h-{size_int // 4} {css_class}"'
|
||||
return mark_safe(f'<!-- heroicon:{style}/{name} -->') # TODO: 替换为实际 SVG
|
||||
```
|
||||
---
|
||||
## 七、模板体系
|
||||
## 八、Redis 工具(core/cache.py)
|
||||
**Redis Key 格式规范**(所有 Redis 操作必须遵守):`{tenant_schema}:{module}:{key}`
|
||||
禁止裸字符串拼接 Key;public schema 操作传 `"public"` 作为 `tenant_schema`。
|
||||
|
||||
```python
|
||||
from django.core.cache import cache
|
||||
|
||||
def get_redis_key(tenant_schema: str, module: str, key: str) -> str:
|
||||
"""
|
||||
构造带租户前缀的 Redis Key。
|
||||
示例:get_redis_key("acme", "permission", "staff:uuid-xxx")
|
||||
→ "acme:permission:staff:uuid-xxx"
|
||||
"""
|
||||
return f"{tenant_schema}:{module}:{key}"
|
||||
|
||||
def cache_get(tenant_schema: str, module: str, key: str):
|
||||
return cache.get(get_redis_key(tenant_schema, module, key))
|
||||
|
||||
def cache_set(tenant_schema: str, module: str, key: str, value, timeout: int = 300):
|
||||
cache.set(get_redis_key(tenant_schema, module, key), value, timeout=timeout)
|
||||
|
||||
def cache_delete(tenant_schema: str, module: str, key: str):
|
||||
cache.delete(get_redis_key(tenant_schema, module, key))
|
||||
```
|
||||
|
||||
---
|
||||
## 九、Celery 多租户任务模板(apps/<mod>/tasks.py)
|
||||
所有 Celery 任务必须接收 `tenant_schema_name` 参数并在任务入口处 `schema_context()`,**禁止依赖调用时的线程 schema 上下文**。
|
||||
|
||||
```python
|
||||
from celery import shared_task
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def example_tenant_task(self, tenant_schema_name: str, **kwargs):
|
||||
"""
|
||||
多租户 Celery 任务模板。
|
||||
调用方示例:
|
||||
example_tenant_task.delay(tenant_schema_name=connection.schema_name, ...)
|
||||
"""
|
||||
try:
|
||||
with schema_context(tenant_schema_name):
|
||||
# --- 业务逻辑 ---
|
||||
pass
|
||||
except Exception as exc:
|
||||
raise self.retry(exc=exc)
|
||||
```
|
||||
|
||||
---
|
||||
## 十、模板体系
|
||||
### 7.1 base.html(全局根模板)
|
||||
包含以下 block 定义(骨架,后续填充):
|
||||
- `{% block title %}` — 页面标题
|
||||
@@ -369,7 +607,7 @@ def heroicon(name: str, size: str = "24", style: str = "outline", css_class: str
|
||||
- `{% block content %}` — 页面主内容
|
||||
- `{% block extra_js %}` — 页面级 JS
|
||||
引入资源顺序:
|
||||
1. Tailwind CSS(编译后的 `main.css`)
|
||||
1. Tailwind CSS(编译后的 `output.css`,即 `tailwindcss -o ./static/css/output.css` 的产出;`main.css` 是 Tailwind 入口源文件,**不直接加载**)
|
||||
2. Flatpickr CSS(条件加载)
|
||||
3. HTMX `htmx.min.js`
|
||||
4. Alpine.js `alpine.min.js`(defer,必须在 HTMX 之后)
|
||||
@@ -453,6 +691,10 @@ uvicorn[standard]==0.32.0
|
||||
sentry-sdk[django]==2.18.0
|
||||
python-decouple==3.8 # .env 读取
|
||||
Pillow==11.0.0 # 图片处理
|
||||
djangorestframework==3.15.2 # DRF(drf-spectacular 依赖)
|
||||
drf-spectacular==0.27.2 # OpenAPI 3.1 schema 自动生成(API_CONTRACT.md §11 MUST)
|
||||
django-htmx==1.21.0 # request.htmx 语义(AGENTS.md §4.2 / 系统管理技术文档.md:431)
|
||||
django-extensions==3.2.3 # shell_plus、runscript 等开发辅助命令
|
||||
```
|
||||
### requirements/development.txt
|
||||
```
|
||||
@@ -461,7 +703,47 @@ ruff==0.7.0
|
||||
black==24.10.0
|
||||
pytest-django==4.9.0
|
||||
factory-boy==3.3.1
|
||||
pytest-mock==3.14.0 # Mock 打桩(测试规范.md:86)
|
||||
responses==0.25.3 # 三方 HTTP 隔离(测试规范.md:87)
|
||||
pytest-cov==5.0.0 # 覆盖率报告(测试规范.md:88)
|
||||
pytest-xdist==3.6.1 # 并行加速(测试规范.md:89)
|
||||
django-debug-toolbar==4.4.6
|
||||
schemathesis==3.36.0 # OpenAPI 契约测试(API_CONTRACT.md §11 MUST)
|
||||
pytest-playwright==0.5.0 # E2E 测试(TECH_STACK.md §10 / AGENTS.md §6)
|
||||
playwright==1.47.0
|
||||
django-htmx==1.21.0 # request.htmx 支持(系统管理技术文档.md:431)
|
||||
django-extensions==3.2.3 # shell_plus、runscript 等(Makefile 依赖)
|
||||
```
|
||||
|
||||
### 测试关键约定(对齐 AGENTS.md §6 / 测试规范.md)
|
||||
> ⚠️ 以下约定**不可省略**,缺少任何一条将导致 CI 失败或测试体系不合规:
|
||||
|
||||
```python
|
||||
# conftest.py — 必须包含 TenantClient fixture
|
||||
import pytest
|
||||
from django_tenants.test.client import TenantClient
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_client(db, tenant):
|
||||
"""所有集成测试必须使用此 client,禁止 Django 原生 Client()"""
|
||||
with schema_context(tenant.schema_name):
|
||||
yield TenantClient(tenant)
|
||||
|
||||
# HTMX 局部请求测试示例(测试规范.md:206)
|
||||
def test_property_list_htmx(tenant_client):
|
||||
response = tenant_client.get(
|
||||
"/property/",
|
||||
HTTP_HX_REQUEST="true", # 必须携带,触发 partial 模板逻辑
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "全局布局标签" not in response.content.decode() # 返回 partial 而非完整页面
|
||||
```
|
||||
|
||||
```ini
|
||||
# pytest.ini / pyproject.toml [tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = config.settings.testing
|
||||
addopts = --cov=apps --cov=core --cov-report=term-missing -n auto
|
||||
```
|
||||
---
|
||||
## 十、Makefile 快捷命令
|
||||
@@ -553,9 +835,9 @@ target-version = ["py312"]
|
||||
profile = "black"
|
||||
line_length = 100
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "config.settings.development"
|
||||
DJANGO_SETTINGS_MODULE = "config.settings.testing"
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
addopts = "--reuse-db"
|
||||
addopts = "--reuse-db --cov=apps --cov=core --cov-report=term-missing -n auto"
|
||||
```
|
||||
---
|
||||
## 十五、执行顺序与验证清单
|
||||
@@ -565,12 +847,13 @@ addopts = "--reuse-db"
|
||||
[ ] 2. 创建 pyproject.toml / .gitignore / .env.example / Makefile
|
||||
[ ] 3. 创建 requirements/ 三个文件
|
||||
[ ] 4. 创建 config/settings/base.py(完整配置)
|
||||
[ ] 5. 创建 config/settings/development.py 和 production.py
|
||||
[ ] 6. 创建 config/urls.py(骨架路由,含 django-tenants URL routing)
|
||||
[ ] 5. 创建 config/settings/development.py、testing.py 和 production.py
|
||||
[ ] 6. 创建 config/urls.py(仅 tenant 路由)与 config/urls_public.py(release API + OpenAPI)
|
||||
[ ] 7. 创建 config/asgi.py(ASGI 入口)
|
||||
[ ] 8. 创建 core/models/base.py(四个抽象基类)
|
||||
[ ] 8b. 创建 core/enums.py(枚举骨架,见第五节;与 ENUMS.md v2.2 对齐)
|
||||
[ ] 9. 创建 core/encryption.py(PhoneEncryption 骨架)
|
||||
[ ] 10. 创建 core/cache.py(Redis 工具骨架)
|
||||
[ ] 10. 创建 core/cache.py(Redis 工具骨架,含 get_redis_key,见第八节)
|
||||
[ ] 11. 创建 core/htmx.py(htmx_response 工具)
|
||||
[ ] 12. 创建 core/templatetags/heroicons.py
|
||||
[ ] 13. 创建 core/middleware/audit.py(骨架)
|
||||
@@ -584,7 +867,7 @@ addopts = "--reuse-db"
|
||||
[ ] 21. 创建 tailwind.config.js(完整色彩/字体规范)
|
||||
[ ] 22. 创建 package.json
|
||||
[ ] 23. 创建 Dockerfile
|
||||
[ ] 24. 创建 docker-compose.yml(5 个服务)
|
||||
[ ] 24. 创建 docker-compose.yml(6 个服务:web/db/redis/celery/celery-beat/tailwind)
|
||||
[ ] 25. 创建 manage.py
|
||||
[ ] 26. 验证:python manage.py check --deploy 无致命错误
|
||||
[ ] 27. 验证:项目目录树与第二节规范 100% 匹配
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 715 KiB After Width: | Height: | Size: 715 KiB |
Reference in New Issue
Block a user