Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d40536b43 | |||
| 57cf32ac18 | |||
| 66749b7653 | |||
| d83080b628 | |||
| 923ff63a3d | |||
| c2209dec85 | |||
| da03669a99 | |||
| a5aeb58e6c |
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 载荷"""
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
用户登出。
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "1.8.2",
|
||||
"version": "1.8.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -4219,7 +4219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-app"
|
||||
version = "1.8.2"
|
||||
version = "1.8.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tauri-app"
|
||||
version = "1.8.2"
|
||||
version = "1.8.3"
|
||||
description = "美家卡智影 - AI 视频创作桌面应用"
|
||||
authors = ["美家卡科技"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '' });
|
||||
|
||||
Reference in New Issue
Block a user