修改文档
This commit is contained in:
287
Project/fonrey/TECH_STACK/API_CONTRACT.md
Normal file
287
Project/fonrey/TECH_STACK/API_CONTRACT.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
> **For AI assistants**: Read this entire file before designing or implementing any API. Contract rules here are mandatory. Do not invent per-module variants unless explicitly allowed.
|
||||||
|
|
||||||
|
# Fonrey API 契约规范(API_CONTRACT)
|
||||||
|
|
||||||
|
**版本**: 1.0
|
||||||
|
**适用范围**: 全模块(account / permission / property / client / complex / org / setting)
|
||||||
|
**关联总纲**: `TECH_STACK/TECH_STACK.md`
|
||||||
|
**最后更新**: 2026-04-27
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 文档定位与原则
|
||||||
|
|
||||||
|
本文件定义 Fonrey 全局 API 契约标准,解决跨模块接口风格漂移问题。模块技术方案中的 API 章节必须遵循本文件,不得各自定义冲突规则。
|
||||||
|
|
||||||
|
### 1.1 强制级别
|
||||||
|
|
||||||
|
- **MUST**:必须遵守,违反视为缺陷
|
||||||
|
- **SHOULD**:建议遵守,若不遵守需在模块文档注明原因
|
||||||
|
- **MAY**:可选能力
|
||||||
|
|
||||||
|
### 1.2 适用接口类型
|
||||||
|
|
||||||
|
- JSON API(`/api/**`)
|
||||||
|
- HTMX 片段端点(HTML response)
|
||||||
|
- 文件上传/下载端点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 请求 / 响应格式规范
|
||||||
|
|
||||||
|
## 2.1 请求体规范(JSON API)
|
||||||
|
|
||||||
|
- `Content-Type` MUST 为 `application/json`
|
||||||
|
- `charset=utf-8` SHOULD 显式声明
|
||||||
|
- 写操作(POST/PUT/PATCH)MUST 传业务 payload;禁止空对象写入
|
||||||
|
|
||||||
|
推荐请求结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"...": "业务字段"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"request_id": "可选,客户端透传"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容说明:历史端点已存在 `filters/sort/pagination` 平铺结构时可继续使用,但新接口 SHOULD 迁移到 `data` 容器。
|
||||||
|
|
||||||
|
## 2.2 成功响应规范(JSON API)
|
||||||
|
|
||||||
|
成功响应 MUST 使用统一 envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"data": {},
|
||||||
|
"meta": {
|
||||||
|
"request_id": "uuid",
|
||||||
|
"timestamp": "2026-04-27T16:30:00+08:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `ok` MUST 为 `true`
|
||||||
|
- `data` MUST 存在(可为空对象 `{}` 或空数组 `[]`)
|
||||||
|
- `meta` SHOULD 包含 `request_id` 与服务端时间
|
||||||
|
|
||||||
|
## 2.3 失败响应规范(JSON API)
|
||||||
|
|
||||||
|
失败响应 MUST 使用统一 envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "权限不足",
|
||||||
|
"code": "PROPERTY_PERMISSION_DENIED",
|
||||||
|
"details": {},
|
||||||
|
"meta": {
|
||||||
|
"request_id": "uuid",
|
||||||
|
"timestamp": "2026-04-27T16:30:00+08:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `error` MUST 为面向用户/调用方可读消息
|
||||||
|
- `code` MUST 为稳定机器可读码(大写下划线)
|
||||||
|
- `details` MAY 提供字段级错误(如校验失败)
|
||||||
|
|
||||||
|
## 2.4 HTMX 响应规范
|
||||||
|
|
||||||
|
- 成功:返回 HTML 片段;必要时通过 `HX-Trigger` 触发前端事件
|
||||||
|
- 失败:
|
||||||
|
- 状态码 MUST 正确(4xx/5xx)
|
||||||
|
- SHOULD 在响应头返回 `HX-Trigger`,例如:
|
||||||
|
- `{"toast:error":"权限不足"}`
|
||||||
|
- `{"toast:error":"请求失败,请重试"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 错误码规范
|
||||||
|
|
||||||
|
## 3.1 命名规则
|
||||||
|
|
||||||
|
- 错误码 MUST 为 `UPPER_SNAKE_CASE`
|
||||||
|
- 推荐前缀:`<MODULE>_`(如 `PROPERTY_` / `CLIENT_` / `ORG_`)
|
||||||
|
|
||||||
|
## 3.2 HTTP 状态码基线
|
||||||
|
|
||||||
|
| HTTP | 使用场景 | 示例 code |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 | 参数错误、业务前置条件不满足 | `PROPERTY_VALIDATION_ERROR` |
|
||||||
|
| 401 | 仅用于纯 API Token 鉴权失败(当前 Web 会话模式一般不用) | `AUTH_UNAUTHORIZED` |
|
||||||
|
| 403 | 已登录但无权限 | `*_PERMISSION_DENIED` |
|
||||||
|
| 404 | 资源不存在或不可见 | `*_NOT_FOUND` |
|
||||||
|
| 409 | 状态冲突、任务未就绪 | `*_STATE_CONFLICT` / `*_JOB_NOT_READY` |
|
||||||
|
| 422 | 字段级校验错误(可选) | `*_VALIDATION_FAILED` |
|
||||||
|
| 429 | 频控触发 | `RATE_LIMITED` |
|
||||||
|
| 500 | 未预期异常 | `INTERNAL_ERROR` |
|
||||||
|
|
||||||
|
## 3.3 稳定性要求
|
||||||
|
|
||||||
|
- `code` MUST 可稳定依赖,不得频繁改名
|
||||||
|
- 错误文案 `error` 可优化,但不应影响调用方流程判断
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 分页规范
|
||||||
|
|
||||||
|
Fonrey 列表查询 MUST 使用 Keyset 分页;禁止 OFFSET 深分页。
|
||||||
|
|
||||||
|
## 4.1 请求格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filters": {},
|
||||||
|
"sort": {"field": "updated_at", "order": "desc"},
|
||||||
|
"pagination": {"mode": "keyset", "cursor": null, "limit": 20}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": "opaque_cursor_2"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"pagination": {"mode": "keyset", "cursor": "opaque_cursor", "limit": 20}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.3 约束
|
||||||
|
|
||||||
|
- `limit` MUST 有上限(建议 ≤ 100)
|
||||||
|
- `cursor` MUST 为不透明字符串,禁止暴露内部排序字段组合
|
||||||
|
- 排序字段 MUST 来自白名单,防止 SQL 注入与慢查询
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 搜索 / 筛选规范
|
||||||
|
|
||||||
|
## 5.1 推荐请求结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filters": {
|
||||||
|
"keyword": "保利",
|
||||||
|
"status": ["active", "pending"],
|
||||||
|
"district_id": "uuid"
|
||||||
|
},
|
||||||
|
"sort": {"field": "updated_at", "order": "desc"},
|
||||||
|
"pagination": {"mode": "keyset", "cursor": null, "limit": 20}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2 语义规范
|
||||||
|
|
||||||
|
- `keyword`:模糊检索词(服务端统一做 trim)
|
||||||
|
- 多选条件 MUST 使用数组(如 `status: []`)
|
||||||
|
- 空数组 `[]` 语义:不限制该条件
|
||||||
|
- `null` 语义:由模块文档明确(默认建议等同“不传”)
|
||||||
|
|
||||||
|
## 5.3 安全与性能
|
||||||
|
|
||||||
|
- 仅允许白名单字段参与筛选和排序
|
||||||
|
- LIKE/全文检索字段 SHOULD 建立索引或搜索策略
|
||||||
|
- 查询快照哈希(用于缓存/导出)SHOULD 对 filters+sort+scope 进行规范化后计算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 上传规范
|
||||||
|
|
||||||
|
Fonrey 优先采用“预签名上传 + 回执提交(commit)”两段式。
|
||||||
|
|
||||||
|
## 6.1 标准流程
|
||||||
|
|
||||||
|
1. 客户端请求 upload-token(业务 API)
|
||||||
|
2. 客户端直传对象存储(R2)
|
||||||
|
3. 客户端调用 commit API 回写元数据
|
||||||
|
|
||||||
|
## 6.2 合约要求
|
||||||
|
|
||||||
|
- upload-token MUST 短时有效(建议 5~15 分钟)
|
||||||
|
- commit MUST 幂等(建议支持 `idempotency_key`)
|
||||||
|
- 上传白名单与大小限制 MUST 在模块文档声明并在服务端校验
|
||||||
|
- SHOULD 校验 `content_type` 与 `size`
|
||||||
|
- MAY 增加 `sha256` 校验确保完整性
|
||||||
|
|
||||||
|
## 6.3 错误码建议
|
||||||
|
|
||||||
|
- `*_UPLOAD_TOKEN_EXPIRED` (409/400)
|
||||||
|
- `*_UPLOAD_FILE_TOO_LARGE` (400)
|
||||||
|
- `*_UPLOAD_FILE_TYPE_NOT_ALLOWED` (400)
|
||||||
|
- `*_UPLOAD_COMMIT_CONFLICT` (409)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 文件下载规范
|
||||||
|
|
||||||
|
下载统一采用“导出任务 + 状态查询 + download endpoint”。
|
||||||
|
|
||||||
|
## 7.1 标准流程
|
||||||
|
|
||||||
|
1. 创建导出任务 `POST /api/**/export/jobs/`
|
||||||
|
2. 轮询任务状态 `GET /api/**/export/jobs/{job_id}/`
|
||||||
|
3. 下载结果 `GET /api/**/export/jobs/{job_id}/download/`
|
||||||
|
|
||||||
|
## 7.2 合约要求
|
||||||
|
|
||||||
|
- 任务未完成下载 MUST 返回 `409` + `*_EXPORT_JOB_NOT_READY`
|
||||||
|
- 下载链接 SHOULD 为一次性或短时有效 URL
|
||||||
|
- 响应 SHOULD 设置 `Content-Disposition`(附件下载)
|
||||||
|
- 文件名 SHOULD 带模块与日期,便于审计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 权限拒绝返回规范
|
||||||
|
|
||||||
|
## 8.1 JSON API
|
||||||
|
|
||||||
|
- 未登录:MUST 返回 `302`(Web Session 场景)或 `401`(纯 API 场景)
|
||||||
|
- 已登录无权限:MUST 返回 `403`
|
||||||
|
- 失败体 MUST 使用统一错误 envelope,`code` 为 `*_PERMISSION_DENIED`
|
||||||
|
|
||||||
|
## 8.2 页面路由(SSR/HTMX)
|
||||||
|
|
||||||
|
- 未登录:302 跳转登录页
|
||||||
|
- 已登录无权限:403 页面(或 HTMX 403 片段)
|
||||||
|
- HTMX 拒绝 SHOULD 触发 `HX-Trigger` toast 事件
|
||||||
|
|
||||||
|
## 8.3 测试强约束
|
||||||
|
|
||||||
|
每个受保护端点 MUST 覆盖三态:
|
||||||
|
- 200(有权限)
|
||||||
|
- 403(已登录无权限)
|
||||||
|
- 302/401(未登录,视端点类型)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 与模块文档的衔接规则
|
||||||
|
|
||||||
|
- 各模块技术方案中的“四、API 设计原则”“六、关键 API 规范”“十二、错误码建议”必须引用本文件
|
||||||
|
- 模块文档可补充模块特有 code 与字段,但不得与本规范冲突
|
||||||
|
- 冲突时以本文件为准;若需例外,必须在模块文档显式记录 ADR 链接
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 落地检查清单(Review Checklist)
|
||||||
|
|
||||||
|
- [ ] 是否使用统一成功/失败 envelope
|
||||||
|
- [ ] 错误码是否为稳定 `UPPER_SNAKE_CASE`
|
||||||
|
- [ ] 列表接口是否全部 Keyset 分页
|
||||||
|
- [ ] filters/sort 字段是否白名单化
|
||||||
|
- [ ] 上传是否采用 token+commit 且具备幂等保障
|
||||||
|
- [ ] 下载是否采用 job 流程并处理未就绪 409
|
||||||
|
- [ ] 权限拒绝是否遵循 200/403/302(401) 三态
|
||||||
|
- [ ] 测试是否覆盖契约关键路径
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
|
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
|
||||||
|
|
||||||
**版本**: 2.1 | **最后更新**: 2026-04-27
|
**版本**: 2.2 | **最后更新**: 2026-04-27
|
||||||
**定位**: 本文档是 Fonrey 项目技术栈的**总索引**。所有跨模块的技术决策、版本约束、目录规范、禁止项在此定稿;**单一模块的具体技术方案**(数据模型、服务层、HTMX 交互、Celery 任务等)见各自子文档(见 §9 索引)。
|
**定位**: 本文档是 Fonrey 项目技术栈的**总索引**。所有跨模块的技术决策、版本约束、目录规范、禁止项在此定稿;**单一模块的具体技术方案**(数据模型、服务层、HTMX 交互、Celery 任务等)见各自子文档(见 §9 索引)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -128,18 +128,19 @@ apps/property/
|
|||||||
|
|
||||||
每个模块的具体技术决策(模型字段、服务层、缓存策略、HTMX/Celery 集成等)见对应子文档:
|
每个模块的具体技术决策(模型字段、服务层、缓存策略、HTMX/Celery 集成等)见对应子文档:
|
||||||
|
|
||||||
| 模块 | 技术方案文档 | PRD | 数据模型 |
|
| 模块 | 技术方案文档 | PRD | 数据模型 | 测试文件 | 最近版本 |
|
||||||
| ----- | ---------------------------------- | -------------------------- | ------------------------------------- |
|
| ----- | ---------------------------------- | -------------------------- | ------------------------------------- | --- | --- |
|
||||||
| 登录认证 | [`登录管理技术方案.md`](./登录管理技术方案.md) | `PRD/登录管理/` | `DATA_MODEL/DATA_MODEL_LOGIN.md` |
|
| 登录认证 | [`登录管理技术方案.md`](./登录管理技术方案.md) | `PRD/登录管理/` | `DATA_MODEL/DATA_MODEL_LOGIN.md` | `tests/integration/account/test_us_account.py` | `v3.1` |
|
||||||
| 权限管理 | [`权限管理系统技术方案.md`](./权限管理系统技术方案.md) | `PRD/权限管理/` | `DATA_MODEL/DATA_MODEL_PERMISSION.md` |
|
| 权限管理 | [`权限管理系统技术方案.md`](./权限管理系统技术方案.md) | `PRD/权限管理/` | `DATA_MODEL/DATA_MODEL_PERMISSION.md` | `tests/integration/permission/test_us_permission.py` | `v2.1` |
|
||||||
| 房源管理 | [`房源管理技术方案.md`](./房源管理技术方案.md) | `PRD/房源管理/` | `DATA_MODEL/DATA_MODEL_PROPERTY.md` |
|
| 房源管理 | [`房源管理技术方案.md`](./房源管理技术方案.md) | `PRD/房源管理/` | `DATA_MODEL/DATA_MODEL_PROPERTY.md` | `tests/integration/property/test_us_property.py` | `v1.0` |
|
||||||
| 客源管理 | [`客源管理技术方案.md`](./客源管理技术方案.md) | `PRD/客源管理/` | `DATA_MODEL/DATA_MODEL_CLIENT.md` |
|
| 客源管理 | [`客源管理技术方案.md`](./客源管理技术方案.md) | `PRD/客源管理/` | `DATA_MODEL/DATA_MODEL_CLIENT.md` | `tests/integration/client/test_us_client.py` | `v1.0` |
|
||||||
| 楼盘管理 | [`楼盘管理技术方案.md`](./楼盘管理技术方案.md) | `PRD/房源管理/`(含楼盘) | `DATA_MODEL/DATA_MODEL_COMPLEX.md` |
|
| 楼盘管理 | [`楼盘管理技术方案.md`](./楼盘管理技术方案.md) | `PRD/房源管理/`(含楼盘) | `DATA_MODEL/DATA_MODEL_COMPLEX.md` | `tests/integration/complex/test_us_complex.py` | `v1.0` |
|
||||||
| 组织人事 | [`组织人事技术方案.md`](./组织人事技术方案.md) | `PRD/组织人事管理/` | `DATA_MODEL/DATA_MODEL_ORG.md` |
|
| 组织人事 | [`组织人事技术方案.md`](./组织人事技术方案.md) | `PRD/组织人事管理/` | `DATA_MODEL/DATA_MODEL_ORG.md` | `tests/integration/org/test_us_org.py` | `v1.0` |
|
||||||
| 系统设置 | [`系统设置技术方案.md`](./系统设置技术方案.md) | `PRD/系统配置/`、`PRD/系统管理/` | `DATA_MODEL/DATA_MODEL_SETTING.md` |
|
| 系统设置 | [`系统设置技术方案.md`](./系统设置技术方案.md) | `PRD/系统配置/`、`PRD/系统管理/` | `DATA_MODEL/DATA_MODEL_SETTING.md` | `tests/integration/setting/test_us_setting.py` | `v1.2` |
|
||||||
| 客户端发布 | 见本文档 §7 | `PRD/发布管理/客户端发布管理模块PRD.md` | — |
|
| 客户端发布 | 见本文档 §7 | `PRD/发布管理/客户端发布管理模块PRD.md` | — | — | `§7(本文)` |
|
||||||
|
|
||||||
**总览数据模型**:[`DATA_MODEL/DATA_MODEL.md`](../DATA_MODEL/DATA_MODEL.md)
|
**总览数据模型**:[`DATA_MODEL/DATA_MODEL.md`](../DATA_MODEL/DATA_MODEL.md)
|
||||||
|
**全局 API 契约**:[`API_CONTRACT.md`](./API_CONTRACT.md)
|
||||||
**MVP 范围与产品总览**:[`PRD/PRD_MVP.md`](../PRD/PRD_MVP.md)
|
**MVP 范围与产品总览**:[`PRD/PRD_MVP.md`](../PRD/PRD_MVP.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -231,6 +232,7 @@ Fonrey 采用 AI vibe coding 模式开发,测试是保证每日迭代质量的
|
|||||||
|
|
||||||
- 本文档仅记录**跨模块共识**与**模块索引**,不展开模块细节
|
- 本文档仅记录**跨模块共识**与**模块索引**,不展开模块细节
|
||||||
- 模块技术方案在子文档中维护,并通过 §8 表格回链
|
- 模块技术方案在子文档中维护,并通过 §8 表格回链
|
||||||
|
- API 契约总则在 `API_CONTRACT.md` 维护;模块文档只做模块特化,不得覆盖全局契约
|
||||||
- 任何技术栈变更(替换组件、升级主版本、新增外部服务)须同步更新本文档 §2、§5、§6
|
- 任何技术栈变更(替换组件、升级主版本、新增外部服务)须同步更新本文档 §2、§5、§6
|
||||||
- 新增模块时,先在 §4 目录结构补位,再在 §8 索引登记子文档
|
- 新增模块时,先在 §4 目录结构补位,再在 §8 索引登记子文档
|
||||||
- 测试规范变更须同步更新 §10 关键结论,完整细节在 [`测试规范.md`](./测试规范.md) 中维护
|
- 测试规范变更须同步更新 §10 关键结论,完整细节在 [`测试规范.md`](./测试规范.md) 中维护
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery + Cloudflare R2
|
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery + Cloudflare R2
|
||||||
**关联 PRD**: `PRD/客源管理/客源管理模块PRD.md`(v1.4)
|
**关联 PRD**: `PRD/客源管理/客源管理模块PRD.md`(v1.4)
|
||||||
**关联数据模型**: `DATA_MODEL/DATA_MODEL_CLIENT.md`(本方案不重复 DDL)
|
**关联数据模型**: `DATA_MODEL/DATA_MODEL_CLIENT.md`(本方案不重复 DDL)
|
||||||
|
**关联契约规范**: `TECH_STACK/API_CONTRACT.md`(全局 API 契约权威)
|
||||||
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
||||||
**最后更新**: 2026-04-27
|
**最后更新**: 2026-04-27
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery + Cloudflare R2
|
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery + Cloudflare R2
|
||||||
**关联 PRD**: `PRD/房源管理/房源管理模块PRD.md`(v2.1)
|
**关联 PRD**: `PRD/房源管理/房源管理模块PRD.md`(v2.1)
|
||||||
**关联数据模型**: `DATA_MODEL/DATA_MODEL_PROPERTY.md`(本方案不重复 DDL)
|
**关联数据模型**: `DATA_MODEL/DATA_MODEL_PROPERTY.md`(本方案不重复 DDL)
|
||||||
|
**关联契约规范**: `TECH_STACK/API_CONTRACT.md`(全局 API 契约权威)
|
||||||
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
||||||
**最后更新**: 2026-04-27
|
**最后更新**: 2026-04-27
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis
|
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis
|
||||||
**关联 PRD**: `PRD/权限管理/权限管理模块PRD.md`
|
**关联 PRD**: `PRD/权限管理/权限管理模块PRD.md`
|
||||||
**关联数据模型**: `DATA_MODEL/DATA_MODEL_PERMISSION.md`(本方案不重复 DDL)
|
**关联数据模型**: `DATA_MODEL/DATA_MODEL_PERMISSION.md`(本方案不重复 DDL)
|
||||||
|
**关联契约规范**: `TECH_STACK/API_CONTRACT.md`(全局 API 契约权威)
|
||||||
**最后更新**: 2026-04-27
|
**最后更新**: 2026-04-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery + Cloudflare R2
|
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery + Cloudflare R2
|
||||||
**关联 PRD**: `PRD/房源管理/楼盘管理模块PRD.md`(v1.0)
|
**关联 PRD**: `PRD/房源管理/楼盘管理模块PRD.md`(v1.0)
|
||||||
**关联数据模型**: `DATA_MODEL/DATA_MODEL_COMPLEX.md`(本方案不重复 DDL)
|
**关联数据模型**: `DATA_MODEL/DATA_MODEL_COMPLEX.md`(本方案不重复 DDL)
|
||||||
|
**关联契约规范**: `TECH_STACK/API_CONTRACT.md`(全局 API 契约权威)
|
||||||
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
||||||
**最后更新**: 2026-04-27
|
**最后更新**: 2026-04-27
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery
|
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery
|
||||||
**关联 PRD**: `PRD/登录管理/用户登录管理模块PRD.md`
|
**关联 PRD**: `PRD/登录管理/用户登录管理模块PRD.md`
|
||||||
**关联数据模型**: `DATA_MODEL/DATA_MODEL_LOGIN.md`(本方案不重复 DDL)
|
**关联数据模型**: `DATA_MODEL/DATA_MODEL_LOGIN.md`(本方案不重复 DDL)
|
||||||
|
**关联契约规范**: `TECH_STACK/API_CONTRACT.md`(全局 API 契约权威)
|
||||||
**最后更新**: 2026-04-27
|
**最后更新**: 2026-04-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis
|
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis
|
||||||
**关联 PRD**: `PRD/系统配置/系统配置模块PRD.md`
|
**关联 PRD**: `PRD/系统配置/系统配置模块PRD.md`
|
||||||
**关联数据模型**: `DATA_MODEL/DATA_MODEL_SETTING.md`(本方案不重复 DDL)
|
**关联数据模型**: `DATA_MODEL/DATA_MODEL_SETTING.md`(本方案不重复 DDL)
|
||||||
|
**关联契约规范**: `TECH_STACK/API_CONTRACT.md`(全局 API 契约权威)
|
||||||
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
||||||
**最后更新**: 2026-04-27
|
**最后更新**: 2026-04-27
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery
|
**技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL 16 + Redis + Celery
|
||||||
**关联 PRD**: `PRD/组织人事管理/组织人事管理模块PRD.md`(v1.2)
|
**关联 PRD**: `PRD/组织人事管理/组织人事管理模块PRD.md`(v1.2)
|
||||||
**关联数据模型**: `DATA_MODEL/DATA_MODEL_ORG.md`(本方案不重复 DDL)
|
**关联数据模型**: `DATA_MODEL/DATA_MODEL_ORG.md`(本方案不重复 DDL)
|
||||||
|
**关联契约规范**: `TECH_STACK/API_CONTRACT.md`(全局 API 契约权威)
|
||||||
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
**关联枚举字典**: `DATA_MODEL/ENUMS.md`
|
||||||
**最后更新**: 2026-04-27
|
**最后更新**: 2026-04-27
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
| 序号 | 优先级 | 模块 | 覆盖 US | UI.md 目标文件 | HTML 目标文件 | 当前状态 | 下一步 |
|
| 序号 | 优先级 | 模块 | 覆盖 US | UI.md 目标文件 | HTML 目标文件 | 当前状态 | 下一步 |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| 01 | P0-A | 登录管理 | US-ACCOUNT-001~003 | `UI_DESIGN/登录管理/登录_UI.md` | `UI_DESIGN/登录_UI.html` | 设计中 | 先输出登录 UI.md(第一个任务) |
|
| 01 | P0-A | 登录管理 | US-ACCOUNT-001~003 | `UI_DESIGN/登录管理/登录_UI.md` | `UI_DESIGN/登录_UI.html` | 待评审 | 你评审登录 UI.md + 静态页,给我反馈我再迭代 |
|
||||||
| 02 | P0-A | 房源管理(新增) | US-PROPERTY-001 | `UI_DESIGN/房源管理/新增房源_UI.md` | `UI_DESIGN/新增房源_UI.html` | 待设计 | 完成任务01后开始 |
|
| 02 | P0-A | 房源管理(新增) | US-PROPERTY-001 | `UI_DESIGN/房源管理/新增房源_UI.md` | `UI_DESIGN/新增房源_UI.html` | 待设计 | 完成任务01后开始 |
|
||||||
| 03 | P0-A | 房源管理(详情) | US-PROPERTY-003~008 | `UI_DESIGN/房源管理/房源详情_UI.md` | `UI_DESIGN/房源详情_UI.html` | 待设计 | 完成任务02后开始 |
|
| 03 | P0-A | 房源管理(详情) | US-PROPERTY-003~008 | `UI_DESIGN/房源管理/房源详情_UI.md` | `UI_DESIGN/房源详情_UI.html` | 待设计 | 完成任务02后开始 |
|
||||||
| 04 | P0-B | 楼盘管理(列表) | US-COMPLEX-002 | `UI_DESIGN/楼盘管理/楼盘列表_UI.md` | `UI_DESIGN/楼盘列表_UI.html` | 待设计 | 完成任务03后开始 |
|
| 04 | P0-B | 楼盘管理(列表) | US-COMPLEX-002 | `UI_DESIGN/楼盘管理/楼盘列表_UI.md` | `UI_DESIGN/楼盘列表_UI.html` | 待设计 | 完成任务03后开始 |
|
||||||
|
|||||||
@@ -1111,5 +1111,32 @@
|
|||||||
|
|
||||||
</div><!-- /主容器 -->
|
</div><!-- /主容器 -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 静态原型串联:从登录页跳转后显示一次登录成功提示
|
||||||
|
(function () {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('from') === 'login' && params.get('login') === 'success') {
|
||||||
|
const displayName = params.get('name') || '经纪人';
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-6 right-6 z-[70] w-80 bg-white rounded-lg shadow-lg border border-neutral-200 flex items-start gap-3 p-3';
|
||||||
|
toast.innerHTML = `
|
||||||
|
<svg class="w-5 h-5 text-success-600 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-neutral-800">登录成功</p>
|
||||||
|
<p class="text-xs text-neutral-500 mt-0.5">欢迎回来,${displayName}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.transition = 'opacity .25s ease';
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => toast.remove(), 260);
|
||||||
|
}, 2200);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
440
Project/fonrey/UI_DESIGN/登录_UI.html
Normal file
440
Project/fonrey/UI_DESIGN/登录_UI.html
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=1280">
|
||||||
|
<title>Fonrey 登录管理 · 静态原型</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#F0FDFA',
|
||||||
|
100: '#CCFBF1',
|
||||||
|
200: '#99F6E4',
|
||||||
|
500: '#14B8A6',
|
||||||
|
600: '#0F766E',
|
||||||
|
700: '#115E59',
|
||||||
|
800: '#134E4A'
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
50: '#F8FAFC',
|
||||||
|
100: '#F1F5F9',
|
||||||
|
200: '#E2E8F0',
|
||||||
|
300: '#CBD5E1',
|
||||||
|
400: '#94A3B8',
|
||||||
|
500: '#64748B',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1E293B',
|
||||||
|
900: '#0F172A'
|
||||||
|
},
|
||||||
|
success: { 50: '#F0FDF4', 600: '#16A34A' },
|
||||||
|
warning: { 50: '#FFFBEB', 600: '#D97706' },
|
||||||
|
danger: { 50: '#FEF2F2', 600: '#DC2626' },
|
||||||
|
info: { 50: '#EFF6FF', 600: '#2563EB' }
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
xs: '0 1px 2px rgba(15,23,42,0.04)'
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||||
|
.captcha-track { background: linear-gradient(90deg, #E2E8F0 0%, #F1F5F9 100%); }
|
||||||
|
.captcha-success { background: linear-gradient(90deg, #dcfce7 0%, #bbf7d0 100%); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="loginPrototype()">
|
||||||
|
<div class="fixed inset-0 -z-10">
|
||||||
|
<div class="absolute inset-y-0 left-0 w-[56%] bg-gradient-to-br from-primary-800 via-primary-700 to-primary-600"></div>
|
||||||
|
<div class="absolute -top-16 -left-24 w-96 h-96 rounded-full bg-white/10 blur-2xl"></div>
|
||||||
|
<div class="absolute bottom-0 left-1/3 w-80 h-80 rounded-full bg-primary-200/15 blur-2xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-[1440px] min-h-screen grid grid-cols-12">
|
||||||
|
<section class="col-span-7 px-12 py-12 text-white flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white/10 border border-white/20">
|
||||||
|
<div class="w-7 h-7 rounded-md bg-primary-500/90 flex items-center justify-center text-white font-semibold">F</div>
|
||||||
|
<span class="text-base font-semibold">Fonrey 房睿</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-8 text-4xl font-semibold leading-tight">面向经纪业务的<br>高密度工作台</h1>
|
||||||
|
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl">
|
||||||
|
多租户隔离、角色权限控制、房客源高频操作一致体验。
|
||||||
|
本页面原型覆盖 Tenant 识别、账号密码登录、验证码验证、锁定与会话过期等 P0 场景。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 max-w-2xl">
|
||||||
|
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
|
||||||
|
<div class="text-xs text-primary-100">多租户识别</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold tabular-nums">12位 Tenant ID</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
|
||||||
|
<div class="text-xs text-primary-100">安全策略</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold tabular-nums">5次失败锁定30分钟</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="col-span-5 px-10 py-10 flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md rounded-xl bg-white border border-neutral-200 shadow-lg p-6">
|
||||||
|
|
||||||
|
<template x-if="view === 'tenant'">
|
||||||
|
<div x-cloak>
|
||||||
|
<h2 class="text-xl font-semibold text-neutral-800">欢迎使用 Fonrey 房睿</h2>
|
||||||
|
<p class="mt-2 text-sm text-neutral-500">请输入您公司的专属识别码,以进入对应租户登录页</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-1.5">
|
||||||
|
<label for="tenant-id" class="block text-sm font-medium text-neutral-700">公司识别码(Tenant ID)<span class="text-danger-600">*</span></label>
|
||||||
|
<input
|
||||||
|
id="tenant-id"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="12"
|
||||||
|
:disabled="tenantLoading"
|
||||||
|
x-model="tenantId"
|
||||||
|
@input="sanitizeTenantId"
|
||||||
|
placeholder="请输入12位数字识别码"
|
||||||
|
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20 disabled:bg-neutral-100 disabled:text-neutral-400"
|
||||||
|
aria-describedby="tenant-help tenant-error"
|
||||||
|
>
|
||||||
|
<p id="tenant-help" class="text-xs text-neutral-500">支持粘贴,系统将自动去除空格与非数字字符</p>
|
||||||
|
<div class="min-h-[22px]">
|
||||||
|
<p id="tenant-error" x-show="tenantError" x-text="tenantError" class="text-xs text-danger-600"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="submitTenant"
|
||||||
|
:disabled="tenantLoading"
|
||||||
|
class="mt-1 inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg x-show="tenantLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
|
||||||
|
<span x-text="tenantLoading ? '识别中…' : '确认'"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template x-if="tenantNetworkError">
|
||||||
|
<div class="mt-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-center justify-between">
|
||||||
|
<span>网络连接失败,请检查网络后重试</span>
|
||||||
|
<button @click="tenantNetworkError=false" class="text-primary-600 hover:underline">重试</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-neutral-500">不知道识别码?请联系您公司的系统管理员</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="view === 'login'">
|
||||||
|
<div x-cloak>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs text-neutral-500">正在登录</p>
|
||||||
|
<p class="text-sm font-semibold text-neutral-800 truncate" x-text="tenantName"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="openSwitchModal = true" class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">切换公司</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="sessionExpiredNotice">
|
||||||
|
<div class="mb-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-start justify-between gap-2">
|
||||||
|
<span>登录已过期,请重新登录</span>
|
||||||
|
<button @click="sessionExpiredNotice=false" class="text-neutral-500 hover:text-neutral-700" aria-label="关闭会话过期提示">✕</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold text-neutral-800">账号登录</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-500">请输入用户名和密码,并完成行为验证</p>
|
||||||
|
|
||||||
|
<form class="mt-5 space-y-4" @submit.prevent="submitLogin">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label for="username" class="block text-sm font-medium text-neutral-700">用户名<span class="text-danger-600">*</span></label>
|
||||||
|
<input id="username" type="text" x-model.trim="username" placeholder="请输入用户名"
|
||||||
|
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
|
||||||
|
:disabled="loginLoading || accountState==='locked' || accountState==='disabled'">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label for="password" class="block text-sm font-medium text-neutral-700">密码<span class="text-danger-600">*</span></label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="password" :type="passwordVisible ? 'text' : 'password'" x-model="password" placeholder="请输入密码"
|
||||||
|
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
|
||||||
|
:disabled="loginLoading || accountState==='locked' || accountState==='disabled'">
|
||||||
|
<button type="button" @click="passwordVisible=!passwordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏密码">
|
||||||
|
<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="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">行为验证<span class="text-danger-600">*</span></label>
|
||||||
|
<button type="button" @click="refreshCaptcha" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新验证码">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-neutral-200 p-3">
|
||||||
|
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
|
||||||
|
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
|
||||||
|
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${captchaTarget}%`"></div>
|
||||||
|
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${sliderValue}% - 18px)`"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 rounded-md p-2 border"
|
||||||
|
:class="captchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
|
||||||
|
<input type="range" min="0" max="100" step="1" x-model="sliderValue" @change="verifyCaptcha" @input="captchaState='idle'"
|
||||||
|
:disabled="captchaState==='pass' || loginLoading || accountState==='locked' || accountState==='disabled'"
|
||||||
|
class="w-full accent-primary-600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-1 text-xs"
|
||||||
|
:class="captchaState==='pass' ? 'text-success-600' : (captchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
|
||||||
|
x-text="captchaState==='pass' ? '验证通过' : (captchaState==='fail' ? '验证失败,请重试' : '拖动滑块完成拼图')"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="loginError">
|
||||||
|
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="loginError"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="!canSubmit || loginLoading || accountState==='locked' || accountState==='disabled'">
|
||||||
|
<svg x-show="loginLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
|
||||||
|
<span x-text="loginLoading ? '登录中…' : '登录'"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center justify-between text-xs">
|
||||||
|
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记用户名</a>
|
||||||
|
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记密码</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 border-t border-neutral-200 pt-4 space-y-2">
|
||||||
|
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">手机验证码登录(即将开放)</button>
|
||||||
|
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">微信扫码登录(即将开放)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-4 rounded-md border border-neutral-200 bg-neutral-50 p-2">
|
||||||
|
<summary class="cursor-pointer text-xs text-neutral-500">原型状态切换(仅评审演示)</summary>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<button @click="simulateInvalidCredential" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号密码错误</button>
|
||||||
|
<button @click="simulateLock" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号锁定</button>
|
||||||
|
<button @click="simulateDisabled" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号停用</button>
|
||||||
|
<button @click="sessionExpiredNotice=true" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟会话过期</button>
|
||||||
|
<button @click="resetLoginState" class="col-span-2 px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">重置状态</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div x-show="openSwitchModal" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="openSwitchModal=false">
|
||||||
|
<div class="absolute inset-0 bg-neutral-900/40" @click="openSwitchModal=false"></div>
|
||||||
|
<div class="absolute inset-0 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">
|
||||||
|
<div class="p-5 text-center space-y-3">
|
||||||
|
<div class="mx-auto w-12 h-12 rounded-full bg-warning-50 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9.303 3.376c.866 1.5-.217 3.374-1.948 3.374H4.646c-1.73 0-2.813-1.874-1.948-3.374l7.354-12.748c.866-1.5 3.03-1.5 3.896 0l7.355 12.748Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5h.008v.008H12v-.008Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold text-neutral-800">切换公司</h3>
|
||||||
|
<p class="text-sm text-neutral-500">切换公司将清除当前租户识别信息,并返回识别页。是否继续?</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50">
|
||||||
|
<button @click="openSwitchModal=false" class="px-4 py-1.5 text-sm border border-neutral-300 rounded-md bg-white hover:bg-neutral-50">取消</button>
|
||||||
|
<button @click="confirmSwitchCompany" class="px-4 py-1.5 text-sm rounded-md bg-danger-600 text-white hover:bg-danger-600/90">继续切换</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function loginPrototype() {
|
||||||
|
return {
|
||||||
|
view: 'tenant',
|
||||||
|
tenantId: '',
|
||||||
|
tenantName: '',
|
||||||
|
tenantLoading: false,
|
||||||
|
tenantError: '',
|
||||||
|
tenantNetworkError: false,
|
||||||
|
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
passwordVisible: false,
|
||||||
|
captchaTarget: 46,
|
||||||
|
sliderValue: 0,
|
||||||
|
captchaState: 'idle',
|
||||||
|
loginLoading: false,
|
||||||
|
loginError: '',
|
||||||
|
accountState: 'active',
|
||||||
|
failedCount: 0,
|
||||||
|
sessionExpiredNotice: false,
|
||||||
|
|
||||||
|
openSwitchModal: false,
|
||||||
|
|
||||||
|
sanitizeTenantId() {
|
||||||
|
this.tenantId = this.tenantId.replace(/\D/g, '').slice(0, 12)
|
||||||
|
this.tenantError = ''
|
||||||
|
this.tenantNetworkError = false
|
||||||
|
},
|
||||||
|
|
||||||
|
submitTenant() {
|
||||||
|
this.tenantError = ''
|
||||||
|
this.tenantNetworkError = false
|
||||||
|
|
||||||
|
if (this.tenantId.length !== 12) {
|
||||||
|
this.tenantError = '识别码须为 12 位数字'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tenantLoading = true
|
||||||
|
setTimeout(() => {
|
||||||
|
this.tenantLoading = false
|
||||||
|
|
||||||
|
if (this.tenantId === '999999999999') {
|
||||||
|
this.tenantNetworkError = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tenantId === '202500010001') {
|
||||||
|
this.tenantName = '沪居地产(演示租户)'
|
||||||
|
localStorage.setItem('tenant_id', this.tenantId)
|
||||||
|
localStorage.setItem('tenant_name', this.tenantName)
|
||||||
|
// 串联到 Story 2 独立登录页
|
||||||
|
this.view = 'login'
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `./登录_账号密码_UI.html?tenantId=${this.tenantId}&tenantName=${encodeURIComponent(this.tenantName)}`
|
||||||
|
}, 350)
|
||||||
|
this.resetLoginState()
|
||||||
|
} else {
|
||||||
|
this.tenantError = '识别码无效,请联系您的系统管理员获取正确的识别码'
|
||||||
|
}
|
||||||
|
}, 800)
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshCaptcha() {
|
||||||
|
this.captchaTarget = Math.floor(Math.random() * 60) + 20
|
||||||
|
this.sliderValue = 0
|
||||||
|
this.captchaState = 'idle'
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyCaptcha() {
|
||||||
|
const diff = Math.abs(this.sliderValue - this.captchaTarget)
|
||||||
|
if (diff <= 3) {
|
||||||
|
this.captchaState = 'pass'
|
||||||
|
this.loginError = ''
|
||||||
|
} else {
|
||||||
|
this.captchaState = 'fail'
|
||||||
|
setTimeout(() => this.refreshCaptcha(), 700)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get canSubmit() {
|
||||||
|
return this.username.trim() && this.password && this.captchaState === 'pass'
|
||||||
|
},
|
||||||
|
|
||||||
|
submitLogin() {
|
||||||
|
this.loginError = ''
|
||||||
|
|
||||||
|
if (this.accountState === 'locked') {
|
||||||
|
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.accountState === 'disabled') {
|
||||||
|
this.loginError = '账号已停用,请联系您的管理员'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.canSubmit) {
|
||||||
|
this.loginError = '请先完成用户名、密码和行为验证'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loginLoading = true
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loginLoading = false
|
||||||
|
|
||||||
|
const credentialPass = this.username === 'agent_001' && this.password === 'Fonrey@2025'
|
||||||
|
|
||||||
|
if (credentialPass) {
|
||||||
|
this.loginError = ''
|
||||||
|
this.failedCount = 0
|
||||||
|
this.sessionExpiredNotice = false
|
||||||
|
this.password = ''
|
||||||
|
this.refreshCaptcha()
|
||||||
|
|
||||||
|
// 静态原型串联:登录成功后跳转到主页(当前用房源列表页作为首页)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = './房源列表_UI.html?from=login&login=success'
|
||||||
|
}, 350)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.failedCount += 1
|
||||||
|
this.loginError = '用户名或密码错误,请重新输入'
|
||||||
|
this.password = ''
|
||||||
|
this.refreshCaptcha()
|
||||||
|
|
||||||
|
if (this.failedCount >= 5) {
|
||||||
|
this.accountState = 'locked'
|
||||||
|
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
|
||||||
|
}
|
||||||
|
}, 900)
|
||||||
|
},
|
||||||
|
|
||||||
|
simulateInvalidCredential() {
|
||||||
|
this.loginError = '用户名或密码错误,请重新输入'
|
||||||
|
this.password = ''
|
||||||
|
this.refreshCaptcha()
|
||||||
|
},
|
||||||
|
|
||||||
|
simulateLock() {
|
||||||
|
this.accountState = 'locked'
|
||||||
|
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
|
||||||
|
},
|
||||||
|
|
||||||
|
simulateDisabled() {
|
||||||
|
this.accountState = 'disabled'
|
||||||
|
this.loginError = '账号已停用,请联系您的管理员'
|
||||||
|
},
|
||||||
|
|
||||||
|
resetLoginState() {
|
||||||
|
this.username = ''
|
||||||
|
this.password = ''
|
||||||
|
this.passwordVisible = false
|
||||||
|
this.loginLoading = false
|
||||||
|
this.loginError = ''
|
||||||
|
this.failedCount = 0
|
||||||
|
this.accountState = 'active'
|
||||||
|
this.sessionExpiredNotice = false
|
||||||
|
this.refreshCaptcha()
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmSwitchCompany() {
|
||||||
|
this.openSwitchModal = false
|
||||||
|
this.view = 'tenant'
|
||||||
|
this.tenantName = ''
|
||||||
|
this.tenantId = ''
|
||||||
|
this.tenantError = ''
|
||||||
|
this.tenantNetworkError = false
|
||||||
|
this.resetLoginState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
316
Project/fonrey/UI_DESIGN/登录_账号密码_UI.html
Normal file
316
Project/fonrey/UI_DESIGN/登录_账号密码_UI.html
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=1280">
|
||||||
|
<title>Fonrey 登录管理 · 账号密码登录(Story 2)</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#F0FDFA', 100: '#CCFBF1', 200: '#99F6E4',
|
||||||
|
500: '#14B8A6', 600: '#0F766E', 700: '#115E59', 800: '#134E4A'
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 300: '#CBD5E1',
|
||||||
|
400: '#94A3B8', 500: '#64748B', 600: '#475569', 700: '#334155',
|
||||||
|
800: '#1E293B', 900: '#0F172A'
|
||||||
|
},
|
||||||
|
success: { 50: '#F0FDF4', 600: '#16A34A' },
|
||||||
|
warning: { 50: '#FFFBEB', 600: '#D97706' },
|
||||||
|
danger: { 50: '#FEF2F2', 600: '#DC2626' },
|
||||||
|
info: { 50: '#EFF6FF', 600: '#2563EB' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
.captcha-track { background: linear-gradient(90deg, #E2E8F0 0%, #F1F5F9 100%); }
|
||||||
|
.captcha-success { background: linear-gradient(90deg, #dcfce7 0%, #bbf7d0 100%); }
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-4px); }
|
||||||
|
75% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
.captcha-shake { animation: shake .22s linear 2; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="story2LoginPage()">
|
||||||
|
<div class="fixed inset-0 -z-10">
|
||||||
|
<div class="absolute inset-y-0 left-0 w-[56%] bg-gradient-to-br from-primary-800 via-primary-700 to-primary-600"></div>
|
||||||
|
<div class="absolute -top-16 -left-24 w-96 h-96 rounded-full bg-white/10 blur-2xl"></div>
|
||||||
|
<div class="absolute bottom-0 left-1/3 w-80 h-80 rounded-full bg-primary-200/15 blur-2xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-[1440px] min-h-screen grid grid-cols-12">
|
||||||
|
<section class="col-span-7 px-12 py-12 text-white flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white/10 border border-white/20">
|
||||||
|
<div class="w-7 h-7 rounded-md bg-primary-500/90 flex items-center justify-center text-white font-semibold">F</div>
|
||||||
|
<span class="text-base font-semibold">Fonrey 房睿</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-8 text-4xl font-semibold leading-tight">经纪人账号登录</h1>
|
||||||
|
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl">
|
||||||
|
已完成 Tenant 识别,请使用经纪人账号和密码登录。
|
||||||
|
本页对应 PRD《用户登录管理模块》User Story 2。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 max-w-2xl">
|
||||||
|
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
|
||||||
|
<div class="text-xs text-primary-100">当前租户</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold truncate" x-text="tenantName || '未识别租户'"></div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
|
||||||
|
<div class="text-xs text-primary-100">登录策略</div>
|
||||||
|
<div class="mt-1 text-xl font-semibold">账号密码 + 滑块验证</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="col-span-5 px-10 py-10 flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-md rounded-xl bg-white border border-neutral-200 shadow-lg p-6">
|
||||||
|
|
||||||
|
<template x-if="tenantMissing">
|
||||||
|
<div class="rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 mb-4 flex items-center justify-between gap-2">
|
||||||
|
<span>未检测到有效 Tenant 信息,请先完成识别</span>
|
||||||
|
<a href="./登录_UI.html" class="text-primary-600 hover:underline">返回识别页</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs text-neutral-500">正在登录</p>
|
||||||
|
<p class="text-sm font-semibold text-neutral-800 truncate" x-text="tenantName || '租户未识别'"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="openSwitchModal = true" class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">切换公司</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="sessionExpiredNotice">
|
||||||
|
<div class="mb-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-start justify-between gap-2">
|
||||||
|
<span>登录已过期,请重新登录</span>
|
||||||
|
<button @click="sessionExpiredNotice=false" class="text-neutral-500 hover:text-neutral-700" aria-label="关闭会话过期提示">✕</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold text-neutral-800">账号登录</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-500">请输入用户名和密码,并完成行为验证</p>
|
||||||
|
|
||||||
|
<form class="mt-5 space-y-4" @submit.prevent="submitLogin">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label for="username" class="block text-sm font-medium text-neutral-700">用户名<span class="text-danger-600">*</span></label>
|
||||||
|
<input id="username" type="text" x-model.trim="username" placeholder="请输入用户名" maxlength="50"
|
||||||
|
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
|
||||||
|
:disabled="loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
|
||||||
|
<p class="text-xs text-neutral-500">支持英文字母、数字、下划线,最大 50 字符</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label for="password" class="block text-sm font-medium text-neutral-700">密码<span class="text-danger-600">*</span></label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="password" :type="passwordVisible ? 'text' : 'password'" x-model="password" placeholder="请输入密码"
|
||||||
|
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
|
||||||
|
:disabled="loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
|
||||||
|
<button type="button" @click="passwordVisible=!passwordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏密码">
|
||||||
|
<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="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">行为验证<span class="text-danger-600">*</span></label>
|
||||||
|
<button type="button" @click="refreshCaptcha" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新验证码">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="captchaState==='fail' ? 'captcha-shake' : ''" class="rounded-lg border border-neutral-200 p-3">
|
||||||
|
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
|
||||||
|
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
|
||||||
|
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${captchaTarget}%`"></div>
|
||||||
|
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${sliderValue}% - 18px)`"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 rounded-md p-2 border" :class="captchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
|
||||||
|
<input type="range" min="0" max="100" step="1" x-model="sliderValue" @change="verifyCaptcha" @input="captchaState='idle'"
|
||||||
|
:disabled="captchaState==='pass' || loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing"
|
||||||
|
class="w-full accent-primary-600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-1 text-xs"
|
||||||
|
:class="captchaState==='pass' ? 'text-success-600' : (captchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
|
||||||
|
x-text="captchaState==='pass' ? '验证通过' : (captchaState==='fail' ? '验证码有误,请重新输入' : '拖动滑块完成拼图')"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="loginError">
|
||||||
|
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="loginError"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="!canSubmit || loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
|
||||||
|
<svg x-show="loginLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
|
||||||
|
<span x-text="loginLoading ? '登录中…' : '登录'"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center justify-between text-xs">
|
||||||
|
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记用户名</a>
|
||||||
|
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记密码</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 border-t border-neutral-200 pt-4 space-y-2">
|
||||||
|
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">手机验证码登录(即将开放)</button>
|
||||||
|
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">微信扫码登录(即将开放)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div x-show="openSwitchModal" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="openSwitchModal=false">
|
||||||
|
<div class="absolute inset-0 bg-neutral-900/40" @click="openSwitchModal=false"></div>
|
||||||
|
<div class="absolute inset-0 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">
|
||||||
|
<div class="p-5 text-center space-y-3">
|
||||||
|
<div class="mx-auto w-12 h-12 rounded-full bg-warning-50 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9.303 3.376c.866 1.5-.217 3.374-1.948 3.374H4.646c-1.73 0-2.813-1.874-1.948-3.374l7.354-12.748c.866-1.5 3.03-1.5 3.896 0l7.355 12.748Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5h.008v.008H12v-.008Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-semibold text-neutral-800">切换公司</h3>
|
||||||
|
<p class="text-sm text-neutral-500">切换公司将清除当前租户识别信息,并返回识别页。是否继续?</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50">
|
||||||
|
<button @click="openSwitchModal=false" class="px-4 py-1.5 text-sm border border-neutral-300 rounded-md bg-white hover:bg-neutral-50">取消</button>
|
||||||
|
<button @click="confirmSwitchCompany" class="px-4 py-1.5 text-sm rounded-md bg-danger-600 text-white hover:bg-danger-600/90">继续切换</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function story2LoginPage() {
|
||||||
|
return {
|
||||||
|
tenantId: '',
|
||||||
|
tenantName: '',
|
||||||
|
tenantMissing: false,
|
||||||
|
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
passwordVisible: false,
|
||||||
|
captchaTarget: 46,
|
||||||
|
sliderValue: 0,
|
||||||
|
captchaState: 'idle',
|
||||||
|
loginLoading: false,
|
||||||
|
loginError: '',
|
||||||
|
accountState: 'active',
|
||||||
|
failedCount: 0,
|
||||||
|
sessionExpiredNotice: false,
|
||||||
|
openSwitchModal: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const tenantId = params.get('tenantId') || localStorage.getItem('tenant_id') || ''
|
||||||
|
const tenantName = params.get('tenantName') || localStorage.getItem('tenant_name') || ''
|
||||||
|
|
||||||
|
this.tenantId = tenantId
|
||||||
|
this.tenantName = tenantName
|
||||||
|
this.tenantMissing = !tenantId || !tenantName
|
||||||
|
|
||||||
|
this.sessionExpiredNotice = params.get('reason') === 'session_expired'
|
||||||
|
this.refreshCaptcha()
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshCaptcha() {
|
||||||
|
this.captchaTarget = Math.floor(Math.random() * 60) + 20
|
||||||
|
this.sliderValue = 0
|
||||||
|
this.captchaState = 'idle'
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyCaptcha() {
|
||||||
|
const diff = Math.abs(this.sliderValue - this.captchaTarget)
|
||||||
|
if (diff <= 3) {
|
||||||
|
this.captchaState = 'pass'
|
||||||
|
this.loginError = ''
|
||||||
|
} else {
|
||||||
|
this.captchaState = 'fail'
|
||||||
|
setTimeout(() => this.refreshCaptcha(), 700)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get canSubmit() {
|
||||||
|
return this.username.trim() && this.password && this.captchaState === 'pass'
|
||||||
|
},
|
||||||
|
|
||||||
|
submitLogin() {
|
||||||
|
this.loginError = ''
|
||||||
|
|
||||||
|
if (this.accountState === 'locked') {
|
||||||
|
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.accountState === 'disabled') {
|
||||||
|
this.loginError = '账号已停用,请联系您的管理员'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.username.trim()) {
|
||||||
|
this.loginError = '请输入用户名'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.password) {
|
||||||
|
this.loginError = '请输入密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.captchaState !== 'pass') {
|
||||||
|
this.loginError = '请输入验证码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loginLoading = true
|
||||||
|
setTimeout(() => {
|
||||||
|
this.loginLoading = false
|
||||||
|
|
||||||
|
const credentialPass = this.username === 'agent_001' && this.password === 'Fonrey@2025'
|
||||||
|
|
||||||
|
if (credentialPass) {
|
||||||
|
this.loginError = ''
|
||||||
|
this.failedCount = 0
|
||||||
|
this.password = ''
|
||||||
|
this.refreshCaptcha()
|
||||||
|
|
||||||
|
const displayName = '王顺'
|
||||||
|
window.location.href = `./房源列表_UI.html?from=login&login=success&name=${encodeURIComponent(displayName)}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.failedCount += 1
|
||||||
|
this.loginError = '用户名或密码错误,请重新输入'
|
||||||
|
this.password = ''
|
||||||
|
this.refreshCaptcha()
|
||||||
|
|
||||||
|
if (this.failedCount >= 5) {
|
||||||
|
this.accountState = 'locked'
|
||||||
|
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
|
||||||
|
}
|
||||||
|
}, 900)
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmSwitchCompany() {
|
||||||
|
this.openSwitchModal = false
|
||||||
|
localStorage.removeItem('tenant_id')
|
||||||
|
localStorage.removeItem('tenant_name')
|
||||||
|
window.location.href = './登录_UI.html?from=switch-company'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
239
Project/fonrey/UI_DESIGN/登录管理/登录_UI.md
Normal file
239
Project/fonrey/UI_DESIGN/登录管理/登录_UI.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# 登录管理 UI 设计文档
|
||||||
|
|
||||||
|
> **版本**:v1.0 · **日期**:2026-04-27
|
||||||
|
> **依赖规范**:`UI_SYSTEM/UI_SYSTEM.md v1.2`、`UI_SYSTEM/组件规范设计.md v1.0`
|
||||||
|
> **PRD 来源**:`PRD/登录管理/用户登录管理模块PRD.md`(Story 1、Story 2 + 会话相关要求)
|
||||||
|
> **数据模型来源**:`DATA_MODEL/DATA_MODEL_LOGIN.md`
|
||||||
|
> **技术约束来源**:`TECH_STACK/登录管理技术方案.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 模块概述
|
||||||
|
|
||||||
|
### 1.1 设计目标(P0)
|
||||||
|
|
||||||
|
本设计文档覆盖 `TASK.md` 的登录管理 P0 范围:
|
||||||
|
|
||||||
|
- **US-ACCOUNT-001**:账号密码登录(含验证码、错误提示、锁定态)
|
||||||
|
- **US-ACCOUNT-002**:多租户识别(Tenant ID 识别与切换公司)
|
||||||
|
- **US-ACCOUNT-003**:Token/会话超时相关前端状态(过期提示、重新登录入口)
|
||||||
|
|
||||||
|
### 1.2 页面职责
|
||||||
|
|
||||||
|
| 页面 | URL 建议 | 优先级 | 对应 US |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Tenant 识别页 | `/account/tenant/verify/` | P0 🔴 | US-ACCOUNT-002 |
|
||||||
|
| 登录页 | `/account/login/` | P0 🔴 | US-ACCOUNT-001 / US-ACCOUNT-003 |
|
||||||
|
| 切换公司确认弹窗 | 登录页内 Modal | P0 🔴 | US-ACCOUNT-002 |
|
||||||
|
| 会话过期提示态 | 登录页内 Alert/Toast | P0 🔴 | US-ACCOUNT-003 |
|
||||||
|
|
||||||
|
> 注:`忘记用户名/忘记密码` 链接在登录页展示;其完整流程页面在后续登录模块增强迭代中展开。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 视觉与组件基线(对齐 UI System)
|
||||||
|
|
||||||
|
### 2.1 色彩与层级
|
||||||
|
|
||||||
|
- 页面背景:`bg-neutral-50`
|
||||||
|
- 登录主按钮:`bg-primary-600 hover:bg-primary-700 active:bg-primary-800`
|
||||||
|
- 错误提示:`text-danger-600`
|
||||||
|
- 成功提示:`text-success-600`
|
||||||
|
- 卡片容器:`bg-white border border-neutral-200 rounded-xl shadow-lg`
|
||||||
|
|
||||||
|
### 2.2 组件复用清单
|
||||||
|
|
||||||
|
| 场景 | 组件规范来源 | 使用说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主/次按钮 | `UI_SYSTEM.md §3.1` | 登录=Primary;刷新验证码/切换公司=Secondary/Link |
|
||||||
|
| 输入框/密码框 | `UI_SYSTEM.md §3.2` | 统一 Label 在上、错误提示在下、密码可见切换 |
|
||||||
|
| 确认弹窗 | `UI_SYSTEM.md §3.6` | 切换公司二次确认使用 Confirm Modal |
|
||||||
|
| Toast 提示 | `UI_SYSTEM.md §3.8` | 网络异常、登录成功/失败统一 Toast 反馈 |
|
||||||
|
| 登录页布局模板 | `UI_SYSTEM.md §5.7` | 独立布局,无 Sidebar,品牌区 + 表单区 |
|
||||||
|
|
||||||
|
### 2.3 主题策略说明
|
||||||
|
|
||||||
|
依据 `UI_SYSTEM.md §9.1`:**v1 仅 Light 主题**。
|
||||||
|
本页面不提供用户可见主题切换按钮,但保留 `data-theme="light"` 扩展点,为后续主题系统接入预留。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 页面设计规范
|
||||||
|
|
||||||
|
## 3.1 Tenant 识别页(P0 🔴)
|
||||||
|
|
||||||
|
### 3.1.1 页面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ 左侧品牌区(Logo + Slogan + 租户价值说明) │
|
||||||
|
│ 右侧识别卡片 │
|
||||||
|
│ 标题:欢迎使用 Fonrey 房睿 │
|
||||||
|
│ 描述:请输入您公司的专属识别码 │
|
||||||
|
│ [公司识别码输入框] │
|
||||||
|
│ [确认按钮] │
|
||||||
|
│ 错误提示区(固定高度,防布局抖动) │
|
||||||
|
│ 帮助文案:不知道识别码?请联系管理员 │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1.2 字段与校验
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 规则 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Tenant ID | 文本输入(仅数字) | 是 | 固定 12 位;自动 trim;非数字过滤 |
|
||||||
|
|
||||||
|
### 3.1.3 交互状态
|
||||||
|
|
||||||
|
| 状态 | 触发 | 视觉反馈 |
|
||||||
|
|---|---|---|
|
||||||
|
| Idle | 首次进入 | 按钮可点击(输入满足 12 位) |
|
||||||
|
| Loading | 点击“确认”后 | 按钮 Loading + 禁用;输入框禁用 |
|
||||||
|
| Success | 验证通过 | 展示租户名,自动跳转登录页 |
|
||||||
|
| Invalid | Tenant 无效 | 输入框下方红色文案:识别码无效… |
|
||||||
|
| Network Error | 请求失败/超时 | 错误提示 + “重试”按钮 |
|
||||||
|
|
||||||
|
### 3.1.4 API 对齐
|
||||||
|
|
||||||
|
- `POST /api/auth/tenant/verify/`(PRD)
|
||||||
|
- 请求体:`{ tenant_id }`
|
||||||
|
- 成功返回:`tenant_name / tenant_logo_url / login_url`
|
||||||
|
- 失败返回:`TENANT_NOT_FOUND`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.2 登录页(P0 🔴)
|
||||||
|
|
||||||
|
### 3.2.1 布局结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ 左侧品牌区(租户 Logo / 公司名 / 产品卖点) │
|
||||||
|
│ 右侧登录卡片(max-w-md) │
|
||||||
|
│ [用户名] │
|
||||||
|
│ [密码 + 显示/隐藏] │
|
||||||
|
│ [滑块拼图验证区域 + 刷新] │
|
||||||
|
│ [登录按钮] │
|
||||||
|
│ [忘记用户名] [忘记密码] │
|
||||||
|
│ [手机验证码登录(即将开放,禁用)] │
|
||||||
|
│ [微信扫码登录(即将开放,禁用)] │
|
||||||
|
│ [切换公司](Link) │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2.2 字段规范
|
||||||
|
|
||||||
|
| 字段 | 组件 | 必填 | 校验 | 数据模型映射 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 用户名 | Input | 是 | 1~50 字符;允许字母/数字/下划线(兼容管理员) | `user_accounts.username` |
|
||||||
|
| 密码 | Password Input | 是 | 非空;提交后后端校验 | `user_accounts.password`(哈希) |
|
||||||
|
| 验证码通过票据 | 滑块拼图区域 | 是 | 位置偏差 ±5px + 轨迹特征校验 | Redis `captcha_pass:*` |
|
||||||
|
|
||||||
|
### 3.2.3 主要交互规则
|
||||||
|
|
||||||
|
1. 用户名/密码/验证码三者满足后,“登录”按钮可点击。
|
||||||
|
2. 点击登录后按钮进入 `loading`,避免重复提交。
|
||||||
|
3. 登录失败(账号或密码错误):
|
||||||
|
- 统一提示 `用户名或密码错误,请重新输入`
|
||||||
|
- 自动刷新验证码
|
||||||
|
- 清空密码,保留用户名
|
||||||
|
4. 验证码失败:提示 `验证码有误,请重新验证`,不计入密码错误次数。
|
||||||
|
5. 连续密码错误 ≥ 5 次:
|
||||||
|
- 展示 `账号已被临时锁定,请30分钟后重试`
|
||||||
|
- 登录按钮禁用
|
||||||
|
6. 账号停用:提示 `账号已停用,请联系管理员`。
|
||||||
|
7. Session 过期跳转后,顶部显示提示条:`登录已过期,请重新登录`。
|
||||||
|
8. 登录成功后,前端跳转到首页路由(本静态原型当前映射为 `./房源列表_UI.html?from=login&login=success`,后续可替换为正式 `/home/`)。
|
||||||
|
|
||||||
|
### 3.2.4 登录页状态矩阵
|
||||||
|
|
||||||
|
| 状态 | 触发条件 | UI 表现 |
|
||||||
|
|---|---|---|
|
||||||
|
| Default | 初始打开 | 空表单 + 新验证码 |
|
||||||
|
| Captcha Passed | 验证通过 | 验证区绿色对勾 + 文案 |
|
||||||
|
| Submitting | 点击登录后 | 按钮 spinner,表单禁用 |
|
||||||
|
| Invalid Credential | 401 | 错误 Alert + 密码清空 |
|
||||||
|
| Locked | 423/锁定态 | 锁定警示条 + 按钮 disabled |
|
||||||
|
| Disabled | 账号停用 | 错误提示 + 禁止提交 |
|
||||||
|
| Session Expired | 过期重定向 | 顶部 warning 条 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.3 切换公司确认弹窗(P0 🔴)
|
||||||
|
|
||||||
|
### 3.3.1 触发入口
|
||||||
|
|
||||||
|
- 登录卡片底部 Link:`切换公司`
|
||||||
|
|
||||||
|
### 3.3.2 弹窗内容
|
||||||
|
|
||||||
|
- 标题:`切换公司`
|
||||||
|
- 文案:`切换公司将清除当前租户识别信息,并返回识别页。是否继续?`
|
||||||
|
- 按钮:`取消`(Secondary)/ `继续切换`(Danger)
|
||||||
|
|
||||||
|
### 3.3.3 行为
|
||||||
|
|
||||||
|
- 确认后:清除本地 tenant 缓存并跳转 `/account/tenant/verify/`
|
||||||
|
- 取消后:关闭弹窗,不改变当前状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.4 会话过期提示(P0 🔴)
|
||||||
|
|
||||||
|
- 场景:用户访问业务页时 Session 失效,被重定向回登录页
|
||||||
|
- 位置:登录卡片顶部 Alert(warning)
|
||||||
|
- 文案:`登录已过期,请重新登录`
|
||||||
|
- 可关闭:是(仅隐藏提示,不恢复会话)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 与数据模型/技术方案映射
|
||||||
|
|
||||||
|
## 4.1 关键字段映射
|
||||||
|
|
||||||
|
| UI 关注点 | 数据模型字段/实体 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 账号状态 | `user_accounts.status` | `active/disabled/locked` 驱动登录态文案 |
|
||||||
|
| 锁定截止时间 | `user_accounts.locked_until` | 锁定倒计时文案来源 |
|
||||||
|
| 初始密码标记 | `user_accounts.is_initial_password` | 登录成功后是否强制跳转改密页 |
|
||||||
|
| 登录失败计数 | Redis `login_fail:{tenant}:{username}` | 达阈值触发锁定 |
|
||||||
|
| 登录审计 | `login_attempts` | 失败原因不在前端细分展示 |
|
||||||
|
|
||||||
|
## 4.2 API 映射(前端使用)
|
||||||
|
|
||||||
|
| 目标 | 接口 |
|
||||||
|
|---|---|
|
||||||
|
| 租户识别 | `/api/auth/tenant/verify/` |
|
||||||
|
| 获取验证码 | `/api/account/captcha/generate/` |
|
||||||
|
| 校验验证码 | `/api/account/captcha/verify/` |
|
||||||
|
| 登录提交 | `/api/account/login/` |
|
||||||
|
| 登出 | `/api/account/logout/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 可访问性与易用性
|
||||||
|
|
||||||
|
1. 所有输入框均有可见 Label,不使用仅 placeholder 方案。
|
||||||
|
2. 错误信息与字段通过 `aria-describedby` 关联。
|
||||||
|
3. 图标按钮(显示密码、刷新验证码)必须有 `aria-label`。
|
||||||
|
4. `Tab` 顺序:Tenant ID/用户名 → 密码 → 验证区 → 登录按钮 → 辅助链接。
|
||||||
|
5. Enter 键:当表单合法时触发提交。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 交付物与实现顺序
|
||||||
|
|
||||||
|
1. 本文档:`UI_DESIGN/登录管理/登录_UI.md`(当前)
|
||||||
|
2. 静态原型:`UI_DESIGN/登录_UI.html`(基于本文档)
|
||||||
|
3. 评审后迭代:先改 HTML,再回写本 UI 文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收检查清单(UI 维度)
|
||||||
|
|
||||||
|
- [ ] Tenant ID 12 位数字校验与错误提示完整
|
||||||
|
- [ ] 登录页三要素(用户名/密码/验证码)联动提交规则完整
|
||||||
|
- [ ] 锁定态、停用态、会话过期态均有明确视觉反馈
|
||||||
|
- [ ] 切换公司有二次确认弹窗
|
||||||
|
- [ ] 所有颜色/按钮/输入框样式遵循 UI_SYSTEM Token 与组件规范
|
||||||
|
- [ ] 静态页可用于你进行第一轮视觉与交互评审
|
||||||
@@ -1,102 +1,102 @@
|
|||||||
---
|
---
|
||||||
title: GitHub 上 5000 人收藏的 Vibe Coding 神级指南。
|
title: GitHub 上 5000 人收藏的 Vibe Coding 神级指南。
|
||||||
source: https://mp.weixin.qq.com/s/QMPMSGW6XXk8L-yx4ujQcw
|
source: https://mp.weixin.qq.com/s/QMPMSGW6XXk8L-yx4ujQcw
|
||||||
author: shenwei
|
author: shenwei
|
||||||
published:
|
published:
|
||||||
created: 2025-12-30
|
created: 2025-12-30
|
||||||
description:
|
description:
|
||||||
tags: [ai, github, vibe-coding]
|
tags: [ai, github, vibe-coding]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
#vibe-coding #ai #github
|
#vibe-coding #ai #github
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
原创 逛逛 [逛逛GitHub](https://mp.weixin.qq.com/s/) *2025年12月27日 15:03*
|
原创 逛逛 [逛逛GitHub](https://mp.weixin.qq.com/s/) *2025年12月27日 15:03*
|
||||||
|
|
||||||
Vibe Coding 说白了就是开发个应用不再像程序员一样,苦哈哈地写每一行代码,而是化身为导演。
|
Vibe Coding 说白了就是开发个应用不再像程序员一样,苦哈哈地写每一行代码,而是化身为导演。
|
||||||
|
|
||||||
只需要 保持一种感觉 ,这种感觉可能是对产品逻辑、用户流程、审美和交互的把握,剩下的体力活全交给 Cursor、Windsurf、Trae 等 AI 编程工具。
|
只需要 保持一种感觉 ,这种感觉可能是对产品逻辑、用户流程、审美和交互的把握,剩下的体力活全交给 Cursor、Windsurf、Trae 等 AI 编程工具。
|
||||||
|
|
||||||
用 Karpathy 的话说: 我几乎不写代码了,我只负责调整氛围(Vibe),代码会自动长出来。
|
用 Karpathy 的话说: 我几乎不写代码了,我只负责调整氛围(Vibe),代码会自动长出来。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
前段时间发了一篇文章,盘点了 GitHub 上比较有用的 Vibe Coding 相关开源项目。
|
前段时间发了一篇文章,盘点了 GitHub 上比较有用的 Vibe Coding 相关开源项目。
|
||||||
|
|
||||||
然后在一个 AI 编程的群里,有一个读者分享了另外一个开源项目: vibe-coding-cn
|
然后在一个 AI 编程的群里,有一个读者分享了另外一个开源项目: vibe-coding-cn
|
||||||
|
|
||||||
仔细研究了一下,还挺不错的,分享给大家。
|
仔细研究了一下,还挺不错的,分享给大家。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
01
|
01
|
||||||
|
|
||||||
**项目简介**
|
**项目简介**
|
||||||
|
|
||||||
这个叫 vibe-coding-cn 的开源项目 让国内开发者能光速跟上这波浪潮。
|
这个叫 vibe-coding-cn 的开源项目 让国内开发者能光速跟上这波浪潮。
|
||||||
|
|
||||||
是 Vibe Coding 氛围感编程的 中文指南 ,汇集了目前全球最顶尖的 AI 编程资源。
|
是 Vibe Coding 氛围感编程的 中文指南 ,汇集了目前全球最顶尖的 AI 编程资源。
|
||||||
|
|
||||||
下面是这个开源项目的核心目录:
|
下面是这个开源项目的核心目录:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
这个开源项目对 Vibe Coding 进行了定义,还挺有意思的。
|
这个开源项目对 Vibe Coding 进行了定义,还挺有意思的。
|
||||||
|
|
||||||
Vibe Coding \= **规划驱动 + 上下文固定 + AI 结对执行** ,让「从想法到可维护代码」变成一条可审计的流水线,而不是一团无法迭代的巨石文件。
|
Vibe Coding \= **规划驱动 + 上下文固定 + AI 结对执行** ,让「从想法到可维护代码」变成一条可审计的流水线,而不是一团无法迭代的巨石文件。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
这个中文的 Vibe Coding 中文指南,包括如下几个新的点:
|
这个中文的 Vibe Coding 中文指南,包括如下几个新的点:
|
||||||
|
|
||||||
方法论: 这一部分感觉还是比较玄乎的,其实就是几种准则,看一看就好。
|
方法论: 这一部分感觉还是比较玄乎的,其实就是几种准则,看一看就好。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
AI 编程资源
|
AI 编程资源
|
||||||
|
|
||||||
还推荐了 AI 模型、IDE 等环境。如果你懒得筛选,直接 Cursor + claude-opus-4.5-xhigh,准没错。
|
还推荐了 AI 模型、IDE 等环境。如果你懒得筛选,直接 Cursor + claude-opus-4.5-xhigh,准没错。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
除此之外,还有很多学习资源和文档, 大量提示词 Prompt 优化技巧。
|
除此之外,还有很多学习资源和文档, 大量提示词 Prompt 优化技巧。
|
||||||
|
|
||||||
包含数百个精选提示词,涵盖了需求澄清、系统架构设计、分步执行、自测等全链路脚本。支持 Excel 与 Markdown 互转。
|
包含数百个精选提示词,涵盖了需求澄清、系统架构设计、分步执行、自测等全链路脚本。支持 Excel 与 Markdown 互转。
|
||||||
|
|
||||||
教你如何用自然语言清晰地定义需求,如何让 AI 保持上下文一致,如何一分钟写出一个完整的 Web 应用, 也可以一同学习一下。
|
教你如何用自然语言清晰地定义需求,如何让 AI 保持上下文一致,如何一分钟写出一个完整的 Web 应用, 也可以一同学习一下。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
紧接着这个开源项目,提供一个一个完整流程。帮助你完成基础的设置、开发基础游戏、丰富细节,修复 Bug。
|
紧接着这个开源项目,提供一个一个完整流程。帮助你完成基础的设置、开发基础游戏、丰富细节,修复 Bug。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
给我的感觉,这个开源项目践行 规划就是一切 的理念。
|
给我的感觉,这个开源项目践行 规划就是一切 的理念。
|
||||||
|
|
||||||
让 AI 写代码前,必须有清晰的技术选型、实施规划和模块化设计,防止 AI 因为理解偏差导致项目逻辑混乱。
|
让 AI 写代码前,必须有清晰的技术选型、实施规划和模块化设计,防止 AI 因为理解偏差导致项目逻辑混乱。
|
||||||
|
|
||||||
总而言之,这个开源项目就是 专门为中文开发者设计的 **Vibe Coding 资源库与工作站。**
|
总而言之,这个开源项目就是 专门为中文开发者设计的 **Vibe Coding 资源库与工作站。**
|
||||||
|
|
||||||
**它不仅包含了相关的哲学理论,还提供了一套成体系的工具链、提示词库和开发经验总结,旨在帮助开发者更高效地利用 AI 进行软件开发。**
|
**它不仅包含了相关的哲学理论,还提供了一套成体系的工具链、提示词库和开发经验总结,旨在帮助开发者更高效地利用 AI 进行软件开发。**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
开源地址:https://github.com/tukuaiai/vibe-coding-cn
|
开源地址:https://github.com/tukuaiai/vibe-coding-cn
|
||||||
```
|
```
|
||||||
|
|
||||||
02
|
02
|
||||||
|
|
||||||
**点击下方卡片,关注逛逛 GitHub**
|
**点击下方卡片,关注逛逛 GitHub**
|
||||||
|
|
||||||
这个公众号历史发布过很多有趣的开源项目,如果你懒得翻文章一个个找,你直接关注微信公众号:逛逛 GitHub ,后台对话聊天就行了:
|
这个公众号历史发布过很多有趣的开源项目,如果你懒得翻文章一个个找,你直接关注微信公众号:逛逛 GitHub ,后台对话聊天就行了:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
继续滑动看下一个
|
继续滑动看下一个
|
||||||
|
|
||||||
逛逛GitHub
|
逛逛GitHub
|
||||||
|
|
||||||
向上滑动看下一个
|
向上滑动看下一个
|
||||||
|
|
||||||
逛逛GitHub
|
逛逛GitHub
|
||||||
@@ -1,58 +1,58 @@
|
|||||||
---
|
---
|
||||||
title: YouTube Content Pipeline
|
title: YouTube Content Pipeline
|
||||||
source:
|
source:
|
||||||
author: shenwei
|
author: shenwei
|
||||||
published:
|
published:
|
||||||
created:
|
created:
|
||||||
description:
|
description:
|
||||||
tags: []
|
tags: []
|
||||||
---
|
---
|
||||||
|
|
||||||
# YouTube Content Pipeline
|
# YouTube Content Pipeline
|
||||||
|
|
||||||
As a daily YouTube creator, finding fresh, timely video ideas across the web and X/Twitter is time-consuming. Tracking what you've already covered prevents duplicates and helps you stay ahead of trends.
|
As a daily YouTube creator, finding fresh, timely video ideas across the web and X/Twitter is time-consuming. Tracking what you've already covered prevents duplicates and helps you stay ahead of trends.
|
||||||
|
|
||||||
This workflow automates the entire content scouting and research pipeline:
|
This workflow automates the entire content scouting and research pipeline:
|
||||||
|
|
||||||
• Hourly cron job scans breaking AI news (web + X/Twitter) and pitches video ideas to Telegram
|
• Hourly cron job scans breaking AI news (web + X/Twitter) and pitches video ideas to Telegram
|
||||||
• Maintains a 90-day video catalog with view counts and topic analysis to avoid re-covering topics
|
• Maintains a 90-day video catalog with view counts and topic analysis to avoid re-covering topics
|
||||||
• Stores all pitches in a SQLite database with vector embeddings for semantic dedup (so you never get pitched the same idea twice)
|
• Stores all pitches in a SQLite database with vector embeddings for semantic dedup (so you never get pitched the same idea twice)
|
||||||
• When you share a link in Slack, OpenClaw researches the topic, searches X for related posts, queries your knowledge base, and creates an Asana card with a full outline
|
• When you share a link in Slack, OpenClaw researches the topic, searches X for related posts, queries your knowledge base, and creates an Asana card with a full outline
|
||||||
|
|
||||||
## Skills you Need
|
## Skills you Need
|
||||||
|
|
||||||
- `web_search` (built-in)
|
- `web_search` (built-in)
|
||||||
- [x-research-v2](https://clawhub.ai) or custom X/Twitter search skill
|
- [x-research-v2](https://clawhub.ai) or custom X/Twitter search skill
|
||||||
- [knowledge-base](https://clawhub.ai) skill for RAG
|
- [knowledge-base](https://clawhub.ai) skill for RAG
|
||||||
- Asana integration (or Todoist)
|
- Asana integration (or Todoist)
|
||||||
- `gog` CLI for YouTube Analytics
|
- `gog` CLI for YouTube Analytics
|
||||||
- Telegram topic for receiving pitches
|
- Telegram topic for receiving pitches
|
||||||
|
|
||||||
## How to Set it Up
|
## How to Set it Up
|
||||||
|
|
||||||
1. Set up a Telegram topic for video ideas and configure it in OpenClaw.
|
1. Set up a Telegram topic for video ideas and configure it in OpenClaw.
|
||||||
2. Install the knowledge-base skill and x-research skill.
|
2. Install the knowledge-base skill and x-research skill.
|
||||||
3. Create a SQLite database for pitch tracking:
|
3. Create a SQLite database for pitch tracking:
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE pitches (
|
CREATE TABLE pitches (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
timestamp TEXT,
|
timestamp TEXT,
|
||||||
topic TEXT,
|
topic TEXT,
|
||||||
embedding BLOB,
|
embedding BLOB,
|
||||||
sources TEXT
|
sources TEXT
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
4. Prompt OpenClaw:
|
4. Prompt OpenClaw:
|
||||||
```text
|
```text
|
||||||
Run an hourly cron job to:
|
Run an hourly cron job to:
|
||||||
1. Search web and X/Twitter for breaking AI news
|
1. Search web and X/Twitter for breaking AI news
|
||||||
2. Check against my 90-day YouTube catalog (fetch from YouTube Analytics via gog)
|
2. Check against my 90-day YouTube catalog (fetch from YouTube Analytics via gog)
|
||||||
3. Check semantic similarity against all past pitches in the database
|
3. Check semantic similarity against all past pitches in the database
|
||||||
4. If novel, pitch the idea to my Telegram "video ideas" topic with sources
|
4. If novel, pitch the idea to my Telegram "video ideas" topic with sources
|
||||||
|
|
||||||
Also: when I share a link in Slack #ai_trends, automatically:
|
Also: when I share a link in Slack #ai_trends, automatically:
|
||||||
1. Research the topic
|
1. Research the topic
|
||||||
2. Search X for related posts
|
2. Search X for related posts
|
||||||
3. Query my knowledge base
|
3. Query my knowledge base
|
||||||
4. Create an Asana card in Video Pipeline with a full outline
|
4. Create an Asana card in Video Pipeline with a full outline
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user