8 Commits

Author SHA1 Message Date
小鱼开发 9d40536b43 bump version to 1.8.3 2026-06-10 14:36:24 +08:00
小鱼开发 57cf32ac18 style: 设置密码弹窗标题与输入框改为同行布局
- 标签左对齐(60px 宽度),输入框右侧自适应
- 错误提示也跟随缩进对齐
2026-06-10 09:26:22 +08:00
小鱼开发 66749b7653 fix: 修改密码弹窗旧密码字段增加错误占位区域,统一各字段间距 2026-06-10 07:55:21 +08:00
小鱼开发 d83080b628 fix: 密码弹窗错误提示区域固定高度,避免显示/隐藏时窗口跳动
- SetPasswordModal: errorStyle 从 minHeight 改为固定 height,错误文本条件渲染改为占位渲染
- ResetPasswordModal: 同上
2026-06-10 07:46:20 +08:00
小鱼开发 923ff63a3d feat: 密码登录功能(验证码/密码双模式 + 忘记密码 + 设置密码)
后端:
- security.py: 新增 bcrypt 密码哈希/校验工具
- auth_service.py: 新增 login_with_password、reset_password_with_sms
- auth.py: 新增 /login-password、/has-password、/set-password、/reset-password 接口
- schemas/auth.py: 新增 PasswordLoginRequest、SetPasswordRequest、ResetPasswordRequest、CheckPasswordResponse
- crud/user.py: 新增 update_password

前端:
- Login.tsx: 支持验证码/密码切换登录,密码模式下显示忘记密码入口
- Login.css: 新增登录方式切换标签、密码输入框样式
- authStore.ts: 新增 loginWithPassword
- Settings.tsx: 新增账号安全区块,显示密码状态,打开设置/修改密码弹窗
- SetPasswordModal.tsx: 设置/修改密码弹窗(旧密码校验、密码显示切换、表单验证)
- ResetPasswordModal.tsx: 忘记密码弹窗(手机号+验证码+新密码重置)

