Update nexus: fix conflicts and sync local changes
This commit is contained in:
@@ -1,470 +1,470 @@
|
||||
# Fonrey — 登录与账号认证数据模型(DATA_MODEL_LOGIN)
|
||||
|
||||
> **所属系统**: Fonrey 房产经纪管理系统
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理
|
||||
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
|
||||
> **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、领域概览(Domain Overview)
|
||||
|
||||
### 核心概念
|
||||
|
||||
- **UserAccount(用户账号)**:系统登录主体,必须与员工档案(`org.Staff`)1:1 绑定。分为 Tenant Admin(超级管理账号,每租户唯一)和普通员工账号(username 固定为手机号)。
|
||||
- **LoginAttempt(登录尝试记录)**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。
|
||||
- **PasswordResetToken(密码重置令牌)**:通过邮件找回密码时生成的一次性令牌,有效期 30 分钟,使用后立即失效。
|
||||
- **PasswordHistory(历史密码记录)**:保存最近 3 次密码哈希,用于防止重复使用历史密码。
|
||||
|
||||
### 关键业务规则
|
||||
|
||||
1. **账号与员工强绑定**:每个登录账号 **必须** 与 `org.Staff` 中的员工档案 1:1 绑定(Tenant Admin 例外,可不绑定)。
|
||||
2. **用户名规则差异化**:
|
||||
- Tenant Admin:由平台运营自定义(字母开头,6~30 位,含字母/数字/下划线)
|
||||
- 普通员工:**固定为员工手机号**(11 位数字),创建后不可变更
|
||||
3. **初始密码强制修改**:新账号及密码重置后,`is_initial_password = True`,首次登录必须修改密码,不可跳过。
|
||||
4. **账号锁定机制**:同一账号连续密码错误 ≥ 5 次,状态置为 `locked`,30 分钟后自动恢复;Tenant Admin 可提前手动解锁。
|
||||
5. **员工离职联动**:员工离职时,对应账号的 `status` 自动置为 `disabled`,不可登录,历史操作记录保留。
|
||||
6. **不支持自助注册**:所有账号由有权限的管理角色创建,普通员工账号在新增员工时由系统自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 二、实体关系
|
||||
|
||||
```
|
||||
UserAccount
|
||||
│
|
||||
├── 1:1 ── org.Staff (实名绑定,普通员工必须)
|
||||
├── 1:N ── LoginAttempt (登录审计记录)
|
||||
├── 1:N ── PasswordResetToken (密码重置令牌)
|
||||
├── 1:N ── PasswordHistory (历史密码记录)
|
||||
└── M:1 ── UserAccount.created_by (创建人自引用)
|
||||
```
|
||||
|
||||
### Schema 归属
|
||||
|
||||
| 表 | Schema 位置 | 说明 |
|
||||
|----|------------|------|
|
||||
| `user_accounts` | 租户 Schema | 账号数据按租户隔离,username 唯一性在 Schema 维度生效 |
|
||||
| `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 |
|
||||
| `password_reset_tokens` | 租户 Schema | 令牌与租户账号绑定 |
|
||||
| `password_histories` | 租户 Schema | 历史密码与账号绑定 |
|
||||
|
||||
> **注意**:Tenant ID 验证相关逻辑在 **Public Schema**(`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema)。
|
||||
|
||||
---
|
||||
|
||||
## 三、Schema 定义
|
||||
|
||||
### 3.1 `user_accounts` — 账号主表(租户 Schema)
|
||||
|
||||
**表说明**:系统登录主体,每个租户内独立隔离,`username` 唯一性约束在 Schema 维度生效。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键(审计场景下 BigInt 更直观;跨环境引用使用 UUID 扩展字段见下) |
|
||||
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 登录名;普通员工为手机号(11 位数字);Tenant Admin 为自定义字符串;创建后不可更改 |
|
||||
| `password` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希存储,使用 Django `make_password` |
|
||||
| `email` | `VARCHAR(254)` | `NULL` | `NULL` | 绑定邮箱;用于找回密码/用户名;为空则无法自助找回;同租户唯一 |
|
||||
| `phone_enc` | `TEXT` | `NULL` | `NULL` | 手机号 AES-256-GCM 加密密文(`core.encryption`);普通员工必填 |
|
||||
| `phone_hash` | `VARCHAR(64)` | `NULL` | `NULL` | 手机号 SHA-256 哈希;用于唯一性校验和查询;不可反推原文 |
|
||||
| `staff_id` | `BIGINT` | `FK → org_staff.id`, `NULL`, `UNIQUE` | `NULL` | 员工档案绑定(1:1);普通员工必须有值;Tenant Admin 可为空 |
|
||||
| `is_tenant_admin` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否为该租户的超级管理账号;每个租户最多 1 个(应用层约束) |
|
||||
| `status` | `VARCHAR(10)` | `NOT NULL`, `CHECK(status IN ('active','disabled','locked'))` | `'active'` | 账号状态;`locked` 为密码错误锁定,30 分钟自动恢复 |
|
||||
| `is_initial_password` | `BOOLEAN` | `NOT NULL` | `TRUE` | 初始密码标记;True 时登录成功后强制跳转修改密码页,不可跳过 |
|
||||
| `last_login` | `TIMESTAMPTZ` | `NULL` | `NULL` | 最后登录时间 |
|
||||
| `locked_until` | `TIMESTAMPTZ` | `NULL` | `NULL` | 锁定到期时间;到期后应用层将 status 恢复 active |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 账号创建时间 |
|
||||
| `updated_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 最后更新时间(触发器维护) |
|
||||
| `created_by` | `BIGINT` | `FK → user_accounts.id`, `NULL` | `NULL` | 创建人;普通员工由 Tenant Admin 创建;Tenant Admin 由平台运营创建(可为 NULL) |
|
||||
|
||||
#### 唯一性约束
|
||||
|
||||
```sql
|
||||
UNIQUE (username) -- Schema 内唯一,跨租户不冲突(django-tenants 机制保障)
|
||||
UNIQUE (email) -- 同租户内邮箱唯一(可为 NULL,NULL 不参与唯一性校验)
|
||||
UNIQUE (phone_hash) -- 同租户内手机号唯一(通过 hash 实现,不暴露原文)
|
||||
UNIQUE (staff_id) -- 员工档案 1:1 绑定
|
||||
```
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_user_accounts_username ON user_accounts (username);
|
||||
CREATE UNIQUE INDEX uq_user_accounts_email ON user_accounts (email) WHERE email IS NOT NULL;
|
||||
CREATE UNIQUE INDEX uq_user_accounts_phone ON user_accounts (phone_hash) WHERE phone_hash IS NOT NULL;
|
||||
CREATE INDEX idx_user_accounts_status ON user_accounts (status);
|
||||
CREATE INDEX idx_user_accounts_staff ON user_accounts (staff_id);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
# apps/accounts/models.py
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
|
||||
from django.db import models
|
||||
|
||||
|
||||
class UserAccountManager(BaseUserManager):
|
||||
def create_user(self, username, password, **extra_fields):
|
||||
if not username:
|
||||
raise ValueError("username 不能为空")
|
||||
user = self.model(username=username, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
|
||||
class UserAccount(AbstractBaseUser):
|
||||
"""
|
||||
租户级用户账号。
|
||||
- 普通员工:username 固定为手机号(11 位数字)
|
||||
- Tenant Admin:username 由平台运营自定义(字母开头,6~30 位)
|
||||
注意:此表位于租户 Schema,username 唯一性约束在 Schema 维度生效。
|
||||
"""
|
||||
username = models.CharField(max_length=30)
|
||||
email = models.EmailField(null=True, blank=True)
|
||||
phone_enc = models.TextField(null=True, blank=True) # AES-256-GCM 加密密文
|
||||
phone_hash = models.CharField(max_length=64, null=True, blank=True) # SHA-256 哈希索引
|
||||
staff = models.OneToOneField(
|
||||
'org.Staff',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='account',
|
||||
)
|
||||
is_tenant_admin = models.BooleanField(default=False)
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
choices=[('active', 'Active'), ('disabled', 'Disabled'), ('locked', 'Locked')],
|
||||
default='active',
|
||||
)
|
||||
is_initial_password = models.BooleanField(default=True)
|
||||
last_login = models.DateTimeField(null=True, blank=True)
|
||||
locked_until = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_by = models.ForeignKey(
|
||||
'self',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='created_accounts',
|
||||
)
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
objects = UserAccountManager()
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_accounts'
|
||||
# Schema 内唯一约束
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['username'], name='uq_user_accounts_username'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({'admin' if self.is_tenant_admin else 'staff'})"
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""检查账号是否处于锁定状态(含自动过期判断)"""
|
||||
from django.utils import timezone
|
||||
if self.status == 'locked':
|
||||
if self.locked_until and timezone.now() >= self.locked_until:
|
||||
# 锁定已到期,应用层自动恢复(实际由 service 层处理)
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `login_attempts` — 登录尝试审计表(租户 Schema)
|
||||
|
||||
**表说明**:记录每次登录请求(成功/失败),用于安全审计和锁定判断。数据保留 ≥ 90 天,不得提前清理。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 尝试登录的用户名(冗余存储,即使账号不存在也记录) |
|
||||
| `ip_address` | `INET` | `NOT NULL` | — | 来源 IP 地址(支持 IPv4/IPv6) |
|
||||
| `user_agent` | `TEXT` | `NULL` | `NULL` | 客户端 User-Agent(Electron 版本信息) |
|
||||
| `success` | `BOOLEAN` | `NOT NULL` | — | 是否登录成功 |
|
||||
| `failure_reason` | `VARCHAR(30)` | `NULL` | `NULL` | 失败原因;可选值见下方枚举 |
|
||||
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间 |
|
||||
|
||||
**`failure_reason` 枚举值**:
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `wrong_password` | 用户名或密码错误 |
|
||||
| `wrong_captcha` | 行为验证码失败 |
|
||||
| `account_locked` | 账号已锁定 |
|
||||
| `account_disabled` | 账号已停用 |
|
||||
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_login_attempts_username ON login_attempts (username);
|
||||
CREATE INDEX idx_login_attempts_ip ON login_attempts (ip_address);
|
||||
CREATE INDEX idx_login_attempts_time ON login_attempts (attempted_at DESC);
|
||||
-- 复合索引:按账号查询最近失败记录(锁定判断场景)
|
||||
CREATE INDEX idx_login_attempts_fail_check ON login_attempts (username, success, attempted_at DESC);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class LoginAttempt(models.Model):
|
||||
"""
|
||||
登录尝试审计记录。
|
||||
- 合规保留周期:≥ 90 天
|
||||
- 注意:failure_reason 不得存储密码明文(含错误密码)
|
||||
"""
|
||||
FAILURE_REASONS = [
|
||||
('wrong_password', '用户名或密码错误'),
|
||||
('wrong_captcha', '行为验证码失败'),
|
||||
('account_locked', '账号已锁定'),
|
||||
('account_disabled', '账号已停用'),
|
||||
('tenant_not_found', '租户不存在'),
|
||||
]
|
||||
|
||||
username = models.CharField(max_length=30)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.TextField(null=True, blank=True)
|
||||
success = models.BooleanField()
|
||||
failure_reason = models.CharField(max_length=30, null=True, blank=True, choices=FAILURE_REASONS)
|
||||
attempted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'login_attempts'
|
||||
indexes = [
|
||||
models.Index(fields=['username']),
|
||||
models.Index(fields=['ip_address']),
|
||||
models.Index(fields=['-attempted_at']),
|
||||
models.Index(fields=['username', 'success', '-attempted_at'],
|
||||
name='idx_login_attempts_fail_check'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} @ {self.attempted_at} - {'OK' if self.success else self.failure_reason}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `password_reset_tokens` — 密码重置令牌表(租户 Schema)
|
||||
|
||||
**表说明**:用于通过邮件找回密码的一次性令牌。单次有效,30 分钟过期。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
|
||||
| `token` | `VARCHAR(86)` | `NOT NULL`, `UNIQUE` | — | `secrets.token_urlsafe(64)` 生成(86 字符),全局唯一 |
|
||||
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`created_at + 30 分钟`) |
|
||||
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已使用;使用后立即置 True,防止重放攻击 |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 创建时间 |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_password_reset_tokens_token ON password_reset_tokens (token);
|
||||
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id);
|
||||
CREATE INDEX idx_password_reset_tokens_expiry ON password_reset_tokens (expires_at) WHERE is_used = FALSE;
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class PasswordResetToken(models.Model):
|
||||
"""
|
||||
密码重置令牌。
|
||||
安全约束:
|
||||
- Token 单次有效(is_used=True 后立即失效)
|
||||
- 有效期 30 分钟
|
||||
- 同一账号 1 小时内最多生成 3 个(服务层限频,Redis 计数)
|
||||
"""
|
||||
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='reset_tokens')
|
||||
token = models.CharField(max_length=86, unique=True) # secrets.token_urlsafe(64)
|
||||
expires_at = models.DateTimeField()
|
||||
is_used = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'password_reset_tokens'
|
||||
indexes = [
|
||||
models.Index(fields=['user_id']),
|
||||
]
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
from django.utils import timezone
|
||||
return not self.is_used and timezone.now() < self.expires_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `password_histories` — 历史密码记录表(租户 Schema)
|
||||
|
||||
**表说明**:保存账号最近 3 次密码哈希,用于防止重复使用历史密码(含初始密码)。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
|
||||
| `password_hash` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希值 |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 记录时间(密码修改时间) |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_password_histories_user ON password_histories (user_id, created_at DESC);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class PasswordHistory(models.Model):
|
||||
"""
|
||||
历史密码记录,每个账号保留最近 N 条(默认 3 条)。
|
||||
新密码不得与最近 3 条历史记录相同(含系统初始密码 Fonrey@2025)。
|
||||
"""
|
||||
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='password_histories')
|
||||
password_hash = models.CharField(max_length=128)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'password_histories'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['user', '-created_at']),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Redis 缓存结构(辅助状态,非持久化)
|
||||
|
||||
以下 Redis Key 不存入 PostgreSQL,属于运行时状态,需与数据库状态保持最终一致:
|
||||
|
||||
| Key 格式 | 类型 | TTL | 说明 |
|
||||
|----------|------|-----|------|
|
||||
| `captcha_token:{uuid}` | STRING | 3 分钟 | 滑块验证会话 Token;验证通过后生成 `captcha_pass_token` |
|
||||
| `captcha_pass:{uuid}` | STRING | 3 分钟 | 一次性通过凭证;登录提交时校验后立即删除 |
|
||||
| `login_fail:{tenant_id}:{username}` | STRING(计数) | 30 分钟 | 连续密码错误次数;≥ 5 触发锁定;TTL 30 分钟自动清零 |
|
||||
| `recover_email:{email}` | STRING(计数) | 1 小时 | 找回邮件发送次数;上限 3 次/小时 |
|
||||
| `recover_reset:{account_id}` | STRING(计数) | 1 小时 | 同一账号密码重置 Token 生成次数;上限 3 次/小时 |
|
||||
| `tenant_verify_ip:{ip}` | STRING(计数) | 1 分钟 | Tenant 验证接口 IP 限流;≥ 10 次拒绝请求 |
|
||||
|
||||
> **一致性说明**:账号锁定状态由 `user_accounts.status` 持久化,Redis 仅做计数触发器。当 Redis 数据丢失(如 Redis 重启),应用层通过 `locked_until` 字段恢复锁定状态判断。
|
||||
|
||||
---
|
||||
|
||||
## 五、账号创建流程与状态机
|
||||
|
||||
### 5.1 账号状态机
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 账号生命周期状态机 │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
[创建账号]
|
||||
│ is_initial_password=True, status=active
|
||||
▼
|
||||
[初始密码态] ─── 使用初始密码登录成功 ───► [强制修改密码页]
|
||||
│ │ 修改成功
|
||||
│ ▼
|
||||
│ [正常使用态]
|
||||
│ status=active
|
||||
│ is_initial_password=False
|
||||
│
|
||||
├── 密码错误 ≥ 5 次 ──────────────────► [锁定态]
|
||||
│ status=locked
|
||||
│ locked_until = now+30min
|
||||
│ │
|
||||
│ ┌───────────────┤
|
||||
│ │ 30分钟到期 │ 管理员手动解锁
|
||||
│ ▼ ▼
|
||||
│ [正常使用态] ◄─── [管理员操作]
|
||||
│
|
||||
└── 员工离职 / 管理员停用 ──► [停用态]
|
||||
status=disabled
|
||||
│
|
||||
员工复职/管理员恢复
|
||||
│
|
||||
▼
|
||||
[正常使用态]
|
||||
```
|
||||
|
||||
### 5.2 账号创建触发时机
|
||||
|
||||
| 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 |
|
||||
|----------|----------|--------|--------------|---------|
|
||||
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | 自定义(字母开头,6~30 位) | 平台运营自定义 |
|
||||
| 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统(Tenant Admin 触发) | 固定为员工手机号(11 位) | 系统统一初始密码(部署配置) |
|
||||
|
||||
---
|
||||
|
||||
## 六、关联约束与数据完整性
|
||||
|
||||
### 6.1 与 `org.Staff` 的关联规则
|
||||
|
||||
```
|
||||
org_staff (1) ──── (0..1) user_accounts
|
||||
```
|
||||
|
||||
- 普通员工账号:`staff_id` **必须**有值,且在 `org.Staff` 中对应记录的 `status` 为 active
|
||||
- Tenant Admin:`staff_id` **可为空**(平台运营账号可不绑定实名档案)
|
||||
- 员工离职时(`org.Staff.status` → `resigned`),触发账号 `status` → `disabled`(由 `org` App Service 层调用 `accounts` 服务执行,避免循环依赖)
|
||||
- 账号删除:**不允许物理删除**,仅允许 `status=disabled`,审计记录永久保留
|
||||
|
||||
### 6.2 跨 App 依赖方向
|
||||
|
||||
```
|
||||
accounts ──► org (单向依赖:accounts.UserAccount.staff_id → org.Staff)
|
||||
org ──► accounts (反向触发,通过 Service 层调用,不通过 FK 反查)
|
||||
```
|
||||
|
||||
> **设计原则**:避免循环 FK 依赖,跨 App 的状态联动通过 Service 层的显式调用完成,不在 Model 层建立反向 FK。
|
||||
|
||||
---
|
||||
|
||||
## 七、迁移说明(Django Migrations)
|
||||
|
||||
### 初始迁移顺序
|
||||
|
||||
```
|
||||
0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在)
|
||||
0002_login_attempts.py # LoginAttempt 表
|
||||
0003_password_reset_tokens.py # PasswordResetToken 表
|
||||
0004_password_histories.py # PasswordHistory 表
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- `accounts` App 的迁移依赖 `org` App(`org.Staff` 表须先创建),需在 `INSTALLED_APPS` 中确保 `org` 在 `accounts` 之前
|
||||
- 所有迁移均在**租户 Schema** 下执行(`django-tenants` 的 `migrate_schemas` 命令)
|
||||
- 不得为 `email` 字段设置 `NOT NULL` 约束(允许为空,是否绑定邮箱属于用户选择)
|
||||
|
||||
---
|
||||
|
||||
## 八、设计决策说明(ADR)
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| 主键类型 | `BIGSERIAL` (BigInt) | 登录审计场景下 BigInt 主键更简洁高效;跨环境引用场景少,无需 UUID 的随机性 |
|
||||
| `phone` 字段拆分为 `phone_enc` + `phone_hash` | 是 | 与 `org.Staff` 保持一致;`phone_enc` 保存原文用于展示,`phone_hash` 用于唯一性校验和快速查询,避免加密字段全表扫描 |
|
||||
| 不扩展 Django `User` | 使用 `AbstractBaseUser` | 避免 `django.contrib.auth.User` 的全局唯一性限制(多租户下同一 username 在不同租户是允许的) |
|
||||
| `LoginAttempt` 不设外键到 `UserAccount` | 是(冗余存储 username) | 即使账号被删除(停用),审计记录仍需保留;使用 username 字符串字段保证审计完整性 |
|
||||
| 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 |
|
||||
| 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |
|
||||
# Fonrey — 登录与账号认证数据模型(DATA_MODEL_LOGIN)
|
||||
|
||||
> **所属系统**: Fonrey 房产经纪管理系统
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **关联模块**: `apps/accounts/` — 账号认证、登录安全、密码管理
|
||||
> **关联 PRD**: `Project/fonrey/PRD/登录管理/用户登录管理模块PRD.md` (v1.3)
|
||||
> **关联技术方案**: `Project/fonrey/TECH_STACK/登录管理技术方案.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、领域概览(Domain Overview)
|
||||
|
||||
### 核心概念
|
||||
|
||||
- **UserAccount(用户账号)**:系统登录主体,必须与员工档案(`org.Staff`)1:1 绑定。分为 Tenant Admin(超级管理账号,每租户唯一)和普通员工账号(username 固定为手机号)。
|
||||
- **LoginAttempt(登录尝试记录)**:记录每次登录行为(成功/失败),用于安全审计和账号锁定判断,保留 ≥ 90 天。
|
||||
- **PasswordResetToken(密码重置令牌)**:通过邮件找回密码时生成的一次性令牌,有效期 30 分钟,使用后立即失效。
|
||||
- **PasswordHistory(历史密码记录)**:保存最近 3 次密码哈希,用于防止重复使用历史密码。
|
||||
|
||||
### 关键业务规则
|
||||
|
||||
1. **账号与员工强绑定**:每个登录账号 **必须** 与 `org.Staff` 中的员工档案 1:1 绑定(Tenant Admin 例外,可不绑定)。
|
||||
2. **用户名规则差异化**:
|
||||
- Tenant Admin:由平台运营自定义(字母开头,6~30 位,含字母/数字/下划线)
|
||||
- 普通员工:**固定为员工手机号**(11 位数字),创建后不可变更
|
||||
3. **初始密码强制修改**:新账号及密码重置后,`is_initial_password = True`,首次登录必须修改密码,不可跳过。
|
||||
4. **账号锁定机制**:同一账号连续密码错误 ≥ 5 次,状态置为 `locked`,30 分钟后自动恢复;Tenant Admin 可提前手动解锁。
|
||||
5. **员工离职联动**:员工离职时,对应账号的 `status` 自动置为 `disabled`,不可登录,历史操作记录保留。
|
||||
6. **不支持自助注册**:所有账号由有权限的管理角色创建,普通员工账号在新增员工时由系统自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 二、实体关系
|
||||
|
||||
```
|
||||
UserAccount
|
||||
│
|
||||
├── 1:1 ── org.Staff (实名绑定,普通员工必须)
|
||||
├── 1:N ── LoginAttempt (登录审计记录)
|
||||
├── 1:N ── PasswordResetToken (密码重置令牌)
|
||||
├── 1:N ── PasswordHistory (历史密码记录)
|
||||
└── M:1 ── UserAccount.created_by (创建人自引用)
|
||||
```
|
||||
|
||||
### Schema 归属
|
||||
|
||||
| 表 | Schema 位置 | 说明 |
|
||||
|----|------------|------|
|
||||
| `user_accounts` | 租户 Schema | 账号数据按租户隔离,username 唯一性在 Schema 维度生效 |
|
||||
| `login_attempts` | 租户 Schema | 审计记录属于租户,跨租户不可见 |
|
||||
| `password_reset_tokens` | 租户 Schema | 令牌与租户账号绑定 |
|
||||
| `password_histories` | 租户 Schema | 历史密码与账号绑定 |
|
||||
|
||||
> **注意**:Tenant ID 验证相关逻辑在 **Public Schema**(`shared_apps`),使用 `django-tenants` 的 `TenantModel`,不在本文档范围内,详见 `DATA_MODEL.md` §四(公共 Schema)。
|
||||
|
||||
---
|
||||
|
||||
## 三、Schema 定义
|
||||
|
||||
### 3.1 `user_accounts` — 账号主表(租户 Schema)
|
||||
|
||||
**表说明**:系统登录主体,每个租户内独立隔离,`username` 唯一性约束在 Schema 维度生效。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键(审计场景下 BigInt 更直观;跨环境引用使用 UUID 扩展字段见下) |
|
||||
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 登录名;普通员工为手机号(11 位数字);Tenant Admin 为自定义字符串;创建后不可更改 |
|
||||
| `password` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希存储,使用 Django `make_password` |
|
||||
| `email` | `VARCHAR(254)` | `NULL` | `NULL` | 绑定邮箱;用于找回密码/用户名;为空则无法自助找回;同租户唯一 |
|
||||
| `phone_enc` | `TEXT` | `NULL` | `NULL` | 手机号 AES-256-GCM 加密密文(`core.encryption`);普通员工必填 |
|
||||
| `phone_hash` | `VARCHAR(64)` | `NULL` | `NULL` | 手机号 SHA-256 哈希;用于唯一性校验和查询;不可反推原文 |
|
||||
| `staff_id` | `BIGINT` | `FK → org_staff.id`, `NULL`, `UNIQUE` | `NULL` | 员工档案绑定(1:1);普通员工必须有值;Tenant Admin 可为空 |
|
||||
| `is_tenant_admin` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否为该租户的超级管理账号;每个租户最多 1 个(应用层约束) |
|
||||
| `status` | `VARCHAR(10)` | `NOT NULL`, `CHECK(status IN ('active','disabled','locked'))` | `'active'` | 账号状态;`locked` 为密码错误锁定,30 分钟自动恢复 |
|
||||
| `is_initial_password` | `BOOLEAN` | `NOT NULL` | `TRUE` | 初始密码标记;True 时登录成功后强制跳转修改密码页,不可跳过 |
|
||||
| `last_login` | `TIMESTAMPTZ` | `NULL` | `NULL` | 最后登录时间 |
|
||||
| `locked_until` | `TIMESTAMPTZ` | `NULL` | `NULL` | 锁定到期时间;到期后应用层将 status 恢复 active |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 账号创建时间 |
|
||||
| `updated_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 最后更新时间(触发器维护) |
|
||||
| `created_by` | `BIGINT` | `FK → user_accounts.id`, `NULL` | `NULL` | 创建人;普通员工由 Tenant Admin 创建;Tenant Admin 由平台运营创建(可为 NULL) |
|
||||
|
||||
#### 唯一性约束
|
||||
|
||||
```sql
|
||||
UNIQUE (username) -- Schema 内唯一,跨租户不冲突(django-tenants 机制保障)
|
||||
UNIQUE (email) -- 同租户内邮箱唯一(可为 NULL,NULL 不参与唯一性校验)
|
||||
UNIQUE (phone_hash) -- 同租户内手机号唯一(通过 hash 实现,不暴露原文)
|
||||
UNIQUE (staff_id) -- 员工档案 1:1 绑定
|
||||
```
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_user_accounts_username ON user_accounts (username);
|
||||
CREATE UNIQUE INDEX uq_user_accounts_email ON user_accounts (email) WHERE email IS NOT NULL;
|
||||
CREATE UNIQUE INDEX uq_user_accounts_phone ON user_accounts (phone_hash) WHERE phone_hash IS NOT NULL;
|
||||
CREATE INDEX idx_user_accounts_status ON user_accounts (status);
|
||||
CREATE INDEX idx_user_accounts_staff ON user_accounts (staff_id);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
# apps/accounts/models.py
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
|
||||
from django.db import models
|
||||
|
||||
|
||||
class UserAccountManager(BaseUserManager):
|
||||
def create_user(self, username, password, **extra_fields):
|
||||
if not username:
|
||||
raise ValueError("username 不能为空")
|
||||
user = self.model(username=username, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
|
||||
class UserAccount(AbstractBaseUser):
|
||||
"""
|
||||
租户级用户账号。
|
||||
- 普通员工:username 固定为手机号(11 位数字)
|
||||
- Tenant Admin:username 由平台运营自定义(字母开头,6~30 位)
|
||||
注意:此表位于租户 Schema,username 唯一性约束在 Schema 维度生效。
|
||||
"""
|
||||
username = models.CharField(max_length=30)
|
||||
email = models.EmailField(null=True, blank=True)
|
||||
phone_enc = models.TextField(null=True, blank=True) # AES-256-GCM 加密密文
|
||||
phone_hash = models.CharField(max_length=64, null=True, blank=True) # SHA-256 哈希索引
|
||||
staff = models.OneToOneField(
|
||||
'org.Staff',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='account',
|
||||
)
|
||||
is_tenant_admin = models.BooleanField(default=False)
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
choices=[('active', 'Active'), ('disabled', 'Disabled'), ('locked', 'Locked')],
|
||||
default='active',
|
||||
)
|
||||
is_initial_password = models.BooleanField(default=True)
|
||||
last_login = models.DateTimeField(null=True, blank=True)
|
||||
locked_until = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_by = models.ForeignKey(
|
||||
'self',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='created_accounts',
|
||||
)
|
||||
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
objects = UserAccountManager()
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_accounts'
|
||||
# Schema 内唯一约束
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['username'], name='uq_user_accounts_username'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({'admin' if self.is_tenant_admin else 'staff'})"
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""检查账号是否处于锁定状态(含自动过期判断)"""
|
||||
from django.utils import timezone
|
||||
if self.status == 'locked':
|
||||
if self.locked_until and timezone.now() >= self.locked_until:
|
||||
# 锁定已到期,应用层自动恢复(实际由 service 层处理)
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `login_attempts` — 登录尝试审计表(租户 Schema)
|
||||
|
||||
**表说明**:记录每次登录请求(成功/失败),用于安全审计和锁定判断。数据保留 ≥ 90 天,不得提前清理。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 尝试登录的用户名(冗余存储,即使账号不存在也记录) |
|
||||
| `ip_address` | `INET` | `NOT NULL` | — | 来源 IP 地址(支持 IPv4/IPv6) |
|
||||
| `user_agent` | `TEXT` | `NULL` | `NULL` | 客户端 User-Agent(Electron 版本信息) |
|
||||
| `success` | `BOOLEAN` | `NOT NULL` | — | 是否登录成功 |
|
||||
| `failure_reason` | `VARCHAR(30)` | `NULL` | `NULL` | 失败原因;可选值见下方枚举 |
|
||||
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间 |
|
||||
|
||||
**`failure_reason` 枚举值**:
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `wrong_password` | 用户名或密码错误 |
|
||||
| `wrong_captcha` | 行为验证码失败 |
|
||||
| `account_locked` | 账号已锁定 |
|
||||
| `account_disabled` | 账号已停用 |
|
||||
| `tenant_not_found` | 租户不存在(理论上不应出现,防御性记录) |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_login_attempts_username ON login_attempts (username);
|
||||
CREATE INDEX idx_login_attempts_ip ON login_attempts (ip_address);
|
||||
CREATE INDEX idx_login_attempts_time ON login_attempts (attempted_at DESC);
|
||||
-- 复合索引:按账号查询最近失败记录(锁定判断场景)
|
||||
CREATE INDEX idx_login_attempts_fail_check ON login_attempts (username, success, attempted_at DESC);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class LoginAttempt(models.Model):
|
||||
"""
|
||||
登录尝试审计记录。
|
||||
- 合规保留周期:≥ 90 天
|
||||
- 注意:failure_reason 不得存储密码明文(含错误密码)
|
||||
"""
|
||||
FAILURE_REASONS = [
|
||||
('wrong_password', '用户名或密码错误'),
|
||||
('wrong_captcha', '行为验证码失败'),
|
||||
('account_locked', '账号已锁定'),
|
||||
('account_disabled', '账号已停用'),
|
||||
('tenant_not_found', '租户不存在'),
|
||||
]
|
||||
|
||||
username = models.CharField(max_length=30)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.TextField(null=True, blank=True)
|
||||
success = models.BooleanField()
|
||||
failure_reason = models.CharField(max_length=30, null=True, blank=True, choices=FAILURE_REASONS)
|
||||
attempted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'login_attempts'
|
||||
indexes = [
|
||||
models.Index(fields=['username']),
|
||||
models.Index(fields=['ip_address']),
|
||||
models.Index(fields=['-attempted_at']),
|
||||
models.Index(fields=['username', 'success', '-attempted_at'],
|
||||
name='idx_login_attempts_fail_check'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} @ {self.attempted_at} - {'OK' if self.success else self.failure_reason}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `password_reset_tokens` — 密码重置令牌表(租户 Schema)
|
||||
|
||||
**表说明**:用于通过邮件找回密码的一次性令牌。单次有效,30 分钟过期。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
|
||||
| `token` | `VARCHAR(86)` | `NOT NULL`, `UNIQUE` | — | `secrets.token_urlsafe(64)` 生成(86 字符),全局唯一 |
|
||||
| `expires_at` | `TIMESTAMPTZ` | `NOT NULL` | — | 过期时间(`created_at + 30 分钟`) |
|
||||
| `is_used` | `BOOLEAN` | `NOT NULL` | `FALSE` | 是否已使用;使用后立即置 True,防止重放攻击 |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 创建时间 |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uq_password_reset_tokens_token ON password_reset_tokens (token);
|
||||
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id);
|
||||
CREATE INDEX idx_password_reset_tokens_expiry ON password_reset_tokens (expires_at) WHERE is_used = FALSE;
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class PasswordResetToken(models.Model):
|
||||
"""
|
||||
密码重置令牌。
|
||||
安全约束:
|
||||
- Token 单次有效(is_used=True 后立即失效)
|
||||
- 有效期 30 分钟
|
||||
- 同一账号 1 小时内最多生成 3 个(服务层限频,Redis 计数)
|
||||
"""
|
||||
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='reset_tokens')
|
||||
token = models.CharField(max_length=86, unique=True) # secrets.token_urlsafe(64)
|
||||
expires_at = models.DateTimeField()
|
||||
is_used = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'password_reset_tokens'
|
||||
indexes = [
|
||||
models.Index(fields=['user_id']),
|
||||
]
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
from django.utils import timezone
|
||||
return not self.is_used and timezone.now() < self.expires_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 `password_histories` — 历史密码记录表(租户 Schema)
|
||||
|
||||
**表说明**:保存账号最近 3 次密码哈希,用于防止重复使用历史密码(含初始密码)。
|
||||
|
||||
#### 字段定义
|
||||
|
||||
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
|
||||
| `user_id` | `BIGINT` | `FK → user_accounts.id`, `NOT NULL` | — | 关联账号 |
|
||||
| `password_hash` | `VARCHAR(128)` | `NOT NULL` | — | PBKDF2+SHA256 哈希值 |
|
||||
| `created_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 记录时间(密码修改时间) |
|
||||
|
||||
#### 索引
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_password_histories_user ON password_histories (user_id, created_at DESC);
|
||||
```
|
||||
|
||||
#### Django Model 定义
|
||||
|
||||
```python
|
||||
class PasswordHistory(models.Model):
|
||||
"""
|
||||
历史密码记录,每个账号保留最近 N 条(默认 3 条)。
|
||||
新密码不得与最近 3 条历史记录相同(含系统初始密码 Fonrey@2025)。
|
||||
"""
|
||||
user = models.ForeignKey(UserAccount, on_delete=models.CASCADE, related_name='password_histories')
|
||||
password_hash = models.CharField(max_length=128)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'password_histories'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['user', '-created_at']),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Redis 缓存结构(辅助状态,非持久化)
|
||||
|
||||
以下 Redis Key 不存入 PostgreSQL,属于运行时状态,需与数据库状态保持最终一致:
|
||||
|
||||
| Key 格式 | 类型 | TTL | 说明 |
|
||||
|----------|------|-----|------|
|
||||
| `captcha_token:{uuid}` | STRING | 3 分钟 | 滑块验证会话 Token;验证通过后生成 `captcha_pass_token` |
|
||||
| `captcha_pass:{uuid}` | STRING | 3 分钟 | 一次性通过凭证;登录提交时校验后立即删除 |
|
||||
| `login_fail:{tenant_id}:{username}` | STRING(计数) | 30 分钟 | 连续密码错误次数;≥ 5 触发锁定;TTL 30 分钟自动清零 |
|
||||
| `recover_email:{email}` | STRING(计数) | 1 小时 | 找回邮件发送次数;上限 3 次/小时 |
|
||||
| `recover_reset:{account_id}` | STRING(计数) | 1 小时 | 同一账号密码重置 Token 生成次数;上限 3 次/小时 |
|
||||
| `tenant_verify_ip:{ip}` | STRING(计数) | 1 分钟 | Tenant 验证接口 IP 限流;≥ 10 次拒绝请求 |
|
||||
|
||||
> **一致性说明**:账号锁定状态由 `user_accounts.status` 持久化,Redis 仅做计数触发器。当 Redis 数据丢失(如 Redis 重启),应用层通过 `locked_until` 字段恢复锁定状态判断。
|
||||
|
||||
---
|
||||
|
||||
## 五、账号创建流程与状态机
|
||||
|
||||
### 5.1 账号状态机
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 账号生命周期状态机 │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
[创建账号]
|
||||
│ is_initial_password=True, status=active
|
||||
▼
|
||||
[初始密码态] ─── 使用初始密码登录成功 ───► [强制修改密码页]
|
||||
│ │ 修改成功
|
||||
│ ▼
|
||||
│ [正常使用态]
|
||||
│ status=active
|
||||
│ is_initial_password=False
|
||||
│
|
||||
├── 密码错误 ≥ 5 次 ──────────────────► [锁定态]
|
||||
│ status=locked
|
||||
│ locked_until = now+30min
|
||||
│ │
|
||||
│ ┌───────────────┤
|
||||
│ │ 30分钟到期 │ 管理员手动解锁
|
||||
│ ▼ ▼
|
||||
│ [正常使用态] ◄─── [管理员操作]
|
||||
│
|
||||
└── 员工离职 / 管理员停用 ──► [停用态]
|
||||
status=disabled
|
||||
│
|
||||
员工复职/管理员恢复
|
||||
│
|
||||
▼
|
||||
[正常使用态]
|
||||
```
|
||||
|
||||
### 5.2 账号创建触发时机
|
||||
|
||||
| 账号类型 | 触发时机 | 创建者 | username 规则 | 初始密码 |
|
||||
|----------|----------|--------|--------------|---------|
|
||||
| Tenant Admin | 平台运营在系统后台开通租户时 | 平台运营 | 自定义(字母开头,6~30 位) | 平台运营自定义 |
|
||||
| 普通员工 | Tenant Admin 在「新增员工」时系统自动生成 | 系统(Tenant Admin 触发) | 固定为员工手机号(11 位) | 系统统一初始密码(部署配置) |
|
||||
|
||||
---
|
||||
|
||||
## 六、关联约束与数据完整性
|
||||
|
||||
### 6.1 与 `org.Staff` 的关联规则
|
||||
|
||||
```
|
||||
org_staff (1) ──── (0..1) user_accounts
|
||||
```
|
||||
|
||||
- 普通员工账号:`staff_id` **必须**有值,且在 `org.Staff` 中对应记录的 `status` 为 active
|
||||
- Tenant Admin:`staff_id` **可为空**(平台运营账号可不绑定实名档案)
|
||||
- 员工离职时(`org.Staff.status` → `resigned`),触发账号 `status` → `disabled`(由 `org` App Service 层调用 `accounts` 服务执行,避免循环依赖)
|
||||
- 账号删除:**不允许物理删除**,仅允许 `status=disabled`,审计记录永久保留
|
||||
|
||||
### 6.2 跨 App 依赖方向
|
||||
|
||||
```
|
||||
accounts ──► org (单向依赖:accounts.UserAccount.staff_id → org.Staff)
|
||||
org ──► accounts (反向触发,通过 Service 层调用,不通过 FK 反查)
|
||||
```
|
||||
|
||||
> **设计原则**:避免循环 FK 依赖,跨 App 的状态联动通过 Service 层的显式调用完成,不在 Model 层建立反向 FK。
|
||||
|
||||
---
|
||||
|
||||
## 七、迁移说明(Django Migrations)
|
||||
|
||||
### 初始迁移顺序
|
||||
|
||||
```
|
||||
0001_initial_user_accounts.py # UserAccount 表(依赖 org.Staff 表已存在)
|
||||
0002_login_attempts.py # LoginAttempt 表
|
||||
0003_password_reset_tokens.py # PasswordResetToken 表
|
||||
0004_password_histories.py # PasswordHistory 表
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- `accounts` App 的迁移依赖 `org` App(`org.Staff` 表须先创建),需在 `INSTALLED_APPS` 中确保 `org` 在 `accounts` 之前
|
||||
- 所有迁移均在**租户 Schema** 下执行(`django-tenants` 的 `migrate_schemas` 命令)
|
||||
- 不得为 `email` 字段设置 `NOT NULL` 约束(允许为空,是否绑定邮箱属于用户选择)
|
||||
|
||||
---
|
||||
|
||||
## 八、设计决策说明(ADR)
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| 主键类型 | `BIGSERIAL` (BigInt) | 登录审计场景下 BigInt 主键更简洁高效;跨环境引用场景少,无需 UUID 的随机性 |
|
||||
| `phone` 字段拆分为 `phone_enc` + `phone_hash` | 是 | 与 `org.Staff` 保持一致;`phone_enc` 保存原文用于展示,`phone_hash` 用于唯一性校验和快速查询,避免加密字段全表扫描 |
|
||||
| 不扩展 Django `User` | 使用 `AbstractBaseUser` | 避免 `django.contrib.auth.User` 的全局唯一性限制(多租户下同一 username 在不同租户是允许的) |
|
||||
| `LoginAttempt` 不设外键到 `UserAccount` | 是(冗余存储 username) | 即使账号被删除(停用),审计记录仍需保留;使用 username 字符串字段保证审计完整性 |
|
||||
| 历史密码单独建表 | `PasswordHistory` 独立表 | 而非在 `UserAccount` 中存 JSON 数组,便于查询和维护,支持未来扩展保留次数 |
|
||||
| 锁定到期时间持久化 | `locked_until` 字段 | Redis 可能重启丢失数据,持久化 `locked_until` 保证 Redis 故障时锁定状态不丢失 |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,280 +1,280 @@
|
||||
# Fonrey 房睿 — MVP 范围书
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: Product Team
|
||||
**Last Updated**: 2026-04-24
|
||||
**Version**: 1.0
|
||||
|
||||
> **For AI assistants**: 本文件定义 Phase 1(MVP)的边界。在任何功能实现前,先对照本文确认是否在范围内。范围外的功能禁止在 MVP 阶段实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品背景与目标
|
||||
|
||||
**Fonrey(房睿)** 是一套面向中小型房产经纪公司的 B2B SaaS 管理平台,解决以下核心痛点:
|
||||
|
||||
- 房源/客源信息散乱,全靠人工记录
|
||||
- 跟进记录缺失,数据流失严重
|
||||
- 重复录入浪费大量经纪人时间
|
||||
- 无法支撑 89,000+ 数据量级下的高效房客匹配
|
||||
|
||||
**MVP 目标**:在一家种子客户(单租户)环境下,完整跑通"录入房源 → 录入客源 → 匹配带看 → 成交"的核心业务链路。
|
||||
|
||||
---
|
||||
|
||||
## 2. MVP 核心功能清单(Phase 1 必须实现)
|
||||
|
||||
### 2.1 优先级定义
|
||||
|
||||
| 优先级 | 含义 |
|
||||
|--------|------|
|
||||
| **P0** | MVP 上线前必须完成,阻断核心业务链路 |
|
||||
| **P1** | MVP 上线后第一个迭代周期内完成 |
|
||||
| **P2** | 已规划,列入路线图但不阻断上线 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 模块优先级矩阵
|
||||
|
||||
#### 🏠 房源管理
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 录入住宅(二手出售/出租) | **P0** | 核心业务入口 |
|
||||
| 房源列表(二手&租赁) | **P0** | 含筛选、排序、分页 |
|
||||
| 房源详情页 | **P0** | 含基本信息、产证、交易信息展示 |
|
||||
| 跟进记录(全部/写入/修改/其他) | **P0** | 含钥匙、委托、实勘 |
|
||||
| 图片管理(相册上传/分类/排序) | **P0** | 核心房源内容 |
|
||||
| 业主联系人管理 | **P0** | 含新增/编辑/查看同业主房源 |
|
||||
| 价格调整(调价/调价记录) | **P0** | 核心运营操作 |
|
||||
| 房源状态变更(在售/暂缓/成交/下架) | **P0** | 状态机核心 |
|
||||
| 房源维护完成度(诊断面板) | **P1** | 提升数据质量 |
|
||||
| 敏感信息跟进(查看权限控制) | **P1** | 需配合权限模块 |
|
||||
| 附件管理 | **P1** | 非阻断性 |
|
||||
| 市场报盘 | **P1** | 运营辅助功能 |
|
||||
| 价格解读 | **P1** | 分析辅助 |
|
||||
| 录入别墅/商铺/商住/写字楼/其他 | **P2** | 住宅优先,商业类低频 |
|
||||
| 全部商铺列表 / 全部写字楼列表 | **P2** | 配合 P2 录入功能 |
|
||||
| 房源广场 | **P2** | 跨租户/公共池功能 |
|
||||
|
||||
#### 🏙️ 楼盘管理
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 楼盘列表 + 楼盘详情(楼盘信息/楼栋/结构) | **P0** | 房源数据底座,必须先行 |
|
||||
| 区域管理(城区/商圈) | **P0** | 房源关联必须 |
|
||||
| 楼盘照片管理 | **P1** | 数据完善 |
|
||||
| 楼盘价格走势 | **P1** | 分析辅助 |
|
||||
| 周边配套(学校管理) | **P1** | 补充信息 |
|
||||
| 应用数据标准 | **P2** | 明确不做 |
|
||||
|
||||
#### 👥 客源管理
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 录入私客(求购/求租) | **P0** | 核心业务 |
|
||||
| 私客列表(全部/求购/求租) | **P0** | 含筛选、排序 |
|
||||
| 私客详情(基本信息/需求信息) | **P0** | |
|
||||
| 跟进记录(全部/写入/修改/其他) | **P0** | |
|
||||
| 带看管理(预约带看/新增带看) | **P0** | 房客匹配核心 |
|
||||
| 联系人管理 | **P0** | |
|
||||
| 客源状态变更(改等级/改状态) | **P0** | |
|
||||
| 转公客 / 转成交 / 转无效 | **P0** | 生命周期核心 |
|
||||
| 二手配房(智能匹配) | **P1** | 核心价值,但可后续迭代 |
|
||||
| 客源解读 | **P1** | AI 辅助分析 |
|
||||
| 客源信息概览 | **P1** | 汇总视图 |
|
||||
| 客源收藏夹 | **P1** | 辅助功能 |
|
||||
| 公客管理 | **P2** | 私客优先 |
|
||||
| 成交客管理 | **P2** | |
|
||||
| 暂缓私客 | **P2** | |
|
||||
|
||||
#### 🏢 组织人事
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 公司组织结构(部门/门店树) | **P0** | 权限系统基础 |
|
||||
| 员工列表/员工详情 | **P0** | |
|
||||
| 员工入职/账号创建 | **P0** | |
|
||||
| 员工离职 / 调动 | **P1** | |
|
||||
| 员工通讯录 | **P1** | |
|
||||
| 异动记录 | **P1** | |
|
||||
| 奖惩记录 | **P2** | |
|
||||
| 职务管理 | **P1** | |
|
||||
| 门店分布地图 | **P2** | |
|
||||
|
||||
#### 🔐 权限管理
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 角色管理(预设角色 + 自定义角色) | **P0** | 权限基础 |
|
||||
| 人员权限列表 | **P0** | |
|
||||
| 角色批量分配 | **P0** | |
|
||||
| 功能权限(菜单级) | **P0** | |
|
||||
| 数据权限(部门/个人/全司) | **P0** | |
|
||||
| 字段级权限(敏感字段可见性) | **P1** | 配合房源/客源敏感信息 |
|
||||
| 个人特定权限覆盖 | **P1** | |
|
||||
|
||||
#### 🔑 用户登录
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 账号密码登录 | **P0** | |
|
||||
| 多租户识别(子域名/域名) | **P0** | |
|
||||
| Token 管理 / 会话超时 | **P0** | |
|
||||
| 短信验证码登录 | **P1** | |
|
||||
| 密码重置 | **P1** | |
|
||||
| 记住登录状态 | **P1** | |
|
||||
|
||||
#### ⚙️ 系统配置
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 首页设置 | **P1** | |
|
||||
| 房源设置(字段必填/自定义字段/标签) | **P0** | 影响录入表单 |
|
||||
| 相关方设置 | **P1** | |
|
||||
| 客源设置(基本配置/参数配置) | **P1** | |
|
||||
| 人事OA设置 | **P2** | |
|
||||
| 交易设置 | **P2** | |
|
||||
| 财务设置 | **P2** | |
|
||||
| 合同设置 | **P2** | |
|
||||
|
||||
#### 🖥️ 系统管理(运营后台)
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 租户管理(开通/暂停/配置) | **P1** | 单租户种子阶段可手动 |
|
||||
| 系统健康监控 | **P1** | |
|
||||
| 操作审计日志 | **P2** | |
|
||||
| 灰度发布 / 滚动升级 | **P2** | |
|
||||
|
||||
#### 💻 客户端发布
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| Windows 桌面客户端(内置浏览器) | **P1** | 种子客户使用 Web 端可先行 |
|
||||
| 自动更新机制 | **P1** | 配合客户端 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 非目标(Out of Scope — MVP 阶段绝对不做)
|
||||
|
||||
以下功能在 MVP 阶段**明确不实现**,AI 生成代码时不得为这些功能预留接口或引入相关依赖:
|
||||
|
||||
| 功能 | 原因 |
|
||||
|------|------|
|
||||
| 移动端适配 | v2 规划 |
|
||||
| 新房模块(新房管理/新房设置) | 独立模块,后续版本 |
|
||||
| 合同管理模块 | 独立模块,后续版本 |
|
||||
| 财务管理/提成结算 | 独立模块,后续版本 |
|
||||
| 三网发布(安居客/链家/贝壳对接) | 独立模块,后续版本 |
|
||||
| 数据报表/行程量化 | 独立模块,后续版本 |
|
||||
| 在线充值/增值服务 | 独立模块,后续版本 |
|
||||
| 任务管理(OA任务/入职祝福) | 低优先 |
|
||||
| 考勤管理 | 独立 HR 模块 |
|
||||
| 审批流程 | 独立 OA 模块 |
|
||||
| 智慧大屏 / VR换装 | 增值产品 |
|
||||
| 房源广场(跨租户公共池) | 多租户复杂场景 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户故事(MVP 核心路径)
|
||||
|
||||
### Story 1 — 经纪人录入房源
|
||||
> As a **一线经纪人**,
|
||||
> I want to **快速录入一套二手住宅并上传图片和业主联系方式**,
|
||||
> So that **这套房源的信息能被团队所有成员找到和跟进**.
|
||||
|
||||
**验收标准**:
|
||||
- 可在 3 分钟内完成住宅基本信息录入
|
||||
- 上传图片后自动按分类展示
|
||||
- 录入后即刻出现在房源列表
|
||||
|
||||
---
|
||||
|
||||
### Story 2 — 经纪人跟进房源
|
||||
> As a **一线经纪人**,
|
||||
> I want to **对我负责的房源记录每次跟进(面访/电话/钥匙/实勘)**,
|
||||
> So that **我的跟进历史有据可查,团队不会重复联系同一业主**.
|
||||
|
||||
**验收标准**:
|
||||
- 跟进记录按时间线倒序展示
|
||||
- 支持写入跟进、修改跟进、其他跟进(钥匙/委托/实勘)
|
||||
- 敏感信息跟进只对有权限的人员可见
|
||||
|
||||
---
|
||||
|
||||
### Story 3 — 经纪人录入客源
|
||||
> As a **一线经纪人**,
|
||||
> I want to **录入意向购房/租房客户并跟进其需求变化**,
|
||||
> So that **我能在合适时机将客户与合适房源匹配**.
|
||||
|
||||
**验收标准**:
|
||||
- 区分求购/求租两种意向
|
||||
- 支持跟进记录
|
||||
- 可安排带看并记录带看结果
|
||||
|
||||
---
|
||||
|
||||
### Story 4 — 转成交
|
||||
> As a **一线经纪人**,
|
||||
> I want to **将已达成交易的客源标记为"成交"并关联成交房源**,
|
||||
> So that **成交数据进入系统留存,房源状态自动更新**.
|
||||
|
||||
**验收标准**:
|
||||
- 转成交时必须选择关联房源
|
||||
- 成交后客源状态自动变为"成交客"
|
||||
- 关联房源状态建议变更为"成交"(可手动确认)
|
||||
|
||||
---
|
||||
|
||||
### Story 5 — 店长查看团队数据
|
||||
> As a **门店店长**,
|
||||
> I want to **查看本门店所有员工的房源和客源列表**,
|
||||
> So that **我能掌握团队整体情况并合理分配资源**.
|
||||
|
||||
**验收标准**:
|
||||
- 数据权限按部门隔离,店长可见本门店数据
|
||||
- 可筛选查看特定员工的房源/客源
|
||||
- 无法看到其他门店的数据
|
||||
|
||||
---
|
||||
|
||||
## 5. MVP 技术边界
|
||||
|
||||
| 约束 | 决策 |
|
||||
|------|------|
|
||||
| 租户数 | **单租户**种子阶段,多租户架构已就位但不激活多租户切换 UI |
|
||||
| 数据量 | 目标支撑 **89,000 条**房源,测试阶段以 10,000 条压测 |
|
||||
| 浏览器支持 | Chrome 最新版 / Edge 最新版,不支持 IE |
|
||||
| 语言 | 简体中文,不做国际化 |
|
||||
| 移动端 | **不做**,Web 端 Desktop-first |
|
||||
| 导出 | Excel/CSV 导出通过 Celery 异步,不超时 |
|
||||
|
||||
---
|
||||
|
||||
## 6. MVP 交付检查清单
|
||||
|
||||
在 MVP 正式上线前,以下项目必须全部勾选:
|
||||
|
||||
- [ ] 房源录入(住宅)完整流程可用
|
||||
- [ ] 房源列表可筛选/排序/分页
|
||||
- [ ] 客源录入(求购/求租)完整流程可用
|
||||
- [ ] 带看创建与记录可用
|
||||
- [ ] 转成交流程可用
|
||||
- [ ] 楼盘数据可录入(为房源提供底座)
|
||||
- [ ] 员工账号可创建/分配角色
|
||||
- [ ] 权限隔离:经纪人只能看自己数据,店长能看本店数据
|
||||
- [ ] 89,000 条数据量下列表查询 < 2 秒(含索引优化)
|
||||
- [ ] 图片上传到 Cloudflare R2 可用
|
||||
- [ ] 多租户 Schema 隔离验证通过
|
||||
|
||||
---
|
||||
|
||||
## 7. 版本路线图
|
||||
|
||||
| 版本 | 目标 | 核心功能 |
|
||||
|------|------|---------|
|
||||
| **v0.1 MVP** | 单租户种子验证 | P0 功能全部上线 |
|
||||
| **v0.2** | 功能完善 | P1 功能上线,开始多租户测试 |
|
||||
| **v0.3** | 商业化就绪 | Windows 客户端、多租户正式开放、系统配置完善 |
|
||||
| **v1.0** | 正式发布 | 新房模块、合同/财务模块路线图确认 |
|
||||
# Fonrey 房睿 — MVP 范围书
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: Product Team
|
||||
**Last Updated**: 2026-04-24
|
||||
**Version**: 1.0
|
||||
|
||||
> **For AI assistants**: 本文件定义 Phase 1(MVP)的边界。在任何功能实现前,先对照本文确认是否在范围内。范围外的功能禁止在 MVP 阶段实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品背景与目标
|
||||
|
||||
**Fonrey(房睿)** 是一套面向中小型房产经纪公司的 B2B SaaS 管理平台,解决以下核心痛点:
|
||||
|
||||
- 房源/客源信息散乱,全靠人工记录
|
||||
- 跟进记录缺失,数据流失严重
|
||||
- 重复录入浪费大量经纪人时间
|
||||
- 无法支撑 89,000+ 数据量级下的高效房客匹配
|
||||
|
||||
**MVP 目标**:在一家种子客户(单租户)环境下,完整跑通"录入房源 → 录入客源 → 匹配带看 → 成交"的核心业务链路。
|
||||
|
||||
---
|
||||
|
||||
## 2. MVP 核心功能清单(Phase 1 必须实现)
|
||||
|
||||
### 2.1 优先级定义
|
||||
|
||||
| 优先级 | 含义 |
|
||||
|--------|------|
|
||||
| **P0** | MVP 上线前必须完成,阻断核心业务链路 |
|
||||
| **P1** | MVP 上线后第一个迭代周期内完成 |
|
||||
| **P2** | 已规划,列入路线图但不阻断上线 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 模块优先级矩阵
|
||||
|
||||
#### 🏠 房源管理
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 录入住宅(二手出售/出租) | **P0** | 核心业务入口 |
|
||||
| 房源列表(二手&租赁) | **P0** | 含筛选、排序、分页 |
|
||||
| 房源详情页 | **P0** | 含基本信息、产证、交易信息展示 |
|
||||
| 跟进记录(全部/写入/修改/其他) | **P0** | 含钥匙、委托、实勘 |
|
||||
| 图片管理(相册上传/分类/排序) | **P0** | 核心房源内容 |
|
||||
| 业主联系人管理 | **P0** | 含新增/编辑/查看同业主房源 |
|
||||
| 价格调整(调价/调价记录) | **P0** | 核心运营操作 |
|
||||
| 房源状态变更(在售/暂缓/成交/下架) | **P0** | 状态机核心 |
|
||||
| 房源维护完成度(诊断面板) | **P1** | 提升数据质量 |
|
||||
| 敏感信息跟进(查看权限控制) | **P1** | 需配合权限模块 |
|
||||
| 附件管理 | **P1** | 非阻断性 |
|
||||
| 市场报盘 | **P1** | 运营辅助功能 |
|
||||
| 价格解读 | **P1** | 分析辅助 |
|
||||
| 录入别墅/商铺/商住/写字楼/其他 | **P2** | 住宅优先,商业类低频 |
|
||||
| 全部商铺列表 / 全部写字楼列表 | **P2** | 配合 P2 录入功能 |
|
||||
| 房源广场 | **P2** | 跨租户/公共池功能 |
|
||||
|
||||
#### 🏙️ 楼盘管理
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 楼盘列表 + 楼盘详情(楼盘信息/楼栋/结构) | **P0** | 房源数据底座,必须先行 |
|
||||
| 区域管理(城区/商圈) | **P0** | 房源关联必须 |
|
||||
| 楼盘照片管理 | **P1** | 数据完善 |
|
||||
| 楼盘价格走势 | **P1** | 分析辅助 |
|
||||
| 周边配套(学校管理) | **P1** | 补充信息 |
|
||||
| 应用数据标准 | **P2** | 明确不做 |
|
||||
|
||||
#### 👥 客源管理
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 录入私客(求购/求租) | **P0** | 核心业务 |
|
||||
| 私客列表(全部/求购/求租) | **P0** | 含筛选、排序 |
|
||||
| 私客详情(基本信息/需求信息) | **P0** | |
|
||||
| 跟进记录(全部/写入/修改/其他) | **P0** | |
|
||||
| 带看管理(预约带看/新增带看) | **P0** | 房客匹配核心 |
|
||||
| 联系人管理 | **P0** | |
|
||||
| 客源状态变更(改等级/改状态) | **P0** | |
|
||||
| 转公客 / 转成交 / 转无效 | **P0** | 生命周期核心 |
|
||||
| 二手配房(智能匹配) | **P1** | 核心价值,但可后续迭代 |
|
||||
| 客源解读 | **P1** | AI 辅助分析 |
|
||||
| 客源信息概览 | **P1** | 汇总视图 |
|
||||
| 客源收藏夹 | **P1** | 辅助功能 |
|
||||
| 公客管理 | **P2** | 私客优先 |
|
||||
| 成交客管理 | **P2** | |
|
||||
| 暂缓私客 | **P2** | |
|
||||
|
||||
#### 🏢 组织人事
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 公司组织结构(部门/门店树) | **P0** | 权限系统基础 |
|
||||
| 员工列表/员工详情 | **P0** | |
|
||||
| 员工入职/账号创建 | **P0** | |
|
||||
| 员工离职 / 调动 | **P1** | |
|
||||
| 员工通讯录 | **P1** | |
|
||||
| 异动记录 | **P1** | |
|
||||
| 奖惩记录 | **P2** | |
|
||||
| 职务管理 | **P1** | |
|
||||
| 门店分布地图 | **P2** | |
|
||||
|
||||
#### 🔐 权限管理
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 角色管理(预设角色 + 自定义角色) | **P0** | 权限基础 |
|
||||
| 人员权限列表 | **P0** | |
|
||||
| 角色批量分配 | **P0** | |
|
||||
| 功能权限(菜单级) | **P0** | |
|
||||
| 数据权限(部门/个人/全司) | **P0** | |
|
||||
| 字段级权限(敏感字段可见性) | **P1** | 配合房源/客源敏感信息 |
|
||||
| 个人特定权限覆盖 | **P1** | |
|
||||
|
||||
#### 🔑 用户登录
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 账号密码登录 | **P0** | |
|
||||
| 多租户识别(子域名/域名) | **P0** | |
|
||||
| Token 管理 / 会话超时 | **P0** | |
|
||||
| 短信验证码登录 | **P1** | |
|
||||
| 密码重置 | **P1** | |
|
||||
| 记住登录状态 | **P1** | |
|
||||
|
||||
#### ⚙️ 系统配置
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 首页设置 | **P1** | |
|
||||
| 房源设置(字段必填/自定义字段/标签) | **P0** | 影响录入表单 |
|
||||
| 相关方设置 | **P1** | |
|
||||
| 客源设置(基本配置/参数配置) | **P1** | |
|
||||
| 人事OA设置 | **P2** | |
|
||||
| 交易设置 | **P2** | |
|
||||
| 财务设置 | **P2** | |
|
||||
| 合同设置 | **P2** | |
|
||||
|
||||
#### 🖥️ 系统管理(运营后台)
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 租户管理(开通/暂停/配置) | **P1** | 单租户种子阶段可手动 |
|
||||
| 系统健康监控 | **P1** | |
|
||||
| 操作审计日志 | **P2** | |
|
||||
| 灰度发布 / 滚动升级 | **P2** | |
|
||||
|
||||
#### 💻 客户端发布
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| Windows 桌面客户端(内置浏览器) | **P1** | 种子客户使用 Web 端可先行 |
|
||||
| 自动更新机制 | **P1** | 配合客户端 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 非目标(Out of Scope — MVP 阶段绝对不做)
|
||||
|
||||
以下功能在 MVP 阶段**明确不实现**,AI 生成代码时不得为这些功能预留接口或引入相关依赖:
|
||||
|
||||
| 功能 | 原因 |
|
||||
|------|------|
|
||||
| 移动端适配 | v2 规划 |
|
||||
| 新房模块(新房管理/新房设置) | 独立模块,后续版本 |
|
||||
| 合同管理模块 | 独立模块,后续版本 |
|
||||
| 财务管理/提成结算 | 独立模块,后续版本 |
|
||||
| 三网发布(安居客/链家/贝壳对接) | 独立模块,后续版本 |
|
||||
| 数据报表/行程量化 | 独立模块,后续版本 |
|
||||
| 在线充值/增值服务 | 独立模块,后续版本 |
|
||||
| 任务管理(OA任务/入职祝福) | 低优先 |
|
||||
| 考勤管理 | 独立 HR 模块 |
|
||||
| 审批流程 | 独立 OA 模块 |
|
||||
| 智慧大屏 / VR换装 | 增值产品 |
|
||||
| 房源广场(跨租户公共池) | 多租户复杂场景 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户故事(MVP 核心路径)
|
||||
|
||||
### Story 1 — 经纪人录入房源
|
||||
> As a **一线经纪人**,
|
||||
> I want to **快速录入一套二手住宅并上传图片和业主联系方式**,
|
||||
> So that **这套房源的信息能被团队所有成员找到和跟进**.
|
||||
|
||||
**验收标准**:
|
||||
- 可在 3 分钟内完成住宅基本信息录入
|
||||
- 上传图片后自动按分类展示
|
||||
- 录入后即刻出现在房源列表
|
||||
|
||||
---
|
||||
|
||||
### Story 2 — 经纪人跟进房源
|
||||
> As a **一线经纪人**,
|
||||
> I want to **对我负责的房源记录每次跟进(面访/电话/钥匙/实勘)**,
|
||||
> So that **我的跟进历史有据可查,团队不会重复联系同一业主**.
|
||||
|
||||
**验收标准**:
|
||||
- 跟进记录按时间线倒序展示
|
||||
- 支持写入跟进、修改跟进、其他跟进(钥匙/委托/实勘)
|
||||
- 敏感信息跟进只对有权限的人员可见
|
||||
|
||||
---
|
||||
|
||||
### Story 3 — 经纪人录入客源
|
||||
> As a **一线经纪人**,
|
||||
> I want to **录入意向购房/租房客户并跟进其需求变化**,
|
||||
> So that **我能在合适时机将客户与合适房源匹配**.
|
||||
|
||||
**验收标准**:
|
||||
- 区分求购/求租两种意向
|
||||
- 支持跟进记录
|
||||
- 可安排带看并记录带看结果
|
||||
|
||||
---
|
||||
|
||||
### Story 4 — 转成交
|
||||
> As a **一线经纪人**,
|
||||
> I want to **将已达成交易的客源标记为"成交"并关联成交房源**,
|
||||
> So that **成交数据进入系统留存,房源状态自动更新**.
|
||||
|
||||
**验收标准**:
|
||||
- 转成交时必须选择关联房源
|
||||
- 成交后客源状态自动变为"成交客"
|
||||
- 关联房源状态建议变更为"成交"(可手动确认)
|
||||
|
||||
---
|
||||
|
||||
### Story 5 — 店长查看团队数据
|
||||
> As a **门店店长**,
|
||||
> I want to **查看本门店所有员工的房源和客源列表**,
|
||||
> So that **我能掌握团队整体情况并合理分配资源**.
|
||||
|
||||
**验收标准**:
|
||||
- 数据权限按部门隔离,店长可见本门店数据
|
||||
- 可筛选查看特定员工的房源/客源
|
||||
- 无法看到其他门店的数据
|
||||
|
||||
---
|
||||
|
||||
## 5. MVP 技术边界
|
||||
|
||||
| 约束 | 决策 |
|
||||
|------|------|
|
||||
| 租户数 | **单租户**种子阶段,多租户架构已就位但不激活多租户切换 UI |
|
||||
| 数据量 | 目标支撑 **89,000 条**房源,测试阶段以 10,000 条压测 |
|
||||
| 浏览器支持 | Chrome 最新版 / Edge 最新版,不支持 IE |
|
||||
| 语言 | 简体中文,不做国际化 |
|
||||
| 移动端 | **不做**,Web 端 Desktop-first |
|
||||
| 导出 | Excel/CSV 导出通过 Celery 异步,不超时 |
|
||||
|
||||
---
|
||||
|
||||
## 6. MVP 交付检查清单
|
||||
|
||||
在 MVP 正式上线前,以下项目必须全部勾选:
|
||||
|
||||
- [ ] 房源录入(住宅)完整流程可用
|
||||
- [ ] 房源列表可筛选/排序/分页
|
||||
- [ ] 客源录入(求购/求租)完整流程可用
|
||||
- [ ] 带看创建与记录可用
|
||||
- [ ] 转成交流程可用
|
||||
- [ ] 楼盘数据可录入(为房源提供底座)
|
||||
- [ ] 员工账号可创建/分配角色
|
||||
- [ ] 权限隔离:经纪人只能看自己数据,店长能看本店数据
|
||||
- [ ] 89,000 条数据量下列表查询 < 2 秒(含索引优化)
|
||||
- [ ] 图片上传到 Cloudflare R2 可用
|
||||
- [ ] 多租户 Schema 隔离验证通过
|
||||
|
||||
---
|
||||
|
||||
## 7. 版本路线图
|
||||
|
||||
| 版本 | 目标 | 核心功能 |
|
||||
|------|------|---------|
|
||||
| **v0.1 MVP** | 单租户种子验证 | P0 功能全部上线 |
|
||||
| **v0.2** | 功能完善 | P1 功能上线,开始多租户测试 |
|
||||
| **v0.3** | 商业化就绪 | Windows 客户端、多租户正式开放、系统配置完善 |
|
||||
| **v1.0** | 正式发布 | 新房模块、合同/财务模块路线图确认 |
|
||||
|
||||
@@ -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 | 便携版,无需安装,解压即用,适合无管理员权限的企业环境 |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,309 +1,309 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-04-25T00:00:00.000Z" agent="OpenCode" version="21.0.0">
|
||||
<!-- ============================================================
|
||||
5.4.1 找回用户名流程
|
||||
============================================================ -->
|
||||
<diagram id="recover-username" name="5.4.1 找回用户名流程">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
|
||||
connect="1" arrows="1" fold="1" page="1" pageScale="1"
|
||||
pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<!-- Title -->
|
||||
<mxCell id="t1" value="5.4.1 找回用户名流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Start -->
|
||||
<mxCell id="u1" value="用户点击「忘记用户名」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="80" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Decision: Is email format valid? -->
|
||||
<mxCell id="u2" value="展示「找回用户名」页面
|
||||
(邮箱输入框 + 发送按钮)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="170" width="350" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User inputs email -->
|
||||
<mxCell id="u3" value="用户输入邮箱并点击「发送」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="260" width="350" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Decision: Email format valid? -->
|
||||
<mxCell id="u4" value="邮箱格式校验通过?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="350" width="300" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Format invalid -->
|
||||
<mxCell id="u5" value="提示「请输入有效的邮箱地址」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="620" y="360" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Backend query -->
|
||||
<mxCell id="u6" value="服务端查询邮箱是否绑定账号
|
||||
(不向前端返回查询结果)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="470" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Unified response to frontend -->
|
||||
<mxCell id="u7" value="统一响应前端:
|
||||
「如该邮箱已绑定账号,您将收到邮件」
|
||||
发送按钮进入 60 秒倒计时" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="580" width="350" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Decision: Email exists? -->
|
||||
<mxCell id="u8" value="邮箱已绑定 Tenant Admin 账号?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="475" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Email found: send email -->
|
||||
<mxCell id="u9" value="后台:异步发送邮件
|
||||
(包含用户名、发送时间)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="580" width="220" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Email not found: silent -->
|
||||
<mxCell id="u10" value="后台:静默处理
|
||||
(不发送邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="660" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Rate limit check -->
|
||||
<mxCell id="u11" value="同一邮箱 1 小时内已发送 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="700" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Rate limit exceeded -->
|
||||
<mxCell id="u12" value="拒绝发送
|
||||
(达到频率上限)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="715" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User receives email -->
|
||||
<mxCell id="u13" value="用户查收邮件,获取用户名" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="810" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Return to login -->
|
||||
<mxCell id="u14" value="点击「返回登录」
|
||||
回到登录界面" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="700" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Edges -->
|
||||
<mxCell id="e1" edge="1" source="u1" target="u2" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e2" edge="1" source="u2" target="u3" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e3" edge="1" source="u3" target="u4" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e4" value="否" edge="1" source="u4" target="u5" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="385" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e5" value="" edge="1" source="u5" target="u3" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="730" y="285" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e6" value="是" edge="1" source="u4" target="u6" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e7" edge="1" source="u6" target="u7" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e8" edge="1" source="u6" target="u8" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="600" y="500" /><mxPoint x="630" y="510" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e9" value="是" edge="1" source="u8" target="u11" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e10" value="否(普通员工邮箱或不存在)" edge="1" source="u8" target="u10" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><mxPoint x="760" y="660" as="targetPoint" /></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e11" value="否(未超限)" edge="1" source="u11" target="u9" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e12" value="是(超过 3 次)" edge="1" source="u11" target="u12" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="735" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e13" edge="1" source="u9" target="u13" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e14" edge="1" source="u13" target="u14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="835" /><mxPoint x="425" y="835" /><mxPoint x="425" y="750" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e15" edge="1" source="u7" target="u14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
<!-- ============================================================
|
||||
5.4.2 找回密码流程
|
||||
============================================================ -->
|
||||
<diagram id="recover-password" name="5.4.2 找回密码流程">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
|
||||
connect="1" arrows="1" fold="1" page="1" pageScale="1"
|
||||
pageWidth="850" pageHeight="1300" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<!-- Title -->
|
||||
<mxCell id="title" value="5.4.2 找回密码流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- ===== STEP 1 Header ===== -->
|
||||
<mxCell id="step1hdr" value="步骤一:身份验证" style="text;html=1;strokeColor=none;fillColor=#dae8fc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="65" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Start -->
|
||||
<mxCell id="p1" value="用户点击「忘记密码」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="115" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Show form -->
|
||||
<mxCell id="p2" value="展示「找回密码」页面(Stepper)
|
||||
步骤一:用户名 + 绑定邮箱输入框" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="200" width="400" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User submits -->
|
||||
<mxCell id="p3" value="用户输入用户名 + 邮箱,点击「下一步」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="300" width="400" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Backend verification -->
|
||||
<mxCell id="p4" value="服务端校验用户名与邮箱是否匹配" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="390" width="400" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Unified response -->
|
||||
<mxCell id="p5" value="统一响应前端:
|
||||
「如信息匹配,重置链接将发送至您的邮箱」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="480" width="400" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Decision: Match? -->
|
||||
<mxCell id="p6" value="用户名与邮箱匹配?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="395" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Rate limit check -->
|
||||
<mxCell id="p7" value="同一账号 1 小时内已发 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="490" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Matched: generate token -->
|
||||
<mxCell id="p8" value="生成加密 Token
|
||||
(secrets.token_urlsafe(32),有效期 30 分钟)
|
||||
异步发送重置邮件" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="590" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- No match: silent -->
|
||||
<mxCell id="p9" value="不匹配:静默处理
|
||||
(不发邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="405" width="180" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Rate limit exceeded -->
|
||||
<mxCell id="p10" value="已超频率上限
|
||||
静默处理" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="505" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- ===== STEP 2 Header ===== -->
|
||||
<mxCell id="step2hdr" value="步骤二:用户点击邮件重置链接" style="text;html=1;strokeColor=none;fillColor=#d5e8d4;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="690" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User clicks link -->
|
||||
<mxCell id="p11" value="用户点击邮件中的重置链接" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="740" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Validate token -->
|
||||
<mxCell id="p12" value="服务端校验 Token 有效性
|
||||
(is_used=False AND expires_at 未过期)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="830" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Token decision -->
|
||||
<mxCell id="p13" value="Token 有效?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="930" width="250" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Invalid token -->
|
||||
<mxCell id="p14" value="提示「链接已过期或已使用,请重新申请」
|
||||
提供「重新申请」按钮(跳回步骤一)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="620" y="940" width="260" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- ===== STEP 3 Header ===== -->
|
||||
<mxCell id="step3hdr" value="步骤三:输入并提交新密码" style="text;html=1;strokeColor=none;fillColor=#fff2cc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="1030" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Show reset form -->
|
||||
<mxCell id="p15" value="展示「重置密码」表单
|
||||
(新密码 + 确认新密码 + 密码强度指示)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="1080" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User submits new password -->
|
||||
<mxCell id="p16" value="用户输入新密码并提交" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1180" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Complexity check -->
|
||||
<mxCell id="p17" value="密码复杂度校验
|
||||
(≥8位,含字母+数字,两次一致)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1265" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Complexity fail -->
|
||||
<mxCell id="p18" value="实时提示不满足的规则
|
||||
(逐条红色 ✗ / 绿色 ✓ 视觉指引)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="1275" width="240" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- History check -->
|
||||
<mxCell id="p19" value="历史密码校验
|
||||
(不得与最近 3 次历史密码相同)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1380" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- History fail -->
|
||||
<mxCell id="p20" value="提示「不得与最近 3 次密码相同」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="1395" width="240" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Success: update password -->
|
||||
<mxCell id="p21" value="✅ 校验通过:
|
||||
① 更新密码(PBKDF2+SHA256 哈希存储)
|
||||
② is_initial_password = False
|
||||
③ 清除该账号所有有效 Session
|
||||
④ 标记 Token 为 is_used = True" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="1500" width="400" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- End -->
|
||||
<mxCell id="p22" value="跳转登录界面
|
||||
提示「密码已重置,请使用新密码登录」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1630" width="300" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Edges Step 1 -->
|
||||
<mxCell id="ep1" edge="1" source="p1" target="p2" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep2" edge="1" source="p2" target="p3" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep3" edge="1" source="p3" target="p4" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep4" edge="1" source="p4" target="p5" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep5" edge="1" source="p4" target="p6" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="625" y="415" /><mxPoint x="650" y="430" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep6" value="否" edge="1" source="p6" target="p9" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="430" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep7" value="是" edge="1" source="p6" target="p7" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep8" value="是(超限)" edge="1" source="p7" target="p10" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="525" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep9" value="否(未超限)" edge="1" source="p7" target="p8" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<!-- Step 2 edges -->
|
||||
<mxCell id="ep10" edge="1" source="p5" target="p11" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep11" edge="1" source="p11" target="p12" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep12" edge="1" source="p12" target="p13" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep13" value="否(无效/过期)" edge="1" source="p13" target="p14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="965" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep14" value="重新申请" edge="1" source="p14" target="p2" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="960" y="970" /><mxPoint x="960" y="230" /><mxPoint x="625" y="230" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep15" value="是(有效)" edge="1" source="p13" target="p15" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<!-- Step 3 edges -->
|
||||
<mxCell id="ep16" edge="1" source="p15" target="p16" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep17" edge="1" source="p16" target="p17" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep18" value="不通过" edge="1" source="p17" target="p18" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1305" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep19" value="" edge="1" source="p18" target="p16" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep20" value="通过" edge="1" source="p17" target="p19" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep21" value="不通过" edge="1" source="p19" target="p20" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1420" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep22" value="" edge="1" source="p20" target="p16" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep23" value="通过" edge="1" source="p19" target="p21" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep24" edge="1" source="p21" target="p22" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-04-25T00:00:00.000Z" agent="OpenCode" version="21.0.0">
|
||||
<!-- ============================================================
|
||||
5.4.1 找回用户名流程
|
||||
============================================================ -->
|
||||
<diagram id="recover-username" name="5.4.1 找回用户名流程">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
|
||||
connect="1" arrows="1" fold="1" page="1" pageScale="1"
|
||||
pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<!-- Title -->
|
||||
<mxCell id="t1" value="5.4.1 找回用户名流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Start -->
|
||||
<mxCell id="u1" value="用户点击「忘记用户名」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="80" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Decision: Is email format valid? -->
|
||||
<mxCell id="u2" value="展示「找回用户名」页面
|
||||
(邮箱输入框 + 发送按钮)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="170" width="350" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User inputs email -->
|
||||
<mxCell id="u3" value="用户输入邮箱并点击「发送」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="260" width="350" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Decision: Email format valid? -->
|
||||
<mxCell id="u4" value="邮箱格式校验通过?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="350" width="300" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Format invalid -->
|
||||
<mxCell id="u5" value="提示「请输入有效的邮箱地址」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="620" y="360" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Backend query -->
|
||||
<mxCell id="u6" value="服务端查询邮箱是否绑定账号
|
||||
(不向前端返回查询结果)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="470" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Unified response to frontend -->
|
||||
<mxCell id="u7" value="统一响应前端:
|
||||
「如该邮箱已绑定账号,您将收到邮件」
|
||||
发送按钮进入 60 秒倒计时" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="580" width="350" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Decision: Email exists? -->
|
||||
<mxCell id="u8" value="邮箱已绑定 Tenant Admin 账号?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="475" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Email found: send email -->
|
||||
<mxCell id="u9" value="后台:异步发送邮件
|
||||
(包含用户名、发送时间)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="580" width="220" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Email not found: silent -->
|
||||
<mxCell id="u10" value="后台:静默处理
|
||||
(不发送邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="660" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Rate limit check -->
|
||||
<mxCell id="u11" value="同一邮箱 1 小时内已发送 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="700" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Rate limit exceeded -->
|
||||
<mxCell id="u12" value="拒绝发送
|
||||
(达到频率上限)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="715" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User receives email -->
|
||||
<mxCell id="u13" value="用户查收邮件,获取用户名" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="810" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Return to login -->
|
||||
<mxCell id="u14" value="点击「返回登录」
|
||||
回到登录界面" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="700" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Edges -->
|
||||
<mxCell id="e1" edge="1" source="u1" target="u2" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e2" edge="1" source="u2" target="u3" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e3" edge="1" source="u3" target="u4" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e4" value="否" edge="1" source="u4" target="u5" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="385" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e5" value="" edge="1" source="u5" target="u3" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="730" y="285" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e6" value="是" edge="1" source="u4" target="u6" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e7" edge="1" source="u6" target="u7" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e8" edge="1" source="u6" target="u8" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="600" y="500" /><mxPoint x="630" y="510" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e9" value="是" edge="1" source="u8" target="u11" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e10" value="否(普通员工邮箱或不存在)" edge="1" source="u8" target="u10" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><mxPoint x="760" y="660" as="targetPoint" /></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e11" value="否(未超限)" edge="1" source="u11" target="u9" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e12" value="是(超过 3 次)" edge="1" source="u11" target="u12" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="735" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e13" edge="1" source="u9" target="u13" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e14" edge="1" source="u13" target="u14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="835" /><mxPoint x="425" y="835" /><mxPoint x="425" y="750" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e15" edge="1" source="u7" target="u14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
<!-- ============================================================
|
||||
5.4.2 找回密码流程
|
||||
============================================================ -->
|
||||
<diagram id="recover-password" name="5.4.2 找回密码流程">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
|
||||
connect="1" arrows="1" fold="1" page="1" pageScale="1"
|
||||
pageWidth="850" pageHeight="1300" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<!-- Title -->
|
||||
<mxCell id="title" value="5.4.2 找回密码流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- ===== STEP 1 Header ===== -->
|
||||
<mxCell id="step1hdr" value="步骤一:身份验证" style="text;html=1;strokeColor=none;fillColor=#dae8fc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="65" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Start -->
|
||||
<mxCell id="p1" value="用户点击「忘记密码」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="115" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Show form -->
|
||||
<mxCell id="p2" value="展示「找回密码」页面(Stepper)
|
||||
步骤一:用户名 + 绑定邮箱输入框" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="200" width="400" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User submits -->
|
||||
<mxCell id="p3" value="用户输入用户名 + 邮箱,点击「下一步」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="300" width="400" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Backend verification -->
|
||||
<mxCell id="p4" value="服务端校验用户名与邮箱是否匹配" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="390" width="400" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Unified response -->
|
||||
<mxCell id="p5" value="统一响应前端:
|
||||
「如信息匹配,重置链接将发送至您的邮箱」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="480" width="400" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Decision: Match? -->
|
||||
<mxCell id="p6" value="用户名与邮箱匹配?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="395" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Rate limit check -->
|
||||
<mxCell id="p7" value="同一账号 1 小时内已发 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="490" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Matched: generate token -->
|
||||
<mxCell id="p8" value="生成加密 Token
|
||||
(secrets.token_urlsafe(32),有效期 30 分钟)
|
||||
异步发送重置邮件" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="590" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- No match: silent -->
|
||||
<mxCell id="p9" value="不匹配:静默处理
|
||||
(不发邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="405" width="180" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Rate limit exceeded -->
|
||||
<mxCell id="p10" value="已超频率上限
|
||||
静默处理" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="505" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- ===== STEP 2 Header ===== -->
|
||||
<mxCell id="step2hdr" value="步骤二:用户点击邮件重置链接" style="text;html=1;strokeColor=none;fillColor=#d5e8d4;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="690" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User clicks link -->
|
||||
<mxCell id="p11" value="用户点击邮件中的重置链接" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="740" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Validate token -->
|
||||
<mxCell id="p12" value="服务端校验 Token 有效性
|
||||
(is_used=False AND expires_at 未过期)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="830" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Token decision -->
|
||||
<mxCell id="p13" value="Token 有效?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="930" width="250" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Invalid token -->
|
||||
<mxCell id="p14" value="提示「链接已过期或已使用,请重新申请」
|
||||
提供「重新申请」按钮(跳回步骤一)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="620" y="940" width="260" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- ===== STEP 3 Header ===== -->
|
||||
<mxCell id="step3hdr" value="步骤三:输入并提交新密码" style="text;html=1;strokeColor=none;fillColor=#fff2cc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="1030" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Show reset form -->
|
||||
<mxCell id="p15" value="展示「重置密码」表单
|
||||
(新密码 + 确认新密码 + 密码强度指示)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="1080" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- User submits new password -->
|
||||
<mxCell id="p16" value="用户输入新密码并提交" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1180" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Complexity check -->
|
||||
<mxCell id="p17" value="密码复杂度校验
|
||||
(≥8位,含字母+数字,两次一致)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1265" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Complexity fail -->
|
||||
<mxCell id="p18" value="实时提示不满足的规则
|
||||
(逐条红色 ✗ / 绿色 ✓ 视觉指引)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="1275" width="240" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- History check -->
|
||||
<mxCell id="p19" value="历史密码校验
|
||||
(不得与最近 3 次历史密码相同)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1380" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- History fail -->
|
||||
<mxCell id="p20" value="提示「不得与最近 3 次密码相同」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="1395" width="240" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Success: update password -->
|
||||
<mxCell id="p21" value="✅ 校验通过:
|
||||
① 更新密码(PBKDF2+SHA256 哈希存储)
|
||||
② is_initial_password = False
|
||||
③ 清除该账号所有有效 Session
|
||||
④ 标记 Token 为 is_used = True" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="1500" width="400" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- End -->
|
||||
<mxCell id="p22" value="跳转登录界面
|
||||
提示「密码已重置,请使用新密码登录」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1630" width="300" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<!-- Edges Step 1 -->
|
||||
<mxCell id="ep1" edge="1" source="p1" target="p2" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep2" edge="1" source="p2" target="p3" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep3" edge="1" source="p3" target="p4" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep4" edge="1" source="p4" target="p5" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep5" edge="1" source="p4" target="p6" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="625" y="415" /><mxPoint x="650" y="430" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep6" value="否" edge="1" source="p6" target="p9" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="430" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep7" value="是" edge="1" source="p6" target="p7" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep8" value="是(超限)" edge="1" source="p7" target="p10" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="525" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep9" value="否(未超限)" edge="1" source="p7" target="p8" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<!-- Step 2 edges -->
|
||||
<mxCell id="ep10" edge="1" source="p5" target="p11" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep11" edge="1" source="p11" target="p12" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep12" edge="1" source="p12" target="p13" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep13" value="否(无效/过期)" edge="1" source="p13" target="p14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="965" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep14" value="重新申请" edge="1" source="p14" target="p2" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="960" y="970" /><mxPoint x="960" y="230" /><mxPoint x="625" y="230" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep15" value="是(有效)" edge="1" source="p13" target="p15" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<!-- Step 3 edges -->
|
||||
<mxCell id="ep16" edge="1" source="p15" target="p16" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep17" edge="1" source="p16" target="p17" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep18" value="不通过" edge="1" source="p17" target="p18" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1305" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep19" value="" edge="1" source="p18" target="p16" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep20" value="通过" edge="1" source="p17" target="p19" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep21" value="不通过" edge="1" source="p19" target="p20" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1420" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep22" value="" edge="1" source="p20" target="p16" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep23" value="通过" edge="1" source="p19" target="p21" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep24" edge="1" source="p21" target="p22" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -1,334 +1,334 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-04-25T00:00:00.000Z" agent="OpenCode" version="21.0.0">
|
||||
|
||||
<!-- ============================================================
|
||||
5.4.1 找回用户名流程
|
||||
============================================================ -->
|
||||
<diagram id="recover-username" name="5.4.1 找回用户名流程">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
|
||||
connect="1" arrows="1" fold="1" page="1" pageScale="1"
|
||||
pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
|
||||
<!-- Title -->
|
||||
<mxCell id="t1" value="5.4.1 找回用户名流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Start -->
|
||||
<mxCell id="u1" value="用户点击「忘记用户名」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="80" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Decision: Is email format valid? -->
|
||||
<mxCell id="u2" value="展示「找回用户名」页面
(邮箱输入框 + 发送按钮)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="170" width="350" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User inputs email -->
|
||||
<mxCell id="u3" value="用户输入邮箱并点击「发送」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="260" width="350" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Decision: Email format valid? -->
|
||||
<mxCell id="u4" value="邮箱格式校验通过?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="350" width="300" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Format invalid -->
|
||||
<mxCell id="u5" value="提示「请输入有效的邮箱地址」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="620" y="360" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Backend query -->
|
||||
<mxCell id="u6" value="服务端查询邮箱是否绑定账号
(不向前端返回查询结果)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="470" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Unified response to frontend -->
|
||||
<mxCell id="u7" value="统一响应前端:
「如该邮箱已绑定账号,您将收到邮件」
发送按钮进入 60 秒倒计时" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="580" width="350" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Decision: Email exists? -->
|
||||
<mxCell id="u8" value="邮箱已绑定 Tenant Admin 账号?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="475" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Email found: send email -->
|
||||
<mxCell id="u9" value="后台:异步发送邮件
(包含用户名、发送时间)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="580" width="220" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Email not found: silent -->
|
||||
<mxCell id="u10" value="后台:静默处理
(不发送邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="660" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Rate limit check -->
|
||||
<mxCell id="u11" value="同一邮箱 1 小时内已发送 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="700" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Rate limit exceeded -->
|
||||
<mxCell id="u12" value="拒绝发送
(达到频率上限)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="715" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User receives email -->
|
||||
<mxCell id="u13" value="用户查收邮件,获取用户名" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="810" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Return to login -->
|
||||
<mxCell id="u14" value="点击「返回登录」
回到登录界面" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="700" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Edges -->
|
||||
<mxCell id="e1" edge="1" source="u1" target="u2" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e2" edge="1" source="u2" target="u3" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e3" edge="1" source="u3" target="u4" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e4" value="否" edge="1" source="u4" target="u5" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="385" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e5" value="" edge="1" source="u5" target="u3" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="730" y="285" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e6" value="是" edge="1" source="u4" target="u6" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e7" edge="1" source="u6" target="u7" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e8" edge="1" source="u6" target="u8" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="600" y="500" /><mxPoint x="630" y="510" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e9" value="是" edge="1" source="u8" target="u11" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e10" value="否(普通员工邮箱或不存在)" edge="1" source="u8" target="u10" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><mxPoint x="760" y="660" as="targetPoint" /></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e11" value="否(未超限)" edge="1" source="u11" target="u9" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e12" value="是(超过 3 次)" edge="1" source="u11" target="u12" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="735" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e13" edge="1" source="u9" target="u13" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e14" edge="1" source="u13" target="u14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="835" /><mxPoint x="425" y="835" /><mxPoint x="425" y="750" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e15" edge="1" source="u7" target="u14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
|
||||
<!-- ============================================================
|
||||
5.4.2 找回密码流程
|
||||
============================================================ -->
|
||||
<diagram id="recover-password" name="5.4.2 找回密码流程">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
|
||||
connect="1" arrows="1" fold="1" page="1" pageScale="1"
|
||||
pageWidth="850" pageHeight="1300" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
|
||||
<!-- Title -->
|
||||
<mxCell id="title" value="5.4.2 找回密码流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== STEP 1 Header ===== -->
|
||||
<mxCell id="step1hdr" value="步骤一:身份验证" style="text;html=1;strokeColor=none;fillColor=#dae8fc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="65" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Start -->
|
||||
<mxCell id="p1" value="用户点击「忘记密码」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="115" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Show form -->
|
||||
<mxCell id="p2" value="展示「找回密码」页面(Stepper)
步骤一:用户名 + 绑定邮箱输入框" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="200" width="400" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User submits -->
|
||||
<mxCell id="p3" value="用户输入用户名 + 邮箱,点击「下一步」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="300" width="400" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Backend verification -->
|
||||
<mxCell id="p4" value="服务端校验用户名与邮箱是否匹配" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="390" width="400" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Unified response -->
|
||||
<mxCell id="p5" value="统一响应前端:
「如信息匹配,重置链接将发送至您的邮箱」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="480" width="400" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Decision: Match? -->
|
||||
<mxCell id="p6" value="用户名与邮箱匹配?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="395" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Rate limit check -->
|
||||
<mxCell id="p7" value="同一账号 1 小时内已发 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="490" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Matched: generate token -->
|
||||
<mxCell id="p8" value="生成加密 Token
(secrets.token_urlsafe(32),有效期 30 分钟)
异步发送重置邮件" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="590" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- No match: silent -->
|
||||
<mxCell id="p9" value="不匹配:静默处理
(不发邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="405" width="180" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Rate limit exceeded -->
|
||||
<mxCell id="p10" value="已超频率上限
静默处理" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="505" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== STEP 2 Header ===== -->
|
||||
<mxCell id="step2hdr" value="步骤二:用户点击邮件重置链接" style="text;html=1;strokeColor=none;fillColor=#d5e8d4;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="690" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User clicks link -->
|
||||
<mxCell id="p11" value="用户点击邮件中的重置链接" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="740" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Validate token -->
|
||||
<mxCell id="p12" value="服务端校验 Token 有效性
(is_used=False AND expires_at 未过期)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="830" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Token decision -->
|
||||
<mxCell id="p13" value="Token 有效?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="930" width="250" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Invalid token -->
|
||||
<mxCell id="p14" value="提示「链接已过期或已使用,请重新申请」
提供「重新申请」按钮(跳回步骤一)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="620" y="940" width="260" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== STEP 3 Header ===== -->
|
||||
<mxCell id="step3hdr" value="步骤三:输入并提交新密码" style="text;html=1;strokeColor=none;fillColor=#fff2cc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="1030" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Show reset form -->
|
||||
<mxCell id="p15" value="展示「重置密码」表单
(新密码 + 确认新密码 + 密码强度指示)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="1080" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User submits new password -->
|
||||
<mxCell id="p16" value="用户输入新密码并提交" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1180" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Complexity check -->
|
||||
<mxCell id="p17" value="密码复杂度校验
(≥8位,含字母+数字,两次一致)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1265" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Complexity fail -->
|
||||
<mxCell id="p18" value="实时提示不满足的规则
(逐条红色 ✗ / 绿色 ✓ 视觉指引)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="1275" width="240" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- History check -->
|
||||
<mxCell id="p19" value="历史密码校验
(不得与最近 3 次历史密码相同)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1380" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- History fail -->
|
||||
<mxCell id="p20" value="提示「不得与最近 3 次密码相同」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="1395" width="240" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Success: update password -->
|
||||
<mxCell id="p21" value="✅ 校验通过:
① 更新密码(PBKDF2+SHA256 哈希存储)
② is_initial_password = False
③ 清除该账号所有有效 Session
④ 标记 Token 为 is_used = True" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="1500" width="400" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- End -->
|
||||
<mxCell id="p22" value="跳转登录界面
提示「密码已重置,请使用新密码登录」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1630" width="300" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Edges Step 1 -->
|
||||
<mxCell id="ep1" edge="1" source="p1" target="p2" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep2" edge="1" source="p2" target="p3" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep3" edge="1" source="p3" target="p4" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep4" edge="1" source="p4" target="p5" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep5" edge="1" source="p4" target="p6" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="625" y="415" /><mxPoint x="650" y="430" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep6" value="否" edge="1" source="p6" target="p9" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="430" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep7" value="是" edge="1" source="p6" target="p7" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep8" value="是(超限)" edge="1" source="p7" target="p10" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="525" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep9" value="否(未超限)" edge="1" source="p7" target="p8" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
|
||||
<!-- Step 2 edges -->
|
||||
<mxCell id="ep10" edge="1" source="p5" target="p11" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep11" edge="1" source="p11" target="p12" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep12" edge="1" source="p12" target="p13" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep13" value="否(无效/过期)" edge="1" source="p13" target="p14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="965" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep14" value="重新申请" edge="1" source="p14" target="p2" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="960" y="970" /><mxPoint x="960" y="230" /><mxPoint x="625" y="230" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep15" value="是(有效)" edge="1" source="p13" target="p15" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
|
||||
<!-- Step 3 edges -->
|
||||
<mxCell id="ep16" edge="1" source="p15" target="p16" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep17" edge="1" source="p16" target="p17" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep18" value="不通过" edge="1" source="p17" target="p18" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1305" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep19" value="" edge="1" source="p18" target="p16" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep20" value="通过" edge="1" source="p17" target="p19" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep21" value="不通过" edge="1" source="p19" target="p20" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1420" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep22" value="" edge="1" source="p20" target="p16" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep23" value="通过" edge="1" source="p19" target="p21" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep24" edge="1" source="p21" target="p22" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
|
||||
</mxfile>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2026-04-25T00:00:00.000Z" agent="OpenCode" version="21.0.0">
|
||||
|
||||
<!-- ============================================================
|
||||
5.4.1 找回用户名流程
|
||||
============================================================ -->
|
||||
<diagram id="recover-username" name="5.4.1 找回用户名流程">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
|
||||
connect="1" arrows="1" fold="1" page="1" pageScale="1"
|
||||
pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
|
||||
<!-- Title -->
|
||||
<mxCell id="t1" value="5.4.1 找回用户名流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Start -->
|
||||
<mxCell id="u1" value="用户点击「忘记用户名」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="80" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Decision: Is email format valid? -->
|
||||
<mxCell id="u2" value="展示「找回用户名」页面
(邮箱输入框 + 发送按钮)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="170" width="350" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User inputs email -->
|
||||
<mxCell id="u3" value="用户输入邮箱并点击「发送」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="260" width="350" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Decision: Email format valid? -->
|
||||
<mxCell id="u4" value="邮箱格式校验通过?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="350" width="300" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Format invalid -->
|
||||
<mxCell id="u5" value="提示「请输入有效的邮箱地址」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="620" y="360" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Backend query -->
|
||||
<mxCell id="u6" value="服务端查询邮箱是否绑定账号
(不向前端返回查询结果)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="470" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Unified response to frontend -->
|
||||
<mxCell id="u7" value="统一响应前端:
「如该邮箱已绑定账号,您将收到邮件」
发送按钮进入 60 秒倒计时" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="580" width="350" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Decision: Email exists? -->
|
||||
<mxCell id="u8" value="邮箱已绑定 Tenant Admin 账号?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="475" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Email found: send email -->
|
||||
<mxCell id="u9" value="后台:异步发送邮件
(包含用户名、发送时间)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="580" width="220" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Email not found: silent -->
|
||||
<mxCell id="u10" value="后台:静默处理
(不发送邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="660" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Rate limit check -->
|
||||
<mxCell id="u11" value="同一邮箱 1 小时内已发送 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="630" y="700" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Rate limit exceeded -->
|
||||
<mxCell id="u12" value="拒绝发送
(达到频率上限)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="715" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User receives email -->
|
||||
<mxCell id="u13" value="用户查收邮件,获取用户名" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="810" width="220" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Return to login -->
|
||||
<mxCell id="u14" value="点击「返回登录」
回到登录界面" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="700" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Edges -->
|
||||
<mxCell id="e1" edge="1" source="u1" target="u2" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e2" edge="1" source="u2" target="u3" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e3" edge="1" source="u3" target="u4" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e4" value="否" edge="1" source="u4" target="u5" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="385" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e5" value="" edge="1" source="u5" target="u3" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="730" y="285" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e6" value="是" edge="1" source="u4" target="u6" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e7" edge="1" source="u6" target="u7" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e8" edge="1" source="u6" target="u8" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="600" y="500" /><mxPoint x="630" y="510" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e9" value="是" edge="1" source="u8" target="u11" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e10" value="否(普通员工邮箱或不存在)" edge="1" source="u8" target="u10" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><mxPoint x="760" y="660" as="targetPoint" /></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e11" value="否(未超限)" edge="1" source="u11" target="u9" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e12" value="是(超过 3 次)" edge="1" source="u11" target="u12" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="735" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e13" edge="1" source="u9" target="u13" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e14" edge="1" source="u13" target="u14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="750" y="835" /><mxPoint x="425" y="835" /><mxPoint x="425" y="750" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="e15" edge="1" source="u7" target="u14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
|
||||
<!-- ============================================================
|
||||
5.4.2 找回密码流程
|
||||
============================================================ -->
|
||||
<diagram id="recover-password" name="5.4.2 找回密码流程">
|
||||
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1"
|
||||
connect="1" arrows="1" fold="1" page="1" pageScale="1"
|
||||
pageWidth="850" pageHeight="1300" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
|
||||
<!-- Title -->
|
||||
<mxCell id="title" value="5.4.2 找回密码流程" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="175" y="20" width="500" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== STEP 1 Header ===== -->
|
||||
<mxCell id="step1hdr" value="步骤一:身份验证" style="text;html=1;strokeColor=none;fillColor=#dae8fc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="65" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Start -->
|
||||
<mxCell id="p1" value="用户点击「忘记密码」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="115" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Show form -->
|
||||
<mxCell id="p2" value="展示「找回密码」页面(Stepper)
步骤一:用户名 + 绑定邮箱输入框" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="200" width="400" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User submits -->
|
||||
<mxCell id="p3" value="用户输入用户名 + 邮箱,点击「下一步」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="300" width="400" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Backend verification -->
|
||||
<mxCell id="p4" value="服务端校验用户名与邮箱是否匹配" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="390" width="400" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Unified response -->
|
||||
<mxCell id="p5" value="统一响应前端:
「如信息匹配,重置链接将发送至您的邮箱」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="480" width="400" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Decision: Match? -->
|
||||
<mxCell id="p6" value="用户名与邮箱匹配?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="395" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Rate limit check -->
|
||||
<mxCell id="p7" value="同一账号 1 小时内已发 ≥ 3 次?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="490" width="220" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Matched: generate token -->
|
||||
<mxCell id="p8" value="生成加密 Token
(secrets.token_urlsafe(32),有效期 30 分钟)
异步发送重置邮件" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="650" y="590" width="240" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- No match: silent -->
|
||||
<mxCell id="p9" value="不匹配:静默处理
(不发邮件,不报错)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="405" width="180" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Rate limit exceeded -->
|
||||
<mxCell id="p10" value="已超频率上限
静默处理" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="900" y="505" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== STEP 2 Header ===== -->
|
||||
<mxCell id="step2hdr" value="步骤二:用户点击邮件重置链接" style="text;html=1;strokeColor=none;fillColor=#d5e8d4;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="690" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User clicks link -->
|
||||
<mxCell id="p11" value="用户点击邮件中的重置链接" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="740" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Validate token -->
|
||||
<mxCell id="p12" value="服务端校验 Token 有效性
(is_used=False AND expires_at 未过期)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="830" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Token decision -->
|
||||
<mxCell id="p13" value="Token 有效?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="930" width="250" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Invalid token -->
|
||||
<mxCell id="p14" value="提示「链接已过期或已使用,请重新申请」
提供「重新申请」按钮(跳回步骤一)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="620" y="940" width="260" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- ===== STEP 3 Header ===== -->
|
||||
<mxCell id="step3hdr" value="步骤三:输入并提交新密码" style="text;html=1;strokeColor=none;fillColor=#fff2cc;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=1;fontSize=13;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="1030" width="730" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Show reset form -->
|
||||
<mxCell id="p15" value="展示「重置密码」表单
(新密码 + 确认新密码 + 密码强度指示)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="250" y="1080" width="350" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- User submits new password -->
|
||||
<mxCell id="p16" value="用户输入新密码并提交" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1180" width="300" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Complexity check -->
|
||||
<mxCell id="p17" value="密码复杂度校验
(≥8位,含字母+数字,两次一致)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1265" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Complexity fail -->
|
||||
<mxCell id="p18" value="实时提示不满足的规则
(逐条红色 ✗ / 绿色 ✓ 视觉指引)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="1275" width="240" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- History check -->
|
||||
<mxCell id="p19" value="历史密码校验
(不得与最近 3 次历史密码相同)" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1380" width="300" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- History fail -->
|
||||
<mxCell id="p20" value="提示「不得与最近 3 次密码相同」" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="1395" width="240" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Success: update password -->
|
||||
<mxCell id="p21" value="✅ 校验通过:
① 更新密码(PBKDF2+SHA256 哈希存储)
② is_initial_password = False
③ 清除该账号所有有效 Session
④ 标记 Token 为 is_used = True" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="225" y="1500" width="400" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- End -->
|
||||
<mxCell id="p22" value="跳转登录界面
提示「密码已重置,请使用新密码登录」" style="ellipse;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1630" width="300" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<!-- Edges Step 1 -->
|
||||
<mxCell id="ep1" edge="1" source="p1" target="p2" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep2" edge="1" source="p2" target="p3" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep3" edge="1" source="p3" target="p4" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep4" edge="1" source="p4" target="p5" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep5" edge="1" source="p4" target="p6" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="625" y="415" /><mxPoint x="650" y="430" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep6" value="否" edge="1" source="p6" target="p9" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="430" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep7" value="是" edge="1" source="p6" target="p7" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep8" value="是(超限)" edge="1" source="p7" target="p10" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="900" y="525" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep9" value="否(未超限)" edge="1" source="p7" target="p8" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
|
||||
<!-- Step 2 edges -->
|
||||
<mxCell id="ep10" edge="1" source="p5" target="p11" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep11" edge="1" source="p11" target="p12" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep12" edge="1" source="p12" target="p13" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep13" value="否(无效/过期)" edge="1" source="p13" target="p14" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="620" y="965" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep14" value="重新申请" edge="1" source="p14" target="p2" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="960" y="970" /><mxPoint x="960" y="230" /><mxPoint x="625" y="230" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep15" value="是(有效)" edge="1" source="p13" target="p15" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
|
||||
<!-- Step 3 edges -->
|
||||
<mxCell id="ep16" edge="1" source="p15" target="p16" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep17" edge="1" source="p16" target="p17" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep18" value="不通过" edge="1" source="p17" target="p18" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1305" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep19" value="" edge="1" source="p18" target="p16" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep20" value="通过" edge="1" source="p17" target="p19" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep21" value="不通过" edge="1" source="p19" target="p20" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="640" y="1420" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep22" value="" edge="1" source="p20" target="p16" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="760" y="1205" /></Array></mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ep23" value="通过" edge="1" source="p19" target="p21" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
<mxCell id="ep24" edge="1" source="p21" target="p22" parent="1"><mxGeometry relative="1" as="geometry" /></mxCell>
|
||||
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
|
||||
</mxfile>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,488 +1,488 @@
|
||||
<!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'],
|
||||
mono: ['JetBrains Mono', 'SFMono-Regular', 'Menlo', 'monospace']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
html { scroll-behavior: smooth; }
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 text-sm text-neutral-700 antialiased" x-data="clientDetailPage()">
|
||||
<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 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>
|
||||
</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">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>
|
||||
私客列表
|
||||
</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 bg-neutral-50 px-6 py-5">
|
||||
<div class="mx-auto max-w-[1600px] space-y-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<nav class="flex items-center gap-1 text-xs text-neutral-500 mb-1" aria-label="面包屑">
|
||||
<a class="hover:text-neutral-700">客源</a>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
|
||||
<a class="hover:text-neutral-700">私客管理</a>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
|
||||
<span class="text-neutral-700">姚叔叔置换电梯两房(上门)</span>
|
||||
</nav>
|
||||
<h1 class="text-xl font-semibold text-neutral-800">客源详情</h1>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">按 Section 连续展示,点击导航锚点快速定位</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-6 items-start">
|
||||
<section class="col-span-8 min-w-0 space-y-6">
|
||||
<div class="bg-white border border-neutral-200 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 ? 'bg-primary-50 text-primary-700 font-medium' : 'text-neutral-600 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"
|
||||
x-text="item.label"></a>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section id="section-requirements" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">需求信息</h2>
|
||||
<button class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">编辑</button>
|
||||
</header>
|
||||
<dl class="grid grid-cols-3 gap-x-6 gap-y-4">
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">总价</dt><dd class="text-sm text-neutral-900 tabular-nums">550-600万元</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">面积</dt><dd class="text-sm text-neutral-900 tabular-nums">100-110m2</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">居室</dt><dd class="text-sm text-neutral-900">2居</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">装修</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">朝向</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">楼层</dt><dd class="text-sm text-neutral-900">中楼层、低楼层</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">楼龄</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">意向商圈</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">意向小区</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">交通情况</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1 col-span-3"><dt class="text-xs text-neutral-500">备注</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section id="section-follow" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">跟进记录</h2>
|
||||
<button @click="drawerOpen=true" class="inline-flex items-center gap-1.5 px-3 py-1.5 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">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Z"/></svg>
|
||||
写跟进
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-primary-600 text-white">全部</button>
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-neutral-100 text-neutral-600 hover:bg-neutral-200">写入跟进</button>
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-neutral-100 text-neutral-600 hover:bg-neutral-200">敏感信息跟进</button>
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-neutral-100 text-neutral-600 hover:bg-neutral-200">修改跟进</button>
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-neutral-100 text-neutral-600 hover:bg-neutral-200">其他跟进</button>
|
||||
</div>
|
||||
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-neutral-50 flex items-center gap-3 flex-wrap text-xs text-neutral-600">
|
||||
<span class="text-neutral-500">筛选</span>
|
||||
<input class="px-2 py-1 rounded border border-neutral-300 bg-white text-xs" placeholder="开始日期">
|
||||
<span>至</span>
|
||||
<input class="px-2 py-1 rounded border border-neutral-300 bg-white text-xs" placeholder="结束日期">
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" class="rounded border-neutral-300">有录音</label>
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" class="rounded border-neutral-300">有图片</label>
|
||||
</div>
|
||||
|
||||
<ol class="relative border-l-2 border-neutral-200 ml-3 space-y-4 pl-5">
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[27px] top-1 w-3 h-3 rounded-full bg-primary-600 ring-4 ring-white"></span>
|
||||
<div class="rounded-md border border-neutral-200 p-3 bg-white space-y-1">
|
||||
<div class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-info-50 text-info-600 font-medium">电话</span>
|
||||
<time class="tabular-nums">11:25</time>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-700">433弄5楼65.85平想置换,丽晶苑2/3号楼,楼层不要太高,自己房子还没有挂牌。</p>
|
||||
<p class="text-xs text-neutral-500">都市港湾店一组 雷威 · 2026-04-19</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[27px] top-1 w-3 h-3 rounded-full bg-warning-600 ring-4 ring-white"></span>
|
||||
<div class="rounded-md border border-warning-600/20 bg-warning-50 p-3 space-y-1">
|
||||
<div class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-warning-50 text-warning-600 font-medium">敏感查看</span>
|
||||
<time class="tabular-nums">11:23</time>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-700">查看联系人完整号码,系统自动留痕。</p>
|
||||
<p class="text-xs text-neutral-500">系统记录 · 2026-04-19</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="text-center pt-1">
|
||||
<button class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">查看全部跟进</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="section-viewings" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">带看记录</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400">新增预约</button>
|
||||
<button class="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400">新增带看</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-neutral-50 flex items-center gap-3 flex-wrap text-xs text-neutral-600">
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" class="rounded border-neutral-300">归属人带看</label>
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" class="rounded border-neutral-300">其他人带看</label>
|
||||
<input class="px-2 py-1 rounded border border-neutral-300 bg-white text-xs" placeholder="开始日期">
|
||||
<span>至</span>
|
||||
<input class="px-2 py-1 rounded border border-neutral-300 bg-white text-xs" placeholder="结束日期">
|
||||
</div>
|
||||
|
||||
<ol class="relative border-l-2 border-neutral-200 ml-3 space-y-4 pl-5">
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[27px] top-1 w-3 h-3 rounded-full bg-primary-600 ring-4 ring-white"></span>
|
||||
<div class="rounded-md border border-neutral-200 p-3 bg-white space-y-1">
|
||||
<p class="text-xs text-neutral-500 tabular-nums">2026-04-17 20:30</p>
|
||||
<p class="text-sm text-neutral-700">带看情况:客户继续维护</p>
|
||||
<p class="text-sm"><a class="text-primary-600 hover:underline" href="#">金沙丽晶苑一期-001-1201</a></p>
|
||||
<p class="text-xs text-neutral-500"><span class="inline-flex items-center px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 font-medium">一看</span> 带看:雷威 · <a class="text-primary-600 hover:underline" href="#">详情 ></a></p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="section-insights" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">客源解读</h2>
|
||||
<span class="text-xs text-neutral-500">更新时间:2026-04-25 09:12</span>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-white">
|
||||
<p class="text-xs text-neutral-500">活跃行为</p>
|
||||
<p class="mt-1 text-xl font-semibold text-neutral-900 tabular-nums">7 天内</p>
|
||||
</div>
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-white">
|
||||
<p class="text-xs text-neutral-500">工作日活跃</p>
|
||||
<p class="mt-1 text-xl font-semibold text-neutral-900 tabular-nums">-</p>
|
||||
</div>
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-white">
|
||||
<p class="text-xs text-neutral-500">周末活跃</p>
|
||||
<p class="mt-1 text-xl font-semibold text-neutral-900 tabular-nums">-</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="border border-neutral-200 rounded-md p-3 text-center">
|
||||
<p class="text-xs text-neutral-500">价格偏好</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-primary-600 tabular-nums">64%</p>
|
||||
</div>
|
||||
<div class="border border-neutral-200 rounded-md p-3 text-center">
|
||||
<p class="text-xs text-neutral-500">户型偏好</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-primary-600 tabular-nums">22%</p>
|
||||
</div>
|
||||
<div class="border border-neutral-200 rounded-md p-3 text-center">
|
||||
<p class="text-xs text-neutral-500">面积偏好</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-primary-600 tabular-nums">14%</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="section-matches" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">二手配房</h2>
|
||||
<button class="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400">批量分享</button>
|
||||
</header>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-700">优质户型</h3>
|
||||
<article class="border border-neutral-200 rounded-md p-3">
|
||||
<div class="flex gap-3">
|
||||
<div class="w-20 h-14 rounded bg-neutral-100"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-primary-600 hover:underline cursor-pointer">都市港湾</p>
|
||||
<p class="text-xs text-neutral-500">2/2/1 · 101.17m2 · 嘉定 丰庄</p>
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-[11px] rounded bg-warning-50 text-warning-600">朝南户型采光好</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-[11px] rounded bg-info-50 text-info-600">私盘</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-neutral-900 tabular-nums">620万 <span class="text-xs font-normal text-neutral-500">已跌20万 · 61283元/m2</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="col-span-4 min-w-0 space-y-3 sticky top-16 max-h-[calc(100vh-80px)] overflow-y-auto">
|
||||
<section class="bg-white rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<div class="bg-primary-600 px-4 py-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-white/20 text-white">求购</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold text-white truncate">姚叔叔置换电梯两房(上门)先生</h2>
|
||||
<p class="text-xs text-white/80 mt-0.5">带看进度:一看</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded bg-neutral-100 text-neutral-600">私客</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded bg-primary-50 text-primary-700">一看</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded bg-warning-50 text-warning-600">C(一般)</span>
|
||||
</div>
|
||||
|
||||
<dl class="space-y-1.5">
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">最近跟进</dt><dd class="text-xs text-right text-neutral-800 tabular-nums">2026-04-19</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">客户编号</dt><dd class="text-xs text-right text-neutral-800 font-mono">60419C03182A3</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">委托日期</dt><dd class="text-xs text-right text-neutral-800 tabular-nums">2026-04-19</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">需求类型</dt><dd class="text-xs text-right text-neutral-800">二手</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">房源用途</dt><dd class="text-xs text-right text-neutral-800">住宅</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">客户来源</dt><dd class="text-xs text-right text-neutral-800">线下丨门店接待</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button class="flex flex-col items-center gap-1 py-2 text-xs font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700">打电话</button>
|
||||
<button @click="drawerOpen=true" class="flex flex-col items-center gap-1 py-2 text-xs font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700">写跟进</button>
|
||||
<button class="flex flex-col items-center gap-1 py-2 text-xs font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700">报备/常看</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">收藏</button>
|
||||
<button class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">不置顶</button>
|
||||
<button @click="modal='grade'" class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">改等级</button>
|
||||
<button @click="modal='status'" class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">改状态</button>
|
||||
<button class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">转公客</button>
|
||||
<button @click="modal='deal'" class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">转成交</button>
|
||||
<button class="px-2 py-2 text-xs text-left rounded-md text-danger-600 hover:bg-danger-50">转无效</button>
|
||||
<button @click="modal='edit'" class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">编辑客源</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-lg border border-neutral-200 p-3">
|
||||
<header class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-neutral-800">联系人</h3>
|
||||
<div class="text-xs text-primary-600 space-x-2">
|
||||
<button class="hover:underline">查看号码</button>
|
||||
<button class="hover:underline">新增联系人</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-neutral-900">姚叔叔置换电梯两房(上门)先生</p>
|
||||
<p class="text-xs text-neutral-600 tabular-nums">电话1:+86 137****8888</p>
|
||||
<p class="text-xs text-neutral-500">默认拨打 · 接通0次 · 拨打0次</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-lg border border-neutral-200 p-3">
|
||||
<header class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-neutral-800">相关员工</h3>
|
||||
<button class="text-xs text-primary-600 hover:underline">编辑</button>
|
||||
</header>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-700">【首录人】</p>
|
||||
<p class="text-sm text-neutral-900">都市港湾店一组 雷威</p>
|
||||
<p class="text-xs text-neutral-500 tabular-nums">参与时间:2026-04-17 19:21</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-700">【归属人】</p>
|
||||
<p class="text-sm text-neutral-900">都市港湾店一组 雷威</p>
|
||||
<p class="text-xs text-neutral-500 tabular-nums">参与时间:2026-04-17 19:21</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div x-show="modal" x-transition.opacity class="fixed inset-0 z-50 bg-neutral-900/40" @click="modal = null"></div>
|
||||
|
||||
<div x-show="modal === 'grade'" x-transition class="fixed inset-0 z-60 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="w-full max-w-sm bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">改等级</h2><button @click="modal = null" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="px-5 py-4 space-y-3"><p class="text-sm text-neutral-500">原等级:C(一般)</p><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"><option>请选择新等级</option><option>A_urgent</option><option>A</option><option>B</option><option>C</option><option>D</option><option>E</option></select></div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="modal = null" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="modal === 'status'" x-transition class="fixed inset-0 z-60 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="w-full max-w-md bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">改状态</h2><button @click="modal = null" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<p class="text-sm text-neutral-500">原状态:求购</p>
|
||||
<select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>请选择新状态</option><option>暂缓</option><option>转公</option><option>成交</option><option>无效</option></select>
|
||||
<textarea rows="3" class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300" placeholder="请输入更改理由"></textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="modal = null" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="modal === 'deal'" x-transition class="fixed inset-0 z-60 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="w-full max-w-lg bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">转成交</h2><button @click="modal = null" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">状态</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>我购</option><option>我租</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">房源类型</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>二手</option><option>新房</option></select></div>
|
||||
<div class="col-span-2"><label class="block text-xs text-neutral-500 mb-1">成交房源</label><button class="w-full px-3 py-2 text-sm border border-neutral-300 rounded-md text-left text-primary-600 hover:bg-neutral-50">+ 选择成交房源</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="modal = null" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">确认转成交</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="modal === 'edit'" x-transition class="fixed inset-0 z-60 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="w-full max-w-2xl bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto flex flex-col max-h-[85vh]">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">编辑基础信息</h2><button @click="modal = null" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">需求类型</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>二手</option><option>新房</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">来源</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>线下丨门店接待</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">用途</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>住宅</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">付款方式</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>请选择</option></select></div>
|
||||
<div class="col-span-2"><label class="block text-xs text-neutral-500 mb-1">证件号码</label><input class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300" placeholder="请输入证件号码"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="modal = null" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="drawerOpen" x-transition.opacity class="fixed inset-0 z-50 bg-neutral-900/40" @click="drawerOpen = false"></div>
|
||||
<aside x-show="drawerOpen" x-transition:enter="ease-out duration-200" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0" x-transition:leave="ease-in duration-150" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full" class="fixed right-0 top-0 h-full w-[480px] z-60 bg-white shadow-lg flex flex-col border-l border-neutral-200">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">写入跟进</h2><button @click="drawerOpen=false" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">跟进方式</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>电话</option><option>上门</option><option>微信</option><option>短信</option><option>其他</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">跟进内容</label><textarea rows="4" class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300" placeholder="至少6字"></textarea></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">跟进时间</label><input class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300" value="2026-04-25 10:30"></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">附件</label><input type="file" class="w-full text-sm"></div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-neutral-700"><input type="checkbox" checked class="rounded border-neutral-300">是否开放给同事查看</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="drawerOpen=false" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">提交</button></div>
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
function clientDetailPage() {
|
||||
return {
|
||||
modal: null,
|
||||
drawerOpen: false,
|
||||
navItems: [
|
||||
{ id: 'section-requirements', label: '需求信息' },
|
||||
{ id: 'section-follow', label: '跟进记录' },
|
||||
{ id: 'section-viewings', label: '带看记录' },
|
||||
{ id: 'section-insights', label: '客源解读' },
|
||||
{ id: 'section-matches', label: '二手配房' }
|
||||
],
|
||||
activeSection: 'section-requirements',
|
||||
observer: null,
|
||||
scrollToSection(id) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
},
|
||||
init() {
|
||||
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((section) => this.observer.observe(section))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<!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'],
|
||||
mono: ['JetBrains Mono', 'SFMono-Regular', 'Menlo', 'monospace']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
html { scroll-behavior: smooth; }
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 text-sm text-neutral-700 antialiased" x-data="clientDetailPage()">
|
||||
<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 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>
|
||||
</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">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>
|
||||
私客列表
|
||||
</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 bg-neutral-50 px-6 py-5">
|
||||
<div class="mx-auto max-w-[1600px] space-y-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<nav class="flex items-center gap-1 text-xs text-neutral-500 mb-1" aria-label="面包屑">
|
||||
<a class="hover:text-neutral-700">客源</a>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
|
||||
<a class="hover:text-neutral-700">私客管理</a>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
|
||||
<span class="text-neutral-700">姚叔叔置换电梯两房(上门)</span>
|
||||
</nav>
|
||||
<h1 class="text-xl font-semibold text-neutral-800">客源详情</h1>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">按 Section 连续展示,点击导航锚点快速定位</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-6 items-start">
|
||||
<section class="col-span-8 min-w-0 space-y-6">
|
||||
<div class="bg-white border border-neutral-200 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 ? 'bg-primary-50 text-primary-700 font-medium' : 'text-neutral-600 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"
|
||||
x-text="item.label"></a>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section id="section-requirements" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">需求信息</h2>
|
||||
<button class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">编辑</button>
|
||||
</header>
|
||||
<dl class="grid grid-cols-3 gap-x-6 gap-y-4">
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">总价</dt><dd class="text-sm text-neutral-900 tabular-nums">550-600万元</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">面积</dt><dd class="text-sm text-neutral-900 tabular-nums">100-110m2</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">居室</dt><dd class="text-sm text-neutral-900">2居</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">装修</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">朝向</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">楼层</dt><dd class="text-sm text-neutral-900">中楼层、低楼层</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">楼龄</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">意向商圈</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">意向小区</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1"><dt class="text-xs text-neutral-500">交通情况</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
<div class="space-y-1 col-span-3"><dt class="text-xs text-neutral-500">备注</dt><dd class="text-sm text-neutral-900">-</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section id="section-follow" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">跟进记录</h2>
|
||||
<button @click="drawerOpen=true" class="inline-flex items-center gap-1.5 px-3 py-1.5 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">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Z"/></svg>
|
||||
写跟进
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-primary-600 text-white">全部</button>
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-neutral-100 text-neutral-600 hover:bg-neutral-200">写入跟进</button>
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-neutral-100 text-neutral-600 hover:bg-neutral-200">敏感信息跟进</button>
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-neutral-100 text-neutral-600 hover:bg-neutral-200">修改跟进</button>
|
||||
<button class="px-3 py-1 text-xs rounded-full bg-neutral-100 text-neutral-600 hover:bg-neutral-200">其他跟进</button>
|
||||
</div>
|
||||
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-neutral-50 flex items-center gap-3 flex-wrap text-xs text-neutral-600">
|
||||
<span class="text-neutral-500">筛选</span>
|
||||
<input class="px-2 py-1 rounded border border-neutral-300 bg-white text-xs" placeholder="开始日期">
|
||||
<span>至</span>
|
||||
<input class="px-2 py-1 rounded border border-neutral-300 bg-white text-xs" placeholder="结束日期">
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" class="rounded border-neutral-300">有录音</label>
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" class="rounded border-neutral-300">有图片</label>
|
||||
</div>
|
||||
|
||||
<ol class="relative border-l-2 border-neutral-200 ml-3 space-y-4 pl-5">
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[27px] top-1 w-3 h-3 rounded-full bg-primary-600 ring-4 ring-white"></span>
|
||||
<div class="rounded-md border border-neutral-200 p-3 bg-white space-y-1">
|
||||
<div class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-info-50 text-info-600 font-medium">电话</span>
|
||||
<time class="tabular-nums">11:25</time>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-700">433弄5楼65.85平想置换,丽晶苑2/3号楼,楼层不要太高,自己房子还没有挂牌。</p>
|
||||
<p class="text-xs text-neutral-500">都市港湾店一组 雷威 · 2026-04-19</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[27px] top-1 w-3 h-3 rounded-full bg-warning-600 ring-4 ring-white"></span>
|
||||
<div class="rounded-md border border-warning-600/20 bg-warning-50 p-3 space-y-1">
|
||||
<div class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-warning-50 text-warning-600 font-medium">敏感查看</span>
|
||||
<time class="tabular-nums">11:23</time>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-700">查看联系人完整号码,系统自动留痕。</p>
|
||||
<p class="text-xs text-neutral-500">系统记录 · 2026-04-19</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="text-center pt-1">
|
||||
<button class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">查看全部跟进</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="section-viewings" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">带看记录</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400">新增预约</button>
|
||||
<button class="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400">新增带看</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-neutral-50 flex items-center gap-3 flex-wrap text-xs text-neutral-600">
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" class="rounded border-neutral-300">归属人带看</label>
|
||||
<label class="inline-flex items-center gap-1"><input type="checkbox" class="rounded border-neutral-300">其他人带看</label>
|
||||
<input class="px-2 py-1 rounded border border-neutral-300 bg-white text-xs" placeholder="开始日期">
|
||||
<span>至</span>
|
||||
<input class="px-2 py-1 rounded border border-neutral-300 bg-white text-xs" placeholder="结束日期">
|
||||
</div>
|
||||
|
||||
<ol class="relative border-l-2 border-neutral-200 ml-3 space-y-4 pl-5">
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[27px] top-1 w-3 h-3 rounded-full bg-primary-600 ring-4 ring-white"></span>
|
||||
<div class="rounded-md border border-neutral-200 p-3 bg-white space-y-1">
|
||||
<p class="text-xs text-neutral-500 tabular-nums">2026-04-17 20:30</p>
|
||||
<p class="text-sm text-neutral-700">带看情况:客户继续维护</p>
|
||||
<p class="text-sm"><a class="text-primary-600 hover:underline" href="#">金沙丽晶苑一期-001-1201</a></p>
|
||||
<p class="text-xs text-neutral-500"><span class="inline-flex items-center px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 font-medium">一看</span> 带看:雷威 · <a class="text-primary-600 hover:underline" href="#">详情 ></a></p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="section-insights" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">客源解读</h2>
|
||||
<span class="text-xs text-neutral-500">更新时间:2026-04-25 09:12</span>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-white">
|
||||
<p class="text-xs text-neutral-500">活跃行为</p>
|
||||
<p class="mt-1 text-xl font-semibold text-neutral-900 tabular-nums">7 天内</p>
|
||||
</div>
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-white">
|
||||
<p class="text-xs text-neutral-500">工作日活跃</p>
|
||||
<p class="mt-1 text-xl font-semibold text-neutral-900 tabular-nums">-</p>
|
||||
</div>
|
||||
<div class="border border-neutral-200 rounded-md p-3 bg-white">
|
||||
<p class="text-xs text-neutral-500">周末活跃</p>
|
||||
<p class="mt-1 text-xl font-semibold text-neutral-900 tabular-nums">-</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="border border-neutral-200 rounded-md p-3 text-center">
|
||||
<p class="text-xs text-neutral-500">价格偏好</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-primary-600 tabular-nums">64%</p>
|
||||
</div>
|
||||
<div class="border border-neutral-200 rounded-md p-3 text-center">
|
||||
<p class="text-xs text-neutral-500">户型偏好</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-primary-600 tabular-nums">22%</p>
|
||||
</div>
|
||||
<div class="border border-neutral-200 rounded-md p-3 text-center">
|
||||
<p class="text-xs text-neutral-500">面积偏好</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-primary-600 tabular-nums">14%</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="section-matches" class="scroll-mt-24 bg-white rounded-lg border border-neutral-200 p-4 space-y-4 section-anchor">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-neutral-800">二手配房</h2>
|
||||
<button class="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 text-neutral-700 rounded-md hover:bg-neutral-50 hover:border-neutral-400">批量分享</button>
|
||||
</header>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-700">优质户型</h3>
|
||||
<article class="border border-neutral-200 rounded-md p-3">
|
||||
<div class="flex gap-3">
|
||||
<div class="w-20 h-14 rounded bg-neutral-100"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-primary-600 hover:underline cursor-pointer">都市港湾</p>
|
||||
<p class="text-xs text-neutral-500">2/2/1 · 101.17m2 · 嘉定 丰庄</p>
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-[11px] rounded bg-warning-50 text-warning-600">朝南户型采光好</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-[11px] rounded bg-info-50 text-info-600">私盘</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-neutral-900 tabular-nums">620万 <span class="text-xs font-normal text-neutral-500">已跌20万 · 61283元/m2</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="col-span-4 min-w-0 space-y-3 sticky top-16 max-h-[calc(100vh-80px)] overflow-y-auto">
|
||||
<section class="bg-white rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<div class="bg-primary-600 px-4 py-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-white/20 text-white">求购</span>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold text-white truncate">姚叔叔置换电梯两房(上门)先生</h2>
|
||||
<p class="text-xs text-white/80 mt-0.5">带看进度:一看</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded bg-neutral-100 text-neutral-600">私客</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded bg-primary-50 text-primary-700">一看</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 text-xs rounded bg-warning-50 text-warning-600">C(一般)</span>
|
||||
</div>
|
||||
|
||||
<dl class="space-y-1.5">
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">最近跟进</dt><dd class="text-xs text-right text-neutral-800 tabular-nums">2026-04-19</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">客户编号</dt><dd class="text-xs text-right text-neutral-800 font-mono">60419C03182A3</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">委托日期</dt><dd class="text-xs text-right text-neutral-800 tabular-nums">2026-04-19</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">需求类型</dt><dd class="text-xs text-right text-neutral-800">二手</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">房源用途</dt><dd class="text-xs text-right text-neutral-800">住宅</dd></div>
|
||||
<div class="grid grid-cols-[72px_1fr] gap-2"><dt class="text-xs text-neutral-500">客户来源</dt><dd class="text-xs text-right text-neutral-800">线下丨门店接待</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button class="flex flex-col items-center gap-1 py-2 text-xs font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700">打电话</button>
|
||||
<button @click="drawerOpen=true" class="flex flex-col items-center gap-1 py-2 text-xs font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700">写跟进</button>
|
||||
<button class="flex flex-col items-center gap-1 py-2 text-xs font-medium bg-primary-600 text-white rounded-md hover:bg-primary-700">报备/常看</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">收藏</button>
|
||||
<button class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">不置顶</button>
|
||||
<button @click="modal='grade'" class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">改等级</button>
|
||||
<button @click="modal='status'" class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">改状态</button>
|
||||
<button class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">转公客</button>
|
||||
<button @click="modal='deal'" class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">转成交</button>
|
||||
<button class="px-2 py-2 text-xs text-left rounded-md text-danger-600 hover:bg-danger-50">转无效</button>
|
||||
<button @click="modal='edit'" class="px-2 py-2 text-xs text-left rounded-md text-neutral-600 hover:bg-neutral-100">编辑客源</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-lg border border-neutral-200 p-3">
|
||||
<header class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-neutral-800">联系人</h3>
|
||||
<div class="text-xs text-primary-600 space-x-2">
|
||||
<button class="hover:underline">查看号码</button>
|
||||
<button class="hover:underline">新增联系人</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-neutral-900">姚叔叔置换电梯两房(上门)先生</p>
|
||||
<p class="text-xs text-neutral-600 tabular-nums">电话1:+86 137****8888</p>
|
||||
<p class="text-xs text-neutral-500">默认拨打 · 接通0次 · 拨打0次</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-lg border border-neutral-200 p-3">
|
||||
<header class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-neutral-800">相关员工</h3>
|
||||
<button class="text-xs text-primary-600 hover:underline">编辑</button>
|
||||
</header>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-700">【首录人】</p>
|
||||
<p class="text-sm text-neutral-900">都市港湾店一组 雷威</p>
|
||||
<p class="text-xs text-neutral-500 tabular-nums">参与时间:2026-04-17 19:21</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-700">【归属人】</p>
|
||||
<p class="text-sm text-neutral-900">都市港湾店一组 雷威</p>
|
||||
<p class="text-xs text-neutral-500 tabular-nums">参与时间:2026-04-17 19:21</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div x-show="modal" x-transition.opacity class="fixed inset-0 z-50 bg-neutral-900/40" @click="modal = null"></div>
|
||||
|
||||
<div x-show="modal === 'grade'" x-transition class="fixed inset-0 z-60 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="w-full max-w-sm bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">改等级</h2><button @click="modal = null" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="px-5 py-4 space-y-3"><p class="text-sm text-neutral-500">原等级:C(一般)</p><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"><option>请选择新等级</option><option>A_urgent</option><option>A</option><option>B</option><option>C</option><option>D</option><option>E</option></select></div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="modal = null" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="modal === 'status'" x-transition class="fixed inset-0 z-60 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="w-full max-w-md bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">改状态</h2><button @click="modal = null" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<p class="text-sm text-neutral-500">原状态:求购</p>
|
||||
<select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>请选择新状态</option><option>暂缓</option><option>转公</option><option>成交</option><option>无效</option></select>
|
||||
<textarea rows="3" class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300" placeholder="请输入更改理由"></textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="modal = null" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="modal === 'deal'" x-transition class="fixed inset-0 z-60 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="w-full max-w-lg bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto flex flex-col">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">转成交</h2><button @click="modal = null" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="px-5 py-4 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">状态</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>我购</option><option>我租</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">房源类型</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>二手</option><option>新房</option></select></div>
|
||||
<div class="col-span-2"><label class="block text-xs text-neutral-500 mb-1">成交房源</label><button class="w-full px-3 py-2 text-sm border border-neutral-300 rounded-md text-left text-primary-600 hover:bg-neutral-50">+ 选择成交房源</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="modal = null" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">确认转成交</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="modal === 'edit'" x-transition class="fixed inset-0 z-60 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div class="w-full max-w-2xl bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto flex flex-col max-h-[85vh]">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">编辑基础信息</h2><button @click="modal = null" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">需求类型</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>二手</option><option>新房</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">来源</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>线下丨门店接待</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">用途</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>住宅</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">付款方式</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>请选择</option></select></div>
|
||||
<div class="col-span-2"><label class="block text-xs text-neutral-500 mb-1">证件号码</label><input class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300" placeholder="请输入证件号码"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="modal = null" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="drawerOpen" x-transition.opacity class="fixed inset-0 z-50 bg-neutral-900/40" @click="drawerOpen = false"></div>
|
||||
<aside x-show="drawerOpen" x-transition:enter="ease-out duration-200" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0" x-transition:leave="ease-in duration-150" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full" class="fixed right-0 top-0 h-full w-[480px] z-60 bg-white shadow-lg flex flex-col border-l border-neutral-200">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200"><h2 class="text-base font-semibold text-neutral-800">写入跟进</h2><button @click="drawerOpen=false" class="p-1 text-neutral-500 hover:bg-neutral-100 rounded-md">x</button></div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">跟进方式</label><select class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300"><option>电话</option><option>上门</option><option>微信</option><option>短信</option><option>其他</option></select></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">跟进内容</label><textarea rows="4" class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300" placeholder="至少6字"></textarea></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">跟进时间</label><input class="w-full px-3 py-2 text-sm rounded-md border border-neutral-300" value="2026-04-25 10:30"></div>
|
||||
<div><label class="block text-xs text-neutral-500 mb-1">附件</label><input type="file" class="w-full text-sm"></div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-neutral-700"><input type="checkbox" checked class="rounded border-neutral-300">是否开放给同事查看</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50"><button @click="drawerOpen=false" class="px-3 py-1.5 text-sm border border-neutral-300 rounded-md hover:bg-white">取消</button><button class="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700">提交</button></div>
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
function clientDetailPage() {
|
||||
return {
|
||||
modal: null,
|
||||
drawerOpen: false,
|
||||
navItems: [
|
||||
{ id: 'section-requirements', label: '需求信息' },
|
||||
{ id: 'section-follow', label: '跟进记录' },
|
||||
{ id: 'section-viewings', label: '带看记录' },
|
||||
{ id: 'section-insights', label: '客源解读' },
|
||||
{ id: 'section-matches', label: '二手配房' }
|
||||
],
|
||||
activeSection: 'section-requirements',
|
||||
observer: null,
|
||||
scrollToSection(id) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
},
|
||||
init() {
|
||||
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((section) => this.observer.observe(section))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,10 @@
|
||||
|
||||
你是 Fonrey 房产经纪管理系统的 **UI/UX 架构师**,负责根据竞品截图和 PRD 功能描述,产出一份标准化的模块级 UI 设计文档。该文档将直接交给 AI Engineer 用于编码实现,必须包含足够的细节,Engineer 无需再问任何问题。
|
||||
|
||||
**注意**
|
||||
以下所有的文档或图片是基于文档库的相对路径。
|
||||
文档库的根路径为:`/mnt/d/Workspace/nexus`
|
||||
|
||||
---
|
||||
|
||||
## 全局设计约束(必须严格遵守)
|
||||
@@ -50,14 +54,20 @@
|
||||
```
|
||||
请读取该文件,理解每个功能点的业务逻辑和验收标准。
|
||||
|
||||
### 3. 竞品参考截图
|
||||
### 3. DATA_MODEL 数据模型文档路径
|
||||
```
|
||||
{{DATA_MODEL文件路径,如:Project/fonrey/DATA_MODEL/DATA_MODEL_PROPERTY.md}}
|
||||
```
|
||||
请读取该文件,理解该模块的数据模型以及字段命名。
|
||||
|
||||
### 4. 竞品参考截图
|
||||
请读取以下截图文件作为视觉参考(所有截图均在 `Project/fonrey/screenshots/` 目录下):
|
||||
|
||||
{{截图列表,格式如下,每行一张:
|
||||
- 功能名称:`Project/fonrey/screenshots/模块/截图名.png`
|
||||
}}
|
||||
|
||||
### 4. MVP 优先级参考
|
||||
### 5. MVP 优先级参考
|
||||
请参考 `Project/fonrey/PRD/PRD_MVP.md`,在设计文档中标注每个页面/功能的优先级(P0/P1/P2)。
|
||||
|
||||
---
|
||||
@@ -319,36 +329,38 @@
|
||||
- `Project/fonrey/UI_DESIGN/客源列表_UI.html`
|
||||
- `Project/fonrey/UI_DESIGN/客源详情_UI.html`
|
||||
3. 【本次模块UI设计文档】本次需要实现的模块设计说明
|
||||
- **待填入**
|
||||
- `Project/fonrey/UI_DESIGN/新增客源_UI.md`
|
||||
4. 【本次模块UI输出静态原型文件】
|
||||
- `Project/fonrey/UI_DESIGN/新增客源_UI.html`**
|
||||
|
||||
## 强制约束(不可违反)
|
||||
### 强制约束(不可违反)
|
||||
|
||||
### 一致性约束
|
||||
#### 一致性约束
|
||||
- 颜色、字体、字号、圆角、阴影、间距等视觉变量,必须与 UI_SYSTEM 保持完全一致,不得自行创造新的变量
|
||||
- 公共组件(导航栏、侧边栏、顶部栏、按钮、表单、卡片、标签等)的样式和结构,必须与现有原型页面中的实现保持一致
|
||||
- 如果现有页面使用了 CSS 变量或特定 class 命名规范,本次输出必须沿用相同的规范
|
||||
|
||||
### 布局约束
|
||||
#### 布局约束
|
||||
- 整体页面框架(如侧边栏宽度、顶栏高度、内容区边距)必须与现有原型页面保持一致
|
||||
- 响应式断点策略(如有)需与已有页面对齐
|
||||
### 代码约束
|
||||
#### 代码约束
|
||||
- 输出单一 HTML 文件,CSS 写在 <style> 标签内,JS 写在 <script> 标签内
|
||||
- 不引入任何外部依赖,除非现有原型页面已经使用了该依赖
|
||||
- 类名、变量名的命名风格与现有代码保持一致
|
||||
|
||||
### 执行步骤(按顺序执行)
|
||||
#### 执行步骤(按顺序执行)
|
||||
1. 通读所有输入材料,识别 UI_SYSTEM 中的核心设计 token
|
||||
2. 分析现有原型页面,提取公共组件的 HTML 结构和 CSS 实现
|
||||
3. 阅读本次模块设计文档,理解页面结构、交互状态和内容层级
|
||||
4. 以现有页面为外壳,将本次模块内容填入正确的内容区域
|
||||
5. 对照设计文档逐项检查还原度,确认无遗漏后输出
|
||||
|
||||
### 输出要求
|
||||
#### 输出要求
|
||||
- 直接输出完整可运行的 HTML 文件内容
|
||||
- 页面中需要数据的地方使用合理的占位内容(不要留空)
|
||||
- 交互状态(hover、active、selected、disabled)需在 CSS 中体现
|
||||
- 输出完成后,列出你在本次实现中做出的所有设计假设或补充决策
|
||||
|
||||
### 注意事项
|
||||
#### 注意事项
|
||||
- 如果设计文档与 UI_SYSTEM 存在冲突,以 UI_SYSTEM 为准,并告知我冲突点
|
||||
- 如果设计文档描述不清晰,不要自行猜测,先列出疑问再继续
|
||||
|
||||
@@ -40,9 +40,64 @@ Vibe Coding 最怕 AI 随意引入不兼容的库。
|
||||
- **核心页面路由:** 定义 MVP 阶段的所有 URL 路径及对应的功能页面。
|
||||
|
||||
- **全局布局:** 描述 Header, Sidebar, Footer 的行为。
|
||||
|
||||
|
||||
### 5. AI 指令手册 (`.cursorrules` 或 `AI_INSTRUCTIONS.md`)
|
||||
|
||||
### 5. 项目骨架设计(`ARCHITECTURE.md`)
|
||||
- **技术栈**
|
||||
- Backend: Django 4.x
|
||||
- 前端交互: HTMX 1.x + Alpine.js 3.x
|
||||
- 样式: [Tailwind / 你的选择]
|
||||
- 数据库: [PostgreSQL / SQLite]
|
||||
|
||||
- **目录结构说明**
|
||||
[说明每个目录的职责]
|
||||
|
||||
- **Django App 划分原则**
|
||||
- 每个 App 对应一个业务域,不跨域
|
||||
- App 列表:accounts / dashboard / [其他]
|
||||
|
||||
- **模板约定**
|
||||
- base.html 定义全局布局和 block 插槽
|
||||
- HTMX 局部刷新的模板放在 partials/ 下
|
||||
- block 结构:title / content / extra_css / extra_js
|
||||
|
||||
- **HTMX 使用约定**
|
||||
- 所有 HTMX 请求的 view 需判断 HX-Request header
|
||||
- 局部响应只返回对应的 partial 模板
|
||||
- 示例:
|
||||
def my_view(request):
|
||||
if request.htmx:
|
||||
return render(request, 'partials/xxx.html', context)
|
||||
return render(request, 'full/xxx.html', context)
|
||||
|
||||
- **Alpine.js 使用约定**
|
||||
- 只负责纯客户端状态(下拉展开、tab 切换、表单校验提示)
|
||||
- 不处理需要服务端数据的逻辑,那部分交给 HTMX
|
||||
|
||||
- **URL 命名规范**
|
||||
- 格式:app_name:resource-action
|
||||
- 示例:accounts:login, dashboard:report-list
|
||||
|
||||
- **环境变量**
|
||||
- 使用 .env 文件管理,通过 django-environ 读取
|
||||
- 敏感配置不进 settings.py 硬编码
|
||||
|
||||
### 6. 任务清单(`TASK.md`)
|
||||
这是 Vibe Coding 中最关键的"指挥文件"。它不只是任务清单,更是 AI 在每次对话中快速定位上下文的锚点。
|
||||
|
||||
**分解 User Story 的标准方法**
|
||||
每个 User Story 在交给 AI 编码前,需要满足 **INVEST 原则** 中最关键的三条:
|
||||
|
||||
| 原则 | 实操含义 |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| **Independent(独立)** | 这个 Story 能单独开发、单独测试,不强依赖其他未完成的 Story |
|
||||
| **Small(小)** | 一个 Story 的编码工作控制在一次对话内能完成 |
|
||||
| **Testable(可验证)** | 有明确的验收标准,你能用眼睛或操作来判断是否完成 |
|
||||
**拆分的实用规则:** 如果一个 Story 的描述里出现了"并且"或"还可以",就应该拆成两个。
|
||||
|
||||
|
||||
|
||||
### 6. AI 指令手册 (`.cursorrules` 或 `AI_INSTRUCTIONS.md`)
|
||||
|
||||
这是 Vibe Coding 的“灵魂”,专门给 Claude 看的“说明书”。
|
||||
|
||||
@@ -74,4 +129,61 @@ Vibe Coding 最怕 AI 随意引入不兼容的库。
|
||||
|
||||
2. **环境对齐:** 将文档喂给 Claude,问它:“基于这些文档,你认为实现 MVP 的第一步是什么?请列出任务清单。”
|
||||
|
||||
3. **循环迭代:** 按照 `任务清单 -> 生成代码 -> 测试反馈 -> 更新文档` 的循环进行。
|
||||
3. **循环迭代:** 按照 `任务清单 -> 生成代码 -> 测试反馈 -> 更新文档` 的循环进行。
|
||||
|
||||
```
|
||||
准备阶段(你现在所在的位置)
|
||||
↓
|
||||
① 整理 TASK.md,按 P0/P1/P2 排优先级
|
||||
↓
|
||||
② 从 Phase 1 挑第一个 Story 开始
|
||||
↓
|
||||
③ 每次开启新对话,给 AI 提供以下上下文包:
|
||||
- TASK.md(让 AI 知道全局和当前任务)
|
||||
- 相关模块的 PRD 片段
|
||||
- 对应的 UI HTML 原型
|
||||
- Tech Stack 文档
|
||||
- 相关的 Data Model
|
||||
↓
|
||||
④ AI 完成编码后,你验收(对照验收标准)
|
||||
↓
|
||||
⑤ 验收通过 → 在 TASK.md 勾选 [x] → 进入下一个 Story
|
||||
↓
|
||||
⑥ 每完成一个模块,检查是否需要更新 Data Model 或 Tech 文档
|
||||
```
|
||||
|
||||
### 给 AI 的标准开场提示词
|
||||
|
||||
每次开始一个新 Story 时,用这个模板开场:
|
||||
|
||||
```
|
||||
## 当前任务
|
||||
我正在开发 [项目名],现在要实现 US-XXX:[Story 标题]
|
||||
|
||||
## 上下文文件(我将依次提供)
|
||||
1. TASK.md - 了解项目全局状态和本任务优先级
|
||||
2. Tech Stack 文档 - 了解技术约束
|
||||
3. Data Model - 了解数据结构
|
||||
4. 本模块 PRD - 了解功能需求
|
||||
5. 本模块 UI 原型 HTML - 了解视觉要求
|
||||
|
||||
## 本次任务的验收标准
|
||||
- [条件1]
|
||||
- [条件2]
|
||||
|
||||
## 约束
|
||||
- 只实现本 Story 范围内的功能,Phase 2/3 的内容不要提前实现
|
||||
- 如遇到需要修改 Data Model 的情况,先告知我,不要擅自修改
|
||||
- 完成后告诉我你新建或修改了哪些文件
|
||||
```
|
||||
|
||||
**TASK.md 是活文档,AI 也要帮你维护它。**
|
||||
|
||||
每次 Story 完成后,让 AI 顺手输出一行更新指令,比如:
|
||||
|
||||
```
|
||||
请在 TASK.md 中将 US-003 标记为已完成,并在备注中记录:
|
||||
"Session 存储改用 httpOnly cookie,与原方案不同"
|
||||
```
|
||||
|
||||
这样你的 TASK.md 不只是任务清单,还成了项目的**决策日志**,下一次换新对话时 AI 能快速理解为什么某些事情是现在这样做的。
|
||||
Reference in New Issue
Block a user