兼容:
- 零数据库迁移,password_hash 字段已存在(nullable)
- 现有接口不变,完全向后兼容旧版本
2026-06-09 23:26:50 +08:00
小鱼开发 c2209dec85 refactor: 移除输入路径的 validate_safe_path 验证,放宽文件访问限制 2026-06-09 11:22:57 +08:00
小鱼开发 da03669a99 chore: 更新提示词素材库并修正评论区用语 2026-06-08 17:12:56 +08:00
小鱼开发 a5aeb58e6c ci: retrigger build for v1.8.2 2026-06-08 14:10:11 +08:00
20 changed files with 1335 additions and 537 deletions
+1 -1
View File
@@ -1 +1 @@
1.8.2
1.8.3
@@ -1,14 +1,15 @@
你是一位专业的【口播类短视频】脚本创作专家,专注于家装 / 装修领域的抖音 / 视频号口播内容创作。
【核心定位与脚本类型】
(一)核心定位
精准锁定:新房装修不懂各工序停工养护时长、盲目赶工期容易留下装修隐患的业主,围绕装修七大工序标准停工等待时间创作,按原文顺序排列,不打乱不随机调整。
(二)脚本类型
装修口播短视频脚本,无多余开头、无结尾引导,直接进入正文干货内容,简洁直白适配短平快口播。
【平台适配】
竖屏 9:16 拍摄
【核心强制规则】
无专属开头范式,去掉所有引入铺垫话术,直接进入正文工序停工时长内容。
中间核心(七大装修工序停工时长内容,文案可微调句式口语化,保持原意不变,严格按原文顺序不打乱):
你的任务是生成装修避坑口播文案,必须严格遵守以下所有规则,不得有任何偏差:
1. 固定开头:第一行意思【装修这几个时间点必须停工】,文案可微调句式口语化,保持原意不变
2. 固定结尾:最后一行必须是【关注我,装修不踩坑】
3. 中间内容:根据下面给出的七个装修停工时间点,文案可微调句式口语化,保持原意不变,严格按原文顺序不打乱
4. 格式要求:每组单独成行,格式为先讲工序,再讲时间
以下是七大装修工序停工时长内容,文案可微调句式口语化,保持原意不变,严格按原文顺序不打乱:
砌墙施工完成后,必须停工等待 5 天再进行下一道工序。
水电工程完工后,固定停工两天静置养护。
全屋防水涂料涂刷完毕,需要停工静置 3 天。
@@ -16,16 +17,17 @@
美缝施工结束后,停工两天自然干透固化。
墙面腻子刮涂完成,停工静置养护 3 天。
全屋乳胶漆涂刷完工,至少停工通风静置 7 天。
中间核心详细分析(贴合口播逻辑,不篡改原文核心)
中间核心详细分析(贴合口播逻辑,不篡改原文核心,格式严格为"先讲工序,再讲停工几天"
排序逻辑:严格照搬原文七大工序先后顺序,不打乱、不随机重排,贴合装修施工真实流程,条理清晰一目了然。
文案调整要求:仅做口语化精简微调,保留每道工序名称、停工天数全部核心信息,不增减内容、不改变原意,适配短视频短促口播风格。
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 60-80 字,按每秒 4 字核算,对应时长 15-20s,内容精炼简短、节奏紧凑
文案调整要求:仅做口语化调,保留每道工序名称、停工天数全部核心信息,不增减内容、不改变原意,适配短视频短促口播风格。
字数与时长控制:纯文字 + 数字扣除标点,按每秒 4 字核算
内容适配性:纯干货直给,无多余废话,每句对应一道工序标准停工时长,适合做知识点短句口播,记忆点强、实用性高。
结尾范式:无额外结尾话术,正文内容结束即收尾,不添加福利引导、不额外延伸。
【开篇 & 语言要求】
无开篇引入,直接切入正文知识点;全程短句口语化,直白易懂、干练简洁,只播报核心工序与停工天数,不做多余解释说教。
可微调句式语序,严禁改动工序顺序、停工天数、施工节点核心内容,语句简短利落,适配短时长口播节奏。
【内置固定原文案】
装修这几个时间点必须停工。
砌墙结束之后,要停工 5 天。
水电完工之后,要停工两天。
防水刷完之后,要停工 3 天。
@@ -33,172 +35,15 @@
美缝做完之后,要停工两天。
腻子刮完之后,要停工 3 天。
乳胶漆刷完之后,要停工 7 天。
关注我,装修不踩坑。
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
原始门窗原貌-毛坯基础
厨卫原始毛坯状态-毛坯基础
地面原始水泥基层-毛坯基础
客厅原始墙面-毛坯基础
强弱电箱原始特写-毛坯基础
毛坯全屋广角全景-毛坯基础
阳台原始结构空镜-毛坯基础
墙面点位弹线-现场交底
开关插座定位-现场交底
开工仪式简单镜头-现场交底
施工方案现场讲解-现场交底
甲乙工长三方对接-现场交底
给排水点位标记-现场交底
装修合同核对-现场交底
卧室原始状态-翻新基础
厨卫原始状态-翻新基础
客厅原始状态-翻新基础
卷尺实测尺寸-量房勘测
手绘户型草图-量房勘测
激光水平仪测量-量房勘测
电脑户型图制作-量房勘测
设计师入户-量房勘测
全屋地板铺设施工-主材安装
全屋开关面板安装-主材安装
卫浴洁具进场安装-主材安装
厨卫集成吊顶安装-主材安装
室内房门安装固定-主材安装
橱柜柜体现场组装-主材安装
灯具筒灯射灯安装-主材安装
衣柜移门五金安装-主材安装
全屋五金调试-收尾细节
成品瑕疵修补-收尾细节
柜体门缝调整-收尾细节
门窗缝隙密封处理-收尾细节
全屋基础开荒保洁-美缝开荒
地面残留胶迹清理-美缝开荒
撕美缝胶-美缝开荒
玻璃胶收边打胶细节-美缝开荒
瓷砖缝隙清理清灰-美缝开荒
美缝扩缝-美缝开荒
美缝施工-美缝开荒
美缝检查-美缝开荒
门窗玻璃清洁-美缝开荒
切割机施工特写-墙体拆除
地板拆除-墙体拆除
墙体拆除-墙体拆除
墙面表层铲除-墙体拆除
局部墙体剔凿修补-墙体拆除
建筑垃圾实时掉落-墙体拆除
拆改后现场全貌-墙体拆除
柜子拆除-墙体拆除
门洞扩宽切割-墙体拆除
非墙体拆除-墙体拆除
飘窗拆除改造-墙体拆除
工地杂物清扫整理-工地清运
施工地面清扫除尘-工地清运
袋装垃圾搬运出场-工地清运
装修垃圾集中堆放-工地清运
新墙红砖错缝砌筑-新建砌筑
新建墙体垂直找平-新建砌筑
新旧墙体拉结筋施工-新建砌筑
水泥砂浆搅拌-新建砌筑
砌墙完工整体展示-新建砌筑
红砖现场码放-新建砌筑
轻体砖隔断搭建-新建砌筑
门头过梁安装固定-新建砌筑
中央空调风口预留-吊顶造型
双眼皮吊顶封板施工-吊顶造型
吊顶完工展示-吊顶造型
吊顶水平对齐-吊顶造型
吊顶石膏板批腻子-吊顶造型
吊顶转角整板防裂-吊顶造型
吊顶造型裁切及安装-吊顶造型
吊顶钉眼防锈漆点涂-吊顶造型
木龙骨基础框架固定-吊顶造型
石膏板固定-吊顶造型
石膏板开孔-吊顶造型
石膏板裁切-吊顶造型
轻钢龙骨骨架搭建-吊顶造型
全屋定制柜体打底-柜体木作
木作封边贴皮-柜体木作
环保板材现场堆放-柜体木作
阳台储物柜基层制作-柜体木作
墙面防潮膜铺设防护-隔音防潮
墙面隔音棉填充-隔音防潮
强弱电间距查验-水电验收
水电完工全屋环视-水电验收
水管打压测试操作-水电验收
管线走向拍照留存-水电验收
线路通电检测检查-水电验收
隐蔽工程线管覆盖-水电验收
隐蔽工程细节巡检-水电验收
下水管道改造调整-水路施工
卫生间冷热水管排布-水路施工
厨卫地漏原位查看-水路施工
厨房水管走顶铺设-水路施工
悬挂式马桶施工-水路施工
水管保温棉包裹防护-水路施工
水管卡扣固定工艺-水路施工
水管对接-水路施工
水管铺设-水路施工
热水器管路预留对接-水路施工
阳台洗衣水管定位-水路施工
中央空调装管-电路施工
吊顶灯线预留走线-电路施工
地面线管开槽处理-电路施工
墙面线槽开槽施工-电路施工
底盒内电线整理-电路施工
底盒暗盒预埋安装-电路施工
弱电网线单独排布-电路施工
强弱电信号防干扰锡箔纸屏蔽膜-电路施工
强弱电管分槽铺设-电路施工
电管对接-电路施工
电管铺设-电路施工
电箱内部线路整理-电路施工
电线穿管布线特写-电路施工
装修材料堆放-电路施工
全屋墙面铲除大白-墙面基层
全屋批刮第一遍腻子-墙面基层
墙固施工-墙面基层
墙面裂缝挂网防裂-墙面基层
墙面阴阳角找直处理-墙面基层
腻子干透精细打磨-墙面基层
地面地砖地膜保护-成品保护
开关面板保护贴膜-成品保护
柜体成品保护包裹-成品保护
门窗门套包裹防护-成品保护
乳胶漆修补-面漆涂刷
乳胶漆效果展示-面漆涂刷
乳胶漆调配-面漆涂刷
墙面底漆均匀涂刷-面漆涂刷
墙面纯色面漆涂刷-面漆涂刷
背景墙艺术漆施工-面漆涂刷
门窗边角精细刷涂-面漆涂刷
顶面乳胶漆滚涂施工-面漆涂刷
厨卫下水管道包裹-包管找平
地面自流平施工处理-包管找平
墙面全屋水泥砂浆找平-包管找平
管道隔音棉加装-包管找平
下水口瓷砖铺贴-瓷砖铺贴
厨卫墙地通缝铺贴-瓷砖铺贴
地砖干铺施工工艺-瓷砖铺贴
墙砖定位-瓷砖铺贴
墙面拉毛加固处理-瓷砖铺贴
止逆阀安装-瓷砖铺贴
沙子-瓷砖铺贴
瓷砖完工展示-瓷砖铺贴
瓷砖开孔-瓷砖铺贴
瓷砖找平器调平固定-瓷砖铺贴
瓷砖泡水预处理-瓷砖铺贴
砖面挖孔定位-瓷砖铺贴
窗台石门槛石安装-瓷砖铺贴
贴墙砖-瓷砖铺贴
铺地砖-瓷砖铺贴
铺贴完成成品保护-瓷砖铺贴
卫生间基层清理-防水施工
厨卫闭水试验蓄水-防水施工
墙面地面防水涂料涂刷-防水施工
墙面防水上翻涂刷-防水施工
楼下渗水查验确认-防水施工
管根圆弧加固处理-防水施工
防水涂层完工特写-防水施工
阳台户外防水施工-防水施工
瓷砖完工展示-瓷砖铺贴
美缝施工-美缝开荒
全屋批刮第一遍腻子-墙面基层
墙面纯色面漆涂刷-面漆涂刷
吸睛画面-恶搞开篇
工地恶搞-恶搞开篇
搞笑涂料施工-恶搞开篇
@@ -211,34 +56,44 @@
水管错位-施工翻车镜
电线乱接-施工翻车镜
防水翻车漏水-施工翻车镜
墙面漆面细节查验-全屋验收
柜体开合顺畅度检查-全屋验收
踢脚线安装验收-软装进场
验收合格签字确认-全屋验收
窗帘轨道窗帘安装-软装进场
【分镜固定结构规则】
开篇的分镜为: 一段人物出镜
其他都是空镜补充
“分镜文案 "等于" 配音文案”,“配音文案” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
开篇的分镜为:一段人物出镜
中间内容全部用空镜,空镜(内置完整素材库标题)与文案内容需匹配
结尾的分镜为:一段人物出镜
“分镜文案 “等于” 配音文案”,“配音文案”严格按照每句一段。
每个分镜的 “分镜时长” 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 “分镜文案” 的纯文字字数 / 4},严格控制在 1-8 秒,可以是两位小数
type 为 segment = 人物出镜;type 为 empty_shot = 从内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
“empty_shot”(空镜补充)对应上述素材库标题,文案内容需匹配,如无法匹配则选择近似的空镜
“empty_shot”(空镜补充)对应上述素材库标题,文案内容需完全匹配
【输出格式要求】
输出的内容必须包含以下部分,只输出纯 JSON,不要包含 markdown 代码块或其他说明文字:
一、分镜内容
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主
voiceover: “配音文案”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点
duration: “分镜时长”(如 “5s”,时长为 "配音文案" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配)
voiceover: “配音文案”(严格与文案内容匹配
duration: “分镜时长”(如 “2s”,时长为 配音文案 的字数(含数字,不含标点符号)/4,严格控制在 1-8 秒,可以是两位小数,如 “不要正五孔插座” 总共 7个文字,则是 “1.75s”
【示例】
[
{
"id": 1,
"type": "empty_shot",
"scene": "新建墙体垂直找平 - 新建砌筑",
"voiceover": "砌墙完工之后,一定要停工静置等待 5 天。",
"duration": "4.25s"
id: 1,
type: “segment”,
scene: “人物出镜”,
voiceover: “装修这几个时间点必须停工。”,
duration: “3.00s”
},
{
“id”: 2,
“type”: “empty_shot”,
“scene”: “砌墙完工整体展示-新建砌筑”,
“voiceover”: “砌墙结束之后,要停工5天。”,
“duration”: “2.75s”
},
{
“id”: 3,
“type”: “empty_shot”,
“scene”: “水电完工全屋环视-水电验收”,
“voiceover”: “水电完工之后,要停工两天。”,
“duration”: “2.75s”
}
]
]
@@ -36,171 +36,17 @@
第六,房子做完闭水试验,你必须亲自去楼下邻居家看看有没有漏水,如果只让师傅拍照片,你根本不知道他是什么时候拍的。真出了问题还得你来赔付。
记不住的,我整理了装修全流程避坑手册。评论区回复避坑,拿去用。
【内置完整素材库标题】
合同签署
卧室原始结构-毛坯基础
原始门窗原貌-毛坯基础
厨卫原始毛坯状态-毛坯基础
地面原始水泥基层-毛坯基础
客厅原始墙面-毛坯基础
强弱电箱原始特写-毛坯基础
毛坯全屋广角全景-毛坯基础
阳台原始结构空镜-毛坯基础
墙面点位弹线-现场交底
开关插座定位-现场交底
开工仪式简单镜头-现场交底
施工方案现场讲解-现场交底
甲乙工长三方对接-现场交底
给排水点位标记-现场交底
装修合同核对-现场交底
卧室原始状态-翻新基础
厨卫原始状态-翻新基础
客厅原始状态-翻新基础
卷尺实测尺寸-量房勘测
手绘户型草图-量房勘测
激光水平仪测量-量房勘测
电脑户型图制作-量房勘测
设计师入户-量房勘测
全屋地板铺设施工-主材安装
全屋开关面板安装-主材安装
卫浴洁具进场安装-主材安装
厨卫集成吊顶安装-主材安装
室内房门安装固定-主材安装
橱柜柜体现场组装-主材安装
灯具筒灯射灯安装-主材安装
衣柜移门五金安装-主材安装
全屋五金调试-收尾细节
成品瑕疵修补-收尾细节
柜体门缝调整-收尾细节
门窗缝隙密封处理-收尾细节
全屋基础开荒保洁-美缝开荒
地面残留胶迹清理-美缝开荒
撕美缝胶-美缝开荒
玻璃胶收边打胶细节-美缝开荒
瓷砖缝隙清理清灰-美缝开荒
美缝扩缝-美缝开荒
美缝施工-美缝开荒
美缝检查-美缝开荒
门窗玻璃清洁-美缝开荒
切割机施工特写-墙体拆除
地板拆除-墙体拆除
墙体拆除-墙体拆除
墙面表层铲除-墙体拆除
局部墙体剔凿修补-墙体拆除
建筑垃圾实时掉落-墙体拆除
拆改后现场全貌-墙体拆除
柜子拆除-墙体拆除
门洞扩宽切割-墙体拆除
非墙体拆除-墙体拆除
飘窗拆除改造-墙体拆除
工地杂物清扫整理-工地清运
施工地面清扫除尘-工地清运
袋装垃圾搬运出场-工地清运
装修垃圾集中堆放-工地清运
新墙红砖错缝砌筑-新建砌筑
新建墙体垂直找平-新建砌筑
新旧墙体拉结筋施工-新建砌筑
水泥砂浆搅拌-新建砌筑
砌墙完工整体展示-新建砌筑
红砖现场码放-新建砌筑
轻体砖隔断搭建-新建砌筑
门头过梁安装固定-新建砌筑
中央空调风口预留-吊顶造型
双眼皮吊顶封板施工-吊顶造型
吊顶完工展示-吊顶造型
吊顶水平对齐-吊顶造型
吊顶石膏板批腻子-吊顶造型
吊顶转角整板防裂-吊顶造型
吊顶造型裁切及安装-吊顶造型
吊顶钉眼防锈漆点涂-吊顶造型
木龙骨基础框架固定-吊顶造型
石膏板固定-吊顶造型
石膏板开孔-吊顶造型
石膏板裁切-吊顶造型
轻钢龙骨骨架搭建-吊顶造型
全屋定制柜体打底-柜体木作
木作封边贴皮-柜体木作
环保板材现场堆放-柜体木作
阳台储物柜基层制作-柜体木作
墙面防潮膜铺设防护-隔音防潮
墙面隔音棉填充-隔音防潮
强弱电间距查验-水电验收
水电完工全屋环视-水电验收
水管打压测试操作-水电验收
管线走向拍照留存-水电验收
线路通电检测检查-水电验收
隐蔽工程线管覆盖-水电验收
隐蔽工程细节巡检-水电验收
下水管道改造调整-水路施工
卫生间冷热水管排布-水路施工
厨卫地漏原位查看-水路施工
厨房水管走顶铺设-水路施工
悬挂式马桶施工-水路施工
水管保温棉包裹防护-水路施工
水管卡扣固定工艺-水路施工
水管对接-水路施工
水管铺设-水路施工
热水器管路预留对接-水路施工
阳台洗衣水管定位-水路施工
中央空调装管-电路施工
吊顶灯线预留走线-电路施工
地面线管开槽处理-电路施工
墙面线槽开槽施工-电路施工
底盒内电线整理-电路施工
底盒暗盒预埋安装-电路施工
弱电网线单独排布-电路施工
强弱电信号防干扰锡箔纸屏蔽膜-电路施工
强弱电管分槽铺设-电路施工
电管对接-电路施工
电管铺设-电路施工
电箱内部线路整理-电路施工
电线穿管布线特写-电路施工
装修材料堆放-电路施工
全屋墙面铲除大白-墙面基层
全屋批刮第一遍腻子-墙面基层
墙固施工-墙面基层
墙面裂缝挂网防裂-墙面基层
墙面阴阳角找直处理-墙面基层
腻子干透精细打磨-墙面基层
地面地砖地膜保护-成品保护
开关面板保护贴膜-成品保护
柜体成品保护包裹-成品保护
门窗门套包裹防护-成品保护
乳胶漆修补-面漆涂刷
乳胶漆效果展示-面漆涂刷
乳胶漆调配-面漆涂刷
墙面底漆均匀涂刷-面漆涂刷
讨好装修师傅
封窗施工
阳台窗外防水斜坡
墙面纯色面漆涂刷-面漆涂刷
背景墙艺术漆施工-面漆涂刷
门窗边角精细刷涂-面漆涂刷
顶面乳胶漆滚涂施工-面漆涂刷
厨卫下水管道包裹-包管找平
地面自流平施工处理-包管找平
墙面全屋水泥砂浆找平-包管找平
管道隔音棉加装-包管找平
下水口瓷砖铺贴-瓷砖铺贴
厨卫墙地通缝铺贴-瓷砖铺贴
地砖干铺施工工艺-瓷砖铺贴
墙砖定位-瓷砖铺贴
墙面拉毛加固处理-瓷砖铺贴
止逆阀安装-瓷砖铺贴
沙子-瓷砖铺贴
瓷砖完工展示-瓷砖铺贴
瓷砖开孔-瓷砖铺贴
瓷砖找平器调平固定-瓷砖铺贴
瓷砖泡水预处理-瓷砖铺贴
砖面挖孔定位-瓷砖铺贴
窗台石门槛石安装-瓷砖铺贴
贴墙砖-瓷砖铺贴
铺地砖-瓷砖铺贴
铺贴完成成品保护-瓷砖铺贴
卫生间基层清理-防水施工
乳胶漆调配-面漆涂刷
卫生间陶粒回填
防水翻车漏水-施工翻车镜
轻钢龙骨骨架搭建-吊顶造型
木龙骨基础框架固定-吊顶造型
全屋定制板材检查
厨卫闭水试验蓄水-防水施工
墙面地面防水涂料涂刷-防水施工
墙面防水上翻涂刷-防水施工
楼下渗水查验确认-防水施工
管根圆弧加固处理-防水施工
防水涂层完工特写-防水施工
阳台户外防水施工-防水施工
吸睛画面-恶搞开篇
工地恶搞-恶搞开篇
搞笑涂料施工-恶搞开篇
@@ -212,15 +58,9 @@
墙面空鼓-施工翻车镜
水管错位-施工翻车镜
电线乱接-施工翻车镜
防水翻车漏水-施工翻车镜
墙面漆面细节查验-全屋验收
柜体开合顺畅度检查-全屋验收
踢脚线安装验收-软装进场
验收合格签字确认-全屋验收
窗帘轨道窗帘安装-软装进场
【分镜固定结构规则】
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近装修监工、节点把控主题,优先选工地恶搞、墙面空鼓、毛坯全景等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选吊顶造型、防水施工、面漆涂刷、全屋定制相关空镜)
开篇的分镜为:(可选用讨好装修师傅、恶搞开篇或施工翻车镜,最好能贴近话术内容和主题)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文案”,“配音文案” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
@@ -240,22 +80,22 @@ duration: “分镜时长”(如 “5s”,时长为 "配音文案" 的字数
{
"id": 1,
"type": "empty_shot",
"scene": "防水翻车漏水",
"voiceover": "新房装修刷防水,一上来就开刷的工人,直接撵走别客气",
"duration": "5.75s"
"scene": "讨好装修师傅",
"voiceover": "装修真的没必要全程监工,累不说,关键你是看不明白",
"duration": "5.50s"
},
{
"id": 2,
"type": "segment",
"scene": "人物出镜",
"voiceover": "他不是在赶工期,只是在图省事,这 4 点一定要做好。",
"duration": "5.25s"
"voiceover": "再说了,师傅要是真想坑你,你站那儿也没用。",
"duration": "4.50s"
},
{
"id": 3,
"type": "empty_shot",
"scene": "卫生间基层清理 - 防水施工",
"voiceover": "第一,基层要清理干净,裂缝凹陷补平,管口封好防渗漏。",
"duration": "5.50s"
"scene": "墙体掉落-施工翻车镜",
"voiceover": "今天我告诉你几个监工关键时间点,你必须在场。",
"duration": "5.00s"
}
]
+128
View File
@@ -21,17 +21,23 @@ from app.crud import user as user_crud
from app.db.session import AsyncSession, get_db
from app.models.user import User
from app.schemas.auth import (
CheckPasswordResponse,
MobileLoginRequest,
PasswordLoginRequest,
RefreshTokenRequest,
ResetPasswordRequest,
SendSmsCodeRequest,
SetPasswordRequest,
TokenResponse,
)
from app.schemas.common import ApiResponse, success_response
from app.schemas.user import UpdateNicknameRequest, UserProfileResponse
from app.services.auth_service import (
login_with_password,
login_with_sms,
logout,
refresh_access_token,
reset_password_with_sms,
send_sms_code,
)
@@ -123,6 +129,128 @@ async def login(
)
@router.post("/login-password", response_model=ApiResponse[TokenResponse])
async def login_password(
request: PasswordLoginRequest,
db: AsyncSession = Depends(get_db),
http_request: Request = None,
):
"""
手机号密码登录
流程与验证码登录一致,只是校验方式改为密码。
"""
client_ip = None
if http_request:
xff = http_request.headers.get("x-forwarded-for")
if xff:
client_ip = xff.split(",")[0].strip()
else:
xri = http_request.headers.get("x-real-ip")
client_ip = xri or (http_request.client.host if http_request.client else None)
try:
result = await login_with_password(
db,
mobile=request.mobile,
password=request.password,
device_id=request.device_id,
device_name=request.device_name,
os_info=request.os_info,
app_version=request.app_version,
ip=client_ip,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
return success_response(
data=TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
user=result["user"],
),
message="登录成功",
)
@router.get("/has-password", response_model=ApiResponse[CheckPasswordResponse])
async def check_has_password(
current_user: User = Depends(get_current_user),
):
"""检查当前用户是否已设置密码"""
return success_response(
data=CheckPasswordResponse(has_password=bool(current_user.password_hash)),
message="查询成功",
)
@router.post("/set-password", response_model=ApiResponse[dict])
async def set_password(
request: SetPasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
设置或修改密码
- 首次设置密码:old_password 可不传
- 修改密码:必须传 old_password 校验
"""
from app.core.security import hash_password, verify_password
user = await user_crud.get(db, id=current_user.id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
# 如果已有密码,必须提供旧密码
if user.password_hash:
if not request.old_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="修改密码需要提供旧密码",
)
if not verify_password(request.old_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="旧密码错误",
)
# 更新密码
new_hash = hash_password(request.new_password)
await user_crud.update_password(db, user_id=user.id, password_hash=new_hash)
return success_response(data={}, message="密码设置成功")
@router.post("/reset-password", response_model=ApiResponse[dict])
async def reset_password(
request: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""
短信验证码重置密码
无需登录,通过短信验证码验证身份后直接重置密码。
"""
try:
await reset_password_with_sms(
db,
mobile=request.mobile,
code=request.code,
new_password=request.new_password,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
return success_response(data={}, message="密码重置成功")
@router.post("/refresh", response_model=ApiResponse[dict])
async def refresh_token(
request: RefreshTokenRequest,
+19 -2
View File
@@ -1,10 +1,13 @@
"""
安全工具 - JWT Token 生成与验证
===============================
安全工具 - JWT Token 生成与验证 + 密码哈希
==========================================
支持双 Token 体系:
- Access Token:短效(30 分钟),用于 API 请求认证
- Refresh Token:长效(30 天),用于换取新的 Access Token
密码哈希:
- 使用 bcrypt 进行密码哈希和校验
"""
from __future__ import annotations
@@ -15,11 +18,25 @@ from typing import Any
import jwt
from jwt import PyJWTError
from passlib.context import CryptContext
from app.config import get_settings
settings = get_settings()
# bcrypt 密码哈希上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""对明文密码进行 bcrypt 哈希"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""校验明文密码与哈希密码是否匹配"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
"""
+13
View File
@@ -67,6 +67,19 @@ class UserCRUD(CRUDBase[User]):
await db.refresh(user)
return user
async def update_password(
self, db: AsyncSession, *, user_id: str, password_hash: str
) -> User | None:
"""更新用户密码"""
user = await self.get(db, id=user_id)
if user is None:
return None
user.password_hash = password_hash
await db.commit()
await db.refresh(user)
return user
async def update_extra(
self, db: AsyncSession, *, user_id: str, extra: dict
) -> bool:
+32
View File
@@ -19,12 +19,38 @@ class MobileLoginRequest(BaseModel):
app_version: str | None = Field(None, description="应用版本号")
class PasswordLoginRequest(BaseModel):
"""手机号密码登录请求"""
mobile: str = Field(..., description="手机号", min_length=11, max_length=20)
password: str = Field(..., description="密码", min_length=6, max_length=128)
device_id: str = Field(..., description="设备唯一标识")
device_name: str | None = Field(None, description="设备名称")
os_info: str | None = Field(None, description="操作系统信息")
app_version: str | None = Field(None, description="应用版本号")
class SendSmsCodeRequest(BaseModel):
"""发送短信验证码请求"""
mobile: str = Field(..., description="手机号", min_length=11, max_length=20)
class SetPasswordRequest(BaseModel):
"""设置/修改密码请求"""
old_password: str | None = Field(None, description="旧密码(修改时必填)", max_length=128)
new_password: str = Field(..., description="新密码", min_length=6, max_length=128)
class ResetPasswordRequest(BaseModel):
"""短信验证码重置密码请求"""
mobile: str = Field(..., description="手机号", min_length=11, max_length=20)
code: str = Field(..., description="短信验证码", min_length=4, max_length=10)
new_password: str = Field(..., description="新密码", min_length=6, max_length=128)
class RefreshTokenRequest(BaseModel):
"""刷新 Token 请求"""
@@ -39,6 +65,12 @@ class TokenResponse(BaseModel):
user: UserInfo = Field(..., description="用户信息")
class CheckPasswordResponse(BaseModel):
"""检查是否设置过密码响应"""
has_password: bool = Field(..., description="是否已设置密码")
class TokenPayload(BaseModel):
"""Token 载荷"""
+109
View File
@@ -288,6 +288,115 @@ async def refresh_access_token(
}
async def login_with_password(
db: AsyncSession,
*,
mobile: str,
password: str,
device_id: str,
device_name: str | None = None,
os_info: str | None = None,
app_version: str | None = None,
ip: str | None = None,
source: str = "mobile_password",
) -> dict[str, Any]:
"""
手机号密码登录。
流程:
1. 查询用户
2. 校验密码
3. 更新登录信息
4. 踢掉旧设备(SSE 推送)
5. 创建/覆盖设备记录
6. 签发双 Token
"""
from app.core.security import verify_password
# 1. 查询用户
user = await user_crud.get_by_mobile(db, mobile=mobile)
if user is None:
raise ValueError("用户不存在")
# 2. 校验密码
if not user.password_hash:
raise ValueError("该账号未设置密码,请使用验证码登录")
if not verify_password(password, user.password_hash):
raise ValueError("密码错误")
# 检查用户状态
if not user.is_active:
raise ValueError("账号已被封禁,请联系客服")
# 3. 更新登录信息
await user_crud.update_login_info(db, user_id=user.id, ip=ip)
# 4. 踢掉旧设备(SSE 推送)
await _kick_old_device(str(user.id))
# 5. 签发双 Token
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
refresh_token_hash = _hash_refresh_token(refresh_token)
# 6. 创建/覆盖设备记录
await device_crud.create_or_update(
db,
user_id=user.id,
device_id=device_id,
device_name=device_name,
os_info=os_info,
app_version=app_version,
refresh_token_hash=refresh_token_hash,
)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"user": {
"id": user.id,
"mobile": user.mobile,
"nickname": user.nickname or "",
"avatar": user.avatar_url or "",
},
}
async def reset_password_with_sms(
db: AsyncSession,
*,
mobile: str,
code: str,
new_password: str,
) -> bool:
"""
短信验证码重置密码。
流程:
1. 校验验证码
2. 查询用户
3. 更新密码哈希
"""
from app.core.security import hash_password
settings = get_settings()
# 1. 校验验证码(白名单内的手机号跳过校验)
if mobile not in settings.sms_code_whitelist_set and not await verify_sms_code(mobile, code):
raise ValueError("验证码错误或已过期")
# 2. 查询用户
user = await user_crud.get_by_mobile(db, mobile=mobile)
if user is None:
raise ValueError("用户不存在")
# 3. 更新密码
new_hash = hash_password(new_password)
await user_crud.update_password(db, user_id=user.id, password_hash=new_hash)
return True
async def logout(db: AsyncSession, *, user_id: str) -> bool:
"""
用户登出。
+3 -3
View File
@@ -11,11 +11,11 @@
DO $$
DECLARE
v_mobile TEXT := '15359215971'; -- ← 修改:手机号
v_nickname TEXT := '家迪装饰'; -- ← 修改:昵称(可为空)
v_mobile TEXT := '13559213930'; -- ← 修改:手机号
v_nickname TEXT := '俊宏'; -- ← 修改:昵称(可为空)
v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion
v_invited_by UUID := NULL; -- ← 修改:邀请人 user_id(没有则留 NULL
v_gift_points INT := 10000; -- ← 修改:赠送初始积分(0 表示不赠送)
v_gift_points INT := 2000; -- ← 修改:赠送初始积分(0 表示不赠送)
v_gift_days INT := 365; -- ← 修改:赠送积分有效期(天)
v_user_id UUID;
v_batch_id BIGINT;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tauri-app",
"private": true,
"version": "1.8.2",
"version": "1.8.3",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -4219,7 +4219,7 @@ dependencies = [
[[package]]
name = "tauri-app"
version = "1.8.2"
version = "1.8.3"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tauri-app"
version = "1.8.2"
version = "1.8.3"
description = "美家卡智影 - AI 视频创作桌面应用"
authors = ["美家卡科技"]
edition = "2021"
+16 -67
View File
@@ -60,50 +60,10 @@ fn normalize_path(path: &std::path::Path) -> String {
}
}
/// 验证路径在允许的目录内,防止路径遍历攻击
/// 允许的目录:应用数据目录(app_local_data_dir
fn validate_safe_path(path: &str) -> Result<String, String> {
let path = std::path::Path::new(path);
// 获取绝对路径
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("无法获取当前目录: {}", e))?
.join(path)
};
// 检查是否在允许的目录内
let allowed_dir = crate::storage::paths::get_app_data_dir()
.map_err(|e| format!("无法获取应用数据目录: {}", e))?;
// 规范化路径
let canonical = abs_path.canonicalize()
.unwrap_or(abs_path.clone());
// 同时规范化允许目录(Windows 上 canonicalize 会添加 \\?\ 前缀,
// 必须与输入路径保持一致的规范化格式才能正确比较)
let allowed_canonical = allowed_dir.canonicalize()
.unwrap_or(allowed_dir.clone());
// 检查是否在允许目录下
if !canonical.starts_with(&allowed_canonical) {
return Err(format!("路径不在允许目录内: {}", path.display()));
}
Ok(canonical.to_string_lossy().to_string())
}
/// 清理并验证输出路径
/// 清理输出路径(转绝对路径)
pub fn sanitize_output_path(path: &str) -> Result<String, String> {
let path = std::path::Path::new(path);
// 获取父目录并验证
if let Some(parent) = path.parent() {
validate_safe_path(&parent.to_string_lossy())?;
}
// 确保是绝对路径
if path.is_absolute() {
Ok(path.to_string_lossy().to_string())
@@ -215,8 +175,7 @@ pub async fn standardize_video(
target_width: u32,
target_height: u32,
) -> Result<(), String> {
// 验证路径安全
let safe_input = validate_safe_path(input_path)?;
let safe_input = input_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
let vf = format!(
@@ -244,8 +203,7 @@ pub async fn standardize_video(
* 拼接视频 - 快速模式 (要求编码/分辨率一致)
*/
pub async fn concat_videos_copy(app: &AppHandle, list_path: &str, output_path: &str) -> Result<(), String> {
// 验证路径安全
let safe_list = validate_safe_path(list_path)?;
let safe_list = list_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
@@ -321,9 +279,8 @@ pub async fn concat_videos_robust(
* 音画合并 - 优化音质
*/
pub async fn add_audio_to_video(app: &AppHandle, video_path: &str, audio_path: &str, output_path: &str) -> Result<(), String> {
// 验证路径安全
let safe_video = validate_safe_path(video_path)?;
let safe_audio = validate_safe_path(audio_path)?;
let safe_video = video_path.to_string();
let safe_audio = audio_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
@@ -353,8 +310,7 @@ pub async fn create_cover_video(
target_width: u32,
target_height: u32,
) -> Result<(), String> {
// 验证路径安全
let safe_input = validate_safe_path(input_path)?;
let safe_input = input_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
let vf = format!(
@@ -491,13 +447,13 @@ pub async fn burn_ass_subtitle(
output_path: &str,
overlay_image: Option<&str>,
) -> Result<(), String> {
// 输入路径验证HTTP URL 直接传递,本地文件需要安全检查
// 输入路径:HTTP URL 直接传递,本地文件直接传递
let safe_video = if video_path.starts_with("http://") || video_path.starts_with("https://") {
video_path.to_string()
} else {
validate_safe_path(video_path)?
video_path.to_string()
};
let safe_ass = validate_safe_path(ass_path)?;
let safe_ass = ass_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
// 准备临时工作目录(ASS + 字体),使用相对路径避开 Windows C: 盘符问题
@@ -505,7 +461,7 @@ pub async fn burn_ass_subtitle(
// 如果有 overlay 图片,先 overlay 再 burn 字幕(两步避免 filter_complex 音频映射问题)
if let Some(img_path) = overlay_image {
let safe_img = validate_safe_path(img_path)?;
let safe_img = img_path.to_string();
let temp_output = format!("{}.tmp_overlay.mp4", safe_output);
// Step 1: overlay PNG 到视频
@@ -588,14 +544,8 @@ pub async fn mix_bgm_to_video(
video_volume: f64,
bgm_volume: f64,
) -> Result<(), String> {
let safe_video = validate_safe_path(video_path)?;
// BGM 可以是用户通过系统文件选择器选取的任意本地文件,
// 验证文件存在且不含路径遍历字符
let safe_bgm = if !bgm_path.contains("..") && std::path::Path::new(bgm_path).exists() {
bgm_path.to_string()
} else {
return Err(format!("BGM 文件不存在或路径非法: {}", bgm_path));
};
let safe_video = video_path.to_string();
let safe_bgm = bgm_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
// 构建 filter_complex:
@@ -635,9 +585,8 @@ pub async fn replace_audio_track(
audio_path: &str,
output_path: &str,
) -> Result<(), String> {
// 验证路径安全
let safe_video = validate_safe_path(video_path)?;
let safe_audio = validate_safe_path(audio_path)?;
let safe_video = video_path.to_string();
let safe_audio = audio_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
@@ -839,7 +788,7 @@ pub async fn extract_audio_segment(
duration: f64,
output_path: &str,
) -> Result<(), String> {
let safe_input = validate_safe_path(input_path)?;
let safe_input = input_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
let start_str = format!("{:.3}", start);
@@ -873,7 +822,7 @@ pub async fn extract_audio_from_video(
input_path: &str,
output_path: &str,
) -> Result<(), String> {
let safe_input = validate_safe_path(input_path)?;
let safe_input = input_path.to_string();
let safe_output = sanitize_output_path(output_path)?;
let args = vec![
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "美家卡智影",
"version": "1.8.2",
"version": "1.8.3",
"identifier": "cn.meijiaka.ai-zy",
"build": {
"beforeDevCommand": "npm run dev",
@@ -0,0 +1,320 @@
/**
* 忘记密码弹窗 - 短信验证码重置密码
* =================================
*
* 流程:手机号 → 验证码 → 新密码 → 确认密码
*/
import { useState, useEffect, useCallback } from 'react';
import './ConfirmModal.css';
import { client } from '../../api/client';
import { toast } from '../../store/uiStore';
interface ResetPasswordModalProps {
open: boolean;
onClose: () => void;
}
export default function ResetPasswordModal({ open, onClose }: ResetPasswordModalProps) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [countdown, setCountdown] = useState(0);
const [sending, setSending] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
// 倒计时
useEffect(() => {
if (countdown <= 0) { return; }
const timer = setTimeout(() => setCountdown(c => c - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const isPhoneValid = /^1[3-9]\d{9}$/.test(phone);
const isCodeValid = /^\d{4,6}$/.test(code);
const pwdMatch = newPassword === confirmPassword;
const canSubmit = isPhoneValid && isCodeValid && newPassword.length >= 6 && pwdMatch && !submitting;
// 发送验证码
const handleSendCode = useCallback(async () => {
if (!isPhoneValid || countdown > 0 || sending) { return; }
setSending(true);
try {
await client.post('/auth/send-code', { mobile: phone });
setCountdown(60);
toast.success('验证码已发送');
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : '发送失败,请重试');
} finally {
setSending(false);
}
}, [isPhoneValid, countdown, sending, phone]);
// 提交重置
const handleSubmit = async () => {
if (!canSubmit) { return; }
setSubmitting(true);
try {
await client.post('/auth/reset-password', {
mobile: phone,
code,
new_password: newPassword,
});
toast.success('密码重置成功,请使用新密码登录');
handleClose();
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : '重置失败,请重试');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
setPhone('');
setCode('');
setNewPassword('');
setConfirmPassword('');
setCountdown(0);
setShowNew(false);
setShowConfirm(false);
onClose();
};
if (!open) { return null; }
const inputStyle: React.CSSProperties = {
width: '100%',
height: '44px',
borderRadius: '8px',
border: '1px solid var(--border-light)',
padding: '0 12px',
fontSize: '14px',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.15s ease',
};
const fieldStyle: React.CSSProperties = { marginBottom: '16px' };
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '13px',
fontWeight: 500,
color: 'var(--text-secondary)',
marginBottom: '6px',
};
const errorStyle: React.CSSProperties = {
fontSize: '12px',
color: '#e74c3c',
marginTop: '4px',
height: '18px',
lineHeight: '18px',
overflow: 'hidden',
};
const pwdMismatch = confirmPassword.length > 0 && !pwdMatch;
const pwdTooShort = newPassword.length > 0 && newPassword.length < 6;
return (
<div className="confirm-modal-overlay" onClick={handleClose}>
<div
className="confirm-modal-container"
style={{ width: '100%', maxWidth: '380px', padding: '24px' }}
onClick={e => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button className="confirm-modal-close" onClick={handleClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* 标题 */}
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<div
className="confirm-modal-icon"
style={{ color: 'var(--text-tertiary)', margin: '0 auto 12px' }}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h3 className="confirm-modal-title" style={{ marginBottom: '4px' }}></h3>
<p style={{ fontSize: '12px', color: 'var(--text-tertiary)', margin: 0 }}>
</p>
</div>
{/* 表单 */}
<div>
{/* 手机号 */}
<div style={fieldStyle}>
<label style={labelStyle}></label>
<div style={{ display: 'flex', alignItems: 'center', border: '1px solid var(--border-light)', borderRadius: '8px', overflow: 'hidden', background: 'var(--bg-input)' }}>
<span style={{ padding: '0 12px', fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)', borderRight: '1px solid var(--border-light)', height: '44px', display: 'flex', alignItems: 'center', flexShrink: 0 }}>+86</span>
<input
type="tel"
placeholder="请输入手机号"
value={phone}
onChange={e => { const v = e.target.value.replace(/\D/g, '').slice(0, 11); setPhone(v); }}
maxLength={11}
style={{ border: 'none', background: 'transparent', flex: 1, height: '44px', padding: '0 12px', fontSize: '14px', fontWeight: 600, letterSpacing: '2px', outline: 'none', color: 'var(--text-primary)' }}
/>
</div>
</div>
{/* 验证码 */}
<div style={fieldStyle}>
<label style={labelStyle}></label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
placeholder="请输入验证码"
value={code}
onChange={e => { const v = e.target.value.replace(/\D/g, '').slice(0, 6); setCode(v); }}
maxLength={6}
style={{ ...inputStyle, flex: 1, letterSpacing: '4px' }}
/>
<button
onClick={handleSendCode}
disabled={!isPhoneValid || countdown > 0 || sending}
style={{
flexShrink: 0,
height: '44px',
padding: '0 16px',
border: '1px solid var(--primary)',
borderRadius: '8px',
background: 'transparent',
color: (!isPhoneValid || countdown > 0 || sending) ? 'var(--text-tertiary)' : 'var(--primary)',
fontSize: '13px',
fontWeight: 500,
fontFamily: 'var(--font-family)',
cursor: (!isPhoneValid || countdown > 0 || sending) ? 'default' : 'pointer',
whiteSpace: 'nowrap',
}}
>
{sending ? '发送中...' : countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</button>
</div>
</div>
{/* 新密码 */}
<div style={fieldStyle}>
<label style={labelStyle}></label>
<div style={{ position: 'relative' }}>
<input
type={showNew ? 'text' : 'password'}
placeholder="至少6位字符"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
style={inputStyle}
/>
<button
onClick={() => setShowNew(!showNew)}
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-tertiary)',
padding: '4px',
display: 'flex',
alignItems: 'center',
}}
>
{showNew ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
)}
</button>
</div>
<div style={errorStyle}>{pwdTooShort ? '密码至少需要6位' : ''}</div>
</div>
{/* 确认密码 */}
<div style={fieldStyle}>
<label style={labelStyle}></label>
<div style={{ position: 'relative' }}>
<input
type={showConfirm ? 'text' : 'password'}
placeholder="再次输入新密码"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
style={inputStyle}
/>
<button
onClick={() => setShowConfirm(!showConfirm)}
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-tertiary)',
padding: '4px',
display: 'flex',
alignItems: 'center',
}}
>
{showConfirm ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
)}
</button>
</div>
<div style={errorStyle}>{pwdMismatch ? '两次输入的密码不一致' : ''}</div>
</div>
</div>
{/* 按钮 */}
<div className="confirm-modal-actions" style={{ marginTop: '4px' }}>
<button className="confirm-modal-btn cancel" onClick={handleClose}>
</button>
<button
className="confirm-modal-btn primary"
onClick={handleSubmit}
disabled={!canSubmit}
>
{submitting ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
<path d="M21 12a9 9 0 11-6.219-8.56" />
</svg>
...
</span>
) : (
'重置密码'
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,329 @@
/**
* 设置/修改密码弹窗
* ================
*
* 首次设置密码:只需输入新密码 + 确认密码
* 修改密码:需要输入旧密码 + 新密码 + 确认密码
*/
import { useState } from 'react';
import './ConfirmModal.css';
import { client } from '../../api/client';
import { toast } from '../../store/uiStore';
interface SetPasswordModalProps {
open: boolean;
hasPassword: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function SetPasswordModal({
open,
hasPassword,
onClose,
onSuccess,
}: SetPasswordModalProps) {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [showOld, setShowOld] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
if (!open) {return null;}
const canSubmit = () => {
if (hasPassword && oldPassword.length < 6) {return false;}
if (newPassword.length < 6) {return false;}
if (newPassword !== confirmPassword) {return false;}
if (submitting) {return false;}
return true;
};
const handleSubmit = async () => {
if (!canSubmit()) {return;}
setSubmitting(true);
try {
const payload: { old_password?: string; new_password: string } = {
new_password: newPassword,
};
if (hasPassword && oldPassword) {
payload.old_password = oldPassword;
}
await client.post('/auth/set-password', payload);
toast.success(hasPassword ? '密码修改成功' : '密码设置成功');
// 清空表单
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
onSuccess();
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : '操作失败,请重试');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setShowOld(false);
setShowNew(false);
setShowConfirm(false);
onClose();
};
const inputStyle: React.CSSProperties = {
width: '100%',
height: '44px',
borderRadius: '8px',
border: '1px solid var(--border-light)',
padding: '0 12px',
fontSize: '14px',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.15s ease',
};
const fieldStyle: React.CSSProperties = {
marginBottom: '16px',
};
const rowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '12px',
};
const rowLabelStyle: React.CSSProperties = {
fontSize: '13px',
fontWeight: 500,
color: 'var(--text-secondary)',
width: '60px',
flexShrink: 0,
textAlign: 'right',
};
const rowInputWrapStyle: React.CSSProperties = {
flex: 1,
position: 'relative',
};
const errorStyle: React.CSSProperties = {
fontSize: '12px',
color: '#e74c3c',
marginTop: '4px',
height: '18px',
lineHeight: '18px',
overflow: 'hidden',
};
const pwdMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
const pwdTooShort = newPassword.length > 0 && newPassword.length < 6;
return (
<div className="confirm-modal-overlay" onClick={handleClose}>
<div
className="confirm-modal-container"
style={{ width: '100%', maxWidth: '380px', padding: '24px' }}
onClick={e => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button className="confirm-modal-close" onClick={handleClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* 标题 */}
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<div
className="confirm-modal-icon"
style={{ color: 'var(--text-tertiary)', margin: '0 auto 12px' }}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h3 className="confirm-modal-title" style={{ marginBottom: '4px' }}>
{hasPassword ? '修改密码' : '设置登录密码'}
</h3>
<p style={{ fontSize: '12px', color: 'var(--text-tertiary)', margin: 0 }}>
{hasPassword ? '请输入旧密码和新密码' : '设置密码后可用密码登录'}
</p>
</div>
{/* 表单 */}
<div>
{hasPassword && (
<div style={fieldStyle}>
<div style={rowStyle}>
<label style={rowLabelStyle}></label>
<div style={rowInputWrapStyle}>
<input
type={showOld ? 'text' : 'password'}
placeholder="请输入旧密码"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
style={inputStyle}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
/>
<button
onClick={() => setShowOld(!showOld)}
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-tertiary)',
padding: '4px',
display: 'flex',
alignItems: 'center',
}}
>
{showOld ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
)}
</button>
</div>
</div>
<div style={{ ...errorStyle, paddingLeft: '72px' }} />
</div>
)}
<div style={fieldStyle}>
<div style={rowStyle}>
<label style={rowLabelStyle}></label>
<div style={rowInputWrapStyle}>
<input
type={showNew ? 'text' : 'password'}
placeholder="至少6位字符"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
style={inputStyle}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
/>
<button
onClick={() => setShowNew(!showNew)}
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-tertiary)',
padding: '4px',
display: 'flex',
alignItems: 'center',
}}
>
{showNew ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
)}
</button>
</div>
</div>
<div style={{ ...errorStyle, paddingLeft: '72px' }}>{pwdTooShort ? '密码至少需要6位' : ''}</div>
</div>
<div style={fieldStyle}>
<div style={rowStyle}>
<label style={rowLabelStyle}></label>
<div style={rowInputWrapStyle}>
<input
type={showConfirm ? 'text' : 'password'}
placeholder="再次输入新密码"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
style={inputStyle}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
/>
<button
onClick={() => setShowConfirm(!showConfirm)}
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-tertiary)',
padding: '4px',
display: 'flex',
alignItems: 'center',
}}
>
{showConfirm ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
)}
</button>
</div>
</div>
<div style={{ ...errorStyle, paddingLeft: '72px' }}>{pwdMismatch ? '两次输入的密码不一致' : ''}</div>
</div>
</div>
{/* 按钮 */}
<div className="confirm-modal-actions" style={{ marginTop: '4px' }}>
<button className="confirm-modal-btn cancel" onClick={handleClose}>
</button>
<button
className="confirm-modal-btn primary"
onClick={handleSubmit}
disabled={!canSubmit()}
>
{submitting ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
<path d="M21 12a9 9 0 11-6.219-8.56" />
</svg>
...
</span>
) : (
'保存'
)}
</button>
</div>
</div>
</div>
);
}
+58
View File
@@ -92,7 +92,41 @@
.login-card-desc {
color: var(--text-tertiary);
font-size: var(--font-sm);
margin-bottom: var(--spacing-lg);
}
/* 登录方式切换 */
.login-type-switch {
display: flex;
gap: 0;
margin-bottom: var(--spacing-xl);
border-radius: var(--radius-lg);
background: var(--bg-input);
padding: 3px;
}
.login-type-switch button {
flex: 1;
height: 36px;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-tertiary);
font-size: var(--font-sm);
font-weight: 500;
font-family: var(--font-family);
cursor: pointer;
transition: all var(--transition-fast);
}
.login-type-switch button.active {
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 1px 3px rgb(0 0 0 / 8%);
}
.login-type-switch button:hover:not(.active) {
color: var(--text-secondary);
}
/* 表单 */
@@ -192,6 +226,30 @@
color: var(--text-placeholder);
}
/* 密码输入框 */
.form-field input[type='password'] {
height: 48px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
padding: 0 var(--spacing-md);
font-size: var(--font-base);
background: var(--bg-input);
color: var(--text-primary);
transition: border-color var(--transition-fast);
width: 100%;
box-sizing: border-box;
outline: none;
}
.form-field input[type='password']:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgb(54 178 106 / 10%);
}
.form-field input[type='password']::placeholder {
color: var(--text-placeholder);
}
.send-code-btn {
flex-shrink: 0;
height: 48px;
+145 -88
View File
@@ -3,25 +3,28 @@ import { useAuthStore } from '../../store';
import { toast } from '../../store/uiStore';
import { client } from '../../api/client';
import TermsModal from '../../components/Modal/TermsModal';
import ResetPasswordModal from '../../components/Modal/ResetPasswordModal';
import './Login.css';
/**
* 登录页面
*
* 改进:
* - 使用全局 authStore 替代局部的 onLoginSuccess 回调
* - 使用新的 uiStore toast 方法
* 支持手机号验证码登录和密码登录两种方式
*/
export default function Login() {
const { login, isLoading } = useAuthStore();
const { login, loginWithPassword, isLoading } = useAuthStore();
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [agreed, setAgreed] = useState(false);
const [countdown, setCountdown] = useState(0);
const [sending, setSending] = useState(false);
const [logging, setLogging] = useState(false);
const [termsOpen, setTermsOpen] = useState(false);
const [termsTab, setTermsTab] = useState<'terms' | 'privacy'>('terms');
const [resetModalOpen, setResetModalOpen] = useState(false);
// 登录方式:'code' = 验证码, 'password' = 密码
const [loginType, setLoginType] = useState<'code' | 'password'>('code');
// 倒计时逻辑(必须放在所有条件返回之前,遵循 Hooks 规则)
useEffect(() => {
@@ -35,7 +38,10 @@ export default function Login() {
// 手机号格式验证
const isPhoneValid = /^1[3-9]\d{9}$/.test(phone);
const isCodeValid = /^\d{4,6}$/.test(code);
const canLogin = isPhoneValid && isCodeValid && agreed && !logging;
const isPasswordValid = password.length >= 6;
const canLogin = loginType === 'code'
? isPhoneValid && isCodeValid && agreed && !logging
: isPhoneValid && isPasswordValid && agreed && !logging;
// 发送验证码
const handleSendCode = useCallback(async () => {
@@ -62,9 +68,11 @@ export default function Login() {
setLogging(true);
try {
await login(phone, code);
// 登录成功后 authStore 会自动更新全局状态
// App.tsx 会检测到 isAuthenticated 变化并切换界面
if (loginType === 'code') {
await login(phone, code);
} else {
await loginWithPassword(phone, password);
}
toast.success('登录成功');
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : '登录失败,请重试');
@@ -99,7 +107,25 @@ export default function Login() {
{/* 登录卡片 */}
<div className="login-card">
<h2></h2>
<p className="login-card-desc">使</p>
<p className="login-card-desc">
{loginType === 'code' ? '使用手机号验证码快速登录' : '使用手机号密码登录'}
</p>
{/* 登录方式切换 */}
<div className="login-type-switch">
<button
className={loginType === 'code' ? 'active' : ''}
onClick={() => setLoginType('code')}
>
</button>
<button
className={loginType === 'password' ? 'active' : ''}
onClick={() => setLoginType('password')}
>
</button>
</div>
{/* 加载状态 */}
{isLoading ? (
@@ -119,91 +145,117 @@ export default function Login() {
</div>
) : (
<div className="login-form" onKeyDown={handleKeyDown}>
{/* 手机号 */}
<div className="form-field">
<label className="form-label"></label>
<div className="phone-input-group">
<span className="phone-prefix">+86</span>
<input
type="tel"
placeholder="请输入手机号"
value={phone}
onChange={e => {
const v = e.target.value.replace(/\D/g, '').slice(0, 11);
setPhone(v);
}}
maxLength={11}
autoFocus
/>
{/* 手机号 */}
<div className="form-field">
<label className="form-label"></label>
<div className="phone-input-group">
<span className="phone-prefix">+86</span>
<input
type="tel"
placeholder="请输入手机号"
value={phone}
onChange={e => {
const v = e.target.value.replace(/\D/g, '').slice(0, 11);
setPhone(v);
}}
maxLength={11}
autoFocus
/>
</div>
</div>
</div>
{/* 验证码 */}
<div className="form-field">
<label className="form-label"></label>
<div className="code-input-group">
<input
type="text"
placeholder="请输入验证码"
value={code}
onChange={e => {
const v = e.target.value.replace(/\D/g, '').slice(0, 6);
setCode(v);
}}
maxLength={6}
/>
<button
className="send-code-btn"
onClick={handleSendCode}
disabled={!isPhoneValid || countdown > 0 || sending}
>
{sending ? '发送中...' : countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</button>
</div>
</div>
{/* 登录按钮 */}
<button className="login-btn" onClick={handleLogin} disabled={!canLogin}>
{logging ? (
<span className="login-btn-loading">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ animation: 'spin 1s linear infinite' }}
>
<path d="M21 12a9 9 0 11-6.219-8.56" />
</svg>
...
</span>
{loginType === 'code' ? (
/* 验证码 */
<div className="form-field">
<label className="form-label"></label>
<div className="code-input-group">
<input
type="text"
placeholder="请输入验证码"
value={code}
onChange={e => {
const v = e.target.value.replace(/\D/g, '').slice(0, 6);
setCode(v);
}}
maxLength={6}
/>
<button
className="send-code-btn"
onClick={handleSendCode}
disabled={!isPhoneValid || countdown > 0 || sending}
>
{sending ? '发送中...' : countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</button>
</div>
</div>
) : (
'登录'
/* 密码 */
<div className="form-field">
<label className="form-label"></label>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</div>
)}
</button>
{/* 协议 */}
<div className="login-agreement">
<input
type="checkbox"
id="agree-checkbox"
checked={agreed}
onChange={e => setAgreed(e.target.checked)}
/>
<label htmlFor="agree-checkbox">
<a href="#" onClick={e => { e.preventDefault(); setTermsTab('terms'); setTermsOpen(true); }}>
</a>
<a href="#" onClick={e => { e.preventDefault(); setTermsTab('privacy'); setTermsOpen(true); }}>
</a>
</label>
{/* 登录按钮 */}
<button className="login-btn" onClick={handleLogin} disabled={!canLogin}>
{logging ? (
<span className="login-btn-loading">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ animation: 'spin 1s linear infinite' }}
>
<path d="M21 12a9 9 0 11-6.219-8.56" />
</svg>
...
</span>
) : (
'登录'
)}
</button>
{/* 忘记密码(仅在密码登录模式显示) */}
{loginType === 'password' && (
<div style={{ textAlign: 'right', marginTop: '-8px' }}>
<a
href="#"
onClick={e => { e.preventDefault(); setResetModalOpen(true); }}
style={{ fontSize: 'var(--font-sm)', color: 'var(--primary)', textDecoration: 'none' }}
>
</a>
</div>
)}
{/* 协议 */}
<div className="login-agreement">
<input
type="checkbox"
id="agree-checkbox"
checked={agreed}
onChange={e => setAgreed(e.target.checked)}
/>
<label htmlFor="agree-checkbox">
<a href="#" onClick={e => { e.preventDefault(); setTermsTab('terms'); setTermsOpen(true); }}>
</a>
<a href="#" onClick={e => { e.preventDefault(); setTermsTab('privacy'); setTermsOpen(true); }}>
</a>
</label>
</div>
</div>
</div>
)}
</div>
@@ -215,6 +267,11 @@ export default function Login() {
defaultTab={termsTab}
onClose={() => setTermsOpen(false)}
/>
<ResetPasswordModal
open={resetModalOpen}
onClose={() => setResetModalOpen(false)}
/>
</div>
);
}
+53 -1
View File
@@ -2,11 +2,13 @@ import { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import AppHeader from '../../components/Layout/AppHeader';
import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal';
import SetPasswordModal from '../../components/Modal/SetPasswordModal';
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { saveAppConfig } from '../../api/modules/config';
import { invoke } from '@tauri-apps/api/core';
import { toast } from '../../store/uiStore';
import { client } from '../../api/client';
import '../ContentManagement/ContentManagement.css';
const CURRENT_VERSION = __APP_VERSION__;
@@ -28,6 +30,11 @@ export default function Settings() {
// ── 环境切换 ──
const [showEnvModal, setShowEnvModal] = useState(false);
// ── 密码管理 ──
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [hasPassword, setHasPassword] = useState(false);
const [checkingPassword, setCheckingPassword] = useState(false);
// ── 缓存清理 ──
const [cacheSize, setCacheSize] = useState(0);
const [clearingCache, setClearingCache] = useState(false);
@@ -145,14 +152,28 @@ export default function Settings() {
}
}, []);
// 检查是否已设置密码
const checkHasPassword = useCallback(async () => {
setCheckingPassword(true);
try {
const res = await client.get<{ has_password: boolean }>('/auth/has-password');
setHasPassword(res.has_password);
} catch {
setHasPassword(false);
} finally {
setCheckingPassword(false);
}
}, []);
useEffect(() => {
fetchCacheSize();
checkHasPassword();
return () => {
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
}
};
}, [fetchCacheSize]);
}, [fetchCacheSize, checkHasPassword]);
// 清理媒体缓存(视频缓存 + BGM 缓存)
const handleClearCache = async () => {
@@ -280,6 +301,27 @@ export default function Settings() {
</div>
</div>
{/* 账号安全 */}
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="settings-row">
<span className="settings-row-label"></span>
<div className="settings-row-value" style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-md)', justifyContent: 'flex-end' }}>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}>
{checkingPassword ? '检查中...' : hasPassword ? '已设置' : '未设置'}
</span>
<button
className="btn btn-sm about-check-btn"
onClick={() => setShowPasswordModal(true)}
>
{hasPassword ? '修改密码' : '设置密码'}
</button>
</div>
</div>
</div>
</div>
{/* 缓存清理 */}
<div className="settings-section">
<h2></h2>
@@ -334,6 +376,16 @@ export default function Settings() {
onSave={handleSaveEnv}
onCancel={() => setShowEnvModal(false)}
/>
<SetPasswordModal
open={showPasswordModal}
hasPassword={hasPassword}
onClose={() => setShowPasswordModal(false)}
onSuccess={() => {
setHasPassword(true);
setShowPasswordModal(false);
}}
/>
</div>
);
}
+39
View File
@@ -37,6 +37,7 @@ interface AuthState {
interface AuthActions {
login: (_phone: string, _code: string) => Promise<void>;
loginWithPassword: (_phone: string, _password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => boolean;
loadFromStorage: () => Promise<void>;
@@ -282,6 +283,44 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
}
},
// 手机号密码登录
loginWithPassword: async (phone: string, password: string) => {
if (isLoggingIn) {return;}
isLoggingIn = true;
try {
const data = await client.post<{ accessToken: string; refreshToken: string; user: UserInfo }>('/auth/login-password', {
mobile: phone,
password,
deviceId: generateDeviceId(),
deviceName: '美家卡智影桌面端',
osInfo: navigator.userAgent,
appVersion: await getAppVersion(),
});
const newState = {
isAuthenticated: true,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
user: data.user,
isLoading: false,
showKickModal: false,
kickMessage: '',
};
set(newState);
await saveAuthState(newState);
clearAuthCache();
connectSSE(data.accessToken);
} catch (error) {
console.error('[authStore] 密码登录失败:', error);
isLoggingIn = false;
throw error;
} finally {
isLoggingIn = false;
}
},
closeKickModal: () => {
clearAuthState();
set({ ...initialState, isLoading: false, showKickModal: false, kickMessage: '' });