223 Commits

Author SHA1 Message Date
yuming 93945ce25d ci: 构建镜像加 --no-cache 绕开 buildx 旧 layer 缓存
上次推的 d0ac3d9 已经把 statics.vue 改成干净版(1362 行,
grep 全项目找不到 handleCancelDeleteAuthor 等函数),但 CI 依然
报同样 5 处孤儿引用错误。唯一解释是 docker buildx 复用了几天前
的 COPY app/ layer(那个 layer 里 statics.vue 还是老的 1591 行)。

修改:docker build 加 --no-cache --pull,强制这次全新构建。
下次可以移除 --no-cache(旧缓存已被覆盖)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-04 15:12:06 +08:00
yuming 93abc85cff ci: 换回 Debian 源匹配(runner 实际是 Debian 11 不是 Ubuntu)
上次日志显示 gitea act_runner 用的 ubuntu-latest 底层是 Debian 11
bullseye,sources.list 里全是 deb.debian.org,我上一版 sed 只匹配
archive.ubuntu.com,一行都没换。

修正:
- sed 匹配 deb.debian.org / security.debian.org,换到阿里云
- Ubuntu 源规则保留兼容(万一以后 runner 镜像换)
- 拿到诊断日志后清理详细输出,恢复静默

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-04 14:40:06 +08:00
yuming a7741b9de2 ci: 装 Docker CLI 步骤加详细排查日志
之前 apt-get update 用 -qq 静默,看不到到底卡在哪。加日志:
- 打印 os-release、sources 文件列表和内容
- 换源后对比确认 sed 生效
- DNS 解析 + HTTP 探测 mirrors.aliyun.com
- apt-get update 去 -qq 输出详细

拿到日志再定位下一步(DNS 问题?network 问题?sources 格式问题?)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-04 14:35:18 +08:00
yuming 9038c39afa ci: 换 runner 内 Ubuntu apt 源到阿里云
上次跑到 apt-get update 60s 超时(timeout 兜底生效)。原因:
- Runner (ubuntu-latest) 用的是 archive.ubuntu.com / security.ubuntu.com
- 之前的 sed 只匹配 deb.debian.org,对 Ubuntu 源根本没换

修改:
- 换 Ubuntu 源到 mirrors.aliyun.com,兼容 legacy /etc/apt/sources.list、
  deb822 .sources 和 sources.list.d/*.list 三种格式
- update timeout 60s → 90s,install 180s → 240s,给国内源一点余量

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-04 14:28:12 +08:00
yuming 1899f90b5c ci: 装 Docker CLI 前先探测 + apt 加超时兜底
上次两次构建都在"安装 Docker CLI"这步 3h9m 卡死超时。原因:
- Runner 是 ubuntu-latest(Ubuntu 源),但 sed 只匹配 deb.debian.org,实际未换源
- apt-get 走原始 Ubuntu 源,网络出去慢就无限挂到全局 timeout

修改:
- 先 command -v docker 探测(gitea act_runner 大概率预装了 docker)
- 没有再走 apt 装,用 timeout 包裹各命令(update 60s / install 180s),超时快速失败而非 3h 挂死

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-04 11:27:31 +08:00
yuming 45d3a3de4d feat: 数据看板视频封面接口 + Cookie 编辑弹窗全屏化
- Controllers/VideoController.cs:补回 upstream merge 时丢失的 GetVideoCover 接口(/api/video/cover/{id}),修复数据看板作者视频列表封面加载失败
- app/src/App.vue:.full-modal 改成 flex + height:100vh 全屏抽屉式布局,footer 常驻底部,body flex:1 承担滚动,解决大视口下 modal 底部大片空白
- app/src/pages/cok/CookieTable.vue:删除 .modal-scroll-body 的双层滚动 hack(max-height + overflow:scroll),让 .ant-modal-body 独家承担滚动,修复小屏下 Cookie 编辑弹窗内容滑不动

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-03 23:34:20 +08:00
yuming e6c5587637 feat: Modal 小屏滚动 + 数据看板视频播放 + Docker Alpine 化
- 数据看板作者视频播放接口从 /api/video/stream 改为 /api/Video/play(后端只有 Play action,之前笔误导致 404)
- 全局 Modal 小屏适配:body max-height + 自定义 10px 深色滚动条,兼容 full-modal 和登录页 login-modal
- Docker 镜像换 Alpine + 内置 ffmpeg,避开 Ubuntu apt 网络问题
- 新增 .dockerignore 排除 node_modules / logs / videos / db 等构建噪音
- 移除已废弃的 DyQrCodeService(旧扫码登录)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-03 18:03:57 +08:00
yuming 30060fe29c feat: 将本地独有功能移植到 upstream 最新版本
基于 upstream/master (c5922f9) 新建分支,移植以下功能:
1. 扫码登录(DyQrCodeService + ConfigController 3个接口)
2. 视频预览组件(VideoPlayer.vue)
3. 下载配置服务(DownloadConfigService + AppConfig 6个扩展字段)
4. Gitea CI/CD(.gitea/workflows/deploy.yml)
5. 生产 Dockerfile(Dockerfile.prod,含中国镜像源配置)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 20:58:08 +08:00
jianzhichu c5922f9b08 1、博主签名字符串长度限制
2、仅下载图片时-一直重复下载BUG修复
3、cookie授权界面优化
2026-06-16 22:09:22 +08:00
jianzhichu e83ad8d85c 手动添加关注博主BUG修复 2026-06-10 20:26:16 +08:00
jianzhichu cc96e03cb9 1 2026-06-09 22:32:48 +08:00
jianzhichu 522fc6f699 修改查询腾讯docker镜像接口,检查最新版本的接口 2026-06-09 21:49:04 +08:00
jianzhichu c88afc2fd0 123 2026-06-08 12:28:34 +08:00
jianzhichu e48bada605 1 2026-05-10 23:45:21 +08:00
jianzhichu fdd19cd22b 1 2026-05-10 14:31:16 +08:00
jianzhichu 83e185a1f8 readme 2026-05-10 14:27:12 +08:00
jianzhichu 5e89b9c66f 重置cookie工具 2026-05-10 14:25:46 +08:00
jianzhichu 432ab60dd0 注释掉很多之前为了兼容旧数据的脚本(数据量大会导致启动时耗时很久)
增加重置密码功能
2026-05-01 17:54:53 +08:00
jianzhichu a21f745eac 1 2026-04-26 11:48:03 +08:00
jianzhichu dd22d46f26 1 2026-04-09 23:50:52 +08:00
jianzhichu 4a7aeeed8c 抽屉滚动事件不触发修复 2026-03-28 13:55:18 +08:00
jianzhichu b1bf488e84 1 2026-03-26 21:09:49 +08:00
jianzhichu a67093405a 移除发布文件 2026-03-26 21:09:43 +08:00
jianzhichu 01073fce52 关注列表bug 2026-03-26 20:52:14 +08:00
jianzhichu fc29ee21ea 收藏夹更新 2026-03-26 20:10:26 +08:00
jianzhichu 63f44adeca 自定义收藏夹bug 2026-03-26 20:07:42 +08:00
jianzhichu 8f738f1714 1 2026-03-19 20:52:48 +08:00
jianzhichu 77dbb8048e 关注列表增加2个搜索条件 2026-03-19 20:22:21 +08:00
jianzhichu 2aef300ab0 readme 2026-03-19 19:54:04 +08:00
jianzhichu 36d301d32b 封板 2026-03-18 19:22:47 +08:00
jianzhichu 3a47b1e028 访问不了外网的问题 docker-compose dns 2026-03-15 08:30:54 +08:00
jianzhichu 0900d2d754 yaml 2026-03-14 22:57:00 +08:00
jianzhichu c87bfcd3a7 REDME 2026-03-14 22:52:50 +08:00
jianzhichu 1fa67daab1 喝茶 2026-03-14 22:50:09 +08:00
jianzhichu 21e1aca7b5 配置关闭刮削开关 2026-03-14 19:29:58 +08:00
jianzhichu dbf98c781d 已知问题优化 2026-03-13 19:12:07 +08:00
jianzhichu e82e3a9140 头像存储目录优化 2026-03-01 12:00:54 +08:00
jianzhichu 5c57a2b040 合集 tvshow.nfo文件优化 2026-02-12 19:40:38 +08:00
jianzhichu cd24804273 合集 刮削优化 2026-02-12 17:29:22 +08:00
jianzhichu 9784114c90 一点点优化 2026-02-12 14:35:04 +08:00
jianzhichu 650e1ac3fc 图片合成视频 尺寸优化 2026-02-12 12:30:06 +08:00
jianzhichu 3439a7ad38 诸多优化 2026-02-11 20:44:31 +08:00
jianzhichu 8b98abc94d 优化 2026-02-06 23:17:50 +08:00
jianzhichu c1d5db18a9 210 2026-02-02 23:35:07 +08:00
jianzhichu 46bec7a0d4 关注列表 保存路径,同步关注列表的时候直接把保存路径设置好了=博主名字(这样可以避免你没设置路径,但是博主又改名字,导致同一个博主的视频,后面视频分散在多个文件夹),已经设置过路径的不会变 2026-02-01 21:58:11 +08:00
jianzhichu 77f227a6c5 修复黑暗模式sort列背景白色问题 2026-01-30 22:01:11 +08:00
jianzhichu a3e6af672e 优化 2026-01-28 22:07:44 +08:00
jianzhichu 8fd06cf2a0 一点小优化 2026-01-28 13:04:52 +08:00
jianzhichu 3578962a4c nfo文件封面识别失败问题处理 2026-01-28 09:56:18 +08:00
jianzhichu 8af041cf91 修复bug 2026-01-27 22:44:06 +08:00
jianzhichu edbcc13086 优化 2026-01-26 22:42:05 +08:00
jianzhichu 85986cdaa3 quartz数据库持久化 2026-01-26 18:29:19 +08:00
jianzhichu f13d73e023 find_博主sec_uid uid 2026-01-25 23:05:37 +08:00
jianzhichu 66093d3ed0 增加H264 265选择 2026-01-25 21:34:17 +08:00
jianzhichu b63621593b 优化,以及增加视频编码选择 2026-01-24 21:50:37 +08:00
jianzhichu e92e290a4e 优化 2026-01-24 15:51:45 +08:00
jianzhichu 3f6b1874eb 代码整理 2026-01-23 22:24:00 +08:00
jianzhichu 60f6e3847d 代码整理 2026-01-23 22:23:40 +08:00
jianzhichu c609eb4e6f readme 2026-01-23 13:58:39 +08:00
jianzhichu 36f11d4d79 抖音授权时配置的路径为上面 表格里面 容器内路径 解释 2026-01-23 13:54:30 +08:00
jianzhichu bdd70f73e4 合集下载路径 bug修复 2026-01-23 13:41:43 +08:00
jianzhichu 8390511675 曲线图,增加弹窗显示30天的数据 2026-01-22 23:24:51 +08:00
jianzhichu e011bd089f 关注博主列表同步bug修复 2026-01-22 21:38:42 +08:00
jianzhichu b331e73bd3 12 2026-01-22 15:08:02 +08:00
jianzhichu 357b20d211 调整readme 2026-01-22 13:43:28 +08:00
jianzhichu b53a41d3f1 授权配置图 2026-01-22 13:40:38 +08:00
jianzhichu a8c3201c1d 去掉大量多余的配置 2026-01-22 13:26:55 +08:00
jianzhichu eb0769d595 1 2026-01-22 08:41:00 +08:00
jianzhichu 33888aabdd find_user_id 2026-01-22 08:40:31 +08:00
jianzhichu e270397334 完成合集、短剧 2026-01-22 08:33:59 +08:00
jianzhichu 7175b3858a 短剧、合集 2026-01-20 00:20:37 +08:00
jianzhichu 7c6ee0a8a6 收藏夹 2026-01-19 17:30:32 +08:00
jianzhichu 65e6b48d8e 收藏夹 服务 2026-01-19 14:11:23 +08:00
jianzhichu f5449f3ace 结构调整 2026-01-19 09:57:40 +08:00
jianzhichu d9cb721f64 优化动态图视频合成 -处理多尺寸视频合并时变形的问题 2026-01-17 00:51:33 +08:00
jianzhichu dc38a25239 样式优化 2026-01-16 21:04:29 +08:00
jianzhichu 41a21e33fd 样式优化 2026-01-15 22:28:08 +08:00
jianzhichu f376498fe5 登录过期重复跳转问题彻底修复 2026-01-15 01:14:07 +08:00
jianzhichu ff80250012 1、nfo刮削文件优化,修复演员信息(视频作者),建议emby媒体库选影视,或者混合类型-右上角有一个兼容旧数据的按钮一键重置所有刮削文件
2、图文视频 以及动态视频,遇到因版权原因无法下载音频时,需要额外的音频文件,现在放到mp3目录了,docker-compose增加一个映射路径即可 例如: - /vol2/1000/media/dysync/mp3:/app/mp3
3、增加动态视频合成配置(不建议保留原视频,没有额外刮削的,因为有些视频是几十个很短的动图一样的视频拼接成的,保留原视频emby会出现一堆没有封面的视频)
4、容器重启后,手机端会连续跳转登录页很多次的bug修复
5、关注博主保存文件夹路径长度增加,允许_
6、批量永久删除选中的视频(不再下载,慎重)
7、看板页面 隐藏一个功能:双击作者头像,可以删除作者所有视频(永久删除,不再下载,慎重)
8、手机端 视频播放优化
2026-01-15 00:41:47 +08:00
jianzhichu 8da82889d1 默认音频改成随机从mp3目录取 2026-01-14 17:13:15 +08:00
jianzhichu 1f96f31c69 1 2026-01-14 13:29:11 +08:00
jianzhichu 186179bf14 1.9.9 2026-01-13 22:34:57 +08:00
jianzhichu 0727999cd6 修复动态视频多个小动态图视频 合成失败bug
其他优化
2026-01-13 22:14:50 +08:00
jianzhichu e32d783de4 1、nfo文件优化,修复演员信息(视频作者)
2、图文视频,因版权原因无法下载的音频,系统配置新增默认音频上传入口,上传成功后,后面在遇到无法下载音频时,将用该音频进行图文视频合成的音频数据
3、增加动态视频合成配置。
4、容器重启后,手机端会连续跳转登录页很多次的bug修复
5、其他优化
2026-01-12 22:13:59 +08:00
jianzhichu 858a21ec9e 1 2026-01-12 10:52:37 +08:00
jianzhichu 12b9190f45 1 2026-01-12 10:52:07 +08:00
jianzhichu 70c3dc71e5 unraid玩家需要增加 user配置 --user 1000:100 2026-01-12 10:50:45 +08:00
jianzhichu f4f87a8634 兼容原阿里云镜像,新push了一个最新的容器到
registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:latest
登录页面增加提示更新信息
2026-01-11 21:33:22 +08:00
jianzhichu 00e28fc334 优化,去掉原来兼容考虑的逻辑 2026-01-08 21:24:53 +08:00
jianzhichu a943a13347 ffmpeg进一步优化 2026-01-08 15:04:08 +08:00
jianzhichu 46b6a0269c 进一步优化ffmpeg 2026-01-08 15:03:47 +08:00
jianzhichu 3d9a5fddfb dockercompose 2026-01-08 12:57:50 +08:00
jianzhichu aa5d4878cb 版本更新 2026-01-08 00:23:47 +08:00
jianzhichu 1006de4a0d 下载音频时,可能不是mp3 可能是m4a格式,暂时就遇到这2种
ffmpeg命令优化
2026-01-08 00:23:08 +08:00
jianzhichu 4e0169da90 视频增加tag查询 2026-01-05 21:50:46 +08:00
jianzhichu b866cab3d8 图文视频合并bug修复,有些视频尺寸是奇数的,不符合H264编码要求,强制给尺寸+1 2026-01-03 21:56:33 +08:00
jianzhichu 0d35e4665c 免责声明 2026-01-02 21:18:25 +08:00
jianzhichu e34a1d3666 - 2026-01-02 15:44:43 +08:00
jianzhichu 00b9ec3013 尝试自动发布-docker-失败 2026-01-02 09:54:20 +08:00
jianzhichu e8852868cd ffmpeg fpk打包时路径修改 2026-01-01 23:55:59 +08:00
jianzhichu 66718ff478 docker-compose增加内存限制配置 2026-01-01 22:26:44 +08:00
jianzhichu 0532ef1006 2025->2026 2026-01-01 21:12:34 +08:00
jianzhichu 5a907e24f7 登录默认密码改成douyin2026,新年快乐 2026-01-01 21:11:42 +08:00
jianzhichu 87f154a8b7 1、永久删除视频时,同时删除原视频所在文件夹
2、图文视频不下载视频,仅下载图片或mp3时,每次都会重新调用下载资源接口bug修复
2026-01-01 21:09:19 +08:00
jianzhichu b9b3731bbe 腾讯镜像查询调整 2025-12-31 15:52:57 +08:00
jianzhichu 214d1259f4 1 2025-12-31 07:56:21 +08:00
jianzhichu d02a784c1b debug 2025-12-30 12:25:52 +08:00
jianzhichu 66bd441378 清理无效的视频同步记录 2025-12-30 09:27:06 +08:00
jianzhichu 96a66a9f54 处理个小bug-记录存在,文件被删了的情况 2025-12-29 20:26:35 +08:00
jianzhichu 727dba6e08 1 2025-12-28 00:32:55 +08:00
jianzhichu 3db406f699 增加命令行输入参数,数据库存储路径用于飞牛打包 2025-12-28 00:32:34 +08:00
jianzhichu 220c3ff140 关注列表同步bug 2025-12-27 16:45:13 +08:00
jianzhichu 59b454023d 1 2025-12-27 13:19:32 +08:00
jianzhichu 8ae0506d96 readme 2025-12-27 13:18:03 +08:00
jianzhichu e717672e1e 有点文字修改 2025-12-27 12:13:36 +08:00
jianzhichu c3e9e9c17d 改了下说明 2025-12-27 12:01:39 +08:00
jianzhichu 9e5e51fea8 遗漏的提交 2025-12-27 11:48:37 +08:00
jianzhichu cc6906ea63 飞牛fpk打包,新增一个初始化页面。用于飞牛fpk安装完成后初始配置 2025-12-27 11:44:30 +08:00
jianzhichu 44e3962093 最新同步的5条视频,手机浏览器播放 2025-12-21 13:29:40 +08:00
jianzhichu 413f19c537 1 2025-12-20 14:10:50 +08:00
jianzhichu 7666ab109d 优化 2025-12-20 12:19:49 +08:00
jianzhichu 0549688e6f mobile 更新 2025-12-19 09:52:55 +08:00
jianzhichu 086a0ea5af Douyin-User-Info-Extractor 工具有问题,获取到的cookie无效 2025-12-16 22:55:52 +08:00
jianzhichu 20e399b2e2 优化 2025-12-14 19:13:19 +08:00
jianzhichu 9391ace8c7 1 2025-12-11 22:26:16 +08:00
jianzhichu 6c34e7e163 1 2025-12-11 22:24:56 +08:00
jianzhichu 936e4a8ca1 1 2025-12-11 22:23:43 +08:00
jianzhichu c99a195ad4 docs 2025-12-11 22:22:41 +08:00
jianzhichu 6fd92a6662 12 2025-12-11 22:22:04 +08:00
jianzhichu ad9769c07c 1 2025-12-11 22:21:30 +08:00
jianzhichu 1574a7ceeb logdetail 2025-12-11 22:20:15 +08:00
jianzhichu 34910bb121 readme 2025-12-11 22:18:51 +08:00
jianzhichu d0c4322907 增加移动端,可以查看统计数据和日志 2025-12-11 22:16:03 +08:00
jianzhichu fc7886a76e 默认收起菜单 2025-12-08 21:22:56 +08:00
jianzhichu 1a9af56e05 修了一个小bug. 2025-12-08 20:53:10 +08:00
jianzhichu 90a1115c2e readme 2025-12-08 16:49:22 +08:00
jianzhichu 5c05e6465b 增加导出配置及手动添加的关注对象 2025-12-08 16:46:54 +08:00
jianzhichu 2550a1fde8 移除多余的前端页面 以及api 2025-12-08 12:45:30 +08:00
jianzhichu 772859eb63 优化 2025-12-08 11:35:47 +08:00
jianzhichu dba6bcdb36 优化视频下载逻辑顺序 2025-12-08 11:30:38 +08:00
jianzhichu 26380b9b4d 图文视频默认开启 2025-12-08 10:59:21 +08:00
jianzhichu 7b5d535f6b 配置更新... 2025-12-08 07:07:49 +08:00
jianzhichu 41e2bf6ce0 1、视频下载失败-403,重新切换其他下载地址尝试下载
2、支持动态视频下载(抖音里面属于图文-但是又不是图文)--实际是多个视频组合的这种。(没有将视频做合并的计划)
2025-12-07 23:09:02 +08:00
jianzhichu 123124bb51 readme 2025-12-06 13:12:26 +08:00
jianzhichu 72497c5fae 修复:图文视频视频下载 在改变图文视频配置(由下载图文视频改为不下载图文视频,但是下载图片时,对于重新下载的任务,会出现创建视频记录因为没又视频导致获取视频大小异常问题。 2025-12-06 13:12:01 +08:00
jianzhichu c60f7bdf57 增加已删除视频查看 2025-12-05 23:17:04 +08:00
jianzhichu 91056d9cfa 修复视频无法播放BUG 2025-12-05 12:49:57 +08:00
jianzhichu b58a091b53 登录保留时间改成一星期 2025-12-05 09:11:16 +08:00
jianzhichu a36c815861 登录过期不跳转问题 2025-12-05 08:30:51 +08:00
jianzhichu c03dc5beff 1、修复关注同步时报https证书错误问题
2、关注列表点头像跳抖音博主首页
2025-12-04 22:01:38 +08:00
jianzhichu 385e53561a 2 2025-12-04 20:09:35 +08:00
jianzhichu 18d1c4100d 2 2025-12-04 20:09:15 +08:00
jianzhichu f5c4459ad9 2 2025-12-04 20:08:49 +08:00
jianzhichu 4df896b30f Douyin-User-Info-Extractor 2025-12-04 20:08:09 +08:00
jianzhichu ceb08a5136 redme 2025-12-04 08:28:19 +08:00
jianzhichu edd0e71f8b 文件夹,标题 优化 2025-12-04 08:27:21 +08:00
jianzhichu 29d34201bd 文件名长度还是不能太长。。 2025-12-04 00:10:34 +08:00
jianzhichu 25e2a22dd2 1、删除视频,永不下载
2、去重-优先级
3、视频合成优化
4、其他优化
2025-12-03 23:43:10 +08:00
jianzhichu bd3a103f8f 去重优先级,视频合成,没有音频用默认音频 2025-12-02 22:14:42 +08:00
jianzhichu 4663a03483 样式调整 2025-12-02 14:41:59 +08:00
jianzhichu 4b2f9aaa7c 新增关注博主-支持删除 2025-12-02 13:00:05 +08:00
jianzhichu de5813a874 修复关注列表-新增非关注博主,丢失的bug 2025-12-02 09:20:11 +08:00
jianzhichu 6c99825548 添加非关注博主,验证是否已存在 2025-12-01 21:53:24 +08:00
jianzhichu 4e561a8e94 优化版本查看
最新版1.6.7
2025-12-01 21:48:10 +08:00
jianzhichu 2fedbafca8 cookie状态查询 2025-12-01 21:38:13 +08:00
jianzhichu b599880d7c readme 2025-12-01 19:38:34 +08:00
jianzhichu 072314fc52 readme 2025-12-01 19:33:26 +08:00
jianzhichu 4804a82967 优化部分代码 2025-12-01 19:29:58 +08:00
jianzhichu 13dfe09d93 小优化 2025-12-01 17:15:16 +08:00
lijianyou b6b915b0c5 Merge branch 'master' of http://nas.synology2023.online:28418/gitea/dysync.net 2025-12-01 13:33:34 +08:00
jianzhichu d947393b1d 播放页显示视频存储路径 2025-12-01 13:05:48 +08:00
lijianyou 830b6769f9 优化视频数据库数据保存逻辑,每次任务执行保存视频数量超过设置的每次查询数量时 跳出当前循环 2025-12-01 12:06:41 +08:00
jianzhichu c09b30de63 只维护2个版本一个x86 一个arm,取消精简版.统一都带图文视频(将ffmpeg打包到容器 2025-12-01 01:32:38 +08:00
jianzhichu 8744d280c7 处理linux文件命名长度问题,统一调整为60 2025-12-01 01:20:05 +08:00
jianzhichu 77628c9833 文件名长度问题... 2025-12-01 00:41:47 +08:00
jianzhichu 89d311901d 图文视频单独下载,文件名沿用视频名 2025-11-30 22:50:30 +08:00
jianzhichu 635d1c003b readme 2025-11-30 21:50:41 +08:00
jianzhichu 50149a280b bug修复 2025-11-30 21:33:18 +08:00
jianzhichu e42a004765 去掉原来博主配置的界面 2025-11-30 02:08:24 +08:00
jianzhichu d4b3e36591 分享复制链接处理 2025-11-30 02:01:32 +08:00
jianzhichu ea8f8948f8 1 2025-11-30 00:56:06 +08:00
jianzhichu 3de4b60005 1 2025-11-30 00:55:29 +08:00
jianzhichu e456982334 rdme 2025-11-30 00:46:49 +08:00
jianzhichu e6e9ff3cc3 1、去重
2、分享
3、视频播放
4、批量删除,重新下载
5、自定义标题(仅博主视频)
6、授权页面去掉博主添加,新增关注列表
7、图文视频合成优化
8、其他优化
2025-11-30 00:42:25 +08:00
jianzhichu 63b51d211e 1 2025-11-28 08:34:23 +08:00
jianzhichu 3093f2c78a 11 2025-11-28 08:33:58 +08:00
jianzhichu e59f3031da 111 2025-11-28 08:33:41 +08:00
jianzhichu 02200a4595 1 2025-11-26 23:56:35 +08:00
jianzhichu f501b218ca redme 2025-11-26 23:54:58 +08:00
jianzhichu 6bedb30602 同步记录中打开视频播放 2025-11-26 23:52:49 +08:00
jianzhichu 22a65b9833 readme 2025-11-26 10:27:59 +08:00
jianzhichu 4ff41dbaa0 Q群759876963 2025-11-26 09:41:18 +08:00
jianzhichu 12532f8128 rdm 2025-11-26 08:34:19 +08:00
jianzhichu a9bb8f5f48 rdm 2025-11-26 01:55:13 +08:00
jianzhichu ac51f7a50f 配置界面图更新 2025-11-26 01:50:39 +08:00
jianzhichu eecc0be509 md 2025-11-26 01:48:05 +08:00
jianzhichu 639a457b17 md 2025-11-26 01:46:39 +08:00
jianzhichu dd2b8da08a md 2025-11-26 01:40:58 +08:00
jianzhichu def3c442a5 md 2025-11-26 01:35:48 +08:00
jianzhichu 6bde9b75ff 前端优化
arm-ffmpeg DockerFile
2025-11-26 01:18:14 +08:00
jianzhichu 5b41e5eaeb 优化 2025-11-25 23:15:37 +08:00
jianzhichu 975baf80cd readme 2025-11-25 22:00:17 +08:00
jianzhichu b52acfdc62 readme 2025-11-25 21:56:43 +08:00
jianzhichu 898e4c115b 优化 2025-11-25 14:17:25 +08:00
jianzhichu 00aa82e763 修复up主视频不下载图文视频
优化ffmpeg命令
2025-11-25 00:04:45 +08:00
jianzhichu 2db61f4958 修复up主视频下载,不下载图文视频的bug
增加图文视频下载配置(是否合成视频,是否单独下载图片,单独下载音频)
2025-11-24 22:47:06 +08:00
jianzhichu 609649057f 修复配置项不生效bug 2025-11-24 08:17:41 +08:00
jianzhichu 116aa0c3e0 config bug修复 2025-11-24 08:06:39 +08:00
jianzhichu ef5cb09cb1 publish 2025-11-23 23:35:40 +08:00
jianzhichu 225e1614d7 md 2025-11-23 23:30:34 +08:00
jianzhichu 578131ac55 rdm 2025-11-23 23:29:02 +08:00
jianzhichu 309a0c1350 rdm 2025-11-23 23:27:41 +08:00
jianzhichu bd616e9b4f readme-docs 2025-11-23 23:26:45 +08:00
jianzhichu eadb3bb20d readme 2025-11-23 23:21:42 +08:00
jianzhichu 0fd7938901 readme 2025-11-23 23:21:00 +08:00
jianzhichu b4cee4f358 1 2025-11-23 23:07:42 +08:00
jianzhichu db008b2c45 111 2025-11-23 23:07:10 +08:00
jianzhichu 73d612fe50 完成前端改造
1、下一步实现各个分类下载视频中重复视频去除-可配置是否去重
2025-11-23 23:06:13 +08:00
jianzhichu e5f22e45c3 dockerfile 2025-11-23 02:22:59 +08:00
jianzhichu 85d74c7de3 1、完成图片视频
2、优化定时任务,抽出基类服务
3、todo:日志保存时间设置,默认10天
4、todo:同一个视频-只下载一次。就是对于收藏了,又喜欢了,或者又刚好是关注的up主,看哪个定时任务先同步到,后面就不在重复下载了
2025-11-23 02:21:33 +08:00
jianzhichu 8cef13c926 1、增加图片视频
2、优化代码结构
3、up主下载分类逻辑可配置
2025-11-23 00:46:41 +08:00
jianzhichu 7cacdaedd4 增加图片+mp3的这种视频 解析 2025-11-19 21:24:20 +08:00
jianzhichu 8048f7e1e6 博主的视频 增加配置 用标题做文件名,但是要对标题进行处理后才可以做文件名。另外对于可能重复的标题进行处理 2025-11-18 22:38:25 +08:00
227 changed files with 28962 additions and 14447 deletions
+13 -65
View File
@@ -1,67 +1,15 @@
# Git **/node_modules
.git **/.git
.gitignore logs
.gitattributes videos
db
# IDE bin
.vs obj
.vscode .claude
.playwright-mcp
.idea .idea
*.swp .codepilot-uploads
*.swo *.tar.gz
*~
# Build outputs
bin/
obj/
app/dist/
app/node_modules/
app/.vite/
# Logs and temp
logs/
*.log
*.tmp
# Local databases (mounted via volume in prod)
db/*.sqlite
db/*.db
# Video storage (mounted via volume)
videos/
collect/
favorite/
uper/
# Docs (Chinese filenames covered by *.md)
*.md
docs/
README*.md
# Tests
test/
tests/
*.test.*
*.spec.*
# Env
.env
.env.local
.env.*.local
# macOS
.DS_Store .DS_Store
._* **/.DS_Store
smoke-*.png
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Linux
.directory
# Misc
*.bak
*.backup
*.old
+16 -6
View File
@@ -10,17 +10,27 @@ jobs:
steps: steps:
- name: 安装 Docker CLI - name: 安装 Docker CLI
run: | run: |
# 替换 Debian 源为清华镜像,避免国内连 deb.debian.org 超时 if command -v docker >/dev/null 2>&1; then
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true echo "Docker 已预装:$(docker --version)"
sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true exit 0
apt-get update -qq fi
apt-get install -y -qq docker.io echo "Docker 未预装,换 apt 源到阿里云再安装(gitea act_runner 实际是 Debian 11)"
# Debian 源(当前 runner 实际用的)
sed -i 's|deb.debian.org|mirrors.aliyun.com|g;s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
sed -i 's|deb.debian.org|mirrors.aliyun.com|g;s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/*.list 2>/dev/null || true
# Ubuntu 源兼容(万一以后 runner 镜像换成 Ubuntu)
sed -i 's|archive.ubuntu.com|mirrors.aliyun.com|g;s|security.ubuntu.com|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
sed -i 's|archive.ubuntu.com|mirrors.aliyun.com|g;s|security.ubuntu.com|mirrors.aliyun.com|g' /etc/apt/sources.list.d/*.sources 2>/dev/null || true
timeout 90 apt-get update -qq || { echo "apt-get update 超时/失败"; exit 1; }
timeout 240 apt-get install -y -qq docker.io || { echo "docker.io 安装超时/失败"; exit 1; }
docker --version
- name: 拉取代码 - name: 拉取代码
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: 构建镜像 - name: 构建镜像
run: docker build -f Dockerfile.prod -t dysync:latest . # --no-cache:绕开 buildx 旧 layer 缓存(曾出现 CI 用几天前缓存的 statics.vue 导致 vue-tsc 报孤儿引用错误)
run: docker build --no-cache --pull -f Dockerfile.prod -t dysync:latest .
- name: 停止旧容器 - name: 停止旧容器
run: | run: |
+4 -24
View File
@@ -1,30 +1,10 @@
# 构建产物
obj obj
bin bin
.vs
logs logs
upload upload
data1 data1
# IDE
.vs
.idea/
*.csproj.user
# 抖音视频下载目录(本地数据,禁止入库)
videos/
# 构建打包产物
*.tar.gz
# SQLite 数据库及日志(仅忽略实例数据,不忽略迁移脚本)
/db/*.sqlite /db/*.sqlite
/db/*.db-journal /appsettings.json
/db/*.db-wal /Properties/PublishProfiles/fn.pubxml.user
/db/*.db-shm /Properties/PublishProfiles/*.user
# 飞牛 NAS 相关(当前只部署群晖,不入库)
*feiniu*
# 手动部署脚本(与 Gitea CI 流程重复,本地保留即可,不入库以免与 CI 冲突)
/deploy.sh
/docker-compose.yml
+1 -5
View File
@@ -1,8 +1,4 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.Json;
using Microsoft.Extensions.Configuration.Json;
using System;
using System.Collections.Generic;
using System.Linq;
namespace dy.net namespace dy.net
{ {
+18 -64
View File
@@ -1,13 +1,13 @@
using ClockSnowFlake; using ClockSnowFlake;
using dy.net.model.dto;
using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using dy.net.service;
using dy.net.utils;
using dy.net.dto;
namespace dy.net.Controllers namespace dy.net.Controllers
{ {
@@ -17,10 +17,10 @@ namespace dy.net.Controllers
{ {
private readonly IWebHostEnvironment webHostEnvironment; private readonly IWebHostEnvironment webHostEnvironment;
private readonly UserService _userService; private readonly AdminUserService _userService;
public AuthController(UserService userService, IWebHostEnvironment webHostEnvironment ) public AuthController(AdminUserService userService, IWebHostEnvironment webHostEnvironment)
{ {
_userService=userService; _userService = userService;
this.webHostEnvironment = webHostEnvironment; this.webHostEnvironment = webHostEnvironment;
} }
@@ -35,66 +35,21 @@ namespace dy.net.Controllers
public async Task<IActionResult> UpdatePwd(UpdatePwdRequest user) public async Task<IActionResult> UpdatePwd(UpdatePwdRequest user)
{ {
var (code, erro) = await _userService.UpdatePwd(user); var (code, erro) = await _userService.UpdatePwd(user);
return Ok(new { code, erro }); return ApiResult.Success("", erro);
} }
/// <summary>
[Authorize] /// 获取头像
/// </summary>
/// <returns></returns>
[HttpGet] [HttpGet]
public async Task<IActionResult> GetUserAvatar() public async Task<IActionResult> GetUserAvatar()
{ {
var user = await _userService.GetUser(); var user = await _userService.GetUser();
return Ok(new { code = 0, error = "", data = new { user?.Avatar, user?.Id,user?.UserName } }); return ApiResult.Success(new { user?.Avatar, user?.Id, user?.UserName });
} }
/// <summary>
/// 修改用户头像
/// </summary>
/// <param name="file"></param>
/// <returns></returns>
[Authorize]
[HttpPost]
public async Task<IActionResult> UpdateUserAvatar(IFormFile file)
{
if (file != null && file.Length > 0)
{
long maxFileSize = 5 * 1024 * 1024; // 限制文件大小为5MB
if (file.Length > maxFileSize)
{
return Ok(new { code = -1, erro = "文件最大只能上传5M" });
}
var fileName = $"{IdGener.GetGuid()}_{file.FileName}";
var filePath = webHostEnvironment.IsProduction() ?
Path.Combine(Md5Util.UPLOAD_PATH_PRO, fileName) : Path.Combine(Md5Util.UPLOAD_PATH_DEV, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
try
{
// 问了节约空间,删除文件夹下的所有文件
string[] files = Directory.GetFiles(webHostEnvironment.IsProduction() ? Md5Util.UPLOAD_PATH_PRO : Md5Util.UPLOAD_PATH_DEV);
foreach (string mfile in files)
{
if (!mfile.Contains(fileName))
System.IO.File.Delete(mfile);
}
}
catch (Exception ex)
{
Serilog.Log.Error($"delete file error ,{ex.Message}");
}
var update = await _userService.UpdateAvatar( fileName);
return Ok(new { code = update ? 0 : -1, erro = update ? "" : "上传失败", data = update ? fileName : "" });
}
}
else
{
return Ok(new { code = -1, erro = "空文件" });
}
}
@@ -108,7 +63,7 @@ namespace dy.net.Controllers
{ {
if (loginUserInfo == null) if (loginUserInfo == null)
{ {
return Ok(new { code = -1, erro = "参数不能为空" }); return ApiResult.Fail("参数不能为空");
} }
else else
{ {
@@ -116,7 +71,7 @@ namespace dy.net.Controllers
if (user == null) if (user == null)
{ {
return Ok(new { code = -1, erro = "用户名或密码不正确" }); return ApiResult.Fail("用户名或密码不正确");
} }
else else
{ {
@@ -124,19 +79,18 @@ namespace dy.net.Controllers
{ {
var tokenString = GenerateJwtToken(user.UserName); var tokenString = GenerateJwtToken(user.UserName);
return Ok(new { code = 0, erro = "", token = tokenString, expires = 24 * 60 * 60 * 1000, data = user.UserName });
return Ok(new { code = 0, erro = "", token = tokenString, expires = 24 * 60 * 60 * 1000 });
} }
else else
{ {
return Ok(new { code = -1, erro = "用户名或密码不正确" }); return ApiResult.Fail("用户名或密码不正确");
} }
} }
} }
} }
private string GenerateJwtToken(string username) private static string GenerateJwtToken(string username)
{ {
var claims = new[] var claims = new[]
{ {
@@ -147,7 +101,7 @@ namespace dy.net.Controllers
var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(k)); var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(k));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var expires = DateTime.Now.AddDays(1); var expires = DateTime.Now.AddDays(7);
var token = new JwtSecurityToken( var token = new JwtSecurityToken(
issuer: IdGener.GetLong().ToString(), issuer: IdGener.GetLong().ToString(),
+67
View File
@@ -0,0 +1,67 @@
using dy.net.model.dto;
using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class CateController : ControllerBase
{
private readonly DouyinCollectCateService _douyinCollectCateService;
private readonly DouyinQuartzJobService _douyinQuartzJobService;
public CateController(DouyinCollectCateService douyinCollectCateService, DouyinQuartzJobService douyinQuartzJobService)
{
this._douyinCollectCateService = douyinCollectCateService;
_douyinQuartzJobService = douyinQuartzJobService;
}
/// <summary>
/// 分页查询
/// </summary>
/// <returns>分页结果</returns>
[HttpPost("paged")]
public async Task<IActionResult> GetPagedAsync(
DouyinCollectCateRequestDto dto)
{
if (string.IsNullOrWhiteSpace(dto.cookieId))
{
return ApiResult.Success(new { });
}
var (list, totalCount) = await _douyinCollectCateService.GetPagedAsync(dto);
return ApiResult.Success(new
{
data = list,
total = totalCount,
pageIndex = dto.PageIndex,
pageSize = dto.PageSize
});
}
/// <summary>
/// 修改关注同步状态
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("BatchSave")]
public async Task<IActionResult> BatchSave(List<DouyinCollectCateSwitchDto> dto)
{
if (dto.Any(x => !DouyinFileNameHelper.IsValidWithoutSpecialChars(x.SaveFolder)))
{
return ApiResult.Fail("有部分文件名不符合要求,请检查");
}
var result = await _douyinCollectCateService.BatchSwitchSync(dto);
return ApiResult.SuccOrFail(result, result);
}
}
}
+380 -112
View File
@@ -1,219 +1,487 @@
using ClockSnowFlake; using ClockSnowFlake;
using dy.net.dto; using dy.net.extension;
using dy.net.model; using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service; using dy.net.service;
using dy.net.utils;
using jzc.http.lib;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using static Dm.net.buffer.ByteArrayBuffer; using System.Net;
using System.Text.RegularExpressions;
namespace dy.net.Controllers namespace dy.net.Controllers
{ {
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
[Authorize]
public class ConfigController : ControllerBase public class ConfigController : ControllerBase
{ {
private readonly DyCookieService dyCookieService; private readonly DouyinCookieService dyCookieService;
private readonly CommonService commonService; private readonly DouyinCommonService commonService;
private readonly QuartzJobService quartzJobService; private readonly DouyinQuartzJobService quartzJobService;
private readonly DouyinFollowService douyinFollowService;
private readonly DouyinCookieService douyinCookieService;
private readonly DouyinHttpClientService httpClientService;
public ConfigController(DyCookieService dyCookieService, CommonService commonService,QuartzJobService quartzJobService) public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService)
{ {
this.dyCookieService = dyCookieService; this.dyCookieService = dyCookieService;
this.commonService = commonService; this.commonService = commonService;
this.quartzJobService = quartzJobService; this.quartzJobService = quartzJobService;
this.douyinFollowService = douyinFollowService;
this.douyinCookieService = douyinCookieService;
this.httpClientService = httpClientService;
} }
/// <summary>
/// 导出配置
/// </summary>
/// <returns></returns>
[HttpGet("exportConf")]
public async Task<IActionResult> ExportConf()
{
// 1. 组装导出数据
var dto = new AppConfigImportDto()
{
follows = await douyinFollowService.GetHandFollows(),
conf = commonService.GetConfig(),
cookies = await douyinCookieService.GetAllAsync()
};
return ApiResult.Success(dto);
}
[HttpPost("importConf")]
public async Task<IActionResult> ImportConf(AppConfigImportDto dto)
{
if (dto == null)
return ApiResult.Fail("json数据为空");
await HandleFollowsImport(dto.follows);
await HandleConfigImport(dto.conf);
await HandleCookiesImport(dto.cookies);
return ApiResult.Success();
}
private async Task HandleFollowsImport(List<DouyinFollowed> follows)
{
if (follows?.Count > 0)
{
var added = await douyinFollowService.AddHandFollows(follows);
if (added)
Serilog.Log.Debug("关注列表导入成功");
}
}
private async Task HandleConfigImport(AppConfig conf)
{
if (conf == null) return;
if (conf.BatchCount > 30)
{
Serilog.Log.Debug("对不起,为了项目能长久稳定运行,还是最大不要超过30吧。。。");
conf.BatchCount = 30;
}
var updated = await commonService.UpdateConfig(conf);
if (updated)
Serilog.Log.Debug("系统配置导入成功");
}
private async Task HandleCookiesImport(List<DouyinCookie> cookies)
{
if (cookies?.Count > 0)
{
var imported = await douyinCookieService.ImportCookies(cookies);
if (imported)
Serilog.Log.Debug("抖音Cookie配置导入成功");
}
}
/// <summary> /// <summary>
/// 分页查询 /// 分页查询
/// </summary> /// </summary>
/// <returns>分页结果(视频列表和总数)</returns> /// <returns>分页结果</returns>
[HttpPost("paged")] [HttpPost("paged")]
public async Task<IActionResult> GetPagedAsync( public async Task<IActionResult> GetPagedAsync(
PageRequestDto dto) PageRequestDto dto)
{ {
var (list, totalCount) = await dyCookieService.GetPagedAsync(dto.PageIndex, dto.PageSize); var (list, totalCount) = await dyCookieService.GetPagedAsync(dto.PageIndex, dto.PageSize);
return Ok(new
{ return ApiResult.Success(new
code = 0,
data = new
{ {
data = list, data = list,
total=totalCount, total = totalCount,
pageIndex= dto.PageIndex, pageIndex = dto.PageIndex,
pageSize= dto.PageSize pageSize = dto.PageSize
} });
}
);
}
/// <summary>
/// 新增用户Cookie
/// </summary>
[HttpPost("add")]
public async Task<IActionResult> AddAsync([FromBody] DyUserCookies dyUserCookies)
{
if(dyUserCookies.UpSecUserIdsJson!=null)
{
dyUserCookies.UpSecUserIds = JsonConvert.SerializeObject( dyUserCookies.UpSecUserIdsJson);
}
var result = await dyCookieService.Add(dyUserCookies);
if (result)
{
await ReStartJob();
return Ok(new { code = 0 });
}
return BadRequest(new { code=-1, message = "添加失败" });
} }
/// <summary>
/// 查询所有用户Cookie
/// </summary>
/// <returns></returns>
[HttpGet("list")]
public async Task<IActionResult> GetAllList()
{
var follows = await douyinFollowService.GetGroupByCookieAsync();
return ApiResult.Success(follows);
}
/// <summary>
/// 是否已经初始化了
/// </summary>
/// <returns></returns>
[HttpGet("isInit")]
[AllowAnonymous]
public async Task<IActionResult> IsInit()
{
var init = await dyCookieService.IsInit();
return ApiResult.Success(init);
}
/// <summary>
/// 配合工具自动设置cookie
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("FastResetCookie")]
[AllowAnonymous]
public async Task<IActionResult> FastResetCookie([FromBody] DouyinCookieResetDto dto)
{
var cookieValid = await httpClientService.CheckCookie(new DouyinCookie { Cookies = dto.cookie });
if (!cookieValid)
return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie");
var result = await dyCookieService.FastResetCookie(dto.id, dto.cookie);
return result ? ApiResult.Success() : ApiResult.Fail("cookie设置失败");
}
/// <summary>
/// 配合工具自动设置cookie,获取当前设置的所有Cookie
/// </summary>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("Cookies")]
public async Task<IActionResult> GetAllCookies()
{
var cookies = await dyCookieService.GetAllAsync();
if (cookies != null)
return ApiResult.Success(cookies.Select(x => new { id= x.Id,name= x.UserName, status=x.StatusMsg }));
return ApiResult.Success();
}
/// <summary>
/// 非docker初始化
/// </summary>
[HttpPost("deskinit")]
[AllowAnonymous]
public async Task<IActionResult> DeskInitAsync([FromBody] DouyinCookie dyUserCookies)
{
// 1. 基础赋值
dyUserCookies.Id = IdGener.GetLong().ToString();
// 2. 路径权限校验
var (Success, Message) = ValidatePaths(dyUserCookies);
if (!Success)
return ApiResult.Fail(Message);
RemoveCookieLineString(dyUserCookies);
// 3. Cookie 有效性校验
var cookieValid = await httpClientService.CheckCookie(dyUserCookies);
if (!cookieValid)
return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie");
dyUserCookies.StatusCode = 0;
dyUserCookies.StatusMsg = "正常";
// 4. 保存到数据库
var saved = await dyCookieService.Add(dyUserCookies);
return saved ? ApiResult.Success() : ApiResult.Fail("添加失败");
}
private static (bool Success, string Message) ValidatePaths(DouyinCookie cookie)
{
var pathsToCheck = new Dictionary<string, string>
{
{ "收藏存储路径", cookie.SavePath },
{ "喜欢视频存储路径", cookie.FavSavePath },
{ "上传视频存储路径", cookie.UpSavePath },
// { "图片存储路径", cookie.ImgSavePath } // 可随时启用
};
foreach (var (label, path) in pathsToCheck)
{
if (!string.IsNullOrWhiteSpace(path))
{
if (!DouyinFileUtils.HasDirectoryReadWritePermission(path))
{
return (false, $"请在飞牛应用设置里面将 {path} 添加读写权限({label})");
}
}
}
if (string.IsNullOrWhiteSpace(cookie.SavePath))
{
return (false, "收藏存储路径不能为空");
}
return (true, string.Empty);
}
/// <summary>
/// 快速开启或停止
/// </summary>
[HttpPost("switch")]
public async Task<IActionResult> SwitchAsync([FromBody] DouyinCookieSwitchDto dto)
{
var result = await dyCookieService.Switch(dto);
if (result)
{
ReStartJob();
return ApiResult.Success();
}
return ApiResult.Fail("添加失败");
}
/// <summary> /// <summary>
/// 更新用户Cookie /// 更新用户Cookie
/// </summary> /// </summary>
[HttpPost("update")] [HttpPost("update")]
public async Task<IActionResult> UpdateAsync([FromBody] DyUserCookies dyUserCookies) public async Task<IActionResult> AddOrUpdateAsync([FromBody] DouyinCookie dyUserCookies)
{ {
RemoveCookieLineString(dyUserCookies);
if (dyUserCookies.UpSecUserIdsJson != null ) var checkCk = await httpClientService.CheckCookie(dyUserCookies);
if (!checkCk)
{ {
dyUserCookies.UpSecUserIds = JsonConvert.SerializeObject(dyUserCookies.UpSecUserIdsJson); return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie");
} }
dyUserCookies.StatusCode = 0;
dyUserCookies.StatusMsg = "正常";
if (dyUserCookies.Id == "0") if (dyUserCookies.Id == "0")
{ {
dyUserCookies.Id=IdGener.GetLong().ToString(); dyUserCookies.Id = IdGener.GetLong().ToString();
var result = await dyCookieService.Add(dyUserCookies); var result = await dyCookieService.Add(dyUserCookies);
if (result) if (result)
{ {
await ReStartJob(); ReStartJob();
return Ok(new { code = 0 }); return ApiResult.Success();
} }
return BadRequest(new { code = -1, message = "添加失败" }); return ApiResult.Fail("添加失败");
} }
else { else
var result = await dyCookieService.UpdateAsync(dyUserCookies); {
var result = await dyCookieService.UpdateCookieAsync(dyUserCookies);
if (result) if (result)
{ {
await ReStartJob(); ReStartJob();
return Ok(new { code = 0 }); return ApiResult.Success();
} }
return BadRequest(new { code = -1, message = "更新失败" }); return ApiResult.Fail("更新失败");
} }
} }
private static void RemoveCookieLineString(DouyinCookie dyUserCookies)
{
if (dyUserCookies != null && !string.IsNullOrWhiteSpace(dyUserCookies.Cookies))
{
var s = dyUserCookies.Cookies.Replace("\\r\\n", "\r\n");
var ss = s.Trim(new char[] { '\r', '\n' });
dyUserCookies.Cookies = ss;
}
}
/// <summary> /// <summary>
/// 批量删除用户Cookie /// 批量删除用户Cookie
/// </summary> /// </summary>
[HttpGet("delete")] [HttpGet("delete")]
public async Task<IActionResult> DeleteAsync(string id) public async Task<IActionResult> DeleteAsync(string id)
{ {
var count = await dyCookieService.DeleteByIdsAsync(new List<string> { id}); var count = await dyCookieService.DeleteByIdsAsync(new List<string> { id });
if (count > 0) { if (count > 0)
await ReStartJob(); {
ReStartJob();
} }
return Ok(new { code = 0, deletedCount = count }); return ApiResult.Success(count);
} }
[HttpGet("GetConfig")] [HttpGet("GetConfig")]
public IActionResult GetConfig() public IActionResult GetConfig()
{ {
var data = commonService.GetConfig(); var data = commonService.GetConfig();
return Ok(new { code = 0, data = data }); return ApiResult.Success(data);
} }
[HttpPost("UpdateConfig")] [HttpPost("UpdateConfig")]
public async Task<IActionResult> UpdateConfig(AppConfig config) public async Task<IActionResult> UpdateConfig(AppConfig config)
{ {
var data = await commonService.UpdateConfig(config); var update = await commonService.UpdateConfig(config);
if (data) { if (update)
await ReStartJob(); {
if (config.OnlySyncNew)
{
Serilog.Log.Debug("仅同步新视频配置已生效,后续所有类型的视频同步将只会读取最近一页约20条数据");
} }
return Ok(new { code = 0, data = data });
ReStartJob();
}
return ApiResult.Success(update);
} }
/// <summary> /// <summary>
/// 立即执行同步任务 ///
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpGet("ExecuteJobNow")] [HttpGet("ExecuteJobNow")]
[Authorize] //[Authorize]
public async Task<IActionResult> ExecuteJobNow() public async Task<IActionResult> ExecuteJobNow()
{ {
var config = commonService.GetConfig(); var config = commonService.GetConfig();
await quartzJobService.StartJob(config.Cron); if (config != null)
return Ok(new { code = 0 , error = "" }); await quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
return ApiResult.Success();
} }
private void ReStartJob()
{
var config = commonService.GetConfig();
if (config != null)
quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
//避免前端等待
}
/// <summary> /// <summary>
/// 重置指定Cookie的同步状态,强制重新同步 /// 镜像标签
/// </summary> /// </summary>
/// <param name="id">Cookie ID</param>
/// <param name="type">重置类型:collect=收藏, favorite=喜欢, all=全部</param>
/// <returns></returns> /// <returns></returns>
[Authorize] [HttpGet("mytag")]
[HttpPost("ResetSyncStatus")] public async Task<IActionResult> GetMyTag()
public async Task<IActionResult> ResetSyncStatus(string id, string type = "all")
{ {
try var deploy = Appsettings.Get("deploy");
{ var tag = Appsettings.Get("tagName");
var cookie = await dyCookieService.GetByIdAsync(id); tag = deploy == "fn" ? "fn_" + Appsettings.Get("fnVersion") : tag;
if (cookie == null) deploy = deploy == "fn" ? "fnos" : "docker";
{ return ApiResult.Success(new { tag, deploy });
return BadRequest(new { code = -1, error = "Cookie不存在" });
} }
if (type == "collect" || type == "all") [AllowAnonymous]
[HttpGet("checktag")]
public async Task<IActionResult> CheckTag()
{ {
cookie.CollHasSyncd = 0;
}
if (type == "favorite" || type == "all")
{
cookie.FavHasSyncd = 0;
}
if (type == "all")
{
cookie.UperSyncd = 0;
}
var result = await dyCookieService.UpdateAsync(cookie); var deploy = Appsettings.Get("deploy");
if (result) if (string.IsNullOrWhiteSpace(deploy))
{ {
Serilog.Log.Information($"重置Cookie[{cookie.UserName}]的同步状态,类型: {type}"); return await GetDockerTagVersions();
return Ok(new { code = 0, message = "同步状态已重置" });
} }
else else
{ {
return BadRequest(new { code = -1, error = "重置失败" }); if (deploy == "fn")//飞牛
}
}
catch (Exception ex)
{ {
Serilog.Log.Error(ex, "重置同步状态失败"); return ApiResult.Success(new List<string> { "fn_" + Appsettings.Get("fnVersion") });
return StatusCode(500, new { code = -1, error = "重置失败:" + ex.Message }); }
else
{
return await GetDockerTagVersions();
} }
} }
private async Task ReStartJob() {
var config= commonService.GetConfig();
//var cookies = await dyCookieService.GetAllCookies();
//重置同步状态
//foreach (var cookie in cookies)
//{
// cookie.CollHasSyncd = 0;
// cookie.FavHasSyncd = 0;
// cookie.UperSyncd = 0;
// await dyCookieService.UpdateAsync(cookie);
//}
if (config!=null)
quartzJobService.StartJob(config.Cron);
//避免前端等待
} }
private static async Task<IActionResult> GetDockerTagVersions()
{
var data = await JianZhiChuHttpHelper.GetTenImage(Appsettings.Get("tagName"));
if (data.IsSuccessStatusCode)
{
var content = await data.Content.ReadAsStringAsync();
var tagData = JsonConvert.DeserializeObject<DouyinApiResponse<List<string>>>(content);
if (tagData != null && tagData.Data != null && tagData.Data.Count > 0)
return ApiResult.Success(tagData.Data);
return ApiResult.Fail();
}
else
{
return ApiResult.Fail("请求失败");
}
}
/// <summary>
/// 查询mp3目录下有没有音频文件
/// </summary>
/// <returns></returns>
[HttpGet("mp3List")]
public async Task<IActionResult> GetExistMps()
{
var path = Path.Combine(AppContext.BaseDirectory, "mp3");
if (!string.IsNullOrWhiteSpace(ServiceExtension.FnDataFolder))
{
path = ServiceExtension.FnDataFolder;
}
if (Directory.Exists(path))
{
var allowedExtensions = new HashSet<string> { ".mp3", ".wav" };
var customMusics = Directory.GetFiles(path)
.Where(filePath =>
allowedExtensions.Contains(Path.GetExtension(filePath).ToLowerInvariant()) &&
Path.GetFileNameWithoutExtension(filePath) != "silent_10")
.ToList();
var fileNames = customMusics.Select(f => new { filename = Path.GetFileName(f) }).Where(x => x.filename != "silent_10.mp3").ToList();
return ApiResult.Success(fileNames);
}
else
{
return ApiResult.Fail("没有找到默认音频文件");
}
}
/// <summary>
/// 播放音频流
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("getmp3")]
public async Task<IActionResult> GetMp3([FromQuery] string name)
{
var path = Path.Combine(AppContext.BaseDirectory, "mp3", name);
if (!string.IsNullOrWhiteSpace(ServiceExtension.FnDataFolder))
{
path = Path.Combine(ServiceExtension.FnDataFolder, name);
}
if (System.IO.File.Exists(path))
{
//返回mp3文件流
var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read);
return new FileStreamResult(fileStream, "audio/mpeg")
{
FileDownloadName = name
};
}
else
{
return ApiResult.Fail("文件不存在");
}
}
} }
} }
+122
View File
@@ -0,0 +1,122 @@
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class FollowController : ControllerBase
{
private readonly DouyinFollowService _douyinFollowService;
private readonly DouyinQuartzJobService _douyinQuartzJobService;
public FollowController(DouyinFollowService douyinFollowService, DouyinQuartzJobService douyinQuartzJobService)
{
this._douyinFollowService = douyinFollowService;
_douyinQuartzJobService = douyinQuartzJobService;
}
/// <summary>
/// 分页查询
/// </summary>
/// <returns>分页结果</returns>
[HttpPost("paged")]
public async Task<IActionResult> GetPagedAsync(
FollowRequestDto dto)
{
if (string.IsNullOrWhiteSpace(dto.MySelfId))
{
return ApiResult.Success(new { });
}
var (list, totalCount) = await _douyinFollowService.GetPagedAsync(dto);
return ApiResult.Success(new
{
data = list,
total = totalCount,
pageIndex = dto.PageIndex,
pageSize = dto.PageSize
});
}
/// <summary>
/// 重新同步-单次
/// </summary>
/// <returns></returns>
[HttpGet("sync")]
public async Task<IActionResult> SyncFollowList()
{
//后台异步
_douyinQuartzJobService.StartFollowJobOnceAsync();
await Task.Delay(1000);
return ApiResult.Success();
}
[HttpPost("add")]
public async Task<IActionResult> AddFollow(DouyinFollowed followed)
{
if (string.IsNullOrWhiteSpace(followed.mySelfId))
{
return ApiResult.Fail("请先配置抖音授权信息,抖音授权配置里面填写你的uid");
}
var res = await _douyinFollowService.AddAsync(followed);
return ApiResult.SuccOrFail(res, "", res ? "" : "添加失败,或者已存在相同secuid和uid");
}
/// <summary>
/// 修改关注同步状态
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("openOrCloseSync")]
public async Task<IActionResult> OpenOrCloseSync(FollowUpdateDto dto)
{
if (dto.OpenSync)
{
if (!string.IsNullOrWhiteSpace(dto.SavePath))
{
if (!DouyinFileNameHelper.IsValidWithoutSpecialChars(dto.SavePath))
{
return ApiResult.Fail("请输入有效文件夹名称(字母数字中文简体)");
}
if (dto.SavePath.Length > 20)
{
return ApiResult.Fail("请输入有效文件夹名称(最长20)");
}
}
}
var result = await _douyinFollowService.OpenOrCloseSync(dto);
return ApiResult.SuccOrFail(result, result);
}
/// <summary>
/// 修改关注全量同步状态
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("openOrCloseFullSync")]
public async Task<IActionResult> OpenOrCloseFullSync(FollowUpdateDto dto)
{
return await OpenOrCloseSync(dto);
}
/// <summary>
/// 删除关注对象
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete")]
public async Task<IActionResult> DeleteFollow(FollowUpdateDto dto)
{
var result = await _douyinFollowService.DeleteFollow(dto);
return ApiResult.SuccOrFail(result, result);
}
}
}
+60 -6
View File
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Http; using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Net.Http;
namespace dy.net.Controllers namespace dy.net.Controllers
{ {
@@ -9,19 +9,21 @@ namespace dy.net.Controllers
public class LogsController : ControllerBase public class LogsController : ControllerBase
{ {
private readonly IWebHostEnvironment webHostEnvironment; private readonly IWebHostEnvironment webHostEnvironment;
private readonly LogInfoService logInfoService;
public LogsController(IWebHostEnvironment webHostEnvironment) public LogsController(IWebHostEnvironment webHostEnvironment, LogInfoService logInfoService)
{ {
this.webHostEnvironment = webHostEnvironment; this.webHostEnvironment = webHostEnvironment;
this.logInfoService = logInfoService;
} }
[HttpGet] [HttpGet("/api/logs/GetLog/{type}/{date}")]
public async Task<IActionResult> GetLog(string type, string date) public async Task<IActionResult> GetLog([FromRoute] string type, [FromRoute] string date)
{ {
var filePath = Path.Combine(webHostEnvironment.IsDevelopment() ? Directory.GetCurrentDirectory() : AppDomain.CurrentDomain.BaseDirectory, "logs", $"log-{type}-{date}.txt"); var filePath = Path.Combine(webHostEnvironment.IsDevelopment() ? Directory.GetCurrentDirectory() : AppDomain.CurrentDomain.BaseDirectory, "logs", $"log-{type}-{date}.txt");
if (!System.IO.File.Exists(filePath)) if (!System.IO.File.Exists(filePath))
{ {
var msg = $"Log file log-{type}-{date}.txt not found."; var msg = $"{date},没有发现{type}的日志";
//Serilog.Log.Error(msg); //Serilog.Log.Error(msg);
return Ok(msg); return Ok(msg);
} }
@@ -35,5 +37,57 @@ namespace dy.net.Controllers
} }
/// <summary>
/// 获取最近10天的日志文件列表
/// </summary>
[HttpGet("/api/logs/list")]
public IActionResult GetLogFiles()
{
try
{
var _logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
var files = logInfoService.GetLogFiles(_logDirectory);
return ApiResult.Success(files);
}
catch (Exception ex)
{
return ApiResult.Fail("获取日志列表失败," + ex.Message);
}
}
/// <summary>
/// 获取日志文件内容流
/// </summary>
[HttpGet("/api/logs/content")]
public IActionResult GetLogContent([FromQuery] LogContentRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
try
{
var _logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
var stream = logInfoService.GetLogFileStream(_logDirectory, request.Type, request.Date);
// 返回文件流,指定MIME类型为文本
return File(stream, "text/plain", $"log-{request.Type}-{request.Date}.txt");
}
catch (FileNotFoundException ex)
{
return NotFound(new { message = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { message = ex.Message });
}
catch (Exception ex)
{
return StatusCode(500, new { message = "读取日志文件失败", error = ex.Message });
}
}
} }
} }
+381 -184
View File
@@ -1,10 +1,9 @@
using dy.net.dto; using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service; using dy.net.service;
using dy.net.utils; using dy.net.utils;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Drawing.Printing;
namespace dy.net.Controllers namespace dy.net.Controllers
{ {
@@ -12,48 +11,29 @@ namespace dy.net.Controllers
[ApiController] [ApiController]
public class VideoController : ControllerBase public class VideoController : ControllerBase
{ {
private readonly DyCollectVideoService dyCollectVideoService; private readonly DouyinVideoService douyinVideoService;
private readonly DouyinCommonService douyinCommonService;
public VideoController(DyCollectVideoService dyCollectVideoService) public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService)
{ {
this.dyCollectVideoService = dyCollectVideoService; this.douyinVideoService = dyCollectVideoService;
this.douyinCommonService = douyinCommonService;
} }
/// <summary> /// <summary>
/// 分页查询收藏视频 /// 分页查询收藏视频
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
[Authorize]
[HttpPost("paged")] [HttpPost("paged")]
public async Task<IActionResult> GetPagedAsync(VideoPageRequestDTO dto) public async Task<IActionResult> GetPagedAsync(DouyinVideoPageRequestDto dto)
{ {
// 调试日志:输出接收到的排序参数 var (list, totalCount) = await douyinVideoService.GetPagedAsync(dto);
Console.WriteLine($"排序参数 - SortField: {dto.SortField}, SortOrder: {dto.SortOrder}"); return ApiResult.Success(new
var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Tags, dto.Author, dto.ViedoType, dto.Dates, dto.IsDeleted, dto.SortField, dto.SortOrder);
return Ok(new
{
code = 0,
data = new
{ {
data = list, data = list,
total=totalCount, total = totalCount,
pageIndex=dto.PageIndex, pageIndex = dto.PageIndex,
pageSize=dto.PageSize pageSize = dto.PageSize
}
});
}
/// <summary>
/// 获取所有视频类型列表(用于下拉框)
/// </summary>
/// <returns></returns>
[HttpGet("categories")]
public async Task<IActionResult> GetAllCategories()
{
var categories = await dyCollectVideoService.GetAllCategoriesAsync();
return Ok(new
{
code = 0,
data = categories
}); });
} }
@@ -61,158 +41,199 @@ namespace dy.net.Controllers
/// 查询统计数据 /// 查询统计数据
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[Authorize]
[HttpGet("statics")] [HttpGet("statics")]
public async Task<IActionResult> GetStaticsAsync() public async Task<IActionResult> GetStaticsAsync()
{ {
var data = await dyCollectVideoService.GetStatics(); return ApiResult.Success(await douyinVideoService.GetStatics());
return Ok(new
{
code = 0,
data
});
} }
/// <summary> /// <summary>
/// 清理数据库中文件不存在的视频记录 /// 播放视频
/// </summary> /// </summary>
/// <param name="vid"></param>
/// <returns></returns> /// <returns></returns>
[Authorize] [AllowAnonymous]
[HttpPost("cleanup")] [HttpGet("play/{vid}")]
public async Task<IActionResult> CleanupMissingFilesAsync() public async Task<IActionResult> StreamVideo([FromRoute] string vid)
{ {
try try
{ {
Serilog.Log.Information("开始清理文件不存在的视频记录"); var viedo = await douyinVideoService.GetById(vid);
var deletedCount = await dyCollectVideoService.CleanupMissingFilesAsync();
Serilog.Log.Information($"清理完成,删除了 {deletedCount} 条记录"); if (viedo == null)
return Ok(new
{ {
code = 0, return ApiResult.Fail($"视频不存在:{vid}");
data = new
{
deletedCount = deletedCount,
message = $"清理完成,删除了 {deletedCount} 条文件不存在的记录"
} }
}); return await PlayVideoAsync(viedo);
} }
catch (Exception ex) catch (Exception ex)
{ {
Serilog.Log.Error(ex, "清理文件不存在的记录失败"); return ApiResult.Fail($"视频加载失败:{ex.Message}");
return StatusCode(500, new { code = -1, error = "清理失败:" + ex.Message });
} }
} }
/// <summary> /// <summary>
/// 删除指定作者的所有视频数据 /// 抖音视频播放接口(优化版)
/// 解决内存泄漏、资源释放不及时、鲁棒性不足等问题
/// </summary> /// </summary>
/// <param name="authorName">作者名称</param> /// <param name="video">视频实体(修正拼写错误:viedo -> video)</param>
/// <returns></returns> /// <returns>视频流响应</returns>
[Authorize] public async Task<IActionResult> PlayVideoAsync(DouyinVideo video)
[HttpPost("delete-author")]
public async Task<IActionResult> DeleteAuthorVideosAsync([FromBody] DeleteAuthorRequest request)
{ {
// 空值校验
if (video == null)
{
return ApiResult.Fail("视频信息不能为空");
}
// 1. 获取完整物理路径并校验
string videoFullPath = video.VideoSavePath;
if (string.IsNullOrWhiteSpace(videoFullPath))
{
return ApiResult.Fail("视频保存路径不能为空");
}
// 安全校验:防止路径遍历攻击
try try
{ {
if (string.IsNullOrWhiteSpace(request?.AuthorName)) videoFullPath = Path.GetFullPath(videoFullPath);
}
catch
{ {
return BadRequest(new { code = -1, error = "作者名称不能为空" }); return ApiResult.Fail("视频路径格式非法");
} }
Serilog.Log.Information($"开始删除作者 {request.AuthorName} 的视频数据"); // 2. 验证文件是否存在
var deletedCount = await dyCollectVideoService.DeleteAuthorVideosAsync(request.AuthorName); if (!System.IO.File.Exists(videoFullPath))
Serilog.Log.Information($"删除完成,删除了 {deletedCount} 条记录");
return Ok(new
{ {
code = 0, return ApiResult.Fail($"视频文件不存在:{videoFullPath}");
data = new
{
deletedCount = deletedCount,
message = $"已删除作者 {request.AuthorName} 的 {deletedCount} 条视频记录"
} }
});
try
{
// 3. 获取文件信息(使用using确保FileInfo资源释放)
var fileInfo = new FileInfo(videoFullPath);
long fileSize = fileInfo.Length;
// 校验空文件
if (fileSize == 0)
{
return ApiResult.Fail($"视频文件为空:{videoFullPath}");
}
string contentType = GetContentType(videoFullPath);
// 4. 处理分片请求(Range)
if (Request.Headers.ContainsKey("Range"))
{
return await HandleRangeRequestAsync(videoFullPath, fileSize, contentType);
}
else
{
// 完整文件请求(启用分片处理,兼容前端断点续传)
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
}
}
catch (UnauthorizedAccessException)
{
return ApiResult.Fail($"没有权限访问视频文件:{videoFullPath}");
}
catch (IOException ex)
{
// 记录日志(建议添加日志框架,如Serilog/NLog)
// _logger.LogError(ex, "读取视频文件失败:{Path}", videoFullPath);
return ApiResult.Fail($"读取视频文件失败:{ex.Message}");
} }
catch (Exception ex) catch (Exception ex)
{ {
Serilog.Log.Error(ex, $"删除作者视频失败: {request?.AuthorName}"); // _logger.LogError(ex, "视频播放接口异常:{Path}", videoFullPath);
return StatusCode(500, new { code = -1, error = "删除失败:" + ex.Message }); return ApiResult.Fail($"服务器内部错误:{ex.Message}");
} }
} }
/// <summary> /// <summary>
/// 删除作者请求DTO /// 处理分片请求(Range),异步安全处理流
/// </summary> /// </summary>
public class DeleteAuthorRequest /// <param name="filePath">文件路径</param>
/// <param name="fileSize">文件总大小</param>
/// <param name="contentType">内容类型</param>
/// <returns>分片响应</returns>
private async Task<IActionResult> HandleRangeRequestAsync(string filePath, long fileSize, string contentType)
{ {
public string AuthorName { get; set; } string rangeHeader = Request.Headers["Range"].ToString();
// 安全解析Range头
if (!rangeHeader.StartsWith("bytes="))
{
// 416 - 请求的范围无法满足
Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable;
Response.Headers.Add("Content-Range", $"bytes */{fileSize}");
return new EmptyResult();
} }
// 解析起始位置
/// <summary> string[] rangeParts = rangeHeader.Split('=')[1].Split('-');
/// 获取视频流(支持Range请求,用于视频播放) if (!long.TryParse(rangeParts[0], out long start) || start < 0 || start >= fileSize)
/// </summary>
/// <param name="id">视频ID</param>
/// <returns></returns>
[Authorize]
[HttpGet("stream/{id}")]
public async Task<IActionResult> GetVideoStream(string id)
{ {
try Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable;
{ Response.Headers.Add("Content-Range", $"bytes */{fileSize}");
var video = await dyCollectVideoService.GetByIdAsync(id); return new EmptyResult();
if (video == null || string.IsNullOrWhiteSpace(video.VideoSavePath))
{
return NotFound(new { code = -1, error = "视频不存在" });
} }
if (!System.IO.File.Exists(video.VideoSavePath)) // 计算分片结束位置(每片2MB,可配置)
{ long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1);
return NotFound(new { code = -1, error = "视频文件不存在" }); long chunkSize = end - start + 1;
}
var fileInfo = new FileInfo(video.VideoSavePath); // 设置206分片响应头
var contentType = GetContentType(video.VideoSavePath); Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
// 记录日志,便于调试
var rangeHeader = Request.Headers["Range"].ToString();
Serilog.Log.Debug($"视频流请求: ID={id}, 路径={video.VideoSavePath}, Content-Type={contentType}, Range={rangeHeader}");
var fileStream = new FileStream(video.VideoSavePath, FileMode.Open, FileAccess.Read, FileShare.Read);
// 使用ASP.NET Core内置的Range支持
Response.Headers.Add("Accept-Ranges", "bytes"); Response.Headers.Add("Accept-Ranges", "bytes");
Response.Headers.Add("Content-Length", chunkSize.ToString());
Response.ContentType = contentType;
// 添加缓存控制头,确保浏览器可以缓存视频元数据 // 核心改进:使用异步流 + using确保释放(通过管道直接写入响应流)
// 但允许 Range 请求,这样浏览器可以快速开始播放 await using var fileStream = new FileStream(
Response.Headers.Add("Cache-Control", "public, max-age=3600"); filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 8192, // 增大缓冲区提升性能,减少IO次数
FileOptions.Asynchronous | FileOptions.SequentialScan); // 顺序扫描优化
// 添加 CORS 头(如果需要) // 定位到分片起始位置
Response.Headers.Add("Access-Control-Allow-Origin", "*"); fileStream.Seek(start, SeekOrigin.Begin);
Response.Headers.Add("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
Response.Headers.Add("Access-Control-Allow-Headers", "Range");
Response.Headers.Add("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length");
return File(fileStream, contentType, enableRangeProcessing: true); // 直接写入响应流,避免FileStreamResult的延迟释放问题
} var buffer = new byte[8192];
catch (Exception ex) long remainingBytes = chunkSize;
while (remainingBytes > 0)
{ {
Serilog.Log.Error(ex, $"获取视频流失败: {id}"); int bytesRead = await fileStream.ReadAsync(buffer, 0, (int)Math.Min(remainingBytes, buffer.Length));
return StatusCode(500, new { code = -1, error = "获取视频流失败" }); if (bytesRead == 0) break;
await Response.Body.WriteAsync(buffer.AsMemory(0, bytesRead));
remainingBytes -= bytesRead;
} }
await Response.Body.FlushAsync();
return new EmptyResult();
} }
/// <summary> /// <summary>
/// 获取视频封面图片 /// 获取视频封面图片
/// </summary> /// </summary>
/// <param name="id">视频ID</param> /// <param name="id">视频ID</param>
/// <returns></returns>
[Authorize] [Authorize]
[HttpGet("cover/{id}")] [HttpGet("cover/{id}")]
public async Task<IActionResult> GetVideoCover(string id) public async Task<IActionResult> GetVideoCover([FromRoute] string id)
{ {
try try
{ {
var video = await dyCollectVideoService.GetByIdAsync(id); var video = await douyinVideoService.GetById(id);
if (video == null) if (video == null)
{ {
return GetDefaultCoverImage(); return GetDefaultCoverImage();
@@ -221,13 +242,12 @@ namespace dy.net.Controllers
// 优先使用本地保存的封面 // 优先使用本地保存的封面
if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath) && System.IO.File.Exists(video.VideoCoverSavePath)) if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath) && System.IO.File.Exists(video.VideoCoverSavePath))
{ {
var fileInfo = new FileInfo(video.VideoCoverSavePath);
var contentType = GetImageContentType(video.VideoCoverSavePath); var contentType = GetImageContentType(video.VideoCoverSavePath);
var fileStream = System.IO.File.OpenRead(video.VideoCoverSavePath); var fileStream = System.IO.File.OpenRead(video.VideoCoverSavePath);
return File(fileStream, contentType); return File(fileStream, contentType);
} }
// 如果没有本地封面,返回默认图片 // 本地封面缺失时回落到默认占位图
return GetDefaultCoverImage(); return GetDefaultCoverImage();
} }
catch (Exception ex) catch (Exception ex)
@@ -238,7 +258,7 @@ namespace dy.net.Controllers
} }
/// <summary> /// <summary>
/// 返回默认封面图片(SVG 占位图) /// 默认封面占位图(SVG)
/// </summary> /// </summary>
private IActionResult GetDefaultCoverImage() private IActionResult GetDefaultCoverImage()
{ {
@@ -259,7 +279,6 @@ namespace dy.net.Controllers
{ {
return "image/jpeg"; return "image/jpeg";
} }
var extension = Path.GetExtension(fileName).ToLowerInvariant(); var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch return extension switch
{ {
@@ -273,74 +292,252 @@ namespace dy.net.Controllers
}; };
} }
/// <summary>
/// 播放视频
/// </summary>
/// <param name="vid"></param>
/// <param name="k"></param>
/// <returns></returns>
[HttpGet("/share/{vid}/{k}")]
[AllowAnonymous]
public async Task<IActionResult> Share([FromRoute] string vid, [FromRoute] string k)
{
try
{
var viedo = await douyinVideoService.GetById(vid);
if (viedo == null)
{
return ApiResult.Fail($"视频不存在:{vid}");
}
var expectedKey = (viedo.FileHash + viedo.AuthorId).Md5();
if (expectedKey != k)
{
return ApiResult.Fail($"视频地址无效");
}
return await PlayVideoAsync(viedo);
}
catch (Exception ex)
{
return ApiResult.Fail($"视频加载失败:{ex.Message}");
}
}
/// <summary> /// <summary>
/// 根据文件扩展名获取Content-Type /// 辅助方法:根据文件名获取 MIME 类型(确保前端正确识别视频格式)
/// </summary> /// </summary>
private string GetContentType(string fileName) private static string GetContentType(string filename)
{ {
if (string.IsNullOrWhiteSpace(fileName)) string extension = Path.GetExtension(filename).ToLowerInvariant();
{ return extension switch
Serilog.Log.Warning($"文件名为空,使用默认 video/mp4");
return "video/mp4"; // 抖音视频默认是 MP4
}
var extension = Path.GetExtension(fileName).ToLowerInvariant();
// 如果没有扩展名,尝试从文件名推断(抖音视频格式通常是 mp4)
if (string.IsNullOrEmpty(extension))
{
Serilog.Log.Warning($"文件 {fileName} 没有扩展名,使用默认 video/mp4");
return "video/mp4";
}
var contentType = extension switch
{ {
".mp4" => "video/mp4", ".mp4" => "video/mp4",
".webm" => "video/webm", _ => "application/octet-stream" // 默认二进制流
".ogg" => "video/ogg",
".mov" => "video/quicktime",
".avi" => "video/x-msvideo",
".mkv" => "video/x-matroska",
".flv" => "video/x-flv",
".wmv" => "video/x-ms-wmv",
".3gp" => "video/3gpp",
".m3u8" => "application/x-mpegURL", // HLS 流
_ => null
}; };
// 如果无法识别扩展名,默认使用 video/mp4(抖音视频通常是 MP4)
if (contentType == null)
{
Serilog.Log.Warning($"无法识别的视频扩展名: {extension},文件: {fileName},使用默认 video/mp4");
return "video/mp4";
}
return contentType;
}
[AllowAnonymous]
[HttpGet]
public IActionResult Index()
{
List<MyClass> myClasses = new List<MyClass>();
foreach (var drive in DriveInfo.GetDrives())
{
myClasses.Add(new MyClass { name=drive.Name, x1= drive.TotalSize, x2= drive.TotalFreeSpace });
Serilog.Log.Debug($"Drive: {drive.Name}, Total Size: {drive.TotalSize}, Free Space: {drive.TotalFreeSpace}");
}
//var disk=DiskInfoHelper.GetDockerHostTotalDiskSpaceGB();
return Ok(JsonConvert.SerializeObject(myClasses)+"\r\n"+JsonConvert.SerializeObject(myClasses.Sum(x=>x.x1)+"\r\n"+ JsonConvert.SerializeObject(myClasses.Sum(x => x.x2))));
} }
public class MyClass /// <summary>
/// 重新下载
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("redown")]
public async Task<IActionResult> ReDownload(ReDownViedoDto dto)
{ {
public string name { get; set; } if (dto == null)
{
return ApiResult.Fail("参数错误");
}
else
{
var result = await douyinVideoService.ReDownloadViedoAsync(dto);
if (result)
{
return ApiResult.Success(true);
}
else
{
return ApiResult.Fail("错误");
}
}
}
public long x1 { get; set; } /// <summary>
/// 批量删除
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("vdelete/batch")]
public async Task<IActionResult> BathRealDelete(ReDownViedoDto dto)
{
var result = await douyinVideoService.RealDeleteVideos(dto.Ids);
if (result)
{
return ApiResult.Success(true);
}
else
{
return ApiResult.Fail("错误");
}
}
public long x2 { get; set; }
/// <summary>
/// 删除视频-不再下载
/// </summary>
/// <param name="vid"></param>
/// <returns></returns>
[HttpGet("vdelete/{vid}")]
public async Task<IActionResult> DeleteVideo([FromRoute] string vid)
{
if (string.IsNullOrWhiteSpace(vid))
{
return ApiResult.Fail("参数错误");
}
else
{
var res = await douyinVideoService.RealDeleteVideos(new List<string> { vid });
if (res)
{
return ApiResult.Success("删除成功");
}
else
{
return ApiResult.Fail("删除失败");
}
}
}
/// <summary>
/// 查询已删除视频列表
/// </summary>
/// <returns></returns>
[HttpGet("vdelete/get")]
public async Task<IActionResult> GetDeleteVideo()
{
return ApiResult.Success(await douyinCommonService.GetDouyinDeleteVideos());
}
/// <summary>
/// 根据博主id删除博主所有视频
/// </summary>
/// <param name="uperUid"></param>
/// <returns></returns>
[HttpGet("vdelete/byauthor/{uperUid}")]
public async Task<IActionResult> DeleteByAuthor([FromRoute] string uperUid)
{
var videos = await douyinVideoService.GetByAuthorId(uperUid);
if (videos != null && videos.Any())
{
var res = await douyinVideoService.RealDeleteVideos(videos.Select(x => x.Id).ToList());
if (res)
{
return ApiResult.Success("删除成功");
}
else
{
return ApiResult.Fail("删除失败");
}
}
return ApiResult.Fail("未找到该博主视频");
}
//private async Task<(bool flowControl, IActionResult value)> BatchDeleteVideos(List<DouyinVideo> videos)
//{
// return (flowControl: true, value: null);
//}
/// <summary>
/// 查询最新N条数据
/// </summary>
/// <param name="top"></param>
/// <returns></returns>
[HttpGet("top{top}")]
public async Task<IActionResult> GetLastSyncTop([FromRoute] int top = 5)
{
return ApiResult.Success(await douyinVideoService.GetLastSyncTop(top));
}
/// <summary>
/// 删除无效视频记录
/// </summary>
/// <returns></returns>
[HttpGet("removeInvalid")]
public async Task<IActionResult> RemoveInvalidVideo()
{
var data = await douyinVideoService.DeleteInvalidVideo();
return Ok(data);
}
///// <summary>
///// 所有视频重新生成nfo文件
///// </summary>
///// <returns></returns>
//[HttpGet("renfo")]
//public async Task<IActionResult> ReCreateNfo()
//{
// var videos = await douyinVideoService.GetAllAsync();
// if (videos == null || videos.Count == 0)
// {
// return ApiResult.Success("暂无视频数据需要生成NFO文件");
// }
// _ = Task.Run(async () =>
// {
// try
// {
// var totalCount = videos.Count;
// foreach (var video in videos)
// {
// try
// {
// NfoFileGenerator.GenerateVideoNfoFile(video);
// Serilog.Log.Debug($"刮削视频(Path:{video.VideoSavePath})生成NFO成功!");
// await Task.Delay(50);
// }
// catch (Exception singleEx)
// {
// Serilog.Log.Error($"刮削视频(Path:{video.VideoSavePath})生成NFO失败:{singleEx.Message}");
// }
// }
// }
// catch (Exception ex)
// {
// Serilog.Log.Error($"NFO文件生成任务执行异常:{ex.Message}\n{ex.StackTrace}");
// }
// });
// return ApiResult.Success();
//}
/// <summary>
/// 获取7天视频同步趋势数据(曲线图)
/// </summary>
/// <returns>7天图表数据列表</returns>
[HttpGet("chart/{day}")]
public async Task<IActionResult> Chart([FromRoute] int day = 7)
{
try
{
var chartData = await douyinVideoService.GetChartData(day);
return ApiResult.Success(chartData);
}
catch (Exception ex)
{
return ApiResult.Fail(ex.Message);
}
}
[HttpGet("/Move")]
public async Task<IActionResult> Move()
{
await douyinVideoService.HandOldFolderVideos();
return ApiResult.Success(DateTime.Now);
} }
} }
} }
+21
View File
@@ -0,0 +1,21 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 10101
RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main non-free contrib" > /etc/apt/sources.list && \
echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \
echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main non-free contrib" >> /etc/apt/sources.list && \
echo "deb http://mirrors.aliyun.com/debian/ bookworm-backports main non-free contrib" >> /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*
RUN ffmpeg -version
COPY . .
ENV ASPNETCORE_URLS=http://*:10101
ENV TZ=Asia/Shanghai
ENTRYPOINT ["dotnet", "dy.net.dll"]
+17
View File
@@ -0,0 +1,17 @@
# 使用 Ubuntu 20.04 的 .NET 6 运行时镜像
FROM mcr.microsoft.com/dotnet/aspnet:6.0-focal AS base
WORKDIR /app
EXPOSE 10101
# 安装 ffmpeg (Ubuntu 的 universe 仓库包含 ffmpeg)
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*
RUN ffmpeg -version
COPY . .
ENV ASPNETCORE_URLS=http://*:10101
ENV TZ=Asia/Shanghai
ENTRYPOINT ["dotnet", "dy.net.dll"]
+6 -23
View File
@@ -1,27 +1,21 @@
# 多阶段构建 Dockerfile # 多阶段构建 Dockerfile
# 第一阶段:构建前端 # 第一阶段:构建前端
# 使用官方 Docker Hub(mcr 能访问的环境一般也能访问 docker.io)
FROM node:18-alpine AS frontend-builder FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
# 复制前端依赖文件
COPY app/package.json app/pnpm-lock.yaml* ./ COPY app/package.json app/pnpm-lock.yaml* ./
# 配置 npm 和 pnpm 使用国内镜像源
RUN npm config set registry https://registry.npmmirror.com && \ RUN npm config set registry https://registry.npmmirror.com && \
npm install -g pnpm && \ npm install -g pnpm && \
pnpm config set registry https://registry.npmmirror.com && \ pnpm config set registry https://registry.npmmirror.com && \
if [ -f pnpm-lock.yaml ]; then \ if [ -f pnpm-lock.yaml ]; then \
pnpm install --frozen-lockfile; \ pnpm install --no-frozen-lockfile; \
else \ else \
npm install; \ npm install; \
fi fi
# 复制前端源码
COPY app/ ./ COPY app/ ./
# 构建前端
RUN pnpm build || npm run build RUN pnpm build || npm run build
# 第二阶段:构建后端 # 第二阶段:构建后端
@@ -29,41 +23,30 @@ FROM mcr.microsoft.com/dotnet/sdk:6.0 AS backend-builder
WORKDIR /src WORKDIR /src
# 复制项目文件
COPY *.csproj ./ COPY *.csproj ./
RUN dotnet restore dy.net.csproj RUN dotnet restore dy.net.csproj
# 复制所有文件
COPY . . COPY . .
# 删除 macOS 资源分叉文件(._* 文件)
RUN find . -name "._*" -type f -delete RUN find . -name "._*" -type f -delete
# 复制前端构建产物
COPY --from=frontend-builder /app/frontend/dist ./app/dist COPY --from=frontend-builder /app/frontend/dist ./app/dist
# 发布后端
RUN dotnet publish dy.net.csproj -c Release -o /app/publish RUN dotnet publish dy.net.csproj -c Release -o /app/publish
# 第三阶段:运行镜像 # 第三阶段:运行镜像(Alpine 版:apk 包更小、代理下更易通过)
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS final FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS final
WORKDIR /app WORKDIR /app
# 设置时区
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN apk add --no-cache tzdata ffmpeg && \
cp /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone
# 从构建阶段复制发布文件
COPY --from=backend-builder /app/publish . COPY --from=backend-builder /app/publish .
# 暴露端口
EXPOSE 10101 EXPOSE 10101
# 设置环境变量
ENV ASPNETCORE_URLS=http://*:10101 ENV ASPNETCORE_URLS=http://*:10101
ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_ENVIRONMENT=Production
# 启动应用
ENTRYPOINT ["dotnet", "dy.net.dll"] ENTRYPOINT ["dotnet", "dy.net.dll"]
+178 -107
View File
@@ -1,14 +1,7 @@
using ClockSnowFlake; using dy.net.extension;
using dy.net.dto;
using dy.net.extension;
using dy.net.model;
using dy.net.service; using dy.net.service;
using dy.net.utils; using dy.net.utils;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;
using Serilog; using Serilog;
using SqlSugar;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
@@ -16,136 +9,190 @@ namespace dy.net
{ {
public class Program public class Program
{ {
// 常量定义 // 常量定义
private const string DefaultListenUrl = "http://*:10101"; private static readonly string DefaultListenUrl = "http://*:10101";
private const string SpaRootPath = "app/dist"; private const string SpaRootPath = "app/dist";
private const string SpaSourcePath = "app/"; private const string SpaSourcePath = "app/";
private const string SwaggerDocTitle = "dy.net WebApi Docs"; private const string SwaggerDocTitle = "dysync.net WebApi Docs";
public static void Main(string[] args) /// <summary>
///
/// </summary>
public static async Task Main(string[] args)
{ {
// 初始化编码提供器 // 初始化编码提供器
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// 构建Web应用
// 构建Web应用
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var isDevelopment = builder.Environment.IsDevelopment(); var isDevelopment = builder.Environment.IsDevelopment();
// 配置主机
// 配置主机
ConfigureHost(builder, isDevelopment); ConfigureHost(builder, isDevelopment);
// 配置服务--数据库保存路径从命令行参数传入的第一个参数
// 配置服务 string dbPath = args.Length > 0 ? args[0] : "";
var services = builder.Services; ConfigureServices(builder.Services, builder.Configuration, builder.Environment, dbPath);
ConfigureServices(services, builder.Configuration, isDevelopment); // 构建应用
// 构建应用
var app = builder.Build(); var app = builder.Build();
// 配置中间件
ConfigureMiddleware(app, builder.Environment);
Log.Debug($"dy.sync app is started successfully on {DefaultListenUrl}");
// 初始化应用服务
await InitApplicationServices(app, isDevelopment, dbPath);
Console.WriteLine();
// 配置中间件 await app.RunAsync();
ConfigureMiddleware(app, isDevelopment);
// 初始化应用服务
InitApplicationServices(app);
// 启动应用
Serilog.Log.Debug("dy.net service started successfully");
app.Run();
} }
/// <summary> /// <summary>
/// 配置主机设置 /// 配置主机设置
/// </summary> /// </summary>
private static void ConfigureHost(WebApplicationBuilder builder, bool isDevelopment) private static void ConfigureHost(WebApplicationBuilder builder, bool isDevelopment)
{ {
// 设置监听地址 InitAppsettings(builder);
//from docker yaml file 环境变量 或者 dockerfile 或appsettings.json
// 设置监听地址
builder.WebHost.UseUrls(DefaultListenUrl); builder.WebHost.UseUrls(DefaultListenUrl);
// 配置日志
// 配置配置文件
builder.Host.ConfigureAppConfiguration((context, config) =>
{
config.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddEnvironmentVariables();
});
// 配置日志
builder.Host.ConfigureLogging(logging => logging.ClearProviders())
.UseSerilog();
builder.ConfigureLogging(); builder.ConfigureLogging();
} }
/// <summary> private static void InitAppsettings(WebApplicationBuilder builder)
/// 配置依赖注入服务
/// </summary>
private static void ConfigureServices(IServiceCollection services, IConfiguration config, bool isDevelopment)
{ {
// 雪花ID生成器 // 配置配置文件
services.AddSnowFlakeId(options => options.WorkId = new Random().Next(1, 127)); builder.Host.ConfigureAppConfiguration((context, config) =>
{
// 定义配置文件名(统一小写,适配Linux大小写敏感特性)
const string configFileName = "appsettings.json";
// MVC控制器 // 步骤1:获取多维度的候选基础路径(覆盖不同部署场景)
services.AddControllers(); var candidateBasePaths = new List<string>
{
// 候选1:程序集所在目录(优先,解决工作目录不一致问题)
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
// 候选2:当前工作目录(兜底)
Directory.GetCurrentDirectory(),
// 候选3:应用根目录(针对单文件发布/容器部署场景)
AppContext.BaseDirectory,
// 候选4:自定义环境变量指定的配置目录(灵活性扩展)
Environment.GetEnvironmentVariable("APP_CONFIG_DIR")
}
// 过滤空值和无效路径
.Where(path => !string.IsNullOrEmpty(path) && Directory.Exists(path))
.Distinct() // 去重
.ToList();
// HTTP客户端 // 步骤2:遍历候选路径,查找存在的配置文件
string configFilePath = null;
foreach (var basePath in candidateBasePaths)
{
var tempPath = Path.Combine(basePath, configFileName);
if (File.Exists(tempPath))
{
configFilePath = tempPath;
break; // 找到第一个存在的配置文件即可
}
}
// 步骤3:配置加载(增加容错和日志提示)
if (!string.IsNullOrEmpty(configFilePath))
{
// 找到配置文件,正常加载
var basePath = Path.GetDirectoryName(configFilePath);
config.SetBasePath(basePath)
.AddJsonFile(configFileName, optional: false, reloadOnChange: true)
.AddEnvironmentVariables();
// 可选:输出日志,确认配置文件加载路径(方便排查)
//Console.WriteLine($"成功加载配置文件:{configFilePath}");
}
else
{
// 未找到配置文件,抛出明确异常(或根据需求调整为兜底逻辑)
throw new FileNotFoundException(
$"未找到配置文件 {configFileName},已检查以下路径:{string.Join("; ", candidateBasePaths)}",
configFileName);
}
});
}
/// <summary>
/// 配置依赖注入服务
/// </summary>
private static void ConfigureServices(IServiceCollection services, IConfiguration config, IWebHostEnvironment environment, string dbPath)
{
//打印logo
//PrintApp();
services.AddSingleton(new Appsettings(config));
// 雪花ID生成器
services.AddSnowFlakeId(options => options.WorkId = new Random().Next(1, 100));
// MVC控制器+异常拦截器
services.AddControllers().AddGlobalExceptionFilter();
// HTTP客户端
services.AddHttpClients(); services.AddHttpClients();
// 数据库 // 数据库
services.AddSqlsugar(config); services.AddSqlsugar(dbPath);
// 定时任务 // 定时任务
services.AddQuartzService(config); services.AddQuartzService(dbPath);
// 仓储和服务注册 // 仓储和服务注册
services.AddServicesFromNamespace("dy.net.repository") services.AddServicesFromNamespace("dy.net.repository")
.AddServicesFromNamespace("dy.net.service"); .AddServicesFromNamespace("dy.net.service");
// SPA静态文件支持 services.AddScoped<FFmpegHelper>();
// SPA静态文件支持
services.AddSpaStaticFiles(options => options.RootPath = SpaRootPath); services.AddSpaStaticFiles(options => options.RootPath = SpaRootPath);
// 开发环境启用Swagger // 开发环境启用Swagger
if (isDevelopment) //if (environment.IsDevelopment())
{ //{
services.AddSwagger(); // services.AddSwagger();
} //}
// 响应压缩 // 响应压缩
services.AddResponseCompression(); services.AddResponseCompression();
// JWT认证 // JWT认证
services.ConfigureJwtAuthentication(); services.ConfigureJwtAuthentication();
} }
/// <summary> /// <summary>
/// 配置中间件 /// 配置中间件
/// </summary> /// </summary>
private static void ConfigureMiddleware(WebApplication app, bool isDevelopment) private static void ConfigureMiddleware(WebApplication app, IWebHostEnvironment environment)
{ {
// 响应压缩 // 响应压缩
app.UseResponseCompression(); app.UseResponseCompression();
// 开发环境启用SwaggerUI // 开发环境启用SwaggerUI
if (isDevelopment) //if (environment.IsDevelopment())
{ //{
app.UseCustomSwaggerUI(options => options.Title = SwaggerDocTitle); // app.UseCustomSwaggerUI(options => options.Title = SwaggerDocTitle);
} //}
// 路由 // 路由
app.UseRouting(); app.UseRouting();
// 认证授权 // 认证授权
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// 配置文件上传路径 // 配置文件上传路径
//ConfigureUploadPath(app, isDevelopment); //ConfigureUploadPath(app, isDevelopment);
// API路由映射 // API路由映射
app.MapControllers(); app.MapControllers();
// 生产环境启用SPA app.UseStaticFiles();
if (!isDevelopment) // 生产环境启用SPA
if (!environment.IsDevelopment())
{ {
app.UseSpaStaticFiles(); app.UseSpaStaticFiles();
app.UseSpa(spa => spa.Options.SourcePath = SpaSourcePath); app.UseSpa(spa => spa.Options.SourcePath = SpaSourcePath);
@@ -153,47 +200,71 @@ namespace dy.net
} }
/// <summary> /// <summary>
/// 初始化应用服务数据 /// 初始化应用服务数据
/// </summary> /// </summary>
private static void InitApplicationServices(WebApplication app) private static async Task InitApplicationServices(WebApplication app, bool isDevelopment, string dbPath)
{ {
using var scope = app.Services.CreateScope(); using var scope = app.Services.CreateScope();
var services = scope.ServiceProvider; var services = scope.ServiceProvider;
try try
{ {
// 初始化用户 // 初始化用户
var userService = services.GetRequiredService<UserService>(); var userService = services.GetRequiredService<AdminUserService>();
userService.InitUser(); userService.InitUser("douyin", "douyin2026");
// 初始化Cookie var pwdTxt = Path.Combine(AppContext.BaseDirectory, "db", "pwd.txt");
var cookieService = services.GetRequiredService<DyCookieService>(); if (!string.IsNullOrWhiteSpace(dbPath))
cookieService.Init(); {
pwdTxt = Path.Combine(dbPath, "pwd.txt");
}
// 初始化配置 if (File.Exists(pwdTxt))
var commonService = services.GetRequiredService<CommonService>(); {
var pwd = File.ReadAllText(pwdTxt);
var reset = userService.ResetPwd(pwd);
File.Delete(pwdTxt);
}
var commonService = services.GetRequiredService<DouyinCommonService>();
// 更新视频类型--兼容老版本--不再需要
//commonService.UpdateCollectViedoType();
// 重置博主作品同步状态为未同步
//commonService.UpdateAllCookieSyncedToZero();
//await commonService.UpdateFollowedSavePathAsync();
//await commonService.UpdateImageVideoSavePath();
// 初始化Cookie
var cookieService = services.GetRequiredService<DouyinCookieService>();
var deploy = Appsettings.Get("deploy");
if (deploy != null && deploy == "docker")//docker环境直接初始化一个默认的配置
{
cookieService.InitCookie();
}
// 初始化配置
var config = commonService.InitConfig(); var config = commonService.InitConfig();
// 更新收藏视频类型--兼容老版本-原来的旧数据没有这个类型字段 //Serilog.Log.Debug("isRestart1=" + config.IsFirstRunning);
commonService.UpdateCollectViedoType(); //await cookieService.UpdateCookieToSupportOldVersionAsync();
// 重置博主作品同步状态为未同步
commonService.UpdateAllCookieSyncedToZero();
// 启动定时任务
var quartzJobService = services.GetRequiredService<QuartzJobService>();
quartzJobService.StartJob(config?.Cron ?? "30");
Serilog.Log.Debug("系统初始化完成,会默认将-博主作品同步功能-同步全部作品重置为关闭(若要开启,可以到抖音授权页面中修改)"); if (!isDevelopment)
Serilog.Log.Debug($"默认设置的每次读取行数为:{config.BatchCount},可前往系统配置页修改"); {
var cookie = await cookieService.GetOpendCookies();
if (cookie != null && cookie.Any())
{
// 启动定时任务
var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>();
await quartzJobService.InitOrReStartAllJobs(config?.Cron <= 0 ? "30" : config.Cron.ToString());
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
var logger = services.GetRequiredService<ILogger<Program>>(); Serilog.Log.Error(ex, "Failed to initialize services on startup");
logger.LogError(ex, "Failed to initialize services on startup");
} }
} }
} }
} }
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
<History>True|2025-11-12T15:06:38.7834109Z||;False|2025-11-12T23:01:02.2269478+08:00||;True|2025-10-22T22:08:11.6423148+08:00||;True|2025-10-22T21:54:24.7180547+08:00||;True|2025-10-22T21:40:03.5194315+08:00||;True|2025-10-22T21:28:28.8962766+08:00||;True|2025-10-22T21:22:47.9631689+08:00||;True|2025-10-22T21:18:24.5274318+08:00||;True|2025-10-22T21:14:51.6386326+08:00||;False|2025-10-22T21:14:07.6282769+08:00||;True|2025-10-22T21:03:48.9892860+08:00||;True|2025-10-22T21:00:56.3617243+08:00||;False|2025-10-22T21:00:30.5472941+08:00||;True|2025-10-22T20:51:29.6155916+08:00||;False|2025-10-22T20:50:47.1882956+08:00||;True|2025-10-22T15:01:04.1668366+08:00||;True|2025-10-22T14:49:43.6340569+08:00||;True|2025-10-22T14:38:39.4685603+08:00||;True|2025-10-21T18:35:28.8392541+08:00||;True|2025-10-20T10:31:05.5865212+08:00||;True|2025-10-20T10:20:41.5717101+08:00||;True|2025-10-19T10:08:33.6669332+08:00||;False|2025-10-19T10:07:22.3337545+08:00||;False|2025-10-19T10:05:22.9484805+08:00||;True|2025-10-10T16:54:27.1472888+08:00||;False|2025-10-10T16:53:44.1700030+08:00||;False|2025-10-10T16:52:48.1740453+08:00||;False|2025-10-10T16:51:21.5067253+08:00||;False|2025-10-10T16:50:19.2140597+08:00||;False|2025-10-10T16:49:21.2213290+08:00||;False|2025-10-10T16:48:47.7229948+08:00||;False|2025-10-10T16:48:15.1258700+08:00||;True|2025-09-30T13:29:42.2354235+08:00||;True|2025-09-25T11:44:52.6858389+08:00||;True|2025-09-25T11:08:27.8810109+08:00||;True|2025-09-25T09:28:50.2563374+08:00||;True|2025-09-24T16:14:02.3072184+08:00||;True|2025-09-23T22:38:52.4414757+08:00||;True|2025-09-23T22:19:07.1006356+08:00||;True|2025-09-23T22:18:01.5945225+08:00||;True|2025-09-23T22:06:15.6293613+08:00||;True|2025-09-23T21:53:14.2977444+08:00||;True|2025-09-23T21:46:55.2322411+08:00||;False|2025-09-23T21:45:23.7858854+08:00||;False|2025-09-23T21:03:51.8325689+08:00||;True|2025-09-23T10:03:32.2251920+08:00||;False|2025-09-23T09:38:08.2372201+08:00||;False|2025-09-23T09:37:49.9390545+08:00||;True|2024-03-11T21:31:45.8102398+08:00||;True|2024-03-11T07:26:24.7660541+08:00||;True|2024-03-08T22:08:40.0154831+08:00||;True|2024-03-03T10:14:36.8109114+08:00||;True|2024-03-02T18:44:57.3288537+08:00||;True|2024-01-24T17:51:37.9164415+08:00||;True|2024-01-24T16:36:50.5612157+08:00||;True|2024-01-24T15:51:35.7556653+08:00||;True|2024-01-17T23:40:40.7526618+08:00||;True|2024-01-17T23:36:10.3692844+08:00||;True|2024-01-17T23:22:03.2378834+08:00||;True|2024-01-03T11:35:44.7118292+08:00||;True|2024-01-03T11:11:23.4270453+08:00||;True|2024-01-03T11:04:35.2081526+08:00||;True|2024-01-03T10:57:03.7053107+08:00||;True|2024-01-03T10:51:50.7463989+08:00||;False|2024-01-03T10:50:24.9775312+08:00||;True|2024-01-03T10:47:30.1128183+08:00||;True|2024-01-03T10:42:55.8640657+08:00||;True|2024-01-03T09:24:24.3436056+08:00||;True|2024-01-02T23:40:38.2001198+08:00||;True|2024-01-02T23:08:36.7230444+08:00||;True|2024-01-02T22:53:43.9658255+08:00||;True|2024-01-02T22:25:38.5545279+08:00||;False|2024-01-02T22:24:42.6577609+08:00||;True|2024-01-02T22:04:33.4708889+08:00||;True|2024-01-02T21:49:57.9249898+08:00||;True|2023-12-28T22:42:58.5976180+08:00||;True|2023-12-28T22:24:09.2051205+08:00||;True|2023-12-28T22:05:48.4865497+08:00||;True|2023-12-28T21:50:57.4549336+08:00||;True|2023-12-28T21:45:30.4557939+08:00||;True|2023-12-28T21:19:59.3614594+08:00||;False|2023-12-28T21:17:04.3069097+08:00||;False|2023-12-28T21:15:56.7788810+08:00||;False|2023-12-28T21:14:57.3617402+08:00||;False|2023-12-28T21:10:45.4382469+08:00||;True|2023-12-28T19:45:33.1431914+08:00||;True|2023-12-26T11:30:36.6418716+08:00||;True|2023-12-26T11:17:40.6433651+08:00||;True|2023-12-26T09:13:56.0741559+08:00||;True|2023-12-26T09:10:55.5358737+08:00||;True|2023-12-25T23:15:34.6534359+08:00||;True|2023-12-25T23:09:47.1566176+08:00||;False|2023-12-25T23:07:32.1118803+08:00||;True|2023-12-25T22:59:50.4643459+08:00||;True|2023-12-25T22:50:14.3584798+08:00||;False|2023-12-25T22:49:20.0279513+08:00||;True|2023-12-25T22:44:07.4183973+08:00||;True|2023-12-25T22:35:27.3847085+08:00||;True|2023-12-25T22:23:21.3542658+08:00||;True|2023-12-25T22:12:00.5219522+08:00||;</History>
<LastFailureDetails />
</PropertyGroup>
</Project>
@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<DeleteExistingFiles>true</DeleteExistingFiles> <DeleteExistingFiles>true</DeleteExistingFiles>
@@ -17,5 +15,8 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<ProjectGuid>680660ef-acae-43a9-ab6c-b75532e758ad</ProjectGuid> <ProjectGuid>680660ef-acae-43a9-ab6c-b75532e758ad</ProjectGuid>
<SelfContained>false</SelfContained> <SelfContained>false</SelfContained>
<!--<DockerArch>x86</DockerArch>-->
<!--<DockerArch>arm</DockerArch> -->
<!--<DockerCoreVersion>2.2.0</DockerCoreVersion>-->
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<DeleteExistingFiles>true</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>bin\Release\net6.0\publish\</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net6.0</TargetFramework>
<ProjectGuid>680660ef-acae-43a9-ab6c-b75532e758ad</ProjectGuid>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- 单文件压缩 -->
<Optimize>true</Optimize>
<!-- 编译级优化,减少生成代码体积 -->
<TrimUnusedDependencies>true</TrimUnusedDependencies>
<!-- 裁剪未使用的NuGet依赖 -->
</PropertyGroup>
</Project>
+2 -2
View File
@@ -2,13 +2,13 @@
"profiles": { "profiles": {
"dy.net": { "dy.net": {
"commandName": "Project", "commandName": "Project",
"launchBrowser": true, //"launchBrowser": true,
"launchUrl": "swagger", "launchUrl": "swagger",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },
"dotnetRunMessages": true, "dotnetRunMessages": true,
"applicationUrl": "http://localhost:5193" "applicationUrl": "http://localhost:10101"
}, },
"IIS Express": { "IIS Express": {
"commandName": "IISExpress", "commandName": "IISExpress",
+245 -73
View File
@@ -1,122 +1,191 @@
# dysync.net - 抖音同步工具(收藏/喜欢/指定博主,无缝对接Emby/Jellyfin) # dysync.net - 抖音同步工具(抖小云)
`dysync.net` 是一款基于 **.NET Core 6.0** 和 **Vue** 开发的工具,用于同步抖音收藏夹、「我喜欢」的视频及指定博主作品,解决个人收藏视频易失效的问题。支持多账号同步,内置视频信息刮削功能,同步后的视频可直接在 Emby 或 Jellyfin 中播放。 `dysync.net` 是一款基于 **.NET Core ** 和 **Vue** 开发的工具,用于同步抖音收藏夹、「我喜欢」的视频及指定博主作品,支持多账号同步,内置视频信息刮削功能,同步后的视频可直接在 Emby 或 Jellyfin 中播放。
> 🔧 问题反馈:使用中遇到任何问题,欢迎添加 QQ:279225040 咨询 > 🔧 **问题反馈**:如使用中遇到任何问题,欢迎进Q群反馈:759876963,不一定及时回复,见谅。
--- ---
### 📺 Emby 播放效果预览 ## 📺 Emby 效果预览
![Emby播放效果](docs/emby.png) ![Emby播放效果](docs/emby.png)
--- ---
## 📌 1. 获取抖音关键信息(必做!同步核心凭证) ## 📋 目录
Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,避免遗漏或错误。 - [dysync.net - 抖音同步工具(抖小云)](#dysyncnet---抖音同步工具抖小云)
- [📺 Emby 播放效果预览](#-emby-播放效果预览)
### 1.1 提取抖音 Cookie - [📋 目录](#-目录)
1. 打开 **抖音网页版** (https://www.douyin.com/) 并登录目标账号; - [1. 获取抖音关键信息(必做!同步核心凭证)](#1-获取抖音关键信息必做同步核心凭证)
2. 进入「我的收藏」页面,确保页面加载完成; - [1.1 提取抖音 Cookie](#11-提取抖音-cookie)
3. 按 `F12` 打开浏览器「开发者工具」,切换到「Network (网络)」标签; - [1.2 提取 `sec_user_id`(个人/指定博主)](#12-提取-sec_user_id个人指定博主)
4. 刷新页面,在搜索框中输入 `v1/web/aweme/listcollection` 筛选请求; - [2. 路径映射规则(核心!错配会导致无法访问/数据丢失)](#2-路径映射规则核心错配会导致无法访问数据丢失)
5. 点击任意一条筛选结果,在右侧「Headers (标头)」中找到 `Cookie` 字段,**完整复制整段内容**(不可删减字符)。 - [3. 默认账号密码(首次登录用)](#3-默认账号密码首次登录用)
- [4. 运行方式(推荐 Docker Compose)](#4-运行方式推荐-docker-compose)
![获取Cookie步骤](docs/getcookies.png) - [镜像版本](#镜像版本)
![提取个人sec_user_id](docs/secUserId.png) - [构建命令示例](#构建命令示例)
- [方式一:Docker 命令行](#方式一docker-命令行)
### 1.2 提取 `sec_user_id`(个人/指定博主) - [方式二:Docker Compose 运行(推荐)](#方式二docker-compose-运行推荐)
- **个人 sec_user_id**(同步自己的收藏/喜欢用): - [🚀 5. 软件截图](#-5-软件截图)
进入自己的抖音主页,浏览器地址栏中 `sec_user_id=` 后的字符串即为个人 ID(如 `https://www.douyin.com/user/sec_user_id=xxx`)。 - [🚀 5. 已有功能与计划](#-5-已有功能与计划)
- **博主 sec_user_id**(同步指定博主作品用):
1. 进入目标博主主页;
2. 方式1:直接复制地址栏中 `user/` 到 `?from_tab_name`中间部分内容即是博主的 `sec_user_id`;
3. 方式2:按 `F12` → 「Network」→ 任意请求 → 「Headers」→ 提取 `sec_user_id` 字段值。
> ⚠️ 风控提示:同步博主作品时,**慎用开启全量同步**(一次性下载过多易被抖音限制访问)。
![提取博主sec_user_id](docs/upers-uid.png)
--- ---
## 📁 2. 路径映射规则(核心!错配会导致无法访问/数据丢失) ## 1. 获取抖音关键信息(必做!同步核心凭证)
Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,避免遗漏或错误。
### 1.1 提取`Cookie`以及 `sec_uer_id`
1. 打开 **抖音网页版** (https://www.douyin.com/) 并登录目标账号;
2. 进入个人主页,按下F12进入开发者模式、并切换到 `网络`(也可能叫`network`).
3. 在筛选框中输入`/follow`.
4. 点击自己头像边上的 `关注` 按钮、会弹出你的关注列表,然后在右侧网络请求里面会出现多个请求,随便选一个.
4. 在请求的标签里面切换到`负载`(也可能叫`payload`)
5. 找到sec_user_id,复制值即可
6. 在请求的标签里面切换到`标头`(也可能叫`Headers`)
7. 往下拉,直到出现`Cookie` ,复制完整的值,注意前后不要带换行符,很多人会多复制个换行符出来.
![cookie](docs/findcookie_1_2_3.png)
![cookie](docs/findcookie_4.png)
![cookie](docs/findcookie_5.png)
![cookie](docs/findcookie_6_7.png)
### 1.2 提取 博主的`sec_user_id`以及博主的uid
- **对于想下载博主视频,但是又不想关注博主,需要用到**
- 1.进入博主主页,按`F12` 点击`Network` 或`网络` 筛选器里面填`/web/aweme/post` 然后切到`预览`或`preview` 展开 json数据结果 找到`aweme_list` 然后随便点开其中一个子项 即可找到`aweme_list[0].author.sec_uid` 这便是博主的uid 。后续在关注列表中,需要手动添加非关注博主同步视频时将会要用到。
![获取Cookie步骤](docs/finduser_id.png)
---
## 2. 路径映射规则(核心!错配会导致无法访问/数据丢失)
为实现视频在 Emby/Jellyfin 中正常播放及数据持久化,需正确配置本地路径与容器路径的映射: 为实现视频在 Emby/Jellyfin 中正常播放及数据持久化,需正确配置本地路径与容器路径的映射:
| 存储类型 | 容器内路径 | 本地路径配置要求 | 用途说明 | | 存储类型 | 容器内路径 | 本地路径配置要求 | 用途说明 |
|------------------|------------------|----------------------------------------------------------------------------------|------------------------------| | ------------ | --------------- | --------------------------------------------------------------------- | -------------------------- |
| 个人收藏视频 | `/app/collect` | 映射到本地目录(如 NAS:`/volume1/抖音/收藏`、电脑:`D:/抖音/收藏`) | 存储同步后的收藏视频 | | 个人收藏视频 | `/app/collect` | 映射到本地目录(如 NAS:`/volume1/抖音/收藏`、电脑:`D:/抖音/收藏`) | 存储同步后的收藏视频 |
| 个人喜欢视频 | `/app/favorite` | 映射到本地目录(建议与收藏视频路径区分,如 `/volume1/抖音/喜欢`) | 存储同步后的「我喜欢」视频 | | 个人喜欢视频 | `/app/favorite` | 映射到本地目录(建议与收藏视频路径区分,如 `/volume1/抖音/喜欢`) | 存储同步后的「我喜欢」视频 |
| 图文视频 | `/app/images` | 映射到本地目录(建议与收藏视频路径区分,如 `/volume1/抖音/图文视频`) | 存储同步后的「图文视频」 |
| 指定博主视频 | `/app/uper` | 映射到本地目录(如 `/volume1/抖音/博主作品`) | 存储同步后的博主视频 | | 指定博主视频 | `/app/uper` | 映射到本地目录(如 `/volume1/抖音/博主作品`) | 存储同步后的博主视频 |
| 数据库文件 | `/app/db` | 映射到本地稳定目录(如 `/volume1/抖音/工具数据库`) | 持久化配置、同步记录(防止容器删除后数据丢失) | | 指定博主视频 | `/app/mix` | 映射到本地目录(如 `/volume1/抖音/合集`) | 存储同步后的合集视频 |
| 多账号视频(可选)| `/app/user1`/`/app/user2` 等 | 为每个账号分配独立本地路径(如 `/volume1/抖音/账号A`、`/volume1/抖音/账号B`) | 实现多账号隔离同步 | | 指定博主视频 | `/app/series` | 映射到本地目录(如 `/volume1/抖音/短剧`) | 存储同步后的短剧视频 |
| 数据库文件 | `/app/db` | 映射到本地稳定目录(如 `/volume1/抖音/db`) | 持久化配置、同步记录 |
| 数据库文件 | `/app/mp3` | 映射到本地稳定目录(如 `/volume1/抖音/mp3`) | 图文视频自定义音频(用于原视频音频带版权的情况) |
> ✅ 关键注意: > ✅ **关键注意**:
> 1. 本地路径需与后续后台「抖音授权」页面配置的「文件存储路径」**完全一致**; > 1. 抖音授权时配置的路径为上面 表格里面 容器内路径 如 `/app/collect` 且注意 必须是`/`开头,路径可以自己随便命名,只要保证 授权里面配的路径有在docker-compose里面添加映射即可
> 2. 未配置路径映射时,文件仅存于容器内部,Emby/Jellyfin 无法访问,且容器删除后数据丢失。 > 2. 未配置路径映射时,文件仅存于容器内部,且容器删除后数据丢失。
![路径配置示例](docs/auth2.png) ![路径配置示例](docs/auth2.png)
![路径配置示例](docs/auth.png)
--- ---
## 🔑 3. 默认账号密码(首次登录用) ## 3. 默认账号密码(首次登录用)
首次访问后台管理页面时,使用以下默认账号密码: 首次访问后台管理页面时,使用以下默认账号密码:
- **用户名**:`douyin` - **用户名**:`douyin`
- **密码**:`douyin2025` - **密码**:`douyin2026`
> ⚠️ 安全建议:登录后修改密码。 > ⚠️ **安全建议**:登录后修改密码。
--- ---
## 🚀 4. 运行方式(推荐 Docker Compose,更易维护) ## 4. 运行方式(推荐 Docker Compose)
### 镜像版本
| 镜像标签 | 架构 |
| ----------------- | -------------- |
| `beta_2.2.0` | x86_64 (amd64) |
| `arm_2.2.0` | ARM64 |
| `latest` | x86_64 (amd64) |
### 构建命令示例
将下方命令中的「本地路径」替换为你的实际路径,终端执行即可: 将下方命令中的「本地路径」替换为你的实际路径,终端执行即可:
```bash
### Arm设备请使用 registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0 版本镜像
### 方式一:Docker 命令行
docker run -d --restart=always \
-v /你的/本地/收藏视频路径:/app/collect \
-v /你的/本地/喜欢视频路径:/app/favorite \
-v /你的/本地/数据库路径:/app/db \
-v /你的/本地/博主视频路径:/app/uper \
-p 10101:10101 \
--name dysync2025 \
registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net
# 注意:-p 后面的容器端口必须为 10101(源码固定)
### 方式二:Docker Compose 运行(推荐) ### 方式一:Docker Compose 运行(推荐)
### Arm设备请使用 registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0 版本镜像
创建 docker-compose.yml 文件,复制以下内容,替换「本地路径」后执行 docker-compose up -d: 创建 docker-compose.yml 文件,复制以下内容,替换「本地路径」后执行 docker-compose up -d:
version: '3.8' <span style="color: red; font-weight: bold;">需要注意:unraid玩家需要增加 user配置 --user 1000:100</span>
```yaml
services: services:
dysync: dysync:
image: registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net image: ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_2.2.0
container_name: dysync2025 container_name: dysync2026 # 容器名称
restart: always restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止
ports: ports:
- "10101:10101" - "10101:10101"
volumes: volumes:
- /volume1/docker/dysync/db:/app/db # 基础路径映射
- /vol2/1000/media/dysync/db:/app/db # 数据库目录(持久化配置和同步记录)
# 默认音频目录(用于图文/动态视频合成,版权受限音频的替代)
- /vol2/1000/media/dysync/mp3:/app/mp3
# 账号1 - 视频存储目录
- /vol2/1000/media/dysync/dy1/coll:/app/collect # 个人收藏视频
- /vol2/1000/media/dysync/dy1/fav:/app/favorite # 个人喜欢视频
- /vol2/1000/media/dysync/dy1/up:/app/uper # 指定博主视频
- /vol2/1000/media/dysync/dy1/mix:/app/mix # 合集(不需要可删除)
- /vol2/1000/media/dysync/dy1/series:/app/series # 短剧(不需要可删除)
# 账号2 - 多账号路径映射示例(可选:授权时可复用账号1路径)
- /vol2/1000/media/dysync/dy2/collect:/app/collect2
- /vol2/1000/media/dysync/dy2/fav:/app/favorite2
- /vol2/1000/media/dysync/dy2/up:/app/uper2
- /volume2/mediay/dysync:/app/collect
- /volume2/mediay/dysync_fav:/app/favorite
- /volume2/mediay/dysync_up:/app/uper
# 下面是多账号路径映射示例(可选)
- /volume2/mediay/dysync2:/app/yeyeye
- /volume2/mediay/dysync2_fav:/app/yeyeye_fav
- /volume2/mediay/dysync2_up:/app/yeyeye_up
# 配置DNS服务器(解决域名解析失败导致的无法访问外网)
network_mode: bridge network_mode: bridge
dns: dns:
- 8.8.8.8 # Google DNS - 223.5.5.5 # 阿里云DNS(备用)
- 114.114.114.114 # 国内DNS - 114.114.114.114 # 国内114 DNS(提升国内访问稳定性)
- 223.5.5.5 deploy:
resources:
limits:
memory: 250m # 限制内存占用≤250MB(图文视频下载场景不建议设置)
``` ```
另一个版本去掉了所有视频存储路径设置项,docker只需要映射3个路径就可以,但是!!!
不兼容老的版本,如果觉得这种方式好一些可以重新部署。其他没什么变化。
所有路径都变成 根目录/cookie名字/视频类型/(如果是合集或者短剧这里还有一层)/合集或短剧/名称/视频文件
```
services:
dysync:
image: ccr.ccs.tencentyun.com/jianzhichu/dysync:latest
container_name: dysync_latest # 容器自定义名称
restart: unless-stopped # 容器非手动停止则自动重启
ports:
- "10110:10101" # 端口映射
environment:
# 限制GC堆最大内存为100MB(100*1024*1024=104857600,可根据需求调整)
- COMPlus_GCHeapHardLimit=154857600
# 禁用服务器GC,改用工作站GC(轻量应用更友好)
- COMPlus_GCServer=0
# 可选:强制GC回收后释放内存给操作系统(Linux专属)
- COMPlus_GCConserveMemory=1
# 可选:显示GC日志(排查内存问题时用)
# - COMPlus_GCLogFile=/app/gc.log
volumes:
# 基础路径映射 - 主账号(账号1)
- /vol2/1000/media/dy001/db:/app/db # 数据库目录(持久化配置和同步记录)
- /vol2/1000/media/dy001/mp3:/app/mp3 # 备用音频目录(版权音频无法下载时使用)
- /vol2/1000/media/dy001/:/app/data # 视频存储目录
network_mode: bridge
dns:
- 223.5.5.5 # 阿里云DNS(备用)
- 114.114.114.114 # 国内114 DNS(提升国内访问稳定性)
deploy:
resources:
limits:
memory: 211m # 限制内存占用≤250MB(图文视频下载场景不建议设置)
```
## 🚀 5. 软件截图 ## 🚀 5. 软件截图
![输入图片说明](docs/homepage.png) ![输入图片说明](docs/homepage.png)
@@ -125,8 +194,111 @@ services:
![输入图片说明](docs/datalist.png) ![输入图片说明](docs/datalist.png)
![输入图片说明](docs/auth.png) ![输入图片说明](docs/appconf.png)
![输入图片说明](docs/player.png)
![输入图片说明](docs/follows.png)
![路径配置示例](docs/followset.png)
![输入图片说明](docs/logs.png) ![输入图片说明](docs/logs.png)
![输入图片说明](docs/set.png)
<div align="center">
<img src="docs/mobile-home.png" width="30%" />
<img src="docs/mobile-log.png" width="30%" />
<img src="docs/mobile-logdetail.jpg" width="30%" />
</div>
## 🚀 5. 已有功能与计划
1. ✅ 支持多账号同步(每个账号可单独配置存储路径)
2. ✅ 收藏的视频
3. ✅ 喜欢的视频(点赞的视频)
4. ✅ 图文视频(需要将图片 + mp3 合成视频)
5. ✅ 指定博主的视频,可配置是否单独存放一个总文件夹,是否直接用视频标题做文件名
6. ✅ 清除日志(防止容器被日志占用太多空间,可配置保留天数)
7. ✅ 将项目名称改成 "抖小云" 灵感来源于哪吒电影那坨云
8. ✅ Cookie 过期提醒,在DY授权页可查看
9. ✅ 自动根据去重规则进行去重(可设置去重优先级,同一个视频出现再多个分类时适用)
10. ✅ 在同步记录页面中直接播放视频
11. ✅ 关注列表同步。
11. ✅ 选中记录,批量重新同步。以及分享(比较简陋)
12. ✅ 关注列表支持新增,非关注的博主
13. ✅ 增加永久删除功能,删除后,以后不会再同步该视频
14. ✅ 增加配置项及关注列表(手动添加部分)导出导入功能
15. ✅ 支持移动端
16. ✅ 增加开关配置是否仅同步最近视频
17. ✅ 完成飞牛fpk打包:https://github.com/jianzhichu/FnDepot
18. ✅ 合集、短剧、自定义收藏夹下载。
19. ✅ 增加一个工具dy.cookie.exe在源码根目录,可以快速重置失效cookie,操作说明(一步一步操作即可)。
## 💖 支持项目
如果这个项目对你有帮助,欢迎通过以下方式支持:
### ⭐ Star 项目
点击右上角的 ⭐ Star 按钮,让更多人发现这个项目
### 💰 赞赏支持
- 扫码请作者喝杯奶茶 ☕
| 微信支付 | 支付宝 |
| :-------------------------------------: | :--------------------------------------: |
| <img src="docs/wx.jpg" width="300" /> | <img src="docs/zfb.jpg" width="300" /> |
### 🎁 其他支持方式
- 分享给更多需要的朋友
- 提交 Bug 报告和功能建议
- 贡献代码和文档
---
<p align="center">
<strong>感谢你的支持!❤️</strong>
</p>
# 免责声明
本项目(dysync.net)仅作为技术研究与学习交流的开源项目,其所有代码、功能及相关内容均不代表任何第三方平台的官方服务或立场。使用本项目前,请仔细阅读以下条款:
## 1. 授权与使用限制
- 本项目未获得抖音(Douyin)等任何第三方平台的官方授权、合作或认可,与该等平台无任何关联。
- 项目功能仅允许用于个人技术研究和学习目的,严禁用于商业用途、非法活动或违反任何第三方平台规则的行为。
## 2. 知识产权说明
- 项目中涉及的第三方平台(如抖音)的名称、商标、接口、内容等均为其各自所有者的知识产权。
- 本项目仅对技术实现方式进行研究,不涉及对第三方知识产权的侵占或滥用。如因使用本项目引发知识产权争议,相关责任由使用者自行承担。
## 3. 风险提示与责任豁免
- 使用本项目可能导致违反第三方平台的用户协议或相关规定,进而导致账号限制、功能封禁等风险,此类风险由使用者自行承担。
- 项目开发者不对使用本项目产生的任何直接或间接损失(包括但不限于数据丢失、设备损坏、账号问题等)承担责任。
- 使用者应确保自身行为符合《网络安全法》《著作权法》等相关法律法规,不得利用本项目从事侵权、违法或违背公序良俗的活动。
## 4. 声明的修改与生效
- 本免责声明的最终解释权归项目开发者所有,开发者保留随时修改本声明的权利。
- 修改后的声明将在项目仓库中更新,建议使用者定期查阅。
- 一旦使用本项目的代码、功能或相关资源,即视为您已充分理解并同意本免责声明的全部条款。如不同意,请立即停止使用。
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_BASE_URL=/ VITE_BASE_URL=/
VITE_API_URL=http://localhost VITE_API_URL=/
+2 -1
View File
@@ -1 +1,2 @@
VITE_API_URL=http://localhost:10101 VITE_BASE_URL=/
VITE_API_URL=http://localhost:10101/
+129 -108
View File
@@ -4,112 +4,140 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport"
<title>抖音同步小帮手</title> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>抖小云</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background-color: #ffffff;
height: 100vh;
overflow: hidden;
font-family: "Microsoft YaHei", sans-serif;
}
/* ######### 核心修改:区分 #stepin-app 的两个状态 ######### */
/* 1. 加载阶段:#stepin-app 仅包含加载动画(或为空),应用 flex 居中,保留原有样式 */
#stepin-app {
/* 加载阶段默认样式:flex 居中,保留呼吸圆环效果 */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 35px;
}
/* 2. Vue 挂载后:#stepin-app 非空(包含 Vue 组件),覆盖为普通块级元素,解除约束 */
#stepin-app:not(:has(.breath-ring)) {
/* 核心:取消 flex 布局,改为普通块级元素 */
display: block !important;
/* 覆盖原有 flex 相关属性,解除居中约束 */
align-items: unset !important;
justify-content: unset !important;
/* 确保页面撑满横向宽度 */
width: 100% !important;
/* 保留高度,确保 Vue 页面正常占满视口 */
height: 100% !important;
/* 移除 gap,避免影响 Vue 页面布局 */
gap: 0 !important;
/* 恢复页面滚动功能,解除 overflow: hidden 的限制 */
overflow: auto !important;
}
/* 核心呼吸圆环容器(原有样式保留,无修改) */
.breath-ring {
width: 60px;
height: 60px;
position: relative;
}
/* 圆环基础样式(原有样式保留,无修改) */
.breath-ring::before,
.breath-ring::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
opacity: 0.6;
}
/* 内层圆环:细圆环 + 渐变(原有样式保留,无修改) */
.breath-ring::before {
border: 3px solid transparent;
border-top-color: #1890ff;
border-right-color: #722ed1;
animation: ringRotate 1.5s linear infinite;
}
/* 外层圆环:呼吸扩散动画(原有样式保留,无修改) */
.breath-ring::after {
border: 2px solid #1890ff;
animation: ringBreath 2s ease-in-out infinite;
}
/* 加载文字:极简高级(原有样式保留,无修改) */
.loading-text {
color: #222222;
font-size: 17px;
font-weight: 400;
letter-spacing: 0.5px;
}
.loading-tip {
color: #aaaaaa;
font-size: 13px;
margin-top: -10px;
}
/* 圆环旋转动画(原有样式保留,无修改) */
@keyframes ringRotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 圆环呼吸扩散动画(原有样式保留,无修改) */
@keyframes ringBreath {
0% {
transform: scale(0.8);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 0.2;
}
100% {
transform: scale(0.8);
opacity: 0.8;
}
}
</style>
</head> </head>
<body> <body>
<div id="stepin-app"> <div id="stepin-app">
<style> <!-- <div class="loading-text">正在加载资源,请稍候...</div> -->
html { <!-- 呼吸圆环加载组件 -->
overflow: hidden; <div class="breath-ring"></div>
} <!-- <div class="loading-tip">首次加载较慢,请耐心等待...</div> -->
body {
height: 100vh;
text-align: center;
align-items: center;
background-color: #011627;
display: flex;
justify-content: center;
overflow: hidden;
}
.loader {
color: rgb(201, 195, 195);
font-family: "Poppins", sans-serif;
font-weight: 500;
font-size: 20px;
-webkit-box-sizing: content-box;
box-sizing: content-box;
height: 40px;
padding: 10px 10px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
text-align: center;
justify-content: center;
border-radius: 8px;
}
.words {
overflow: hidden;
}
.word {
display: block;
height: 100%;
padding-left: 6px;
color: #ffca29;
animation: spin_4991 4s infinite;
}
@keyframes spin_4991 {
10% {
-webkit-transform: translateY(-105%);
transform: translateY(-105%);
}
25% {
-webkit-transform: translateY(-100%);
transform: translateY(-100%);
}
35% {
-webkit-transform: translateY(-205%);
transform: translateY(-205%);
}
50% {
-webkit-transform: translateY(-200%);
transform: translateY(-200%);
}
60% {
-webkit-transform: translateY(-305%);
transform: translateY(-305%);
}
75% {
-webkit-transform: translateY(-300%);
transform: translateY(-300%);
}
85% {
-webkit-transform: translateY(-405%);
transform: translateY(-405%);
}
100% {
-webkit-transform: translateY(-400%);
transform: translateY(-400%);
}
}
</style>
<!-- <img style="margin-bottom: 8px;" width="64px" src="/vite.svg" /> -->
<div class="loader">
<!-- <img src="/vite.svg"> -->
<div> loading ...</div>
<!-- <div class="words">
<span class="word">buttons</span>
<span class="word">forms</span>
<span class="word">switches</span>
<span class="word">cards</span>
<span class="word">buttons</span>
</div> -->
</div>
<div class="" style="font-size: 15px; color: rgba(249, 244, 244, 0.55)">首次加载可能较慢,请耐心等待...</div>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
<script> <script>
if (!global) { if (!global) {
@@ -118,11 +146,4 @@
</script> </script>
</body> </body>
<!-- <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/5.15.4/css/all.min.css"> -->
<style>
html {
overflow: hidden;
}
</style>
</html> </html>
+5 -2
View File
@@ -32,11 +32,13 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"qs": "^6.10.1", "qs": "^6.10.1",
"sortablejs": "^1.15.6",
"stepin": "2.1.42-beta", "stepin": "2.1.42-beta",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-json-editor": "^1.4.3", "vue-json-editor": "^1.4.3",
"vue-request": "^2.0.4", "vue-request": "^2.0.4",
"vue-router": "^4.1.6" "vue-router": "^4.1.6",
"vue-sortablejs": "^1.0.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/container-queries": "^0.1.0", "@tailwindcss/container-queries": "^0.1.0",
@@ -50,8 +52,9 @@
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.7",
"babel-preset-es2015": "^6.24.1", "babel-preset-es2015": "^6.24.1",
"babel-register": "^6.26.0", "babel-register": "^6.26.0",
"baseline-browser-mapping": "^2.9.15",
"body-parser": "^1.20.1", "body-parser": "^1.20.1",
"crypto-js": "^4.1.1", "crypto-js": "^4.2.0",
"gh-pages": "^3.1.0", "gh-pages": "^3.1.0",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-clean": "^0.4.0", "gulp-clean": "^0.4.0",
+37 -35
View File
@@ -11,9 +11,6 @@ importers:
'@ant-design/icons-vue': '@ant-design/icons-vue':
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0(vue@3.5.21(typescript@4.9.5)) version: 6.1.0(vue@3.5.21(typescript@4.9.5))
'@fortawesome/fontawesome-free':
specifier: ^5.15.4
version: 5.15.4
'@vueuse/core': '@vueuse/core':
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.11.1(vue@3.5.21(typescript@4.9.5)) version: 10.11.1(vue@3.5.21(typescript@4.9.5))
@@ -92,7 +89,7 @@ importers:
version: 6.14.0 version: 6.14.0
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.6.2(vite@4.5.14(@types/node@14.18.63)(less@4.1.3))(vue@3.5.21(typescript@4.9.5)) version: 4.6.2(vite@4.5.14(@types/node@14.18.63)(less@4.4.1))(vue@3.5.21(typescript@4.9.5))
autoprefixer: autoprefixer:
specifier: ^10.4.7 specifier: ^10.4.7
version: 10.4.21(postcss@8.5.6) version: 10.4.21(postcss@8.5.6)
@@ -127,11 +124,11 @@ importers:
specifier: ^11.6.0 specifier: ^11.6.0
version: 11.11.1 version: 11.11.1
less: less:
specifier: 4.1.3 specifier: ^4.1.3
version: 4.1.3 version: 4.4.1
less-loader: less-loader:
specifier: ^12.3.0 specifier: ^12.3.0
version: 12.3.0(less@4.1.3) version: 12.3.0(less@4.4.1)
mockjs: mockjs:
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0 version: 1.1.0
@@ -152,13 +149,13 @@ importers:
version: 0.24.1(@babel/parser@7.28.4)(rollup@4.52.0)(vue@3.5.21(typescript@4.9.5)) version: 0.24.1(@babel/parser@7.28.4)(rollup@4.52.0)(vue@3.5.21(typescript@4.9.5))
vite: vite:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.5.14(@types/node@14.18.63)(less@4.1.3) version: 4.5.14(@types/node@14.18.63)(less@4.4.1)
vite-plugin-compression: vite-plugin-compression:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1(vite@4.5.14(@types/node@14.18.63)(less@4.1.3)) version: 0.5.1(vite@4.5.14(@types/node@14.18.63)(less@4.4.1))
vitepress: vitepress:
specifier: ^1.0.0-alpha.61 specifier: ^1.0.0-alpha.61
version: 1.6.4(@algolia/client-search@5.37.0)(@types/node@14.18.63)(async-validator@4.2.5)(axios@0.21.4)(less@4.1.3)(nprogress@0.2.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@4.9.5) version: 1.6.4(@algolia/client-search@5.37.0)(@types/node@14.18.63)(async-validator@4.2.5)(axios@0.21.4)(less@4.4.1)(nprogress@0.2.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@4.9.5)
vue-tsc: vue-tsc:
specifier: ^1.0.13 specifier: ^1.0.13
version: 1.8.27(typescript@4.9.5) version: 1.8.27(typescript@4.9.5)
@@ -577,10 +574,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@fortawesome/fontawesome-free@5.15.4':
resolution: {integrity: sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==}
engines: {node: '>=6'}
'@iconify-json/simple-icons@1.2.52': '@iconify-json/simple-icons@1.2.52':
resolution: {integrity: sha512-c41YOMzBhl3hp58WJLxT+Qq3UhBd8GZAMkbS8ddlCuIGLW0COGe2YSfOA2+poA8/bxLhUQODRNjAy3KhiAOtzA==} resolution: {integrity: sha512-c41YOMzBhl3hp58WJLxT+Qq3UhBd8GZAMkbS8ddlCuIGLW0COGe2YSfOA2+poA8/bxLhUQODRNjAy3KhiAOtzA==}
@@ -663,56 +656,67 @@ packages:
resolution: {integrity: sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==} resolution: {integrity: sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.0': '@rollup/rollup-linux-arm-musleabihf@4.52.0':
resolution: {integrity: sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==} resolution: {integrity: sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.0': '@rollup/rollup-linux-arm64-gnu@4.52.0':
resolution: {integrity: sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==} resolution: {integrity: sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.0': '@rollup/rollup-linux-arm64-musl@4.52.0':
resolution: {integrity: sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==} resolution: {integrity: sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.0': '@rollup/rollup-linux-loong64-gnu@4.52.0':
resolution: {integrity: sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==} resolution: {integrity: sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.0': '@rollup/rollup-linux-ppc64-gnu@4.52.0':
resolution: {integrity: sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==} resolution: {integrity: sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.0': '@rollup/rollup-linux-riscv64-gnu@4.52.0':
resolution: {integrity: sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==} resolution: {integrity: sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.0': '@rollup/rollup-linux-riscv64-musl@4.52.0':
resolution: {integrity: sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==} resolution: {integrity: sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.0': '@rollup/rollup-linux-s390x-gnu@4.52.0':
resolution: {integrity: sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==} resolution: {integrity: sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.0': '@rollup/rollup-linux-x64-gnu@4.52.0':
resolution: {integrity: sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==} resolution: {integrity: sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.0': '@rollup/rollup-linux-x64-musl@4.52.0':
resolution: {integrity: sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==} resolution: {integrity: sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.0': '@rollup/rollup-openharmony-arm64@4.52.0':
resolution: {integrity: sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==} resolution: {integrity: sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==}
@@ -2327,9 +2331,9 @@ packages:
webpack: webpack:
optional: true optional: true
less@4.1.3: less@4.4.1:
resolution: {integrity: sha512-w16Xk/Ta9Hhyei0Gpz9m7VS8F28nieJaL/VyShID7cYvP6IL5oHeL6p4TXSDJqZE/lNv0oJ2pGVjJsRkfwm5FA==} resolution: {integrity: sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA==}
engines: {node: '>=6'} engines: {node: '>=14'}
hasBin: true hasBin: true
liftoff@3.1.0: liftoff@3.1.0:
@@ -3952,8 +3956,6 @@ snapshots:
'@esbuild/win32-x64@0.21.5': '@esbuild/win32-x64@0.21.5':
optional: true optional: true
'@fortawesome/fontawesome-free@5.15.4': {}
'@iconify-json/simple-icons@1.2.52': '@iconify-json/simple-icons@1.2.52':
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
@@ -4167,14 +4169,14 @@ snapshots:
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-vue@4.6.2(vite@4.5.14(@types/node@14.18.63)(less@4.1.3))(vue@3.5.21(typescript@4.9.5))': '@vitejs/plugin-vue@4.6.2(vite@4.5.14(@types/node@14.18.63)(less@4.4.1))(vue@3.5.21(typescript@4.9.5))':
dependencies: dependencies:
vite: 4.5.14(@types/node@14.18.63)(less@4.1.3) vite: 4.5.14(@types/node@14.18.63)(less@4.4.1)
vue: 3.5.21(typescript@4.9.5) vue: 3.5.21(typescript@4.9.5)
'@vitejs/plugin-vue@5.2.4(vite@5.4.20(@types/node@14.18.63)(less@4.1.3))(vue@3.5.21(typescript@4.9.5))': '@vitejs/plugin-vue@5.2.4(vite@5.4.20(@types/node@14.18.63)(less@4.4.1))(vue@3.5.21(typescript@4.9.5))':
dependencies: dependencies:
vite: 5.4.20(@types/node@14.18.63)(less@4.1.3) vite: 5.4.20(@types/node@14.18.63)(less@4.4.1)
vue: 3.5.21(typescript@4.9.5) vue: 3.5.21(typescript@4.9.5)
'@volar/language-core@1.11.1': '@volar/language-core@1.11.1':
@@ -6061,11 +6063,11 @@ snapshots:
dependencies: dependencies:
flush-write-stream: 1.1.1 flush-write-stream: 1.1.1
less-loader@12.3.0(less@4.1.3): less-loader@12.3.0(less@4.4.1):
dependencies: dependencies:
less: 4.1.3 less: 4.4.1
less@4.1.3: less@4.4.1:
dependencies: dependencies:
copy-anything: 2.0.6 copy-anything: 2.0.6
parse-node-version: 1.0.1 parse-node-version: 1.0.1
@@ -7309,16 +7311,16 @@ snapshots:
remove-trailing-separator: 1.1.0 remove-trailing-separator: 1.1.0
replace-ext: 1.0.1 replace-ext: 1.0.1
vite-plugin-compression@0.5.1(vite@4.5.14(@types/node@14.18.63)(less@4.1.3)): vite-plugin-compression@0.5.1(vite@4.5.14(@types/node@14.18.63)(less@4.4.1)):
dependencies: dependencies:
chalk: 4.1.2 chalk: 4.1.2
debug: 4.4.3 debug: 4.4.3
fs-extra: 10.1.0 fs-extra: 10.1.0
vite: 4.5.14(@types/node@14.18.63)(less@4.1.3) vite: 4.5.14(@types/node@14.18.63)(less@4.4.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vite@4.5.14(@types/node@14.18.63)(less@4.1.3): vite@4.5.14(@types/node@14.18.63)(less@4.4.1):
dependencies: dependencies:
esbuild: 0.18.20 esbuild: 0.18.20
postcss: 8.5.6 postcss: 8.5.6
@@ -7326,9 +7328,9 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 14.18.63 '@types/node': 14.18.63
fsevents: 2.3.3 fsevents: 2.3.3
less: 4.1.3 less: 4.4.1
vite@5.4.20(@types/node@14.18.63)(less@4.1.3): vite@5.4.20(@types/node@14.18.63)(less@4.4.1):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.5.6 postcss: 8.5.6
@@ -7336,9 +7338,9 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 14.18.63 '@types/node': 14.18.63
fsevents: 2.3.3 fsevents: 2.3.3
less: 4.1.3 less: 4.4.1
vitepress@1.6.4(@algolia/client-search@5.37.0)(@types/node@14.18.63)(async-validator@4.2.5)(axios@0.21.4)(less@4.1.3)(nprogress@0.2.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@4.9.5): vitepress@1.6.4(@algolia/client-search@5.37.0)(@types/node@14.18.63)(async-validator@4.2.5)(axios@0.21.4)(less@4.4.1)(nprogress@0.2.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@4.9.5):
dependencies: dependencies:
'@docsearch/css': 3.8.2 '@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.37.0)(search-insights@2.17.3) '@docsearch/js': 3.8.2(@algolia/client-search@5.37.0)(search-insights@2.17.3)
@@ -7347,7 +7349,7 @@ snapshots:
'@shikijs/transformers': 2.5.0 '@shikijs/transformers': 2.5.0
'@shikijs/types': 2.5.0 '@shikijs/types': 2.5.0
'@types/markdown-it': 14.1.2 '@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 5.2.4(vite@5.4.20(@types/node@14.18.63)(less@4.1.3))(vue@3.5.21(typescript@4.9.5)) '@vitejs/plugin-vue': 5.2.4(vite@5.4.20(@types/node@14.18.63)(less@4.4.1))(vue@3.5.21(typescript@4.9.5))
'@vue/devtools-api': 7.7.7 '@vue/devtools-api': 7.7.7
'@vue/shared': 3.5.21 '@vue/shared': 3.5.21
'@vueuse/core': 12.8.2(typescript@4.9.5) '@vueuse/core': 12.8.2(typescript@4.9.5)
@@ -7356,7 +7358,7 @@ snapshots:
mark.js: 8.11.1 mark.js: 8.11.1
minisearch: 7.2.0 minisearch: 7.2.0
shiki: 2.5.0 shiki: 2.5.0
vite: 5.4.20(@types/node@14.18.63)(less@4.1.3) vite: 5.4.20(@types/node@14.18.63)(less@4.4.1)
vue: 3.5.21(typescript@4.9.5) vue: 3.5.21(typescript@4.9.5)
optionalDependencies: optionalDependencies:
postcss: 8.5.6 postcss: 8.5.6
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

+101 -16
View File
@@ -1,6 +1,6 @@
<template> <template>
<ThemeProvider is-root v-bind="themeConfig" :apply-style="false"> <ThemeProvider is-root v-bind="themeConfig" :apply-style="false">
<stepin-view system-name="抖音小帮手" :class="`${contentClass}`" :user="user" :navMode="navigation" :useTabs="useTabs" :themeList="themeList" v-model:show-setting="showSetting" v-model:theme="theme" @themeSelect="configTheme" logo-src="@/assets/logo.png"> <stepin-view system-name="抖小云" :class="`${contentClass}`" :user="user" :navMode="navigation" :useTabs="useTabs" :themeList="themeList" v-model:show-setting="showSetting" v-model:theme="theme" @themeSelect="configTheme" logo-src="@/assets/logo.png" v-model:collapsed="open">
<template #headerActions> <template #headerActions>
<HeaderActions @showSetting="showSetting = true" /> <HeaderActions @showSetting="showSetting = true" />
</template> </template>
@@ -16,8 +16,6 @@
</stepin-view> </stepin-view>
</ThemeProvider> </ThemeProvider>
<my-personal ref="personalRef" /> <my-personal ref="personalRef" />
<email-set ref="emailRef" />
<!-- <login-modal :unless="['/login']" /> -->
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -28,7 +26,7 @@ import avatar from '@/assets/avatar.png';
import { PageFooter, HeaderActions } from '@/components/layout'; import { PageFooter, HeaderActions } from '@/components/layout';
import Setting from './components/setting'; import Setting from './components/setting';
import { LoginModal } from '@/pages/login'; import { LoginModal } from '@/pages/login';
import { MyPersonal, EmailSet } from '@/pages/personal'; import { MyPersonal } from '@/pages/personal';
import { configTheme, themeList } from '@/theme'; import { configTheme, themeList } from '@/theme';
import { ThemeProvider } from 'stepin'; import { ThemeProvider } from 'stepin';
// logout,profile // logout,profile
@@ -36,6 +34,7 @@ const { logout } = useAccountStore();
const showPersonalDrawer = ref<boolean>(false); const showPersonalDrawer = ref<boolean>(false);
const personalRef = ref(null); const personalRef = ref(null);
const emailRef = ref(null); const emailRef = ref(null);
const open = ref(false);
const showSetting = ref(false); const showSetting = ref(false);
const router = useRouter(); const router = useRouter();
@@ -82,19 +81,10 @@ const user = reactive({
onMounted(() => { onMounted(() => {
useApiStore() useApiStore()
.apiCheckInitStatus() .apiUserInfo()
.then((res) => { .then((res) => {
if (res.code === 0) { if (res.code === 0) {
// useApiStore() user.name = res.data?.userName;
// .apiUserInfo()
// .then((res) => {
// if (res.code === 0 && res.code !== '') {
// if (res.data.avatar && res.data.avatar != null) {
// user.avatar = `/upload/${res.data.avatar}`;
// }
// user.name = res.data.userName;
// }
// });
} }
}); });
}); });
@@ -124,7 +114,11 @@ onMounted(() => {
background: theme('backgroundColor.layout'); background: theme('backgroundColor.layout');
} }
.system-name { .system-name {
// color: #16213e !important; color: #722ed1 !important;
margin-left: 0em !important;
}
.ant-tabs-extra-content {
display: none !important;
} }
} }
@@ -138,6 +132,97 @@ body {
height: 100vh; height: 100vh;
overflow-y: hidden; overflow-y: hidden;
} }
// 小屏下 Modal / Drawer 全局滚动策略
// 挂载到 document.body 的元素 scoped 样式无法命中,此处非 scoped 全局生效
// 策略:让 wrap 承担滚动,不强制 body 内滚——这样鼠标在 modal 任意位置滚轮都能滚,避免 Mac overlay scrollbar 手感"滑不动"
.ant-modal-wrap {
overflow-y: auto !important; // wrap 是 fixed 定位滚动容器;antd 默认就是 auto,此处 !important 兜底
-webkit-overflow-scrolling: touch; // 移动端惯性滚动
// 强制显示滚动条(Mac overlay 默认隐藏,用户误以为"滑不动")
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 5px;
&:hover { background: rgba(0, 0, 0, 0.55); }
}
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
}
.ant-modal {
top: 20px !important; // 覆盖 antd 通过 inline style 设的 top: 100px
padding-bottom: 20px !important;
margin-bottom: 20px;
}
// body 承担滚动:max-height 硬约束,内容超出时滚,内容少时自然收缩(modal 不会撑成一大块留白)
// 100vh - 150px = 减去 top(20) + margin-bottom(20) + header(约 55) + padding(约 40) + 保险 15
.ant-modal .ant-modal-body {
max-height: calc(100vh - 150px) !important;
overflow-y: auto !important;
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 5px;
&:hover { background: rgba(0, 0, 0, 0.55); }
}
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
}
}
// 抖音授权 Cookie 编辑用 full-modal(全屏抽屉式,高度 100vh、footer 常驻底部)
.full-modal .ant-modal {
top: 0 !important;
padding-bottom: 0 !important;
margin: 0 !important;
max-width: 100%;
height: 100vh;
}
.full-modal .ant-modal-content {
height: 100vh;
display: flex;
flex-direction: column;
border-radius: 0;
}
.full-modal .ant-modal-header,
.full-modal .ant-modal-footer {
flex-shrink: 0;
}
.full-modal .ant-modal-body {
flex: 1 1 auto;
min-height: 0;
max-height: none !important;
overflow-y: auto !important;
}
// 登录页整个界面本身就是一个 Modal(LoginModal.vue),必须恢复 antd 默认布局,不能被上面的全局规则改
.login-modal .ant-modal {
top: 100px !important;
padding-bottom: 24px !important;
margin-bottom: 0;
}
// 忘记密码引导 Modal(LoginBox.vue 里的 forgot-modal)同样保留默认布局
.forgot-modal .ant-modal {
top: 100px !important;
padding-bottom: 24px !important;
margin-bottom: 0;
}
// Drawer 组件(抽屉)也保证内容可滚
.ant-drawer-body {
overflow-y: auto !important;
}
.stepin-img-checkbox { .stepin-img-checkbox {
@apply transition-transform; @apply transition-transform;
&:hover { &:hover {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg width="90px" height="90px" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 61.2 (89653) - https://sketch.com -->
<title>logo-black</title>
<desc>Created with Sketch.</desc>
<g id="LOGO" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Artboard" transform="translate(-532.000000, -80.000000)">
<g id="logo-black" transform="translate(532.000000, 80.000000)">
<g id="Group">
<circle id="Combined-Shape" fill="#C71D23" cx="44.8544363" cy="44.8544363" r="44.8544363"></circle>
<path d="M67.558546,39.8714292 L42.0857966,39.8714292 C40.8627004,39.8720094 39.8710953,40.8633548 39.8701949,42.0864508 L39.8687448,47.623783 C39.867826,48.8471055 40.8592652,49.8390642 42.0825879,49.8393845 C42.0827874,49.8393846 42.0829869,49.8393846 42.0831864,49.8387862 L57.5909484,49.838657 C58.8142711,49.8386283 59.8059783,50.830319 59.8059885,52.0536417 C59.8059885,52.0536479 59.8059885,52.053654 59.8059701,52.0536602 L59.8059701,52.6073539 L59.8059701,52.6073539 L59.8059701,53.161115 C59.8059701,56.8310831 56.8308731,59.80618 53.160905,59.80618 L32.1165505,59.80618 C30.8934034,59.806119 29.9018373,58.8145802 29.9017425,57.5914331 L29.9011625,36.5491188 C29.9008781,32.8791508 32.8758931,29.9039718 36.5458611,29.9038706 C36.5459222,29.9038706 36.5459833,29.9038706 36.5460443,29.9040538 L67.5523638,29.9040538 C68.77515,29.9026795 69.7666266,28.9118177 69.7687593,27.6890325 L69.7721938,22.1516997 C69.774326,20.928378 68.7832423,19.9360642 67.5599198,19.9353054 C67.5594619,19.9353051 67.5590039,19.935305 67.558546,19.9366784 L36.5479677,19.9366784 C27.3730474,19.9366784 19.935305,27.3744208 19.935305,36.549341 L19.935305,67.558546 C19.935305,68.7818687 20.927004,69.7735676 22.1503267,69.7735676 L54.8224984,69.7735676 C63.079746,69.7735676 69.7735676,63.079746 69.7735676,54.8224984 L69.7735676,42.0864509 C69.7735676,40.8631282 68.7818687,39.8714292 67.558546,39.8714292 Z" id="G" fill="#FFFFFF"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+2 -2
View File
@@ -49,13 +49,13 @@ const navList = [
]; ];
</script> </script>
<template> <template>
<ThemeProvider :color="{ middle: { 'bg-base': '#1896ff' }, primary: { DEFAULT: '#1896ff' } }" :autoAdapt="true"> <ThemeProvider :color="{ middle: { 'bg-base': '#fff' }, primary: { DEFAULT: '#fff' } }" :autoAdapt="true">
<div class="front-view flex flex-col"> <div class="front-view flex flex-col">
<div class="text-text flex-1"> <div class="text-text flex-1">
<div class="front-header flex items-baseline py-md px-xl"> <div class="front-header flex items-baseline py-md px-xl">
<div class="text-xxl text-text hover:text-text"> <div class="text-xxl text-text hover:text-text">
<!-- <img src="@/assets/png.svg" /> --> <!-- <img src="@/assets/png.svg" /> -->
<!-- 抖音同步小帮手 --> <!-- 抖小云 -->
</div> </div>
<!-- <div style="width: calc(100% - 430px)" class="front-navigation mx-xl flex overflow-hidden items-center text-lg overflow-ellipsis whitespace-nowrap"> <!-- <div style="width: calc(100% - 430px)" class="front-navigation mx-xl flex overflow-hidden items-center text-lg overflow-ellipsis whitespace-nowrap">
<div :class="`front-nav-item flex items-center cursor-pointer mx-base ${nav.children ? 'with-list' : ''}`" v-for="nav in navList"> <div :class="`front-nav-item flex items-center cursor-pointer mx-base ${nav.children ? 'with-list' : ''}`" v-for="nav in navList">
+18 -34
View File
@@ -1,45 +1,35 @@
<script lang="ts" setup> <script lang="ts" setup>
import { LogoutOutlined } from '@ant-design/icons-vue'; import { LogoutOutlined } from '@ant-design/icons-vue';
import { onMounted } from 'vue'; import { onMounted, onUnmounted, computed } from 'vue';
import { ThemeProvider, alert } from 'stepin'; import { ThemeProvider, alert } from 'stepin';
import http from '@/store/http'; import http from '@/store/http';
import { useRouter } from 'vue-router';
const router = useRouter();
// console.log(router.getRoutes());
onMounted(() => {
// alert.info(
// `<div class="text-text">
// Stepin is a fast, light framework to Vue3 – try it out today with the
// <span class="underline">Stepin Template Beta</span>.
// </div>`,
// { renderRaw: true, duration: -1 }
// );
// console.log(router.getRoutes());
if (http.checkAuthorization()) {
// console.log(22222);
router.push('/dashboard');
} else {
router.push('/login');
}
});
</script> </script>
<template> <template>
<ThemeProvider :color="{ middle: { 'bg-base': '#fff','bg-container':'#fff','bg-container-light':'#fff' }, primary: { DEFAULT: '#1896ff' } }" :autoAdapt="true"> <ThemeProvider :color="{
middle: { 'bg-base': '#fff','bg-container':'#fff','bg-container-light':'#fff' },
primary: { DEFAULT: '#1896ff' }
}" :autoAdapt="true">
<div class="front-view flex flex-col" style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)"> <div class="front-view flex flex-col" style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)">
<!-- <div class="text-xxl text-text hover:text-text" style="margin-left:20px;color:#722ed1;"> --> <div class="front-content">
<!-- <img src="/logo.png" /> -->
<!-- 抖音同步小帮手 -->
<!-- </div> -->
<div class="front-content ">
<router-view /> <router-view />
</div> </div>
</div> </div>
</ThemeProvider> </ThemeProvider>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.front-view { .front-view {
height: 100vh;
width: 100vw;
overflow: hidden;
.front-content {
height: 100vh;
overflow: auto; // 防止mobile页面内容溢出
}
// 原样式保留(无冲突)
.front-header { .front-header {
.front-nav-item { .front-nav-item {
&.with-list .front-nav-item-content { &.with-list .front-nav-item-content {
@@ -55,11 +45,5 @@ onMounted(() => {
} }
} }
} }
.front-content {
height: 100vh;
}
.front-view {
height: 100vh;
}
} }
</style> </style>
+553 -66
View File
@@ -1,85 +1,360 @@
<script lang="ts" setup> <script lang="ts" setup>
import { reactive } from 'vue'; // 步骤2:声明父组件传递的 showSetting 事件(关键!)
const emits = defineEmits(['showSetting']);
import { ref, onMounted } from 'vue'; // 移除 h、VNode 导入
import { StepinHeaderAction } from 'stepin'; import { StepinHeaderAction } from 'stepin';
import Notice from '@/components/notice/Notice.vue';
import DayNightSwitch from '@/components/switch/DayNightSwitch.vue'; import DayNightSwitch from '@/components/switch/DayNightSwitch.vue';
import { BellOutlined } from '@ant-design/icons-vue'; import { TagsOutlined, CopyOutlined, GithubOutlined } from '@ant-design/icons-vue';
import Fullscreen from '../fullscreen/Fullscreen.vue'; import Fullscreen from '../fullscreen/Fullscreen.vue';
import { useApiStore } from '@/store';
import { message, Popover, Badge } from 'ant-design-vue'; // 新增:导入 Badge 组件用于气泡提示
defineEmits<{ // 版本数据和当前版本状态
(e: 'showSetting'): void; const dyVersions = ref<string[]>([]);
}>(); const copyLoading = ref<Record<string, boolean>>({});
// 版本 popover 显隐控制
const versionPopoverVisible = ref<boolean>(false);
// 新增:是否有新版本(用于气泡提示)
const hasNewVersion = ref<boolean>(false);
// 新增:localStorage 缓存 key(统一命名,方便维护)
const VERSION_CACHE_KEY = 'dysync_version_cache';
const noticeList = reactive([ // 仓库地址常量
const gitRepos = ref([
{ {
title: '消息', name: 'Gitee',
list: [ url: 'https://gitee.com/deathvicky/dysync.net',
{ color: '#FF6600',
title: '影佑',
content: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
img: 'src/assets/avatar/face-1.jpg',
time: 0,
}, },
], {
name: 'GitHub',
url: 'https://github.com/jianzhichu/dysync.net',
color: '#6c35de',
}, },
]); ]);
const gitCopyLoading = ref<Record<string, boolean>>({});
const popoverVisible = ref<boolean>(false);
// 复制版本号方法(保留原有兼容逻辑,无改动)
const copyVersion = (version: string) => {
const pureVersion = version.replace('(当前版本)', '').replace('(最新版)', '').trim();
copyLoading.value[version] = true;
const doCopy = async () => {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(pureVersion);
message.success(`已复制版本: ${pureVersion}`);
return;
}
const textarea = document.createElement('textarea');
textarea.style.position = 'absolute';
textarea.style.top = '-9999px';
textarea.style.left = '-9999px';
textarea.value = pureVersion;
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
if (success) {
message.success(`已复制版本: ${pureVersion}`);
} else {
throw new Error('execCommand 复制失败');
}
} catch (error) {
message.warning(`复制失败,请手动复制:${pureVersion}`);
console.warn('复制版本号失败:', error);
} finally {
copyLoading.value[version] = false;
}
};
doCopy();
};
// 辅助方法:判断是否为当前版本(模板中使用)
const isCurrentVersion = (version: string) => {
return version.includes('(当前版本)');
};
// 辅助方法:判断是否为最新版本(模板中使用)
const isLatestVersion = (version: string) => {
return version.includes('(最新版)');
};
// 复制仓库地址方法(保留原有逻辑,无改动)
const copyGitUrl = (repo: { name: string; url: string; color: string }) => {
gitCopyLoading.value[repo.url] = true;
const doCopy = async () => {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(repo.url);
message.success(`已复制 ${repo.name} 地址`);
return;
}
const textarea = document.createElement('textarea');
textarea.style.position = 'absolute';
textarea.style.top = '-9999px';
textarea.style.left = '-9999px';
textarea.value = repo.url;
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand('copy');
document.body.removeChild(textarea);
if (success) {
message.success(`已复制 ${repo.name} 地址`);
} else {
throw new Error('execCommand 复制失败');
}
} catch (error) {
message.warning(`复制 ${repo.name} 地址失败,请手动复制`);
console.warn(`复制 ${repo.name} 地址失败:`, error);
} finally {
gitCopyLoading.value[repo.url] = false;
}
};
doCopy();
};
// 新增:统一处理版本数据的方法(提取公共逻辑,避免冗余)
const handleVersionData = (rawData: string[]) => {
const newVersions = [...rawData];
const versionLen = newVersions.length;
if (versionLen > 0) {
if (versionLen === 1) {
newVersions[0] = `${newVersions[0]}(最新版)`;
hasNewVersion.value = false; // 只有一个版本,无新版
} else {
newVersions[0] = `${newVersions[0]}(当前版本)`;
const lastIndex = versionLen - 1;
newVersions[lastIndex] = `${newVersions[lastIndex]}(最新版)`;
// 判断是否有新版本:当前版本 !== 最新版本(去除备注后对比)
const currentVersion = newVersions[0].replace('(当前版本)', '').trim();
const latestVersion = newVersions[lastIndex].replace('(最新版)', '').trim();
hasNewVersion.value = currentVersion !== latestVersion;
}
} else {
hasNewVersion.value = false;
}
return newVersions;
};
// 新增:保存版本数据到 localStorage
const saveVersionToLocalStorage = (versionData: string[]) => {
try {
localStorage.setItem(VERSION_CACHE_KEY, JSON.stringify(versionData));
} catch (error) {
console.warn('版本数据缓存到 localStorage 失败:', error);
}
};
// 新增:从 localStorage 获取缓存的版本数据
const getVersionFromLocalStorage = (): string[] => {
try {
const cacheData = localStorage.getItem(VERSION_CACHE_KEY);
if (cacheData) {
return JSON.parse(cacheData) as string[];
}
} catch (error) {
console.warn('从 localStorage 读取版本缓存失败:', error);
}
return [];
};
// 改造:版本查看方法(获取数据后更新缓存,控制 popover 显隐)
const showVersionNotification = () => {
useApiStore()
.CheckTag()
.then((res) => {
if (res.code === 0) {
// 处理版本数据(添加备注、判断新版)
const processedVersions = handleVersionData(res.data);
dyVersions.value = processedVersions;
// 点击后更新 localStorage 缓存
saveVersionToLocalStorage(processedVersions);
// 打开版本 popover
versionPopoverVisible.value = true;
} else {
message.error(res.message);
}
})
.catch((err) => {
message.error('获取版本列表失败');
console.error(err);
});
};
// 新增:页面加载时自动调用接口获取版本并缓存
const fetchVersionOnMount = () => {
useApiStore()
.CheckTag()
.then((res) => {
if (res.code === 0) {
// 处理版本数据(添加备注、判断新版)
const processedVersions = handleVersionData(res.data);
dyVersions.value = processedVersions;
// 页面加载时缓存到 localStorage
saveVersionToLocalStorage(processedVersions);
} else {
// 接口失败时,尝试读取本地缓存
const cacheVersions = getVersionFromLocalStorage();
if (cacheVersions.length > 0) {
dyVersions.value = handleVersionData(cacheVersions);
}
message.error(res.message);
}
})
.catch((err) => {
// 接口异常时,尝试读取本地缓存
const cacheVersions = getVersionFromLocalStorage();
if (cacheVersions.length > 0) {
dyVersions.value = handleVersionData(cacheVersions);
}
message.error('获取版本列表失败,已加载本地缓存');
console.error(err);
});
};
// 全局注入加载动画样式
onMounted(() => {
// 页面加载时自动调用版本接口
fetchVersionOnMount();
if (!document.querySelector('#custom-spin-style')) {
const style = document.createElement('style');
style.id = 'custom-spin-style';
style.textContent = `
@keyframes custom-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
});
// Git 弹窗控制方法
const showGit = () => {
popoverVisible.value = !popoverVisible.value;
console.log('打开开源地址弹窗');
};
</script> </script>
<template> <template>
<!-- <StepinHeaderAction>
<a-input placeholder="开始搜索...">
<template #prefix>
<search-outlined />
</template>
</a-input>
</StepinHeaderAction> -->
<StepinHeaderAction> <StepinHeaderAction>
<DayNightSwitch /> <DayNightSwitch />
</StepinHeaderAction> </StepinHeaderAction>
<!-- <StepinHeaderAction> -->
<!-- <a-tooltip> -->
<!-- placement="rightTop" -->
<!-- <template #title><span style="color:yellow">Swagger Api</span></template> -->
<!-- <a class="action-item" href="/swagger" target="_blank"> -->
<!-- <ApiOutlined /> -->
<!-- <img class="gitee-logo" alt="swagger api" src="@/assets/swagger.png" /> -->
<!-- </a> -->
<!-- </a-tooltip> -->
<!-- </StepinHeaderAction> --> <!-- 版本查看:改造为 Badge 包裹图标,实现气泡提示 -->
<!-- <StepinHeaderAction>
<a class="action-item" href="http://github.com/stepui/stepin-template" target="_blank">
<GithubOutlined />
</a>
</StepinHeaderAction>
<StepinHeaderAction> <StepinHeaderAction>
<a class="action-item" href="http://gitee.com/stepui/stepin-template" target="_blank"> <div class="action-item">
<img class="gitee-logo" src="@/assets/gitee.svg" /> <a-popover v-model:visible="versionPopoverVisible" placement="bottom" trigger="click" overlay-class="version-popover-overlay" @visible-change="(visible) => versionPopoverVisible = visible">
</a>
</StepinHeaderAction> -->
<!-- <StepinHeaderAction>
<div class="action-item setting" @click="$emit('showSetting')">
<SettingOutlined />
</div>
</StepinHeaderAction> -->
<!-- <a-popover placement="bottomRight">
<StepinHeaderAction>
<div class="action-item notice">
<BellOutlined />
</div>
</StepinHeaderAction>
<template #content> <template #content>
<Notice :data-source="noticeList" /> <div class="version-popover-content">
<!-- 版本列表:纯模板 v-for 实现,替代 VNode 渲染 -->
<div class="custom-version-list" v-if="dyVersions.length > 0">
<div class="custom-version-item" :class="{
'custom-current-version': isCurrentVersion(version),
'custom-latest-version': isLatestVersion(version)
}" v-for="(version, index) in dyVersions" :key="index" :title="isCurrentVersion(version)
? '当前使用版本 - 点击右侧按钮复制版本号'
: isLatestVersion(version)
? '最新版本 - 点击右侧按钮复制版本号'
: '点击右侧按钮复制版本号'">
<!-- 版本文本 -->
<div class="version-text">
<span :style="{
color: isCurrentVersion(version) ? '#1890ff' : isLatestVersion(version) ? '#2e7d32' : '#1f2937'
}">
{{ version }}
</span>
</div>
<!-- 复制按钮 -->
<button class="version-copy-btn" :disabled="copyLoading[version]" @click.stop="copyVersion(version)" :title="copyLoading[version] ? '复制中...' : '复制版本号'">
<CopyOutlined :style="{
fontSize: '14px',
color: copyLoading[version]
? '#d1d5db'
: isCurrentVersion(version)
? '#1890ff'
: isLatestVersion(version)
? '#2e7d32'
: '#9ca3af',
animation: copyLoading[version] ? 'custom-spin 1s linear infinite' : 'none'
}" />
</button>
</div>
</div>
<!-- 暂无版本数据 -->
<div class="no-version-data" v-else>
暂无版本数据
</div>
</div>
</template> </template>
</a-popover> --> <!-- <a-tooltip placement="bottom"> -->
<!-- <StepinHeaderAction> <template #title>
<!-- <span>版本查看</span> -->
</template>
<!-- 改造:用 Badge 包裹图标,有新版时显示气泡提示 -->
<a-badge :dot="hasNewVersion">
<TagsOutlined class="action-icon" @click="showVersionNotification" />
</a-badge>
<!-- </a-tooltip> -->
</a-popover>
</div>
</StepinHeaderAction>
<!-- Git 开源地址弹窗(保留原有实现) -->
<StepinHeaderAction>
<div class="action-item">
<a-popover v-model:visible="popoverVisible" placement="bottom" trigger="click" overlay-class="git-popover-overlay" @visible-change="(visible) => popoverVisible = visible">
<template #content>
<div class="git-popover-content">
<div class="git-repo-item" v-for="repo in gitRepos" :key="repo.url">
<div class="git-repo-name" :style="{ color: repo.color }">
<span class="git-repo-tag" :style="{ backgroundColor: repo.color }"></span>
{{ repo.name }}
</div>
<div class="git-repo-url-wrapper">
<a :href="repo.url" target="_blank" class="git-repo-url" :style="{ color: repo.color }" title="点击打开仓库地址">
{{ repo.url }}
</a>
<button class="git-copy-btn" :disabled="gitCopyLoading[repo.url]" @click.stop="copyGitUrl(repo)" title="复制仓库地址">
<CopyOutlined :style="{
fontSize: '12px',
color: gitCopyLoading[repo.url] ? '#d1d5db' : repo.color,
animation: gitCopyLoading[repo.url] ? 'custom-spin 1s linear infinite' : 'none'
}" />
</button>
</div>
</div>
</div>
</template>
<a-tooltip placement="bottom">
<template #title>
<span>项目开源地址</span>
</template>
<GithubOutlined class="action-icon" @click="showGit" />
</a-tooltip>
</a-popover>
</div>
</StepinHeaderAction>
<StepinHeaderAction>
<Fullscreen class="-mx-xs -my-sm h-[56px] px-xs py-sm flex items-center" target=".stepin-layout" /> <Fullscreen class="-mx-xs -my-sm h-[56px] px-xs py-sm flex items-center" target=".stepin-layout" />
</StepinHeaderAction> --> </StepinHeaderAction>
</template> </template>
<style scoped lang="less"> <style scoped lang="less">
.gitee-logo { /* 顶部按钮样式 */
width: 20px;
}
.action-item { .action-item {
font-size: 20px; font-size: 20px;
height: 100%; height: 100%;
@@ -88,13 +363,225 @@ const noticeList = reactive([
line-height: 40px; line-height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
cursor: pointer;
&.setting { &:hover {
font-size: 18px; color: #1890ff;
}
&.notice {
font-size: 18px;
} }
} }
.action-icon {
font-size: 20px;
}
/* 版本 Popover 样式(纯模板适配) */
:deep(.version-popover-overlay) {
.ant-popover-inner {
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
width: 520px;
}
.ant-popover-arrow-content {
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
}
.version-popover-content {
width: 100%;
box-sizing: border-box;
}
/* 版本列表(模板对应样式) */
.custom-version-list {
width: 100%;
padding: 8px 0;
box-sizing: border-box;
}
.custom-version-item {
width: 100%;
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
border-radius: 4px;
transition: all 0.2s ease;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
white-space: nowrap;
background-color: transparent;
&:hover {
background-color: #f5fafe;
}
&:last-child {
border-bottom: none;
}
}
/* 当前版本/最新版本高亮 */
.custom-current-version {
background-color: rgba(24, 144, 255, 0.05) !important;
&:hover {
background-color: rgba(24, 144, 255, 0.1) !important;
}
}
.custom-latest-version {
background-color: rgba(46, 125, 50, 0.05) !important;
&:hover {
background-color: rgba(46, 125, 50, 0.1) !important;
}
}
/* 版本文本容器 */
.version-text {
display: flex;
align-items: center;
max-width: calc(100% - 40px); /* 修正:原 maxwidth 拼写错误 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 版本复制按钮 */
.version-copy-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background-color: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
flex-shrink: 0;
transition: all 0.2s ease;
&:hover {
background-color: rgba(24, 144, 255, 0.1);
color: #1890ff !important;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
/* 暂无版本数据 */
.no-version-data {
text-align: center;
color: #999;
padding: 20px 0;
font-size: 14px;
background-color: #f9fafb;
border-radius: 8px;
margin: 0 12px;
}
/* Git Popover 样式 */
:deep(.git-popover-overlay) {
.ant-popover-inner {
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
width: 360px;
}
.ant-popover-arrow-content {
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
}
.git-popover-content {
width: 100%;
box-sizing: border-box;
}
.git-repo-item {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.git-repo-name {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.git-repo-tag {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.git-repo-url-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #f9fafb;
border-radius: 4px;
font-size: 13px;
}
.git-repo-url {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
&:hover {
text-decoration: underline;
opacity: 0.8;
}
}
.git-copy-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background-color: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: 8px;
flex-shrink: 0;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
/* 根容器样式 */
.header-actions-root {
display: flex;
align-items: center;
gap: 8px;
}
</style> </style>
+118 -22
View File
@@ -1,41 +1,137 @@
<script lang="ts" setup></script> <script lang="ts" setup>
import { GithubOutlined, CopyrightOutlined } from '@ant-design/icons-vue';
import { ref, onMounted } from 'vue';
import { useApiStore } from '@/store';
// 响应式变量存储年份文本
const yearText = ref('');
const currentVersion = ref(); // 已存在的版本号响应式变量
onMounted(() => {
const currentYear = new Date().getFullYear();
const startYear = 2025;
if (currentYear <= startYear) {
yearText.value = startYear.toString();
} else {
yearText.value = `${startYear}-${currentYear}`;
}
useApiStore()
.getVer()
.then((r) => {
if (r.code == 0) {
currentVersion.value = r.data; // 接口返回后赋值版本号
}
});
});
</script>
<template> <template>
<div class="page-footer"> <footer class="page-footer">
<div class="links"> <div class="footer-content">
<!-- <a class="link" href="https://github.com/stepui/stepin-template" target="_blank"> Stepin 首页 </a> --> <!-- 版权信息容器 -->
<!-- <a class="link" href="https://github.com/stepui/stepin-template" target="_blank">
<GithubOutlined />
</a> -->
<!-- <a class="link" href="https://www.antdv.com/docs/vue/introduce-cn/" target="_blank"> Ant Design </a> -->
</div>
<div class="copyright"> <div class="copyright">
Copyright
<CopyrightOutlined class="icon-copyright" /> <CopyrightOutlined class="icon-copyright" />
2023 dy.net <span>{{ yearText }} 抖小云 dysync.net. </span>
<!-- 将社交链接移动到这里 -->
<!-- <div class="social-links">
<a href="https://github.com/jianzhichu/dysync.net" target="_blank" rel="noopener noreferrer" aria-label="访问我们的 GitHub 仓库" class="social-link">
<GithubOutlined />
</a>
<a href="https://gitee.com/deathvicky/dysync.net" target="_blank" rel="noopener noreferrer" aria-label="访问我们的 Gitee 仓库" class="social-link">
<img class="gitee-icon" src="@/assets/gitee.svg" alt="Gitee" />
</a>
</div> -->
<!-- 新增:显示当前版本号,增加样式分隔提升美观度 -->
<span class="version-text" v-if="currentVersion">{{currentVersion.deploy}}_{{ currentVersion.tag }}</span>
</div> </div>
</div> </div>
</footer>
</template> </template>
<style scoped lang="less"> <style scoped lang="less">
.page-footer { .page-footer {
text-align: center; // 使用 :root 变量,便于主题切换
@apply text-gray-400; --footer-text-color: var(--text-color-secondary, #999);
--footer-hover-color: var(--primary-color, #1890ff);
--version-text-color: var(--text-color-tertiary, #666);
.links { width: 100%;
color: var(--footer-text-color);
padding: 24px 0;
font-size: 14px;
line-height: 1.6;
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
text-align: center;
}
// 版权信息现在是一个父 flex 容器
.copyright {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center;
// 增大 gap 以分隔文字和社交链接组
gap: 10px;
font-size: 13px;
letter-spacing: 0.3px;
}
.version-text {
// 核心修改1:白色文字(醒目,与绿底形成强烈对比)
color: #ffffff;
font-size: 12px;
font-weight: 500;
padding: 2px 6px; // 微调内边距,让绿底区域更饱满,视觉更舒适
// 核心修改2:柔和绿色背景(两种可选方案,不刺眼、显高级)
// 方案1:推荐!清新柔和绿(适配大多数页面,不张扬,高级感足)
background-color: #722ed1;
// 方案2:稍浅薄荷绿(更明亮,醒目度稍高,适合活泼风格页面)
// background-color: #73d13d;
border-radius: 4px; // 适当增大圆角,更显圆润柔和
// 核心修改3:调整光效为白色光晕(匹配白字,提升质感,不突兀)
// text-shadow: 0 0 3px rgba(255, 255, 255, 0.8), 0 0 6px rgba(255, 255, 255, 0.4);
// 可选优化:轻微暗角阴影,让绿底更有层次感,不扁平
// box-shadow: 0 1px 2px rgba(82, 196, 26, 0.2);
transition: all 0.3s ease;
}
// 社交链接组自身也是一个 flex 容器
.social-links {
display: flex;
// 为两个图标之间设置间距
gap: 16px;
// 移除了原来的 margin-bottom
.link { .social-link {
@apply hover:text-gray-400 pl-4 pr-4; color: inherit;
transition: color 0.3s ease, transform 0.2s ease;
display: inline-flex;
opacity: 0.8;
align-items: center;
// 移除了图标和文字的 gap,因为现在没有文字了
&:hover {
color: var(--footer-hover-color);
opacity: 1;
transform: translateY(-2px);
}
svg,
img {
width: 20px;
height: 20px;
display: block;
flex-shrink: 0;
}
} }
} }
.copyright { .gitee-icon {
margin-top: 8px; width: 14px !important;
height: 14px !important;
.icon-copyright {
margin: 0;
}
} }
} }
</style> </style>
+188
View File
@@ -0,0 +1,188 @@
<!-- components/VersionDrawer.vue -->
<script lang="ts" setup>
import { ref, watch, onMounted } from 'vue';
import { Drawer } from 'ant-design-vue';
import { CloseOutlined, CopyOutlined, CheckCircleOutlined } from '@ant-design/icons-vue';
import { useApiStore } from '@/store';
import { message } from 'ant-design-vue';
// ✅ 移除 defineProps/defineEmits 导入,直接使用
const props = defineProps<{
visible: boolean;
currentVersion?: string; // 接收当前版本号
}>();
const emit = defineEmits<{
(e: 'close'): void;
}>();
// 组件挂载时获取配置
onMounted(() => {});
const getVersions = () => {
useApiStore()
.CheckTag()
.then((res) => {
if (res.code == 1) {
dyVersions.value = res.data;
} else {
message.error(res.message);
}
});
};
// 版本数据
const dyVersions = ref<string[]>([]);
// 关闭抽屉
const handleClose = () => {
emit('close');
};
// 复制版本号
const copyVersion = (version: string) => {
navigator.clipboard
.writeText(version)
.then(() => {
message.success(`已复制版本: ${version}`);
})
.catch(() => {
message.error('复制失败,请手动复制');
});
};
// 调试:监听 visible 变化,确认父组件传递的状态是否正确
console.log('抽屉初始状态:', props.visible);
watch(
() => props.visible,
(val) => {
console.log('抽屉 visible 变化:', val);
if (val) {
getVersions();
}
}
);
</script>
<template>
<!-- 强制添加样式,确保不被隐藏 -->
<a-drawer title="版本列表" placement="right" :visible="props.visible" @close="handleClose" :width="450" :mask="true" :mask-closable="true" :z-index="9999" class="version-drawer">
<div class="version-list">
<div v-for="(tag, index) in dyVersions" :key="index" class="version-item">
<div class="version-info">
{{ tag }}
<!-- 当前版本标记 -->
<span v-if="props.currentVersion === tag" class="current-version-tag">
<CheckCircleOutlined class="current-icon" />
当前版本
</span>
</div>
<!-- 复制按钮 -->
<a-tooltip title="复制版本号" placement="top">
<div class="copy-btn" @click.stop="copyVersion(tag)">
<CopyOutlined />
</div>
</a-tooltip>
</div>
<div v-if="dyVersions.length === 0" class="empty-tip">暂无版本数据</div>
</div>
<!-- <div class="close-btn" @click="handleClose">
<CloseOutlined /> 关闭版本列表
</div> -->
</a-drawer>
</template>
<style scoped lang="less">
/* 强制显示抽屉容器,避免被全局样式隐藏 */
.version-drawer {
display: block !important;
}
:deep(.ant-drawer-content-wrapper) {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
z-index: 9999 !important; /* 确保层级最高 */
}
.version-list {
padding: 20px;
line-height: 2;
}
.version-item {
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
&:hover {
background: #f5f5f5;
}
}
.version-info {
display: flex;
align-items: center;
gap: 8px;
}
/* 当前版本标记样式 */
.current-version-tag {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #1890ff;
background: #e6f7ff;
padding: 2px 8px;
border-radius: 12px;
}
.current-icon {
font-size: 12px;
}
/* 复制按钮样式 */
.copy-btn {
color: #999;
font-size: 16px;
cursor: pointer;
transition: color 0.2s;
padding: 4px;
border-radius: 4px;
&:hover {
color: #1890ff;
background: #f0f9ff;
}
}
.empty-tip {
text-align: center;
color: #999;
padding: 40px 0;
}
.close-btn {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
color: #1890ff;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
/* 调整tooltip样式,避免被遮挡 */
:deep(.ant-tooltip) {
z-index: 10000 !important;
}
</style>
+51 -22
View File
@@ -9,7 +9,6 @@ export type Type = 'day' | 'night';
const props = defineProps({ const props = defineProps({
value: { type: String as PropType<Type> }, value: { type: String as PropType<Type> },
// nightColor: { type: String, default: '#1D1D1D' },
nightColor: { type: String, default: '#1a1a2e' }, nightColor: { type: String, default: '#1a1a2e' },
}); });
@@ -17,37 +16,34 @@ const emit = defineEmits<{
(e: 'update:value', value: Type): void; (e: 'update:value', value: Type): void;
}>(); }>();
const STORAGE_KEY = 'theme-mode';
const getInitialValue = () => {
if (typeof window !== 'undefined') {
const cachedValue = localStorage.getItem(STORAGE_KEY);
if (cachedValue === 'day' || cachedValue === 'night') {
return cachedValue;
}
}
return props.value || 'day';
};
const { value: _value } = useModelValue( const { value: _value } = useModelValue(
() => props.value, () => props.value,
(val) => emit('update:value', val), (val) => emit('update:value', val),
'day' getInitialValue()
); );
const switcher: { [key in Type]: Type } = { const switcher: { [key in Type]: Type } = {
day: 'night', day: 'night',
night: 'day', night: 'day',
}; };
const { theme } = storeToRefs(useThemeStore()); const { theme } = storeToRefs(useThemeStore());
let cachedMiddleColors = {};
// 监听主题色变换,更新缓存 // --- 修正点 1: 函数定义移到 watch 之前 ---
let cachedMiddleColors = cloneDeep(theme.value.color.middle); const updateHtmlClass = (mode: Type) => {
watch(
theme,
(val) => {
if (val.color.middle['bg-base'] !== props.nightColor) {
cachedMiddleColors = cloneDeep(val.color.middle);
_value.value = 'day';
} else {
_value.value = 'night';
}
console.log('theme', _value.value);
updateHtmlClass(_value.value);
},
{ deep: true }
);
// 在你的主题store中添加切换逻辑
const updateHtmlClass = (mode) => {
const html = document.documentElement; const html = document.documentElement;
if (mode === 'night') { if (mode === 'night') {
html.classList.add('dark-mode'); html.classList.add('dark-mode');
@@ -55,14 +51,46 @@ const updateHtmlClass = (mode) => {
html.classList.remove('dark-mode'); html.classList.remove('dark-mode');
} }
}; };
// 主题颜色配置
// 监听 theme bg-base 变化,用于同步外部修改
watch(
() => theme.value?.color?.middle?.['bg-base'],
(newBgColor) => {
if (newBgColor) {
if (newBgColor === props.nightColor) {
_value.value = 'night';
} else {
_value.value = 'day';
}
}
}
);
// --- 修正点 2: watch 现在可以安全地调用 updateHtmlClass ---
watch(
_value,
(newMode) => {
// console.log('mode switched to:', newMode);
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, newMode);
}
updateHtmlClass(newMode);
},
{ immediate: true }
);
// --- 修正点 3: 惰性缓存白天模式颜色 ---
const colorCfg = computed(() => { const colorCfg = computed(() => {
if (_value.value === 'day') { if (_value.value === 'day') {
if (Object.keys(cachedMiddleColors).length === 0) {
cachedMiddleColors = cloneDeep(theme.value?.color?.middle || {});
}
return { middle: cachedMiddleColors }; return { middle: cachedMiddleColors };
} }
return { middle: { 'bg-base': props.nightColor } }; return { middle: { 'bg-base': props.nightColor } };
}); });
</script> </script>
<template> <template>
<ThemeProvider is-root :color="colorCfg"> <ThemeProvider is-root :color="colorCfg">
<div @click="() => (_value = switcher[_value])" class="bg-fill-2 day-night-switch hover:border-border relative border-border-2 text-lg rounded-full border border-solid flex items-center"> <div @click="() => (_value = switcher[_value])" class="bg-fill-2 day-night-switch hover:border-border relative border-border-2 text-lg rounded-full border border-solid flex items-center">
@@ -72,6 +100,7 @@ const colorCfg = computed(() => {
</div> </div>
</ThemeProvider> </ThemeProvider>
</template> </template>
<style scoped lang="less"> <style scoped lang="less">
.day-night-switch { .day-night-switch {
.spot { .spot {
+15 -2
View File
@@ -125,7 +125,7 @@ const loadVideoStream = async (videoId: string) => {
// 构建视频流 URL,浏览器会自动发送 Range 请求 // 构建视频流 URL,浏览器会自动发送 Range 请求
// 添加时间戳避免缓存问题 // 添加时间戳避免缓存问题
const url = `/api/video/stream/${videoId}?t=${Date.now()}`; const url = `/api/Video/play/${videoId}?t=${Date.now()}`;
console.log('[VideoPlayer] 设置视频流 URL:', url); console.log('[VideoPlayer] 设置视频流 URL:', url);
// 直接使用 URL,让浏览器处理 Range 请求 // 直接使用 URL,让浏览器处理 Range 请求
@@ -555,11 +555,12 @@ defineExpose({
.video-element { .video-element {
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 70vh; max-height: 45vh;
display: block; display: block;
} }
} }
.loading-overlay { .loading-overlay {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -578,3 +579,15 @@ defineExpose({
} }
</style> </style>
<style lang="less">
// 长视频描述作为标题时限 2 行显示,避免撑爆 modal header
.video-player-modal .modal-title-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
</style>
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
<script lang="ts" setup>
import { reactive, ref, watch, UnwrapRef, onMounted } from 'vue';
import { FormInstance } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { useApiStore, useAccountStore } from '@/store';
import { useRouter } from 'vue-router';
import { ExclamationCircleOutlined, SyncOutlined, SaveOutlined } from '@ant-design/icons-vue';
interface UpSecUserIdItem {
uper?: string;
uid?: string;
syncAll: boolean;
}
type ConfigItem = {
id?: string;
userName: string; // 改为非可选,强制初始化
cookies: string;
savePath: string;
favSavePath: string;
secUserId: string;
status: number;
upSavePath: string;
// imgSavePath: string;
useSinglePath: boolean; // 非可选
};
// 强制初始化所有字段为非undefined值
const newConfig = (config?: ConfigItem): ConfigItem => {
return {
id: '0',
userName: '', // 强制空字符串
cookies: '',
savePath: '',
favSavePath: '',
secUserId: '',
status: 0,
upSavePath: '',
// imgSavePath: '',
useSinglePath: false,
};
};
// 改用 ref 而非 reactive(解决深层响应式问题)
const form = ref<ConfigItem>(newConfig());
const formModel = ref<FormInstance>();
const formLoading = ref(false);
// 重置表单:强制重置 + 清空校验状态
const resetConfig = () => {
form.value = newConfig();
formModel.value?.resetFields(); // 必须调用,清空校验提示
message.info('表单已重置为默认值');
};
// 监听统一路径(改用 ref 的取值方式)
watch(
[() => form.value.savePath, () => form.value.useSinglePath],
([newSavePath, useSinglePath]) => {
if (useSinglePath && newSavePath) {
form.value.favSavePath = newSavePath;
form.value.upSavePath = newSavePath;
// form.value.imgSavePath = newSavePath;
}
},
{ immediate: true }
);
// 校验规则:明确trigger + 不依赖组件自动触发
const formRules = reactive({
userName: [{ required: true, message: '请输入Cookie名称', trigger: 'change' }],
savePath: [{ required: true, message: '请输入收藏存储路径', trigger: 'change' }],
secUserId: [{ required: false, message: '请输入我的secUserId', trigger: 'change' }],
});
const router = useRouter();
// 定义rowCount并暴露到模板(之前定义在onMounted里,模板访问不到)
const rowCount = ref(6);
onMounted(() => {
const accountStore = useAccountStore(); // 移到内部避免提前引用
useApiStore()
.AppisInit()
.then((res) => {
if (res.code == 0 && res.data) {
if (accountStore.logged) {
router.push('/dashboard');
} else {
router.push('/login');
}
}
});
});
const accountStore = useAccountStore();
// 提交:手动指定校验字段 + 强制取值
const submitConfig = async () => {
formLoading.value = true;
try {
// 手动获取表单实例,防止null
const formInstance = formModel.value;
if (!formInstance) {
message.error('表单实例未初始化');
formLoading.value = false;
return;
}
// 手动校验(兜底方案)
const checkResult = manualCheckForm();
if (!checkResult.pass) {
message.error(checkResult.msg);
formLoading.value = false;
return;
}
// 提交时强制解构,确保传递最新值
const res = await useApiStore().DeskInitAsync({ ...form.value });
if (res.code === 0) {
message.success('配置保存成功,即将跳转登录页...');
setTimeout(() => {
router.push('/login');
}, 500);
} else {
message.error(`保存失败:${res.message || '未知错误'}`);
}
} catch (err: any) {
console.error('校验失败:', err);
const firstErr = err.errorFields?.[0]?.errors?.[0] || '请检查所有必填项';
message.error(firstErr);
} finally {
formLoading.value = false;
}
};
// 手动校验函数(兜底方案)
const manualCheckForm = (): { pass: boolean; msg: string } => {
if (!form.value.userName.trim()) {
return { pass: false, msg: '请输入Cookie名称' };
}
if (!form.value.savePath.trim()) {
return { pass: false, msg: '请输入收藏存储路径' };
}
return { pass: true, msg: '' };
};
</script>
<template>
<div class="init-config-page">
<div class="page-header">
<div class="header-content">
<div class="logo-container">
<img src="/logo.png" alt="抖小云Logo" class="logo-img" />
</div>
<h1 class="page-title">抖小云初始化配置信息</h1>
<!-- <p class="page-desc">第一次运行,请花两分钟完成初始配置</p> -->
</div>
</div>
<div class="config-card">
<a-form ref="formModel" :model="form" :rules="formRules" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" layout="horizontal" class="config-form" @submit.prevent="submitConfig">
<!-- Cookie名称 -->
<a-form-item label="Cookie名" name="userName">
<a-input v-model:value="form.userName" placeholder="随便填个名称,作为唯一标识" @input="() => {}" />
</a-form-item>
<!-- Cookie值:现在rows能正常生效了 -->
<a-form-item label="Cookie值" name="cookies">
<div class="cookie-wrapper">
<a-textarea v-model:value="form.cookies" :rows="rowCount" placeholder="粘贴完整Cookie值,也可以先不填,保存配置后,后续可以在“抖音授权”修改" />
<div class="cookie-tip">
<a href="https://gitee.com/deathvicky/dysync.net" target="_blank" class="cookie-link">
查看Cookie以及secUserId获取方式
</a>
</div>
</div>
</a-form-item>
<!-- secUserId -->
<a-form-item label="我的secUserId" name="secUserId">
<div class="input-with-tip">
<a-input v-model:value="form.secUserId" placeholder="同步喜欢视频和关注博主视频必填" @input="() => {}" />
<a-tooltip title="同步喜欢视频和关注博主视频必填">
<ExclamationCircleOutlined style="color: #faad14; margin-left: 8px; font-size: 16px;" />
</a-tooltip>
</div>
</a-form-item>
<!-- 收藏路径 -->
<a-form-item label="收藏存储路径" name="savePath">
<div class="input-with-tip">
<a-input v-model:value="form.savePath" placeholder="/app/collect" @input="() => {}" />
<a-tooltip title="必填">
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px; margin-left: 8px; " />
</a-tooltip>
</div>
</a-form-item>
<!-- 统一路径开关 -->
<a-form-item label="统一存储路径" name="useSinglePath">
<div class="switch-with-desc">
<a-switch v-model:checked="form.useSinglePath" class="custom-large-switch" @change="() => {}" />
<div class="switch-desc">
<span class="main-desc">{{ form.useSinglePath ? '开启' : '关闭' }}</span>
<span class="sub-desc">{{ form.useSinglePath ? '所有类型视频(收藏的、喜欢的、关注博主的)共用一个路径' : '各类型视频(收藏的、喜欢的、关注博主的)文件路径独立配置' }}</span>
</div>
</div>
</a-form-item>
<!-- 其他路径 -->
<a-form-item v-if="!form.useSinglePath" label="喜欢存储路径" name="favSavePath">
<a-input v-model:value="form.favSavePath" placeholder="喜欢视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item>
<a-form-item v-if="!form.useSinglePath" label="关注存储路径" name="upSavePath">
<a-input v-model:value="form.upSavePath" placeholder="关注视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item>
<!-- <a-form-item v-if="!form.useSinglePath" label="图文存储路径" name="imgSavePath">
<a-input v-model:value="form.imgSavePath" placeholder="图文视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item> -->
<!-- 同步状态开关 -->
<a-form-item label="同步状态" name="status">
<div class="switch-with-desc">
<a-switch v-model:checked="form.status" :checked-value="1" :un-checked-value="0" class="custom-large-switch" />
<div class="switch-desc">
<span class="main-desc">{{ form.status === 1 ? '开启' : '关闭' }}</span>
<span class="sub-desc">建议初始不开启,保存设置后,进入系统配置完成其他设置后再手动开启!</span>
</div>
</div>
</a-form-item>
<!-- 操作按钮 -->
<a-form-item :wrapper-col="{ span: 24 }" class="form-actions">
<div class="btn-center-wrapper">
<a-button type="primary" html-type="submit" :loading="formLoading" class="save-btn">
<template #icon>
<SaveOutlined />
</template>保存配置
</a-button>
</div>
</a-form-item>
</a-form>
</div>
</div>
</template>
<style scoped>
.init-config-page {
min-height: 100vh;
background-color: #f5f7fa;
padding: 10px 50px;
box-sizing: border-box;
}
.page-header {
margin-bottom: 10px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.header-content {
text-align: center;
max-width: 800px;
width: 100%;
}
.logo-container {
margin-bottom: 16px;
display: flex;
justify-content: center;
align-items: center;
}
.logo-img {
width: 60px;
height: 60px;
object-fit: contain;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
}
.page-desc {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.config-card {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
.config-form {
--ant-form-item-margin-bottom: 24px;
}
.input-with-tip {
display: flex;
align-items: center;
width: 100%;
}
.switch-with-desc {
display: flex;
align-items: center;
gap: 12px;
}
.switch-desc {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.switch-desc .main-desc {
font-size: 15px;
color: #374151;
font-weight: 500;
}
.switch-desc .sub-desc {
font-size: 13px;
color: #6b7280;
}
:deep(.custom-large-switch) {
transform: scale(1.2);
}
.form-actions {
margin-top: 30px !important;
margin-bottom: 0 !important;
}
.btn-center-wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
gap: 16px;
}
.save-btn {
padding: 0 40px;
height: 44px;
border-radius: 8px;
font-size: 16px;
background-color: #1677ff;
border-color: #1677ff;
}
.save-btn:hover {
background-color: #4096ff;
border-color: #4096ff;
}
/* 文本域恢复默认,让rows生效 */
:deep(.ant-input-textarea) {
font-size: 15px;
height: auto !important; /* 取消固定高度 */
min-height: auto !important;
}
/* Cookie容器和链接样式 */
.cookie-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.cookie-tip {
text-align: left;
}
.cookie-link {
color: #1677ff;
font-size: 13px;
text-decoration: none;
}
.cookie-link:hover {
color: #4096ff;
text-decoration: underline;
}
@media (max-width: 768px) {
.init-config-page {
padding: 10px 16px;
}
.config-card {
padding: 20px;
}
.logo-img {
width: 60px;
height: 60px;
}
.page-title {
font-size: 24px;
}
.switch-with-desc {
flex-direction: row;
align-items: center;
gap: 8px;
}
.switch-desc {
gap: 4px;
}
:deep(.custom-large-switch) {
transform: scale(1.1);
}
}
/* 滚动条样式 */
:deep(.ant-input-textarea-input) {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(150, 150, 150, 0.2) transparent;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar {
width: 6px;
height: 6px;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar-track {
background: transparent;
}
:deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb {
background: rgba(150, 150, 150, 0.2);
border-radius: 3px;
}
:deep(.ant-input-disabled) {
background-color: #f9fafb !important;
color: #6b7280 !important;
cursor: not-allowed;
}
</style>
File diff suppressed because it is too large Load Diff
-129
View File
@@ -1,129 +0,0 @@
<template>
<a-card :bordered="false" :bodyStyle='{"padding-top":"0px","padding-bottom":"100px"}'>
<a-form :model="formState" :label-col="labelCol" :rules="rules" :wrapper-col="wrapperCol" ref="formRef">
<a-divider orientation="left"></a-divider>
<a-form-item has-feedback label="同步周期(分钟)" ref="Cron" name="Cron">
<a-input v-model:value="formState.Cron" placeholder="1:数字-例如20-表示20分钟执行一次;2:cron表达式,根据表达式周期执行" />
</a-form-item>
<a-form-item label="在线Cron表达式">
<a target="_blank" href="https://www.bejson.com/othertools/cron/">查看示例</a>
</a-form-item>
<a-form-item :wrapper-col="{ span: 10, offset: 3 }">
<a-button type="primary" danger @click="onSubmit">进入系统</a-button>
</a-form-item>
</a-form>
</a-card>
</template>
<script lang="ts" setup>
import { reactive, toRaw, ref, watch, createVNode, h } from 'vue';
import type { UnwrapRef } from 'vue';
import { Form } from 'ant-design-vue';
import type { Rule } from 'ant-design-vue/es/form';
import type { FormInstance } from 'ant-design-vue';
import { useApiStore } from '@/store';
import { Modal } from 'ant-design-vue';
import { checkDomain, checksubDomainPrefix, checkPass, checkUserName } from '@/utils/regexHelper';
const formRef = ref<FormInstance>();
const SK = ref(null);
interface FormState {
cloudName: string;
domainName: string;
recordType: string;
ipv6Prefix: string;
domainRecord: string;
AK: string;
SK: string;
DbType: number;
ConnString: string;
Cron: string;
UserName: string;
UswePwd: string;
}
interface CloudInfo {
key: string;
value: string;
doc: string;
}
interface DbTypeInfo {
key: number;
value: string;
conn: string;
}
interface showDBconn {
value: boolean;
}
const showDBconn: UnwrapRef<showDBconn> = reactive({
value: false,
});
const formState: UnwrapRef<FormState> = reactive({
cloudName: 'aliyun',
domainName: '',
recordType: '',
ipv6Prefix: '',
domainRecord: '',
AK: '',
SK: '',
DbType: 2,
ConnString: '',
Cron: '30',
UserName: '',
UswePwd: '',
});
const rules: Record<string, Rule[]> = {
Cron: [{ required: true, message: '请输入任务调度周期', trigger: 'change' }],
};
watch(
() => formState.DbType,
() => {
formRef.value.validateFields(['ConnString']);
},
{ flush: 'post' }
);
//检查数据库连接
import { message } from 'ant-design-vue';
import router from '@/router';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
const isFirst = ref(false);
//提交初始化
const onSubmit = () => {
// console.log('submit!', toRaw(formState));
formRef.value
.validate()
.then(() => {
console.log('values', formState, toRaw(formState));
useApiStore()
.apiInit(toRaw(formState))
.then((res) => {
if (res.code === 0) {
message.success('初始化成功');
setTimeout(() => {
router.push('/login');
}, 1000);
} else {
message.error(res.erro, 8);
}
});
})
.catch((error) => {
console.log('error', error);
});
};
const labelCol = { style: { width: '150px' } };
const wrapperCol = { span: 4 };
</script>
<style scoped>
.ant-card-body {
padding: 5px !important;
}
</style>
-3
View File
@@ -1,3 +0,0 @@
import Init from './Init.vue';
export default Init;
+283 -16
View File
@@ -3,54 +3,321 @@
<login-box class="shadow-lg" @success="onLoginSuccess" @failure="onLoginFail" /> <login-box class="shadow-lg" @success="onLoginSuccess" @failure="onLoginFail" />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import LoginBox from './LoginBox.vue'; import LoginBox from './LoginBox.vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
// import http from '@/store/http'; import { message, notification, Modal } from 'ant-design-vue'; // 引入 Modal 用于确认提示
import { onMounted, ref, h } from 'vue';
import { useApiStore } from '@/store';
import { CopyOutlined } from '@ant-design/icons-vue';
const router = useRouter(); const router = useRouter();
function onLoginSuccess() { // 版本数据存储
router.push('/dashboard'); const currentTag = ref<string>('');
} const latestTag = ref<string>('未知版本');
import { message } from 'ant-design-vue';
function onLoginFail(status, res) { // 复制版本号到剪贴板核心逻辑
console.log(res); const copyToClipboard = async (content: string, type: string) => {
if (res.code === 0) { if (!content || content === '未知版本') {
} else { message.warning(`${type}无有效内容可复制`);
message.error(res.erro, 5); return;
} }
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content);
message.success(`${type}已复制到剪贴板 ✅`);
} else {
const textarea = document.createElement('textarea');
textarea.value = content;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
message.success(`${type}已复制到剪贴板 ✅`);
}
} catch (err) {
console.error('复制失败:', err);
message.error('复制失败,请手动选中复制');
}
};
// 统一的关闭处理:标记缓存 + 关闭通知
const handleNoticeClose = (noticeKey: string) => {
console.log('版本提醒通知已关闭,后续不再提醒');
// 标记为已提醒,存入缓存(确保版本通知和确认弹窗都不再出现)
localStorage.setItem('maintain_notice_shown', 'true');
// 关闭版本通知
notification.close(noticeKey);
};
// 关闭前的确认提示弹窗
const showCloseConfirm = (noticeKey: string) => {
Modal.confirm({
title: '确认关闭',
content: '关闭后该提醒将不再弹出,确定要关闭吗?',
okText: '确定',
cancelText: '取消',
onOk: () => {
// 确认关闭:执行统一处理
handleNoticeClose(noticeKey);
message.success('提醒已关闭,后续不再弹出');
},
onCancel: () => {
// 取消关闭:不执行任何操作,保留版本通知
message.info('已取消关闭');
},
});
};
// 打开右上角 notification 通知
const openVersionNotice = () => {
const noticeKey = `version_notice_${Date.now()}`;
notification.open({
message: '温馨提示一下',
key: noticeKey,
duration: 0,
placement: 'topRight',
// 通知描述内容
description: h('div', { class: 'notice-content' }, [
h('p', { class: 'notice-desc' }, '当前您使用的docker镜像为阿里云镜像,已停止维护。'),
h('p', { class: 'notice-version-item' }, [
h('strong', { class: 'notice-version-label' }, '当前版本:'),
h('span', { style: { color: '#ff4d4f', fontWeight: '500' } }, currentTag.value.replace('[不再维护]', '')),
h(CopyOutlined, {
class: 'notice-copy-icon',
title: '复制当前版本',
onClick: () => copyToClipboard(currentTag.value.replace('[不再维护]', ''), '当前版本'),
}),
]),
h('p', { class: 'notice-version-item' }, [
h('strong', { class: 'notice-version-label' }, '最新版本:'),
h('span', { style: { color: '#52c41a', fontWeight: '500' } }, latestTag.value),
h(CopyOutlined, {
class: 'notice-copy-icon',
title: '复制最新版本',
onClick: () => copyToClipboard(latestTag.value, '最新版本'),
}),
]),
h('p', { class: 'notice-tip' }, '建议升级到最新版本!'),
]),
// 「我已知晓」按钮:点击触发确认弹窗
btn: () =>
h(
'button',
{
class: 'notice-confirm-btn',
onClick: () => {
// 不直接关闭,先弹出确认提示
showCloseConfirm(noticeKey);
},
},
'我已知晓'
),
});
};
onMounted(() => {
useApiStore()
.AppisInit()
.then((res) => {
if (res.code == 0 && res.data) {
} else {
router.push('/init');
}
});
useApiStore()
.CheckTag()
.then((res) => {
if (res.code === 0) {
if (res.data.length > 0) {
const tag = res.data[0];
if (tag.indexOf('不再维护') !== -1) {
const hasShown = localStorage.getItem('maintain_notice_shown');
if (!hasShown) {
currentTag.value = tag;
latestTag.value = res.data.length >= 2 ? res.data[1] : '未知版本';
openVersionNotice();
}
}
}
} else {
message.error(res.message);
}
})
.catch((err) => {
console.error(err);
});
});
function onLoginSuccess() {
if (isMobileBrowser()) router.push('/mobile');
else router.push('/dashboard');
}
const isMobileBrowser = (): boolean => {
if (typeof navigator === 'undefined' || typeof window === 'undefined') {
return false;
}
const userAgent = navigator.userAgent.toLowerCase();
const mobileUA = /android|iphone|ipod|blackberry|windows phone|iemobile|opera mini/i.test(userAgent);
const isTablet = /ipad|tablet|playbook|kindle|android 3\.|android 4\.[0-3]/.test(userAgent);
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const isMobileScreen = window.innerWidth <= 768 && window.innerHeight <= 1024;
return (mobileUA && !isTablet && isTouchDevice) || (isMobileScreen && isTouchDevice);
};
function onLoginFail(reason: string, fields: any) {
console.log('登录失败:', reason, fields);
message.error(reason || '登录失败,请重试', 5);
} }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.login { .login {
height: 100vh; height: 100vh;
// 与深色登录框搭配的渐变背景 min-height: -webkit-fill-available;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
position: relative;
overflow: hidden;
padding: 20px 0;
// 可以添加一些装饰性背景元素
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
width: 400px; width: 280px;
height: 400px; height: 280px;
border-radius: 50%; border-radius: 50%;
background: rgba(54, 191, 250, 0.1); background: rgba(54, 191, 250, 0.1);
top: 15%;
left: 5%;
filter: blur(60px);
@media (min-width: 768px) {
width: 400px;
height: 400px;
top: 20%; top: 20%;
left: 15%; left: 15%;
filter: blur(80px); filter: blur(80px);
} }
}
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
width: 300px; width: 220px;
height: 300px; height: 220px;
border-radius: 50%; border-radius: 50%;
background: rgba(54, 191, 250, 0.08); background: rgba(54, 191, 250, 0.08);
bottom: 5%;
right: 5%;
filter: blur(40px);
@media (min-width: 768px) {
width: 300px;
height: 300px;
bottom: 10%; bottom: 10%;
right: 10%; right: 10%;
filter: blur(60px); filter: blur(60px);
} }
}
}
@supports (bottom: env(safe-area-inset-bottom)) {
.login {
padding-bottom: env(safe-area-inset-bottom);
padding-top: env(safe-area-inset-top);
}
}
</style>
<style lang="less">
.ant-notification {
.ant-notification-notice {
width: 500px !important;
padding: 16px !important;
}
.notice-content {
font-size: 14px !important;
line-height: 1.8 !important;
color: #334155 !important;
width: 480px !important;
.notice-desc {
margin-bottom: 12px !important;
padding-left: 2px !important;
margin: 0 !important;
}
.notice-version-item {
display: flex !important;
align-items: center !important;
margin: 8px 0 !important;
padding: 6px 10px !important;
background-color: #f8fafc !important;
border-radius: 6px !important;
transition: background-color 0.2s ease !important;
&:hover {
background-color: #f1f5f9 !important;
}
}
.notice-version-label {
color: #1e293b !important;
width: 70px !important;
flex-shrink: 0 !important;
font-size: 13px !important;
}
.notice-copy-icon {
flex-shrink: 0 !important;
color: #165dff !important;
font-size: 16px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
margin-left: 8px !important;
&:hover {
color: #0d47a1 !important;
transform: scale(1.1) !important;
}
&:active {
transform: scale(0.95) !important;
}
}
.notice-tip {
margin-top: 12px !important;
color: #64748b !important;
padding-left: 2px !important;
font-style: italic !important;
font-size: 13px !important;
margin: 0 !important;
}
}
.notice-confirm-btn {
background-color: #165dff !important;
color: #ffffff !important;
border: none !important;
border-radius: 4px !important;
padding: 4px 12px !important;
font-size: 13px !important;
cursor: pointer !important;
transition: background-color 0.2s ease !important;
margin-top: 12px !important;
&:hover {
background-color: #0d47a1 !important;
}
}
} }
</style> </style>
+168 -63
View File
@@ -1,25 +1,26 @@
<template> <template>
<div class="login-page min-h-screen flex items-center justify-center p-4 md:p-8">
<ThemeProvider :color="{ <ThemeProvider :color="{
middle: { 'bg-base': '#f8fafc' }, middle: { 'bg-base': '#fff' },
primary: { DEFAULT: '#4f46e5', hover: '#4338ca' } primary: { DEFAULT: '#4f46e5', hover: '#4338ca' }
}"> }">
<!-- 登录卡片 --> <!-- 登录卡片 - 移动端全屏 + PC端保留原宽度 -->
<div class="login-box rounded-2xl shadow-lg p-8 md:p-10 max-w-md w-full border border-gray-200 transition-all duration-500 hover:shadow-xl transform hover:-translate-y-1"> <div class="login-box rounded-2xl shadow-lg p-6 md:p-8 lg:p-10 max-w-md w-full border border-gray-200 transition-all duration-500 hover:shadow-xl transform hover:-translate-y-1 mx-auto" style="box-sizing: border-box; max-width: 90vw;">
<!-- 顶部Logo区域 --> <!-- 顶部Logo区域 - 边框圈精准贴合Logo内容 -->
<div class="flex flex-col items-center mb-8"> <div class="flex flex-col items-center mb-6 md:mb-8">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4"> <div class="w-30 h-30 md:w-36 md:h-36 bg-primary/10 rounded-full flex items-center justify-center mb-4 md:mb-5">
<img src="/public/logo.png"> <div class="w-24 h-24 md:w-28 h-28 rounded-full flex items-center justify-center">
<img src="/logo.png" alt="抖小云logo" class="w-18 h-18 md:w-20 h-20 object-contain" />
</div> </div>
<h2 class="third-title text-2xl font-bold text-gray-800 tracking-tight">抖音同步小帮手</h2> </div>
<h2 class="third-title text-xl md:text-2xl font-bold text-gray-800 tracking-tight">抖小云</h2>
</div> </div>
<a-form :model="form" :wrapperCol="{ span: 24 }" @finish="login" class="login-form w-full text-gray-700"> <a-form :model="form" :wrapperCol="{ span: 24 }" @finish="login" class="login-form w-full text-gray-700">
<!-- 用户名输入框 --> <!-- 用户名输入框 -->
<a-form-item :required="true" name="username" class="mb-5" :validate-status="usernameStatus"> <a-form-item :required="true" name="username" class="mb-4 md:mb-5" :validate-status="usernameStatus">
<a-input v-model:value="form.username" autocomplete="new-username" placeholder="请输入用户名" class="login-input h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20"> <a-input v-model:value="form.username" autocomplete="new-username" placeholder="请输入用户名" class="login-input h-[46px] md:h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" style="padding: 0 12px;">
<template #prefix> <template #prefix>
<svg class="w-5 h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-4 h-4 md:w-5 md:h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle> <circle cx="12" cy="7" r="4"></circle>
</svg> </svg>
@@ -29,50 +30,74 @@
<!-- 密码输入框 --> <!-- 密码输入框 -->
<a-form-item :required="true" name="password" class="mb-2" :validate-status="passwordStatus"> <a-form-item :required="true" name="password" class="mb-2" :validate-status="passwordStatus">
<a-input v-model:value="form.password" autocomplete="new-password" placeholder="请输入密码" class="login-input h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" :type="showPassword ? 'text' : 'password'"> <a-input v-model:value="form.password" autocomplete="new-password" placeholder="请输入密码" class="login-input h-[46px] md:h-[50px] rounded-lg bg-gray-50 border-gray-200 text-gray-800 placeholder:text-gray-400 focus:border-primary focus:bg-white text-base transition-all duration-300 focus:ring-2 focus:ring-primary/20" :type="showPassword ? 'text' : 'password'" style="padding: 0 12px;">
<template #prefix> <template #prefix>
<svg class="w-5 h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-4 h-4 md:w-5 md:h-5 text-gray-400 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path> <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg> </svg>
</template> </template>
<!-- 密码显示/隐藏图标 --> <!-- 密码显示/隐藏图标 -->
<template #suffix> <template #suffix>
<EyeOutlined v-if="showPassword" @click="showPassword = !showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="隐藏密码" /> <div class="w-8 h-8 flex items-center justify-center" @click="showPassword = !showPassword">
<EyeInvisibleOutlined v-else @click="showPassword = !showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="显示密码" /> <EyeOutlined v-if="showPassword" class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="隐藏密码" />
<EyeInvisibleOutlined v-else class="text-gray-400 hover:text-primary cursor-pointer transition-colors duration-300 text-lg" aria-label="显示密码" />
</div>
</template> </template>
</a-input> </a-input>
</a-form-item> </a-form-item>
<!-- 记住密码与忘记密码 --> <!-- 记住密码 -->
<div class="flex items-center justify-between mb-6 px-1"> <div class="flex items-center justify-between mb-5 md:mb-6 px-1">
<a-form-item class="mb-0"> <a-form-item class="mb-0">
<a-checkbox v-model:checked="rememberPassword" class="text-gray-600 hover:text-primary transition-colors duration-300 cursor-pointer"> <a-checkbox v-model:checked="rememberPassword" class="text-gray-600 hover:text-primary transition-colors duration-300 cursor-pointer">
<span class="text-sm">记住密码</span> <span class="text-xs md:text-sm">记住密码</span>
</a-checkbox> </a-checkbox>
</a-form-item> </a-form-item>
<!-- <a href="#" class="text-primary hover:text-primary/80 text-sm font-medium transition-colors duration-300" @click.prevent="handleForgotPassword">
忘记密码? <a-form-item class="mb-0">
</a> --> <div class="text-gray-600 hover:text-primary transition-colors duration-300 cursor-pointer" @click="handleForgotPassword">
<span class="text-xs md:text-sm">忘记密码</span>
</div>
</a-form-item>
</div> </div>
<!-- 登录按钮 --> <!-- 登录按钮 -->
<a-button htmlType="submit" class="h-[52px] w-full rounded-lg transition-all duration-300 hover:bg-primary/90 bg-primary border-primary text-white text-base font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0" type="primary" :loading="loading"> <a-button htmlType="submit" class="h-[48px] md:h-[52px] w-full rounded-lg transition-all duration-300 hover:bg-primary/90 bg-primary border-primary text-white text-base font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0" type="primary" :loading="loading" style="touch-action: manipulation; -webkit-tap-highlight-color: transparent;">
<span v-if="!loading">登录</span> <span v-if="!loading">登录</span>
<span v-else>登录中...</span> <span v-else>登录中...</span>
</a-button> </a-button>
<!-- 注册入口 -->
<!-- <div class="text-center mt-6 text-gray-500 text-sm">
还没有账号?
<a href="#" class="text-primary hover:text-primary/80 font-medium transition-colors duration-300 ml-1" @click.prevent="handleRegister">
立即注册
</a>
</div> -->
</a-form> </a-form>
</div> </div>
</ThemeProvider> </ThemeProvider>
<!-- 忘记密码弹窗 -->
<a-modal v-model:visible="forgotModalVisible" title="密码重置教程" width="50%" :max-width="500" centered :mask-closable="false" :footer="null" class="forgot-modal">
<div class="p-2">
<!-- 步骤说明 -->
<div class="space-y-4 mb-6">
<div class="flex items-start">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">1</div>
<div class="flex-1 text-gray-700 text-sm leading-relaxed">在 db 目录下新建一个文本文件,命名为 <span class="px-2 py-0.5 bg-gray-100 text-primary font-medium rounded text-sm">pwd.txt</span></div>
</div> </div>
<div class="flex items-start">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">2</div>
<div class="flex-1 text-gray-700 text-sm leading-relaxed">打开文件写入你要设置的新密码,留空则自动重置为默认密码:<span class="px-2 py-0.5 bg-gray-100 text-primary font-medium rounded text-sm">douyin2026</span></div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center text-sm mr-3 mt-0.5">3</div>
<div class="flex-1 text-gray-700 text-sm leading-relaxed">重启 docker服务或者飞牛安装的抖小云应用 ,使用新密码登录即可</div>
</div>
</div>
<!-- 知道了按钮 -->
<div class="flex justify-center">
<a-button type="primary" class="h-10 px-8 rounded-lg" @click="forgotModalVisible = false">
我知道了
</a-button>
</div>
</div>
</a-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -88,8 +113,14 @@ const showPassword = ref(false);
const rememberPassword = ref(false); const rememberPassword = ref(false);
// 加载状态 // 加载状态
const loading = ref(false); const loading = ref(false);
// 忘记密码弹窗显示状态
const forgotModalVisible = ref(false);
// 表单状态(用于输入框验证反馈) // 表单状态(用于输入框验证反馈)
const form = reactive({
username: '',
password: '',
});
const usernameStatus = computed(() => (form.username ? 'success' : '')); const usernameStatus = computed(() => (form.username ? 'success' : ''));
const passwordStatus = computed(() => (form.password ? 'success' : '')); const passwordStatus = computed(() => (form.password ? 'success' : ''));
@@ -98,11 +129,6 @@ export interface LoginFormProps {
password: string; password: string;
} }
const form = reactive<LoginFormProps>({
username: '',
password: '',
});
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'success', fields: LoginFormProps): void; (e: 'success', fields: LoginFormProps): void;
(e: 'failure', reason: string, fields: LoginFormProps): void; (e: 'failure', reason: string, fields: LoginFormProps): void;
@@ -158,19 +184,20 @@ async function login(params: LoginFormProps) {
try { try {
const res = await accountStore.login(params.username, params.password); const res = await accountStore.login(params.username, params.password);
emit('success', params); emit('success', params);
console.log(res);
message.success('登录成功!'); message.success('登录成功!');
} catch (e: any) { } catch (e: any) {
// emit('failure', e.message, e.data); emit('failure', e.message || '登录失败', params);
message.error(e.data.erro || '登录失败,请重试'); message.error(e.data?.erro || '登录失败,请重试');
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
// 忘记密码处理 // 忘记密码处理 - 打开弹窗
function handleForgotPassword() { function handleForgotPassword() {
forgotModalVisible.value = true;
emit('forgot-password'); emit('forgot-password');
message.info('忘记密码功能即将上线');
} }
// 注册处理 // 注册处理
@@ -180,15 +207,85 @@ function handleRegister() {
</script> </script>
<style scoped> <style scoped>
/* 页面基础样式 */ /* 全局布局适配 - 替代原外层容器的样式 */
.login-page { :root {
-webkit-text-size-adjust: 100%; /* 禁止iOS文本缩放 */
-webkit-font-smoothing: antialiased; /* 优化字体渲染 */
}
body {
margin: 0;
padding: 0;
overflow-x: hidden;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* 全局居中布局 */
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
}
/* 登录卡片核心样式 */
.login-box {
box-sizing: border-box;
touch-action: manipulation; /* 优化触摸性能 */
/* 移动端全屏,PC端保留原max-w-md(默认28rem/448px)+ 90vw限制 */
}
/* 移动端适配(<768px) */
@media (max-width: 767.98px) {
body {
padding: 0;
}
.login-box {
max-width: 100vw !important; /* 移动端全屏 */
min-height: 100vh;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 20px 16px !important;
padding-top: 20vh !important; /* 移动端垂直居中 */
}
.third-title {
font-size: 1.25rem !important;
}
::v-deep(.ant-input) {
font-size: 14px; /* 移动端字体适配 */
}
}
/* 小屏手机额外适配 */
@media (max-width: 480px) {
.login-box {
padding-top: 15vh !important;
}
}
/* PC端样式(保留原有宽度,仅调小最大宽度) */
@media (min-width: 768px) {
body {
padding: 8px;
}
.login-box {
/* 原宽度是 min(448px, 90vw),现调整为更小的数值,比如 380px(可根据需求修改) */
max-width: min(340px, 90vw) !important;
min-height: auto !important;
border: 1px solid #e5e7eb !important;
border-radius: 1rem !important;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1) !important;
padding-top: 0 !important;
}
} }
/* 输入框聚焦动画优化 */ /* 输入框聚焦动画优化 */
::v-deep(.ant-input) {
box-sizing: border-box;
}
::v-deep(.ant-input:focus) { ::v-deep(.ant-input:focus) {
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2) !important; box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2) !important;
border-color: #4f46e5 !important; border-color: #4f46e5 !important;
outline: none; /* 移除默认轮廓 */
} }
/* 复选框样式优化 */ /* 复选框样式优化 */
@@ -196,29 +293,26 @@ function handleRegister() {
background-color: #4f46e5 !important; background-color: #4f46e5 !important;
border-color: #4f46e5 !important; border-color: #4f46e5 !important;
} }
::v-deep(.ant-checkbox:hover .ant-checkbox-inner) { ::v-deep(.ant-checkbox:hover .ant-checkbox-inner) {
border-color: #4f46e5 !important; border-color: #4f46e5 !important;
} }
::v-deep(.ant-checkbox) {
transform: scale(0.9); /* 移动端复选框适当缩小 */
}
/* 按钮样式优化 */ /* 按钮样式优化 */
::v-deep(.ant-btn-primary) { ::v-deep(.ant-btn-primary) {
background-color: #4f46e5 !important; background-color: #4f46e5 !important;
border-color: #4f46e5 !important; border-color: #4f46e5 !important;
box-sizing: border-box;
} }
::v-deep(.ant-btn-primary:hover) { ::v-deep(.ant-btn-primary:hover) {
background-color: #4338ca !important; background-color: #4338ca !important;
border-color: #4338ca !important; border-color: #4338ca !important;
} }
::v-deep(.ant-btn-primary:focus) { ::v-deep(.ant-btn-primary:focus) {
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.3) !important; box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.3) !important;
} outline: none;
/* 分割线样式优化 */
::v-deep(.ant-divider) {
background-color: #e5e7eb !important;
} }
/* 加载状态动画优化 */ /* 加载状态动画优化 */
@@ -226,24 +320,35 @@ function handleRegister() {
margin-right: 8px !important; margin-right: 8px !important;
} }
/* 响应式调整 */ /* 弹窗样式美化 */
@media (max-width: 375px) { ::v-deep(.forgot-modal .ant-modal-header) {
.login-box { border-radius: 12px 12px 0 0;
padding: 60px 20px !important; background: #f9fafb;
} border-bottom: 1px solid #e5e7eb;
}
::v-deep(.forgot-modal .ant-modal-title) {
color: #1f2937;
font-weight: 600;
font-size: 16px;
}
::v-deep(.forgot-modal .ant-modal-body) {
padding: 16px 20px;
}
::v-deep(.forgot-modal .ant-modal-content) {
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
overflow: hidden;
}
.third-title { /* 防止点击闪烁和触摸高亮 */
font-size: 1.5rem !important; * {
} -webkit-tap-highlight-color: transparent;
tap-highlight-color: transparent;
} }
/* 平滑滚动 */ /* 平滑滚动 */
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} overflow-x: hidden; /* 防止移动端横向滚动 */
/* 防止点击闪烁 */
* {
-webkit-tap-highlight-color: transparent;
} }
</style> </style>
File diff suppressed because it is too large Load Diff
+252 -38
View File
@@ -1,79 +1,293 @@
<template> <template>
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;"> <a-form layout="inline" style="margin-top:5px;margin-bottom:5px;align-items: center;">
<!-- 日期控制 -->
<a-form-item> <a-form-item>
<a-date-picker v-model:value="dateValue" format="YYYYMMDD" :locale="locale" @change="datePickChange" /> <div class="date-control-group">
<a-button type="text" @click="handleDateMinus" class="date-btn"><left-outlined /></a-button>
<span class="current-date-text">{{ currentDateText }}</span>
<a-button type="text" @click="handleDatePlus" class="date-btn" :disabled="isToday"><right-outlined /></a-button>
</div>
</a-form-item> </a-form-item>
<!-- debug/error单选按钮 -->
<a-form-item> <a-form-item>
<a-radio-group v-model:value="typeValue" button-style="solid" @change="typeChange"> <a-radio-group class="log-type-radio-group" v-model:value="typeValue" @change="typeChange" button-style="solid">
<a-radio-button value="debug">debug</a-radio-button> <a-radio-button value="debug">debug</a-radio-button>
<a-radio-button value="error">error</a-radio-button> <a-radio-button value="error">error</a-radio-button>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
<!-- 复制按钮 - 同一行最右侧 -->
<a-form-item style="margin-left: auto;">
<a-button type="text" size="small" @click="copyLogs" class="copy-btn">
<copy-outlined />
</a-button>
</a-form-item>
</a-form> </a-form>
<div class="container"> <div class="container">
<a-card title="" :bordered="true"> <a-card title="" :bordered="true" :class="{ 'log-error': typeValue === 'error' }">
<pre>{{ logs }}</pre> <pre class="card-width-pre">{{ logs }}</pre>
</a-card> </a-card>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineComponent, reactive, ref, watch, onMounted } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useApiStore } from '@/store'; import { useApiStore } from '@/store';
import type { UnwrapRef } from 'vue';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import locale from 'ant-design-vue/es/date-picker/locale/zh_CN'; import { LeftOutlined, RightOutlined } from '@ant-design/icons-vue';
type RangeValue = [Dayjs, Dayjs]; import { message } from 'ant-design-vue';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');
const dateValue = ref<Dayjs>(dayjs(Date()));
const currentDate = ref<Dayjs>(dayjs());
const typeValue = ref<string>('debug'); const typeValue = ref<string>('debug');
const iframeUrl = ref<string>(''); const iframeUrl = ref<string>('');
const dateValue1 = ref<string>();
const logs = ref<string>(''); const logs = ref<string>('');
dateValue1.value = dayjs(Date()).format('YYYYMMDD');
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`; const currentDateText = computed(() => {
const datePickChange = (e, dateStr) => { return currentDate.value.format('YYYYMMDD');
dateValue1.value = dateStr; });
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`;
console.log(iframeUrl); const isToday = computed(() => {
return currentDate.value.isSame(dayjs(), 'day');
});
const handleDateMinus = () => {
currentDate.value = currentDate.value.subtract(1, 'day');
updateIframeUrlAndLoadLogs();
};
const handleDatePlus = () => {
if (isToday.value) return;
currentDate.value = currentDate.value.add(1, 'day');
updateIframeUrlAndLoadLogs();
};
const updateIframeUrlAndLoadLogs = () => {
iframeUrl.value = `${typeValue.value}/${currentDateText.value}`;
loadLogs(); loadLogs();
}; };
const typeChange = (e) => { const typeChange = (e: any) => {
console.log(e.target); typeValue.value = e.target.value;
iframeUrl.value = `type=${e.target.value}&date=${dateValue1.value}`; updateIframeUrlAndLoadLogs();
loadLogs();
}; };
const mIfrm = ref<any>(null);
onMounted(() => { const formatLogTime = (logContent: string) => {
// console.log(mIfrm.value); const timeRegex = /(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+ \+08:00/g;
loadLogs(); return logContent.replace(timeRegex, '$1');
}); };
const loadLogs = () => { const loadLogs = () => {
useApiStore() useApiStore()
.apiGetLogs(iframeUrl.value) .apiGetLogs(iframeUrl.value)
.then((log) => { .then((log) => {
// console.log(log); log = formatLogTime(log);
const lines = log.split('\n'); // 将文本按换行符分割为行数组 const lines = log.split('\n');
const reversedLines = lines.reverse(); // 对行数组进行倒序操作 const reversedLines = lines.reverse();
const reversedText = reversedLines.join('\n'); // 将行数组重新连接为一个字符串 const reversedText = reversedLines.join('\n');
logs.value = reversedText; logs.value = reversedText;
})
.catch((err) => {
console.error('加载日志失败:', err);
logs.value = '日志加载失败,请稍后重试';
}); });
}; };
// 复制日志内容的核心函数
const copyLogs = async () => {
try {
// 空内容判断
if (!logs.value || logs.value === '日志加载失败,请稍后重试') {
message.warning('暂无可复制的日志内容');
return;
}
// 使用浏览器剪贴板API复制内容
await navigator.clipboard.writeText(logs.value);
message.success('日志内容复制成功!');
} catch (err) {
console.error('复制失败:', err);
// 降级方案:兼容不支持Clipboard API的浏览器
const textArea = document.createElement('textarea');
textArea.value = logs.value;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
// 提示反馈
message.success('日志内容复制成功!');
}
};
onMounted(() => {
updateIframeUrlAndLoadLogs();
});
</script> </script>
<style lang='less' scoped> <style lang='less' scoped>
html { // 基础样式:仅清理默认margin/padding,不设置全屏
html,
body {
height: 100vh; height: 100vh;
margin: 0;
padding: 0;
} }
// 表单容器:正常宽度,保留少量间距
:deep(.ant-form) {
width: 100%;
padding: 0 8px;
box-sizing: border-box;
margin: 0;
}
// 日志容器:自适应高度,无固定上限,保留上下留白
.container { .container {
width: 100%; width: 100%;
height: 100%; padding: 0 8px;
// max-height: 400px; margin: 16px 0; // 上下留白,远离屏幕边缘
iframe { box-sizing: border-box;
.word-wrap { overflow: hidden;
color: white !important; }
// pre样式:自适应高度,内容过多时内部滚动
.card-width-pre {
width: 100% !important;
min-width: 100%;
padding: 8px;
margin: 0 !important;
white-space: pre-wrap;
word-break: break-all;
// 核心:最小高度保证容器不塌陷,最大高度限制不超视口一半
min-height: 200px;
max-height: calc(67vh); // 最多占视口一半,绝对不会满屏
box-sizing: border-box;
overflow-y: auto;
overflow-x: auto;
font-size: 14px;
line-height: 1.5;
// 滚动条美化(保留)
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.2);
}
}
.date-control-group {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.date-btn {
width: 36px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.current-date-text {
font-size: 14px;
color: #1f2329;
font-weight: 500;
min-width: 80px;
text-align: center;
}
}
// 复制按钮样式
.copy-btn {
// height: 32px; /* 和日期按钮/单选按钮高度一致 */
// padding: 0 16px;
color: #722ed1;
}
// card-body样式:恢复默认内边距,作为pre的宽度基准
:deep(.ant-card-body) {
padding: 4px; // 恢复合理内边距,作为pre的宽度容器
height: 100%; // 全屏继承容器高度
width: 100%;
box-sizing: border-box;
overflow: hidden; // 新增:隐藏卡片内溢出,确保pre独占滚动区域
}
// error状态下pre文字颜色
.log-error {
.card-width-pre {
color: #f5222d !important;
}
}
// radio-button样式(保留之前的修正版)
:deep(.log-type-radio-group) {
.ant-radio-button-wrapper:first-child {
&.ant-radio-button-wrapper-checked {
background-color: #52c41a !important;
border-color: #52c41a !important;
color: #fff !important;
&:hover,
&:active {
background-color: #389e0d !important;
border-color: #389e0d !important;
}
}
&:not(.ant-radio-button-wrapper-checked):hover {
border-color: #73d13d !important;
color: #52c41a !important;
}
}
.ant-radio-button-wrapper:last-child {
&.ant-radio-button-wrapper-checked {
background-color: #f5222d !important;
border-color: #f5222d !important;
color: #fff !important;
&:hover,
&:active {
background-color: #cf1322 !important;
border-color: #cf1322 !important;
}
}
&:not(.ant-radio-button-wrapper-checked):hover {
border-color: #ff4d4f !important;
color: #f5222d !important;
}
}
.ant-radio-button-wrapper {
&:first-child {
border-radius: 6px 0 0 6px !important;
}
&:last-child {
border-radius: 0 6px 6px 0 !important;
}
&:not(:first-child) {
margin-left: -1px !important;
}
}
.ant-radio-button-wrapper-checked {
&:first-child {
&::before {
background-color: transparent !important;
}
} }
} }
} }
// 调整form布局,让复制按钮靠右
:deep(.ant-form-inline) {
display: flex;
align-items: center;
width: 100%;
}
</style> </style>
-173
View File
@@ -1,173 +0,0 @@
<template>
<a-drawer v-model:visible="visible" class="custom-class" title="邮件配置" placement="right" @after-visible-change="afterVisibleChange" :maskClosable="true">
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="1">
<template #tab>
<span>
<!-- <BellOutlined /> -->
邮件STMP配置
</span>
</template>
<a-form ref="emailFromRef" :model="EmailFormData" name="basic" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onFinish" @finishFailed="onFinishFailed">
<a-form-item label="是否开启" ref="Open">
<a-checkbox v-model:checked="EmailFormData.Open">解析结果邮件通知</a-checkbox>
</a-form-item>
<a-form-item v-if="EmailFormData.Open" label="Stmp地址" ref="Stmp" name="Stmp" :rules="[{ required: EmailFormData.Open, message: '请输入stmp服务地址!',validator:validateStmp }]">
<a-input v-model:value="EmailFormData.Stmp" />
</a-form-item>
<a-form-item v-if="EmailFormData.Open" label="Stmp端口" ref="Port" name="Port" :rules="[{ required: EmailFormData.Open, message: '请输入stmp服务端口!',validator:validatePort }]">
<a-input v-model:value="EmailFormData.Port" placeholder="默认465,yeah-587" />
</a-form-item>
<a-form-item v-if="EmailFormData.Open" label="收件Email" type='email' ref="From" name="From" :rules="[{ required: EmailFormData.Open, message: '请输入收件Email地址!',validator:validateEmail }]">
<a-input v-model:value="EmailFormData.From" />
<span style="color:#888">此处收件人亦是发件人</span>
</a-form-item>
<a-form-item v-if="EmailFormData.Open" label="Stmp授权码" ref="Code" name="Code" :rules="[{ required: EmailFormData.Open, message: '请输入Stmp授权码!' }]">
<a-input v-model:value="EmailFormData.Code" />
<span style="color:#888">邮箱设置开启stmp服务会提示</span>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
<a-button type="primary" html-type="submit">确认</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-drawer>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { defineComponent, ref, onMounted, reactive, watch } from 'vue';
import type { FormInstance } from 'ant-design-vue';
import { useApiStore, useAccountStore } from '@/store';
import http from '@/store/http';
import type { Rule } from 'ant-design-vue/es/form';
import { checkEmail, checkDomain, checkPort } from '@/utils/regexHelper';
const activeKey = ref<string>('1');
const visible = ref<boolean>(false);
function showEmail(vis: boolean) {
visible.value = vis;
if (vis) loadStmpInfo();
}
const emailFromRef = ref<FormInstance>();
const afterVisibleChange = (bool: boolean) => {
if (!bool) {
emailFromRef.value.resetFields();
}
};
const loadStmpInfo = () => {
// useApiStore()
// .apiGetStmpConfig()
// .then((res) => {
// if (res.code === 0) {
// EmailFormData.Open = res.data.open;
// EmailFormData.Stmp = res.data.stmp;
// EmailFormData.Port = res.data.port;
// EmailFormData.Code = res.data.code;
// EmailFormData.From = res.data.from;
// EmailFormData.Id = res.data.id;
// }
// });
};
interface EmailFormState {
Open: boolean;
Stmp: string;
Port: number;
Code: string;
From: string;
Id: string;
To: string;
}
const EmailFormData = reactive<EmailFormState>({
Open: false,
Stmp: 'smtp.qq.com',
Port: 465,
Code: '',
From: '',
Id: '',
To: '',
});
const onFinish = (values: EmailFormState) => {
EmailFormData.To = EmailFormData.From;
// useApiStore()
// .apiSaveStmpConfig(EmailFormData)
// .then((res) => {
// console.log(res);
// if (res.code === 0) {
// message.success('修改配置成功');
// visible.value = false;
// } else {
// message.error(res.erro, 8);
// }
// });
};
//验证邮箱
const validateEmail = async (_rule: Rule, value: string) => {
if (checkEmail(value)) {
return Promise.resolve();
} else {
return Promise.reject('请输入正确的邮箱地址');
}
};
//验证stmp服务
const validateStmp = async (_rule: Rule, value: string) => {
if (checkDomain(value)) {
return Promise.resolve();
} else {
return Promise.reject('请输入正确的stmp-server地址');
}
};
//验证端口
const validatePort = async (_rule: Rule, value: string) => {
if (checkPort(value)) {
return Promise.resolve();
} else {
return Promise.reject('请输入正确的端口');
}
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
watch(
() => EmailFormData.Open,
() => {
emailFromRef.value.validateFields(['Stmp', 'Port', 'From', 'Code']);
},
{ flush: 'post' }
);
// 将updateMessage方法暴露给父组件调用
defineExpose({
showEmail,
});
</script>
<style>
.avatar-uploader > .ant-upload {
width: 100px;
height: 100px;
}
.ant-upload-picture-card-wrapper {
height: 100%;
}
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
margin-top: 50px !important;
}
.ant-upload-select-picture-card {
margin-top: 50px !important;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
.ant-tabs-content {
text-align: center;
}
</style>
+1 -23
View File
@@ -1,26 +1,6 @@
<template> <template>
<a-drawer v-model:visible="visible" class="custom-class" title="个人设置" placement="right" @after-visible-change="afterVisibleChange" :maskClosable="true"> <a-drawer v-model:visible="visible" class="custom-class" title="个人设置" placement="right" @after-visible-change="afterVisibleChange" :maskClosable="true">
<a-tabs v-model:activeKey="activeKey"> <a-tabs v-model:activeKey="activeKey">
<!-- <a-tab-pane key="1" style="height:100%">
<template #tab>
<span>
<user-outlined />
修改头像
</span>
</template>
<a-upload style="" v-model:file-list="fileList" name="file" list-type="picture-card" class="avatar-uploader" :show-upload-list="false" :action="uploadAction" :before-upload="beforeUpload" @change="handleChange" accept=".jpg, .jpeg, .png">
<img v-if="imageUrl" :src="imageUrl" alt="avatar" style="height: 112px; width: 112px; border-radius: 50%;" />
<div v-else>
<loading-outlined v-if="loading"></loading-outlined>
<plus-outlined v-else></plus-outlined>
<div class="ant-upload-text">选择图片</div>
</div>
</a-upload>
<div style="margin-top:20px;">
上传成功即修改成功
</div>
</a-tab-pane> -->
<a-tab-pane key="2"> <a-tab-pane key="2">
<template #tab> <template #tab>
<span> <span>
@@ -60,7 +40,6 @@ import { checkPass } from '@/utils/regexHelper';
import type { Rule } from 'ant-design-vue/es/form'; import type { Rule } from 'ant-design-vue/es/form';
const activeKey = ref<string>('2'); const activeKey = ref<string>('2');
const uploadAction = ref<string>('');
const visible = ref<boolean>(false); const visible = ref<boolean>(false);
function show(vis: boolean) { function show(vis: boolean) {
@@ -122,7 +101,6 @@ const loadUserInfo = () => {
if (res.code === 0) { if (res.code === 0) {
if (res.data.avatar != null && res.data.avatar !== '') imageUrl.value = `/upload/${res.data.avatar}`; if (res.data.avatar != null && res.data.avatar !== '') imageUrl.value = `/upload/${res.data.avatar}`;
passwordFormData.UserId = res.data.id; passwordFormData.UserId = res.data.id;
uploadAction.value = `/api/auth/UpdateUserAvatar?Uid=${res.data.id}`;
} }
}); });
}; };
@@ -150,7 +128,7 @@ const onFinish = (values: PassFormState) => {
visible.value = false; visible.value = false;
http.removeAuthorization(); http.removeAuthorization();
} else { } else {
message.error(res.erro, 8); message.error(res.message, 8);
} }
}); });
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import MyPersonal from './MyPersonal.vue'; import MyPersonal from './MyPersonal.vue';
import EmailSet from './EmailSet.vue'; // import EmailSet from './EmailSet.vue';
export { MyPersonal, EmailSet }; export { MyPersonal };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-745
View File
@@ -1,745 +0,0 @@
<template>
<div class="stats-dashboard">
<div class="dashboard-container">
<!-- 页面标题 -->
<!-- 核心统计概览 -->
<section class="stats-overview">
<div class="stat-card primary-card">
<div class="stat-header">
<span class="stat-meta">总视频数</span>
<div class="stat-icon video-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
</div>
<div class="stat-value">{{ totalVideos }}</div>
<div class="stat-trend">
<!-- <span class="trend-up">较上月 +5.2%</span> -->
</div>
</div>
<div class="stat-card secondary-card">
<div class="stat-header">
<span class="stat-meta">占用空间</span>
<div class="stat-icon size-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12H2v8h20v-8z" />
<path d="M6 18h.01" />
<path d="M10 18h.01" />
</svg>
</div>
</div>
<div class="stat-value">{{ fileSizeTotal }} G</div>
<div class="stat-trend">
<!-- <span class="trend-up">较上月 +12.8%</span> -->
</div>
</div>
</section>
<!-- 次级统计卡片组 -->
<section class="stats-grid">
<!-- 我喜欢的数量卡片 -->
<div class="stat-card mini-card">
<div class="stat-header">
<span class="stat-meta">我喜欢的</span>
<div class="stat-icon like-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</div>
</div>
<div class="stat-value">{{ favoriteCount }}</div>
</div>
<!-- 我收藏的数量卡片 -->
<div class="stat-card mini-card">
<div class="stat-header">
<span class="stat-meta">我收藏的</span>
<div class="stat-icon collect-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
</div>
</div>
<div class="stat-value">{{ collectCount }}</div>
</div>
<!-- 作者总数卡片 -->
<div class="stat-card mini-card">
<div class="stat-header">
<span class="stat-meta">作者总数</span>
<div class="stat-icon author-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
</div>
<div class="stat-value">{{ totalAuthors }}</div>
</div>
<!-- 分类总数卡片 -->
<div class="stat-card mini-card">
<div class="stat-header">
<span class="stat-meta">分类总数</span>
<div class="stat-icon cate-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M17 5v4" />
<path d="M7 5v4" />
</svg>
</div>
</div>
<div class="stat-value">{{ categoryTotal }}</div>
</div>
</section>
<!-- 详细分类统计 -->
<section class="detailed-stats">
<div class="stats-header">
<h2>详细统计</h2>
<div class="tab-controls">
<button class="tab-btn" :class="{ active: currentTab === 'author' }" @click="currentTab = 'author'">
视频作者
</button>
<button class="tab-btn" :class="{ active: currentTab === 'type' }" @click="currentTab = 'type'">
视频分类
</button>
</div>
</div>
<transition name="stats-fade" mode="out-in">
<!-- 作者统计 -->
<div v-if="currentTab === 'author'" key="author-view" class="stats-content">
<div class="authors-grid">
<div class="author-card" v-for="(author, index) in authors" :key="index">
<div class="author-avatar">
<img :src="author.icon" alt="作者头像" />
</div>
<div class="author-info">
<h3 class="author-name">{{ author.name }}</h3>
<p class="author-stats">作品数: {{ author.count }}</p>
</div>
<div class="author-progress">
<div class="progress-bar" :style="{ width: `${(author.count / totalVideos) * 100}%` }"></div>
</div>
</div>
</div>
</div>
<!-- 分类统计 -->
<div v-else key="category-view" class="stats-content">
<div class="categories-grid">
<div class="category-card" v-for="(category, index) in categories" :key="index" :style="{ '--category-color': category.color }">
<div class="category-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 12 12" fill="white">
<use :xlink:href="`#${category.icon}`"></use>
</svg>
</div>
<div class="category-info">
<h3 class="category-name">{{ category.name }}</h3>
<p class="category-stats">作品数: {{ category.count }}</p>
</div>
<div class="category-percentage">
{{ Math.round((category.count / totalVideos) * 100) }}%
</div>
</div>
</div>
</div>
</transition>
</section>
</div>
<!-- SVG图标定义 -->
<svg style="display: none;">
<!-- 所有图标定义保持不变 -->
<symbol id="cup" viewBox="0 0 12 12">
<path d="M18 4h2v16h-2zM4 4h14v2H4zM4 8h10v2H4zM4 12h10v2H4zM4 16h6v2H4zM4 20h6v2H4z" />
</symbol>
<!-- 其他图标省略,保持原样 -->
</svg>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useApiStore } from '@/store';
// 类型接口
interface Author {
name: string;
count: number;
icon: string;
}
interface Category {
name: string;
count: number;
color: string;
icon: string;
}
// 状态管理
const totalVideos = ref<number>(0);
const totalAuthors = ref<number>(0);
const categoryTotal = ref<number>(0);
const fileSizeTotal = ref<number>(0);
const favoriteCount = ref<number>(0);
const collectCount = ref<number>(0);
const categories = ref<Category[]>([]);
const authors = ref<Author[]>([]);
const currentTab = ref<string>('author');
// 组件名称
defineOptions({
name: 'StatsDashboard',
});
// 加载数据
onMounted(() => {
loadDashboardData();
});
const loadDashboardData = async () => {
try {
const res = await useApiStore().VideoStatics();
totalVideos.value = res.data.videoCount;
totalAuthors.value = res.data.authorCount;
categoryTotal.value = res.data.categoryCount;
favoriteCount.value = res.data.favoriteCount;
collectCount.value = res.data.collectCount;
categories.value = res.data.categories;
fileSizeTotal.value = res.data.viedoSizeTotal; // 修复拼写错误
authors.value = res.data.authors;
// 分类图标和颜色随机分配
const cates = getRandomElements(categoriessss.value, categories.value.length);
const colors = getRandomElements(colorArray, categories.value.length);
categories.value.forEach((item, index) => {
item.icon = cates[index];
item.color = colors[index];
});
} catch (err) {
console.error('加载仪表盘数据失败:', err);
}
};
// 随机获取数组元素方法
const getRandomElements = (arr: any[], n: number) => {
if (n <= 0) return [];
if (n >= arr.length) return [...arr];
return [...arr].sort(() => Math.random() - 0.5).slice(0, n);
};
// 图标列表和颜色数组
const categoriessss = ref<any[]>([
'cup',
'spoon',
'fork',
'knife',
'plate',
'bottle',
'toothbrush',
'comb',
'mirror',
'soap',
'mobile',
'tablet',
'camera',
'headphones',
'speaker',
'tv',
'watch',
'charger',
]);
const colorArray = [
'#3A7CA5',
'#D9A566',
'#6B4226',
'#829399',
'#A63446',
'#4F6367',
'#FE5F55',
'#C7EFCF',
'#78C0E0',
'#49BEAA',
'#49DCB1',
'#FF9999',
'#66B2FF',
'#99FF99',
];
</script>
<style scoped>
/* 白天模式默认样式(无dark-mode类时生效) */
.stats-dashboard {
min-height: 100vh;
background-color: #ffffff; /* 白天背景:白色 */
color: #333333; /* 白天文本:深灰 */
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
padding: 20px 0;
}
/* 加宽整体宽度并减少两侧留白 */
.dashboard-container {
max-width: 1400px; /* 从1200px加宽至1600px */
margin: 0 auto;
padding: 0 15px; /* 两侧留白从20px减至15px */
}
/* 响应式适配:避免宽屏溢出 */
@media (max-width: 1700px) {
.dashboard-container {
max-width: 95%; /* 中等屏幕用百分比控制宽度 */
}
}
/* 头部样式 */
.dashboard-header {
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 1px solid #e0e0e0; /* 白天边框:浅灰 */
}
.dashboard-header h1 {
font-size: 28px;
margin: 0 0 10px 0;
color: #333333; /* 白天标题:深灰 */
}
.header-subtitle {
font-size: 16px;
color: #666666; /* 白天副标题:中灰 */
margin: 0;
}
/* 统计卡片样式(增强白天阴影) */
.stat-card {
background: #f5f5f5; /* 白天卡片背景:浅灰 */
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); /* 增强阴影 */
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); /* 增强hover阴影 */
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.stat-meta {
font-size: 14px;
color: #666666; /* 白天元数据:中灰 */
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.video-icon {
background-color: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.size-icon {
background-color: rgba(33, 150, 243, 0.2);
color: #2196f3;
}
.like-icon {
background-color: rgba(233, 30, 99, 0.2);
color: #e91e63;
}
.collect-icon {
background-color: rgba(255, 152, 0, 0.2);
color: #ff9800;
}
.author-icon {
background-color: rgba(156, 39, 176, 0.2);
color: #9c27b0;
}
.cate-icon {
background-color: rgba(0, 188, 212, 0.2);
color: #00bcd4;
}
.stat-value {
font-size: 32px;
font-weight: 700;
margin: 0 0 10px 0;
color: #333333;
line-height: 1;
}
.stat-trend {
font-size: 13px;
}
.trend-up {
color: #4caf50;
display: flex;
align-items: center;
}
.trend-up::before {
content: '↑';
margin-right: 4px;
}
/* 概览区域布局 */
.stats-overview {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
margin-bottom: 30px;
}
@media (min-width: 768px) {
.stats-overview {
grid-template-columns: 2fr 1fr;
}
}
.primary-card {
border-top: 4px solid #4caf50;
}
.secondary-card {
border-top: 4px solid #2196f3;
}
/* 次级统计网格布局 */
.stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
margin-bottom: 30px;
}
@media (min-width: 576px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.mini-card .stat-value {
font-size: 24px;
}
/* 详细统计区域 */
.detailed-stats {
background: #f5f5f5;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.stats-header h2 {
margin: 0;
font-size: 20px;
color: #333333;
}
.tab-controls {
display: flex;
gap: 10px;
}
.tab-btn {
background: transparent;
border: none;
color: #666666;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.tab-btn.active {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
font-weight: 500;
}
.tab-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.05);
color: #333333;
}
/* 统计内容区域 */
.stats-content {
animation: fadeIn 0.5s ease;
}
.authors-grid,
.categories-grid {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
}
@media (min-width: 576px) {
.authors-grid,
.categories-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 992px) {
.authors-grid,
.categories-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* 作者卡片(增强白天阴影) */
.author-card {
background: #eeeeee;
border-radius: 8px;
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); /* 新增阴影 */
}
.author-card:hover {
transform: translateX(5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12); /* 增强hover阴影 */
}
.author-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
overflow: hidden;
}
.author-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.author-info {
flex: 1;
}
.author-name {
margin: 0 0 5px 0;
font-size: 16px;
color: #333333;
}
.author-stats {
margin: 0;
font-size: 13px;
color: #666666;
}
.author-progress {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: #4caf50;
border-radius: 3px;
transition: width 0.5s ease;
}
/* 分类卡片(增强白天阴影) */
.category-card {
background: #eeeeee;
border-radius: 8px;
padding: 15px;
display: flex;
align-items: center;
gap: 15px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); /* 新增阴影 */
}
.category-card:hover {
transform: translateX(5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12); /* 增强hover阴影 */
}
.category-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background-color: var(--category-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.category-info {
flex: 1;
}
.category-name {
margin: 0 0 3px 0;
font-size: 16px;
color: #333333;
}
.category-stats {
margin: 0;
font-size: 13px;
color: #666666;
}
.category-percentage {
font-size: 14px;
font-weight: 500;
color: var(--category-color);
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stats-fade-enter-from,
.stats-fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
.stats-fade-enter-active,
.stats-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* 晚上模式样式 */
html.dark-mode .stats-dashboard {
background-color: #1a1a2e;
color: #eaeaea;
}
html.dark-mode .dashboard-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
html.dark-mode .dashboard-header h1 {
color: #ffffff;
}
html.dark-mode .header-subtitle {
color: #b0b0c3;
}
html.dark-mode .stat-card {
background: rgba(30, 30, 50, 0.8);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
html.dark-mode .stat-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
html.dark-mode .stat-meta {
color: #b0b0c3;
}
html.dark-mode .stat-value {
color: #ffffff;
}
html.dark-mode .detailed-stats {
background: rgba(30, 30, 50, 0.8);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
html.dark-mode .stats-header h2 {
color: #ffffff;
}
html.dark-mode .tab-btn {
color: #b0b0c3;
}
html.dark-mode .tab-btn:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
}
html.dark-mode .author-card {
background: rgba(40, 40, 65, 0.6);
box-shadow: none;
}
html.dark-mode .author-name {
color: #ffffff;
}
html.dark-mode .author-stats {
color: #b0b0c3;
}
html.dark-mode .author-progress {
background: rgba(255, 255, 255, 0.1);
}
html.dark-mode .category-card {
background: rgba(40, 40, 65, 0.6);
box-shadow: none;
}
html.dark-mode .category-name {
color: #ffffff;
}
html.dark-mode .category-stats {
color: #b0b0c3;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+138 -33
View File
@@ -13,19 +13,93 @@ interface NaviGuard {
after?: NavigationHookAfter; after?: NavigationHookAfter;
} }
const loginGuard: NavigationGuard = function (to, from) { /**
// console.log('Authorization', http.checkAuthorization()) * 检测是否为移动端设备(UA + 屏幕宽度双检测)
const account = useAccountStore(); */
if (!http.checkAuthorization() && !/^\/(login|home|init)?$/.test(to.fullPath)) { const isMobile = (): boolean => {
account.setLogged(false) const userAgent = navigator.userAgent.toLowerCase();
return '/login'; const mobileUaReg = /iphone|android|ipad|ipod|mobile|wap|symbian|windows ce|blackberry|webos|ucbrowser/i;
} else { const isSmallScreen = window.innerWidth < 768;
} return mobileUaReg.test(userAgent) || isSmallScreen;
}; };
const dynamicinitRoute = // 标记是否已跳转到移动端路由,防止无限循环
{ let hasRedirectedToMobile = false;
// 新增:标记是否已从/mobile跳转到/dashboard,防止无限循环
let hasRedirectedToDashboard = false;
// 优化后的移动端跳转守卫(登录优先)
const MobileRedirectGuard: NavigationGuard = function (to, from, next) {
// 1. 排除移动端路由和登录页,避免逻辑干扰
const isMobileRoute = to.path === '/mobile';
const isLoginRoute = to.path === '/login';
// 新增:检测当前是否为非移动端设备
const isNonMobile = !isMobile();
// 【新增核心逻辑】:当前路由是/mobile,但不是移动端设备,跳转到/dashboard
if (isMobileRoute && isNonMobile) {
if (!hasRedirectedToDashboard) {
hasRedirectedToDashboard = true;
// 重置移动端跳转标记,避免后续干扰
hasRedirectedToMobile = false;
next({ path: '/dashboard' });
} else {
next();
}
return;
}
if (isMobileRoute) {
hasRedirectedToMobile = true;
// 重置dashboard跳转标记,避免后续干扰
hasRedirectedToDashboard = false;
next();
return;
}
if (isLoginRoute) {
// 登录页无需移动端跳转,直接放行
hasRedirectedToMobile = false; // 重置标记,登录后可正常跳转移动端
hasRedirectedToDashboard = false; // 重置dashboard跳转标记
next();
return;
}
// 2. 移动端检测
if (isMobile()) {
const isAuthorized = http.checkAuthorization();
if (!isAuthorized) {
// 未登录:优先跳登录(保持原有优先级)
next('/login');
} else {
// 已登录:跳移动端(防止重复跳转)
if (!hasRedirectedToMobile) {
hasRedirectedToMobile = true;
hasRedirectedToDashboard = false; // 重置dashboard跳转标记
next({ path: '/mobile' });
} else {
next();
}
}
} else {
// 非移动端:重置所有标记,不影响后续操作
hasRedirectedToMobile = false;
hasRedirectedToDashboard = false;
next();
}
};
const loginGuard: NavigationGuard = function (to, from, next) {
if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) {
console.log(to.fullPath)
const account = useAccountStore();
account.setLogged(false);
// 重置dashboard跳转标记
hasRedirectedToDashboard = false;
next('/login');
} else {
next();
}
};
const dynamicinitRoute = {
path: '/', path: '/',
name: 'login', name: 'login',
redirect: '/login', redirect: '/login',
@@ -38,31 +112,22 @@ const dynamicinitRoute =
component: () => import('@/pages/login'), component: () => import('@/pages/login'),
}; };
const InitGuard: NavigationGuard = function (to, from, next) {
const InitGuard: NavigationGuard = function (to, from) {
if (to.fullPath != '/login') { if (to.fullPath != '/login') {
useApiStore()
.apiCheckInitStatus()
.then((res) => {
// console.log(to.fullPath)
if (res.code === 0) {
} else {
if (!router.hasRoute('login')) { if (!router.hasRoute('login')) {
router.addRoute(dynamicinitRoute) router.addRoute(dynamicinitRoute);
} }
router.push('/login') next('/login');
// return '/init' } else {
} next();
});
} }
}; };
// 进度条 // 进度条
const ProgressGuard: NaviGuard = { const ProgressGuard: NaviGuard = {
before(to, from) { before(to, from, next) {
NProgress.start(); NProgress.start();
next();
}, },
after(to, from) { after(to, from) {
NProgress.done(); NProgress.done();
@@ -70,16 +135,18 @@ const ProgressGuard: NaviGuard = {
}; };
const AuthGuard: NaviGuard = { const AuthGuard: NaviGuard = {
before(to, from) { before(to, from, next) {
const { hasAuthority } = useAuthStore(); const { hasAuthority } = useAuthStore();
if (to.meta?.permission && !hasAuthority(to.meta?.permission)) { if (to.meta?.permission && !hasAuthority(to.meta?.permission)) {
return { name: '403', query: { permission: to.meta.permission, path: to.fullPath } }; next({ name: '403', query: { permission: to.meta.permission, path: to.fullPath } });
} else {
next();
} }
}, },
}; };
const ForbiddenGuard: NaviGuard = { const ForbiddenGuard: NaviGuard = {
before(to) { before(to, from, next) {
if (to.name === '403' && (to.query.permission || to.query.path)) { if (to.name === '403' && (to.query.permission || to.query.path)) {
to.fullPath = to.fullPath to.fullPath = to.fullPath
.replace(/permission=[^&=]*&?/, '') .replace(/permission=[^&=]*&?/, '')
@@ -90,21 +157,59 @@ const ForbiddenGuard: NaviGuard = {
delete to.query.permission; delete to.query.permission;
delete to.query.path; delete to.query.path;
} }
next();
}, },
}; };
// 404 not found // 404 not found
const NotFoundGuard: NaviGuard = { const NotFoundGuard: NaviGuard = {
before(to, from) { before(to, from, next) {
const { loading } = useMenuStore(); const { loading } = useMenuStore();
if (to.meta._is404Page && loading) { if (to.meta._is404Page && loading) {
to.params.loading = true as any; to.params.loading = true as any;
} }
next();
}, },
}; };
// 优化后的页面刷新移动端检测(登录优先)
window.addEventListener('load', () => {
const currentPath = window.location.pathname;
const isNonMobile = !isMobile();
// 【新增】:刷新后如果是/mobile路由且非移动端,跳转到/dashboard
if (currentPath === '/mobile' && isNonMobile) {
router.push('/dashboard').catch(err => {
if (!err.message.includes('NavigationDuplicated')) {
console.error('刷新时从/mobile跳转到/dashboard失败:', err);
}
});
return;
}
if (isMobile() && currentPath !== '/mobile') {
const isAuthorized = http.checkAuthorization();
if (!isAuthorized) {
// 未登录:跳登录(避免重复跳转)
if (currentPath !== '/login') {
router.push('/login').catch(err => {
if (!err.message.includes('NavigationDuplicated')) {
console.error('刷新时跳转登录页失败:', err);
}
});
}
} else {
// 已登录:跳移动端
router.push('/mobile').catch(err => {
if (!err.message.includes('NavigationDuplicated')) {
console.error('刷新时跳转移动端路由失败:', err);
}
});
}
}
});
export default { export default {
// before: [ProgressGuard.before, InitGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before], before: [ProgressGuard.before, MobileRedirectGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
before: [ProgressGuard.before, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
after: [ProgressGuard.after], after: [ProgressGuard.after],
}; };
+1 -1
View File
@@ -9,7 +9,7 @@ const router = createRouter(
routes, routes,
} }
); );
console.log(router) // console.log(router)
// 注册导航守卫 // 注册导航守卫
guards.before.forEach(router.beforeEach); guards.before.forEach(router.beforeEach);
guards.after.forEach(router.afterEach); guards.after.forEach(router.afterEach);
+63 -1
View File
@@ -14,6 +14,31 @@ const routes: RouteRecordRaw[] = [
children: null, children: null,
component: () => import('@/pages/login'), component: () => import('@/pages/login'),
}, },
{
path: '/',
name: 'mobile',
redirect: '/mobile',
meta: {
title: '移动端首页',
renderMenu: false,
icon: 'CreditCardOutlined',
},
children: null,
component: () => import('@/pages/mobile/MobileDashboard.vue'),
},
{
path: '/',
name: 'init',
redirect: '/init',
meta: {
title: '初始化',
renderMenu: false,
icon: 'CreditCardOutlined',
},
children: null,
component: () => import('@/pages/desk/index.vue'),
},
// { // {
// path: '/', // path: '/',
// name: 'init', // name: 'init',
@@ -26,6 +51,7 @@ const routes: RouteRecordRaw[] = [
// children: null, // children: null,
// component: () => import('@/pages/init'), // component: () => import('@/pages/init'),
// }, // },
{ {
path: '/front', path: '/front',
name: '前端', name: '前端',
@@ -45,7 +71,30 @@ const routes: RouteRecordRaw[] = [
}, },
component: () => import('@/pages/login'), component: () => import('@/pages/login'),
}, },
{
path: '/mobile',
name: 'mobile',
meta: {
icon: 'LoginOutlined',
view: 'blank',
target: '_blank',
cacheable: false,
},
children: null,
component: () => import('@/pages/mobile/MobileDashboard.vue'),
},
{
path: '/init',
name: 'init',
meta: {
icon: 'LoginOutlined',
view: 'blank',
target: '_blank',
cacheable: false,
},
children: null,
component: () => import('@/pages/desk/index.vue'),
},
// { // {
// path: '/init', // path: '/init',
// name: '初始化', // name: '初始化',
@@ -107,6 +156,18 @@ const routes: RouteRecordRaw[] = [
}, },
component: () => import('@/pages/workplace/Workplace.vue'), component: () => import('@/pages/workplace/Workplace.vue'),
}, },
{
path: '/follow',
name: '关注列表',
meta: {
icon: 'HeartOutlined',
view: 'self',
target: '_self',
renderMenu: true,
cacheable: false,
},
component: () => import('@/pages/followd/index.vue'),
},
{ {
path: '/cok', path: '/cok',
name: '抖音授权', name: '抖音授权',
@@ -119,6 +180,7 @@ const routes: RouteRecordRaw[] = [
}, },
component: () => import('@/pages/cok/Table.vue'), component: () => import('@/pages/cok/Table.vue'),
}, },
{ {
path: '/set', path: '/set',
name: '系统配置', name: '系统配置',
+1 -1
View File
@@ -39,7 +39,7 @@ export const useAccountStore = defineStore('account', {
if (response.code === 200 && response.data.code === 0) { if (response.code === 200 && response.data.code === 0) {
this.logged = true; this.logged = true;
this.logged2 = true this.logged2 = true
console.log(response) // console.log(response)
http.setAuthorization(`Bearer ${response.data.token}`, response.data.expires); http.setAuthorization(`Bearer ${response.data.token}`, response.data.expires);
// await useMenuStore().getMenuList(); // await useMenuStore().getMenuList();
return response.data; return response.data;
+295 -87
View File
@@ -2,6 +2,7 @@ import { defineStore, storeToRefs } from 'pinia';
import http from './http'; import http from './http';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { Response } from '@/types'; import { Response } from '@/types';
// import { RouteOption } from '@/router/interface'; // import { RouteOption } from '@/router/interface';
// import { addRoutes, removeRoute } from '@/router/dynamicRoutes'; // import { addRoutes, removeRoute } from '@/router/dynamicRoutes';
// import { useSettingStore } from './setting'; // import { useSettingStore } from './setting';
@@ -28,62 +29,34 @@ import { Response } from '@/types';
// } // }
export const useApiStore = defineStore('coreapi', () => { export const useApiStore = defineStore('coreapi', () => {
//检查是否已经初始化过了
async function apiCheckInitStatus() {
return { code: 0 }
// const initStatus = localStorage.getItem('ddns-init'); async function apiGetConfig() {
// if (initStatus && initStatus === '1') { return http.request<any, Response<any>>('/api/config/GetConfig', 'get').then(r => {
// return { code: 0 } return r;
// } else { }).finally(() => {
});
}
// //获取配置
// async function apiGetConfig() {
// return http // return http
// .request<any, Response<any>>('/api/init/Check', 'GET') // .request<any, Response<any>>('/api/config/GetConfig', 'GET')
// .then((res) => { // .then((res) => {
// // console.log(res) // return res;
// if (res.data.code === 0) {
// // localStorage.setItem('ddns-init', '1')
// }
// return res.data;
// }) // })
// .finally(() => { // .finally(() => {
// }); // });
// } // }
}
//初始化系统配置
async function apiInit(request: object) {
console.log(request)
return http
.request<any, Response<any>>('/api/init/Init', 'post_json', request)
.then((res) => {
// console.log(res)
return res.data;
})
.finally(() => {
});
}
//获取配置
async function apiGetConfig() {
return http
.request<any, Response<any>>('/api/config/GetConfig', 'GET')
.then((res) => {
console.log(res)
return res.data;
})
.finally(() => {
});
}
//修改配置 //修改配置
async function apiUpdateConfig(request: object) { async function apiUpdateConfig(request: object) {
return http return http
.request<any, Response<any>>('/api/config/UpdateConfig', 'post_json', request) .request<any, Response<any>>('/api/config/UpdateConfig', 'post_json', request)
.then((res) => { .then((res) => {
console.log(res) console.log(res)
return res.data; return res;
}) })
.finally(() => { .finally(() => {
@@ -91,7 +64,7 @@ export const useApiStore = defineStore('coreapi', () => {
} }
//后台日志 //后台日志
async function apiGetLogs(param: string) { async function apiGetLogs(param: string) {
return http.request<any, Response<any>>('/api/logs/GetLog?' + param, 'get').then(r => { return http.request<any, Response<any>>('/api/logs/GetLog/' + param, 'get').then(r => {
// console.log(r) // console.log(r)
return r.data; return r.data;
}).finally(() => { }).finally(() => {
@@ -101,7 +74,7 @@ export const useApiStore = defineStore('coreapi', () => {
//用户信息-头像 //用户信息-头像
async function apiUserInfo() { async function apiUserInfo() {
return http.request<any, Response<any>>('/api/auth/GetUserAvatar', 'get').then(r => { return http.request<any, Response<any>>('/api/auth/GetUserAvatar', 'get').then(r => {
return r.data; return r;
}).finally(() => { }).finally(() => {
}); });
@@ -109,7 +82,7 @@ export const useApiStore = defineStore('coreapi', () => {
//密码修改 //密码修改
async function apiChangePwd(param: object) { async function apiChangePwd(param: object) {
return http.request<any, Response<any>>('/api/auth/UpdatePwd', 'post_json', param).then(r => { return http.request<any, Response<any>>('/api/auth/UpdatePwd', 'post_json', param).then(r => {
return r.data; return r;
}).finally(() => { }).finally(() => {
}); });
@@ -117,14 +90,23 @@ export const useApiStore = defineStore('coreapi', () => {
//StartJobNow //StartJobNow
async function StartJobNow() { async function StartJobNow() {
return http.request<any, Response<any>>('/api/config/ExecuteJobNow', 'get').then(r => { return http.request<any, Response<any>>('/api/config/ExecuteJobNow', 'get').then(r => {
return r.data; return r;
}).finally(() => { }).finally(() => {
}); });
} }
//视频统计
async function VideoStatics() { async function VideoStatics() {
return http.request<any, Response<any>>('/api/video/statics', 'get').then(r => { return http.request<any, Response<any>>('/api/video/statics', 'get').then(r => {
return r.data; return r;
}).finally(() => {
});
}
//视频曲线
async function VideoChart(day: number) {
return http.request<any, Response<any>>(`/api/video/chart/${day}`, 'get').then(r => {
return r;
}).finally(() => { }).finally(() => {
}); });
@@ -133,35 +115,7 @@ export const useApiStore = defineStore('coreapi', () => {
//视频查询 //视频查询
async function VideoPageList(param: object) { async function VideoPageList(param: object) {
return http.request<any, Response<any>>('/api/video/paged', 'post_json', param).then(r => { return http.request<any, Response<any>>('/api/video/paged', 'post_json', param).then(r => {
return r.data;
}).finally(() => {
});
}
// 清理数据库中文件不存在的视频记录
async function VideoCleanup() {
return http.request<any, Response<any>>('/api/video/cleanup', 'post_json').then(r => {
return r.data;
}).finally(() => {
});
}
// 获取所有视频类型列表
async function VideoCategories() {
return http.request<any, Response<any>>('/api/video/categories', 'get').then(r => {
console.log('VideoCategories 原始响应:', r);
// 响应拦截器已经处理了 code === 0 的情况,直接返回 data
// 如果 r 本身就是 Response 对象,返回 r.data
// 如果 r 已经是 data(拦截器处理后的结果),直接返回
if (r && typeof r === 'object' && 'code' in r && 'data' in r) {
return r.data;
}
return r; return r;
}).catch(err => {
console.error('VideoCategories API 错误:', err);
throw err;
}).finally(() => { }).finally(() => {
}); });
@@ -169,7 +123,14 @@ export const useApiStore = defineStore('coreapi', () => {
//cookies //cookies
async function CookiePageList(param: object) { async function CookiePageList(param: object) {
return http.request<any, Response<any>>('/api/config/paged', 'post_json', param).then(r => { return http.request<any, Response<any>>('/api/config/paged', 'post_json', param).then(r => {
return r.data; return r;
}).finally(() => {
});
}
async function CookieList() {
return http.request<any, Response<any>>('/api/config/list', 'get').then(r => {
return r;
}).finally(() => { }).finally(() => {
}); });
@@ -177,34 +138,277 @@ export const useApiStore = defineStore('coreapi', () => {
async function UpdateConfig(param: object) { async function UpdateConfig(param: object) {
return http.request<any, Response<any>>('/api/config/update', 'post_json', param).then(r => { return http.request<any, Response<any>>('/api/config/update', 'post_json', param).then(r => {
return r.data; return r;
}).finally(() => { }).finally(() => {
}); });
} }
async function DeskInitAsync(param: object) {
return http.request<any, Response<any>>('/api/config/deskinit', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
async function AppisInit() {
return http.request<any, Response<any>>('/api/config/isInit', 'get').then(r => {
return r;
}).finally(() => {
});
}
async function deleteCookie(id: string) { async function deleteCookie(id: string) {
return http.request<any, Response<any>>('/api/config/delete?id=' + id, 'get').then(r => { return http.request<any, Response<any>>('/api/config/delete?id=' + id, 'get').then(r => {
return r.data; return r;
}).finally(() => {
});
}
//follows
async function FollowList(param: object) {
return http.request<any, Response<any>>('/api/follow/paged', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//同步关注列表
async function SyncFollow() {
return http.request<any, Response<any>>('/api/follow/sync', 'get').then(r => {
return r;
}).finally(() => {
});
}
//更新同步关注者状态
async function OpenOrCloseSync(param: object) {
return http.request<any, Response<any>>('/api/follow/openOrCloseSync', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//更新同步关注者状态
async function OpenOrCloseFullSync(param: object) {
return http.request<any, Response<any>>('/api/follow/openOrCloseFullSync', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//重新下载
async function ReDownViedos(param: object) {
return http.request<any, Response<any>>('/api/video/redown', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//批量删除
async function BathRealDelete(param: object) {
return http.request<any, Response<any>>('/api/video/vdelete/batch', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//删除
async function DeleteVideo(param: string) {
return http.request<any, Response<any>>('/api/video/vdelete/' + param, 'get').then(r => {
return r;
}).finally(() => {
});
}
//查询已删除
async function GetDeleteViedos() {
return http.request<any, Response<any>>('/api/video/vdelete/get', 'get').then(r => {
return r;
}).finally(() => {
});
}
//删除博主全部视频
async function DeleteByAuthor(param: string) {
return http.request<any, Response<any>>('/api/video/vdelete/byauthor/' + param, 'get').then(r => {
return r;
}).finally(() => { }).finally(() => {
}); });
} }
// 删除指定作者的所有视频数据 //检查版本
async function DeleteAuthorVideos(authorName: string) { async function getVer() {
return http.request<any, Response<any>>('/api/video/delete-author', 'post_json', { authorName }).then(r => { return http.request<any, Response<any>>('/api/config/mytag', 'get').then(r => {
return r.data; return r;
}).finally(() => { }).finally(() => {
}); });
} }
//检查版本
async function CheckTag() {
return http.request<any, Response<any>>('/api/config/checktag', 'get').then(r => {
return r;
}).finally(() => {
});
}
async function mp3List() {
return http.request<any, Response<any>>('/api/config/mp3List', 'get').then(r => {
return r;
}).finally(() => {
});
}
//快速停止或启动cookie配置
async function SwitchCookieStatus(param: object) {
return http.request<any, Response<any>>('/api/config/switch', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//添加非关注的博主
async function AddFollow(param: object) {
return http.request<any, Response<any>>('/api/follow/add', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//删除非关注的博主
async function DelFollow(param: object) {
return http.request<any, Response<any>>('/api/follow/delete', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//导出配置
async function ExportConf() {
return http.request<any, Response<any>>('/api/config/exportConf', 'get').then(r => {
return r;
}).finally(() => {
});
}
//导入配置
async function ImportConf(param: object) {
return http.request<any, Response<any>>('/api/config/importConf', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//移动端获取日志列表
async function MobileLogs() {
return http.request<any, Response<any>>('/api/logs/list', 'get').then(r => {
return r;
}).finally(() => {
});
}
//移动端获取日志详情
async function LogDetail(type: string, date: string) {
return http.request<any, Response<any>>('/api/logs/content?type=' + type + "&Date=" + date, 'get').then(r => {
return r;
}).finally(() => {
});
}
//TOP
async function TopVideo(param: number) {
return http.request<any, Response<any>>('/api/Video/top' + param, 'get').then(r => {
return r;
}).finally(() => {
});
}
//Renfo
async function Renfo() {
return http.request<any, Response<any>>('/api/Video/renfo', 'get').then(r => {
return r;
}).finally(() => {
});
}
//合集、自定义收藏夹、短剧列表
async function CatePageList(param: object) {
return http.request<any, Response<any>>('/api/cate/paged', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//批量修改 合集、自定义收藏夹、短剧
async function BatchSaveCate(param: object) {
return http.request<any, Response<any>>('/api/cate/BatchSave', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
// // 音频文件上传接口
// async function apiUploadAudio(formData: FormData, options?: { onUploadProgress?: (progressEvent: ProgressEvent) => void }) {
// return http
// .request<any, Response<any>>(
// '/api/config/UploadAudio', // 请求地址
// 'post_form', // 使用新增的 post_form 类型
// formData, // FormData 参数(文件+其他参数)
// {
// onUploadProgress: options?.onUploadProgress, // 上传进度回调(原生 ProgressEvent)
// timeout: 120000 // 上传文件超时时间设为2分钟(可选)
// }
// )
// .then((res) => {
// // console.log('音频上传结果:', res);
// // 适配你的响应格式(如果响应是包裹层,取 data)
// return res;
// })
// .catch((err) => {
// console.error('音频上传失败:', err);
// throw err; // 抛出错误让前端捕获
// });
// }
return { return {
VideoChart,
BatchSaveCate,
CatePageList,
getVer,
mp3List,
BathRealDelete,
DeleteByAuthor,
Renfo,
// apiUploadAudio,
AppisInit,
DeskInitAsync,
SwitchCookieStatus,
TopVideo,
LogDetail,
MobileLogs,
ExportConf,
ImportConf,
GetDeleteViedos,
DelFollow,
AddFollow,
CheckTag,
deleteCookie, deleteCookie,
UpdateConfig, UpdateConfig,
apiCheckInitStatus,
apiInit,
apiGetConfig, apiGetConfig,
apiUpdateConfig, apiUpdateConfig,
apiGetLogs, apiGetLogs,
@@ -213,9 +417,13 @@ export const useApiStore = defineStore('coreapi', () => {
StartJobNow, StartJobNow,
VideoStatics, VideoStatics,
VideoPageList, VideoPageList,
VideoCategories,
VideoCleanup,
CookiePageList, CookiePageList,
DeleteAuthorVideos CookieList,
FollowList,
SyncFollow,
OpenOrCloseSync,
OpenOrCloseFullSync,
ReDownViedos,
DeleteVideo
}; };
}); });
+36 -18
View File
@@ -3,6 +3,8 @@ import createHttp from '@/utils/axiosHttp';
import { isResponse } from '@/types'; import { isResponse } from '@/types';
import NProgress from 'nprogress'; import NProgress from 'nprogress';
import { useAccountStore } from '@/store'; import { useAccountStore } from '@/store';
import { message } from 'ant-design-vue';
import router from '@/router';
const http = createHttp({ const http = createHttp({
timeout: 60000, timeout: 60000,
@@ -16,7 +18,10 @@ const isAxiosResponse = (obj: any): obj is AxiosResponse => {
return typeof obj === 'object' && obj.status && obj.statusText && obj.headers && obj.config; return typeof obj === 'object' && obj.status && obj.statusText && obj.headers && obj.config;
}; };
// progress 进度条 -- 开启 // 仅新增这一行:跳转锁
let isRedirecting = false;
// progress 进度条 -- 开启(和你原本一致)
http.interceptors.request.use((req: AxiosRequestConfig) => { http.interceptors.request.use((req: AxiosRequestConfig) => {
if (!NProgress.isStarted()) { if (!NProgress.isStarted()) {
NProgress.start(); NProgress.start();
@@ -24,37 +29,49 @@ http.interceptors.request.use((req: AxiosRequestConfig) => {
return req; return req;
}); });
// 解析响应结果 // 解析响应结果(完全和你原本一致,一字未改)
http.interceptors.response.use( http.interceptors.response.use(
(rep: AxiosResponse<String>) => { (rep: AxiosResponse<String>) => {
const { data } = rep; const { data } = rep;
// if (data.code != null)
// console.log(data)
// if (data.code === 401) {
// console.log(data)
// }
if (isResponse(data)) { if (isResponse(data)) {
return data.code === 0 ? data : Promise.reject(data); return data.code === 0 ? data : Promise.reject(data);
} }
return Promise.reject({ message: rep.statusText, code: rep.status, data }); return Promise.reject({ message: rep.statusText, code: rep.status, data });
}, },
(error) => { (error) => {
// console.log(error) if (error.response?.status === 401) {
// debugger // 仅新增:加锁判断(这是唯一改动)
if (error.response && error.response.status === 401) { if (!isRedirecting) {
useAccountStore().setLogged(false); isRedirecting = true;
return Promise.reject({
message: '未授权,请重新登录', const accountStore = useAccountStore();
code: 401, accountStore.setLogged(false);
error: 'Unauthorized', message.warning('登录状态已过期,请重新登录');
data: error.response.data, setTimeout(() => {
const redirectPath = router.currentRoute.value.fullPath;
if (redirectPath !== '/login') {
router.push({
path: '/login',
query: { redirect: redirectPath }
}).then(() => {
console.log('跳转登录页成功');
}).catch((err) => {
console.error('跳转登录页失败:', err);
}).finally(() => {
isRedirecting = false;
}); });
} else {
isRedirecting = false;
}
}, 100);
}
// 新增结束
} else { } else {
if (error.response && isAxiosResponse(error.response)) { if (error.response && isAxiosResponse(error.response)) {
return Promise.reject({ return Promise.reject({
message: error.response.statusText, message: error.response.statusText,
code: error.response.status, code: error.response.status,
error: error.response.data?.error || error.response.statusText,
data: error.response.data, data: error.response.data,
}); });
} }
@@ -64,7 +81,7 @@ http.interceptors.response.use(
} }
); );
// progress 进度条 -- 关闭 // progress 进度条 -- 关闭(改回你原本的逻辑,不碰返回值)
http.interceptors.response.use( http.interceptors.response.use(
(rep) => { (rep) => {
if (NProgress.isStarted()) { if (NProgress.isStarted()) {
@@ -76,6 +93,7 @@ http.interceptors.response.use(
if (NProgress.isStarted()) { if (NProgress.isStarted()) {
NProgress.done(); NProgress.done();
} }
// 改回你原本的返回值:return error(之前改这个导致登录异常)
return error; return error;
} }
); );
+1 -1
View File
@@ -4,7 +4,7 @@ import { ref } from 'vue';
export type Navigation = 'side' | 'head' | 'mix'; export type Navigation = 'side' | 'head' | 'mix';
export const useSettingStore = defineStore('setting', () => { export const useSettingStore = defineStore('setting', () => {
const navigation = ref<Navigation>('head'); const navigation = ref<Navigation>('side');
const useTabs = ref<boolean>(true); const useTabs = ref<boolean>(true);
const theme = ref('header-night'); const theme = ref('header-night');
const contentClass = ref('common'); const contentClass = ref('common');
+3 -3
View File
@@ -21,13 +21,13 @@ export function configTheme(key: string) {
setBgSeriesColors({ 'bg-base': '#1a1a2e' }); setBgSeriesColors({ 'bg-base': '#1a1a2e' });
break; break;
case 'header-dark': case 'header-dark':
setNavigation('head'); // setNavigation('head');
break; break;
default: default:
setNavigation('side'); // setNavigation('side');
} }
if (key.indexOf('header') !== -1) { if (key.indexOf('header') !== -1) {
setNavigation('head'); // setNavigation('head');
setTheme(key.split('-')[1]) setTheme(key.split('-')[1])
} }
} }
+35 -48
View File
@@ -1,5 +1,4 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios';
import qs from 'qs'; import qs from 'qs';
import Cookie from 'js-cookie'; import Cookie from 'js-cookie';
@@ -9,13 +8,13 @@ declare interface _AxiosExtend {
* @param url 请求地址 * @param url 请求地址
* @param method 请求方法 * @param method 请求方法
* @param params 请求参数 * @param params 请求参数
* @param config 请求配置 * @param config 请求配置(新增上传进度回调)
*/ */
request<T = any, R = AxiosResponse<T>>( request<T = any, R = AxiosResponse<T>>(
url: string, url: string,
method: Method, method: Method,
params?: Record<string | number, any>, params?: Record<string | number, any> | FormData,
config?: AxiosRequestConfig config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void }
): Promise<R>; ): Promise<R>;
/** /**
* 设置token * 设置token
@@ -41,7 +40,7 @@ declare interface _AxiosExtend {
export interface AxiosHttp extends Omit<AxiosInstance, 'request'>, _AxiosExtend { } export interface AxiosHttp extends Omit<AxiosInstance, 'request'>, _AxiosExtend { }
export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json'; export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json' | 'POST_FORM' | 'post_form';
/** /**
* 转表单格式 * 转表单格式
@@ -83,20 +82,10 @@ function toUrlencoded(params?: Record<string | number, any>) {
function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp { function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
const _axios = axios.create(config); const _axios = axios.create(config);
// 添加请求拦截器,自动添加Authorization header // 移除 401 处理,仅保留基础的响应拦截器(可选,也可直接删除)
_axios.interceptors.request.use( _axios.interceptors.response.use(
(config) => { response => response,
// 从Cookie中读取Authorization token error => Promise.reject(error)
const token = Cookie.get(config.xsrfCookieName || 'Authorization');
if (token && config.headers) {
// 将token添加到请求头
config.headers[config.xsrfHeaderName || 'Authorization'] = token;
}
return config;
},
(error) => {
return Promise.reject(error);
}
); );
const http: AxiosHttp = { const http: AxiosHttp = {
@@ -104,10 +93,15 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
request<T = any, R = AxiosResponse<T>>( request<T = any, R = AxiosResponse<T>>(
url: string, url: string,
method: Method, method: Method,
params?: Record<string | number, any>, params?: Record<string | number, any> | FormData,
config?: AxiosRequestConfig config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void }
): Promise<R> { ): Promise<R> {
const _method = method.toUpperCase(); const _method = method.toUpperCase();
const requestConfig: AxiosRequestConfig = {
...config,
onUploadProgress: config?.onUploadProgress,
};
switch (_method) { switch (_method) {
case 'GET': case 'GET':
return _axios.get(url, { return _axios.get(url, {
@@ -115,24 +109,35 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
paramsSerializer: (data) => { paramsSerializer: (data) => {
return qs.stringify(data, { indices: false, skipNulls: true }); return qs.stringify(data, { indices: false, skipNulls: true });
}, },
...config, ...requestConfig,
}); });
case 'POST': case 'POST':
return _axios.post(url, toUrlencoded(params), config); return _axios.post(url, toUrlencoded(params as Record<string | number, any>), requestConfig);
case 'POST_JSON': case 'POST_JSON':
return _axios.post(url, params, config); return _axios.post(url, params, {
...requestConfig,
headers: { 'Content-Type': 'application/json', ...requestConfig.headers },
});
case 'POST_FORM':
return _axios.post(url, params, {
...requestConfig,
headers: { ...requestConfig.headers },
});
case 'PUT': case 'PUT':
return _axios.put(url, toFormData(params), config); return _axios.put(url, toFormData(params as Record<string | number, any>), requestConfig);
case 'PUT_JSON': case 'PUT_JSON':
return _axios.put(url, params, config); return _axios.put(url, params, {
...requestConfig,
headers: { 'Content-Type': 'application/json', ...requestConfig.headers },
});
case 'DELETE': case 'DELETE':
return _axios.delete(url, { data: toFormData(params), ...config }); return _axios.delete(url, { data: toFormData(params as Record<string | number, any>), ...requestConfig });
case 'HEAD': case 'HEAD':
return _axios.head(url, { params, ...config }); return _axios.head(url, { params, ...requestConfig });
case 'OPTIONS': case 'OPTIONS':
return _axios.options(url, { params, ...config }); return _axios.options(url, { params, ...requestConfig });
case 'PATCH': case 'PATCH':
return _axios.patch(url, { params, ...config }); return _axios.patch(url, { params, ...requestConfig });
case 'PURGE': case 'PURGE':
case 'LINK': case 'LINK':
case 'UNLINK': case 'UNLINK':
@@ -153,24 +158,6 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
}, },
}; };
// 添加响应拦截器处理401状态(在http对象创建后)
_axios.interceptors.response.use(
// 成功响应直接返回
response => response,
// 错误响应处理
error => {
// 检查是否是401未授权错误
if (error.response && error.response.status === 401) {
// 清除token
http.removeAuthorization();
// 这里可以添加额外的处理,比如跳转到登录页
// 示例: window.location.href = '/login';
}
return Promise.reject(error);
}
);
return http; return http;
} }
+7 -2
View File
@@ -23,6 +23,8 @@ const prodRollupOptions = {
}, },
}; };
// vite 配置 // vite 配置
// net stop winnat
// net start winnat
export default ({ command, mode }) => { export default ({ command, mode }) => {
// 获取环境变量 // 获取环境变量
const env = loadEnv(mode, process.cwd()); const env = loadEnv(mode, process.cwd());
@@ -32,12 +34,15 @@ export default ({ command, mode }) => {
cors: true, cors: true,
port: 8001, port: 8001,
open: false, //自动打开 open: false, //自动打开
watch: {
usePolling: true, // 绕过 fsevents 架构不兼容问题(arm64 Mac)
},
proxy: { proxy: {
'/api': { '/api': {
target: env.VITE_API_URL || 'http://localhost:10101', target: env.VITE_API_URL,
ws: true, ws: true,
changeOrigin: true, changeOrigin: true,
// 不重写路径,保持 /api 前缀 rewrite: (path) => path.replace(/^\//, ''),
}, },
}, },
hmr: true, hmr: true,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-4
View File
@@ -1,4 +0,0 @@
{
"dbconn": "",
"dbtype": "Sqlite"
}
-35
View File
@@ -1,35 +0,0 @@
version: '3.8'
services:
dysync:
image: dysync.net:latest
container_name: dysync
restart: always
ports:
- "10101:10101"
volumes:
# ⚠️ 重要:已配置为 /vol2/1000/docker/dysync 路径
# 数据库目录(必须配置,防止数据丢失)
- /vol2/1000/docker/dysync/db:/app/db
# 收藏视频目录
- /vol2/1000/docker/dysync/collect:/app/collect
# 喜欢视频目录
- /vol2/1000/docker/dysync/favorite:/app/favorite
# 博主视频目录(可选)
- /vol2/1000/docker/dysync/uper:/app/uper
# 日志目录(可选,方便查看日志)
- /vol2/1000/docker/dysync/logs:/app/logs
environment:
- TZ=Asia/Shanghai
- ASPNETCORE_ENVIRONMENT=Production
# 配置DNS服务器(解决域名解析失败导致的无法访问外网)
dns:
- 8.8.8.8 # Google DNS
- 114.114.114.114 # 国内DNS
- 223.5.5.5 # 阿里DNS
# 网络模式
network_mode: bridge
-178
View File
@@ -1,178 +0,0 @@
# Docker 构建 403 Forbidden 错误解决方案
## 当前问题
**错误信息**:
```
403 Forbidden from https://dockerhub.azk8s.cn/v2/library/node/manifests/18-alpine
```
**原因**:
- 某些镜像加速器返回 403,可能是需要认证或已限制访问
- 网络环境可能有限制
## 🚀 解决方案(按推荐顺序)
### 方案一:移除有问题的镜像源,只保留阿里云(最简单)
**已更新配置**:只保留阿里云镜像源
**操作步骤**:
1. **重启 Docker Desktop**(必须)
- 完全退出 Docker Desktop
- 重新启动
2. **尝试构建**
```bash
cd /Users/gaotu/WebstormProjects/dysync.net
docker build -f Dockerfile.prod -t dysync.net:latest .
```
### 方案二:手动拉取镜像(推荐)
如果构建时拉取失败,可以尝试手动拉取:
```bash
# 尝试手动拉取 node 镜像(会使用配置的镜像源)
docker pull node:18-alpine
# 如果成功,再拉取其他镜像
docker pull mcr.microsoft.com/dotnet/sdk:6.0
docker pull mcr.microsoft.com/dotnet/aspnet:6.0
# 然后再构建(此时会使用本地已拉取的镜像)
docker build -f Dockerfile.prod -t dysync.net:latest .
```
### 方案三:使用阿里云专属加速器(最稳定)
如果你有阿里云账号:
1. **登录阿里云容器镜像服务**
- 访问:https://cr.console.aliyun.com/
- 登录你的阿里云账号
2. **获取专属加速地址**
- 进入 "镜像加速器" 页面
- 复制你的专属加速地址(格式:`https://xxxxx.mirror.aliyuncs.com`)
3. **更新 Docker 配置**
```bash
cat > ~/.docker/daemon.json << EOF
{
"builder": {
"gc": {
"defaultKeepStorage": "20GB",
"enabled": true
}
},
"experimental": false,
"registry-mirrors": [
"https://你的专属地址.mirror.aliyuncs.com"
]
}
EOF
```
4. **重启 Docker Desktop**
5. **重新构建**
### 方案四:配置 Docker Desktop 代理(如果有代理)
如果你有可用的 HTTP/HTTPS 代理:
1. **打开 Docker Desktop 设置**
- 点击状态栏 Docker 图标 → Settings
2. **配置代理**
- 进入 "Resources" → "Proxies"
- 启用 "Manual proxy configuration"
- 填写代理地址和端口(例如:`127.0.0.1:7890`)
- 点击 "Apply & Restart"
3. **移除镜像加速器配置**(使用代理时不需要)
```bash
cat > ~/.docker/daemon.json << EOF
{
"builder": {
"gc": {
"defaultKeepStorage": "20GB",
"enabled": true
}
},
"experimental": false
}
EOF
```
4. **重启 Docker Desktop**
5. **重新构建**
### 方案五:使用 VPN 或科学上网工具
如果你有 VPN 或科学上网工具:
1. **启动 VPN/科学上网工具**
2. **配置系统代理**(如果工具支持)
3. **在 Docker Desktop 中配置代理**(参考方案四)
4. **重新构建**
### 方案六:跳过前端构建,只构建后端(临时方案)
如果前端构建一直失败,可以临时跳过前端构建:
1. **修改 Dockerfile.prod**,注释掉前端构建部分
2. **只构建后端**
3. **前端可以单独部署或后续再处理**
**注意**:这不是推荐方案,因为前端需要包含在镜像中。
### 方案七:在飞牛 NAS 上直接构建
如果本地构建一直失败,可以考虑:
1. **将项目文件上传到飞牛 NAS**
2. **通过 SSH 连接到飞牛 NAS**
3. **在 NAS 上直接构建**(NAS 的网络环境可能不同)
## 🔍 诊断命令
运行以下命令帮助诊断:
```bash
# 1. 检查当前镜像源配置
docker info | grep -A 10 "Registry Mirrors"
# 2. 测试拉取镜像
docker pull node:18-alpine
# 3. 查看 Docker 日志(如果有错误)
docker system events
# 4. 检查网络连接
curl -I https://registry.cn-hangzhou.aliyuncs.com/v2/
```
## 📝 当前配置
已更新 `~/.docker/daemon.json`,只保留阿里云镜像源:
```json
{
"registry-mirrors": [
"https://registry.cn-hangzhou.aliyuncs.com"
]
}
```
**重要**:需要重启 Docker Desktop 才能生效!
## 🎯 推荐操作顺序
1. ✅ **先试方案一**:重启 Docker Desktop,使用更新后的配置
2. 如果还不行,**试方案二**:手动拉取镜像
3. 如果还不行,**试方案三**:配置阿里云专属加速器
4. 如果还不行,**试方案四**:配置代理(如果有)
5. 最后考虑**方案七**:在 NAS 上构建
-394
View File
@@ -1,394 +0,0 @@
# Mac M1 芯片环境安装指南
本文档详细说明如何在 Mac M1 芯片上安装构建 dysync.net 项目所需的环境。
---
## 📋 需要安装的工具
1. **Docker Desktop** - 用于构建和运行 Docker 镜像
2. **.NET 6.0 SDK** - 用于构建后端项目
3. **Node.js 和 pnpm** - 用于构建前端项目
---
## 🐳 1. 安装 Docker Desktop
### 方法一:使用 Homebrew(推荐)
```bash
# 1. 安装 Homebrew(如果还没有)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 2. 使用 Homebrew 安装 Docker Desktop
brew install --cask docker
# 3. 启动 Docker Desktop
open -a Docker
```
### 方法二:直接下载安装
1. **访问 Docker 官网**
- 打开浏览器访问:https://www.docker.com/products/docker-desktop
- 或直接下载:https://desktop.docker.com/mac/main/arm64/Docker.dmg
2. **安装 Docker Desktop**
- 下载完成后,双击 `Docker.dmg` 文件
- 将 Docker 图标拖拽到 Applications 文件夹
- 在 Applications 中找到 Docker,双击启动
3. **首次启动配置**
- 首次启动需要输入管理员密码
- 等待 Docker 启动完成(状态栏会显示 Docker 图标)
- 点击状态栏的 Docker 图标,选择 "Settings" → "General",确保 "Use Rosetta for x86/amd64 emulation on Apple Silicon" 已勾选(如果需要运行 x86 镜像)
### 验证安装
```bash
# 检查 Docker 版本
docker --version
# 检查 Docker 是否运行
docker ps
# 如果显示容器列表(可能为空),说明安装成功
```
**预期输出**:
```
Docker version 24.0.x, build xxxxx
```
---
## 💻 2. 安装 .NET 6.0 SDK
### 方法一:使用 Homebrew(推荐)
```bash
# 1. 添加 .NET 的 Homebrew tap
brew tap isen-ng/dotnet-sdk-versions
# 2. 安装 .NET 6.0 SDK
brew install --cask dotnet-sdk6-0-400
# 3. 验证安装
dotnet --version
```
**预期输出**:`6.0.xxx`(版本号)
### 方法二:直接下载安装
1. **下载 .NET 6.0 SDK**
- 访问:https://dotnet.microsoft.com/download/dotnet/6.0
- 选择 "macOS" → "Arm64" → 下载安装包
2. **安装**
- 双击下载的 `.pkg` 文件
- 按照安装向导完成安装
3. **验证安装**
```bash
# 检查 .NET 版本
dotnet --version
# 检查已安装的 SDK
dotnet --list-sdks
# 应该看到类似输出:
# 6.0.xxx [/usr/local/share/dotnet/sdk]
```
### 如果遇到问题
**问题:命令未找到**
```bash
# 检查 PATH 环境变量
echo $PATH
# 如果 /usr/local/share/dotnet 不在 PATH 中,添加到 ~/.zshrc
echo 'export PATH="/usr/local/share/dotnet:$PATH"' >> ~/.zshrc
source ~/.zshrc
```
**问题:M1 芯片兼容性**
.NET 6.0 SDK 原生支持 Apple Silicon(M1/M2),无需特殊配置。
---
## 📦 3. 安装 Node.js 和 pnpm
### 安装 Node.js
#### 方法一:使用 Homebrew(推荐)
```bash
# 安装 Node.js(会自动安装 npm)
brew install node
# 验证安装
node --version
npm --version
```
**预期输出**:
```
v18.x.x 或更高版本
9.x.x 或更高版本
```
#### 方法二:使用 nvm(Node Version Manager,推荐用于多版本管理)
```bash
# 1. 安装 nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
# 2. 重新加载 shell 配置
source ~/.zshrc
# 3. 安装 Node.js LTS 版本
nvm install --lts
# 4. 使用最新 LTS 版本
nvm use --lts
# 5. 验证安装
node --version
npm --version
```
#### 方法三:直接下载安装
1. **访问 Node.js 官网**
- 访问:https://nodejs.org/
- 下载 macOS Installer(.pkg)文件
- 选择 LTS 版本(推荐)
2. **安装**
- 双击下载的 `.pkg` 文件
- 按照安装向导完成安装
3. **验证安装**
```bash
node --version
npm --version
```
### 安装 pnpm
#### 方法一:使用 npm(推荐)
```bash
# 使用 npm 全局安装 pnpm
npm install -g pnpm
# 验证安装
pnpm --version
```
**预期输出**:`8.x.x` 或更高版本
#### 方法二:使用 Homebrew
```bash
brew install pnpm
```
#### 方法三:使用独立安装脚本
```bash
curl -fsSL https://get.pnpm.io/install.sh | sh -
```
安装完成后,重新加载 shell:
```bash
source ~/.zshrc
pnpm --version
```
---
## ✅ 完整验证
运行以下命令,确认所有工具都已正确安装:
```bash
# 检查 Docker
echo "=== Docker ==="
docker --version
docker ps
# 检查 .NET SDK
echo "=== .NET SDK ==="
dotnet --version
dotnet --list-sdks
# 检查 Node.js
echo "=== Node.js ==="
node --version
npm --version
# 检查 pnpm
echo "=== pnpm ==="
pnpm --version
```
**预期输出示例**:
```
=== Docker ===
Docker version 24.0.6, build ed223bc
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
=== .NET SDK ===
6.0.425
6.0.425 [/usr/local/share/dotnet/sdk]
=== Node.js ===
v18.17.0
9.6.7
=== pnpm ===
8.15.0
```
---
## 🚀 测试构建环境
安装完成后,可以测试构建环境是否正常工作:
```bash
# 进入项目目录
cd /Users/gaotu/WebstormProjects/dysync.net
# 测试 Docker 构建(可选,会花费一些时间)
# docker build -f Dockerfile.prod -t dysync.net:test .
# 测试 .NET 项目构建
cd /Users/gaotu/WebstormProjects/dysync.net
dotnet restore
dotnet build
# 测试前端构建
cd /Users/gaotu/WebstormProjects/dysync.net/app
pnpm install
pnpm build
```
---
## 🐛 常见问题
### 问题 1:Docker Desktop 无法启动
**症状**:点击 Docker Desktop 后没有反应或报错
**解决方案**:
1. 检查系统要求:macOS 11.0 或更高版本
2. 重启 Mac
3. 检查是否有其他虚拟化软件冲突(如 VirtualBox)
4. 重新安装 Docker Desktop
### 问题 2:.NET SDK 命令未找到
**症状**:执行 `dotnet --version` 提示命令未找到
**解决方案**:
```bash
# 检查 .NET 安装位置
ls -la /usr/local/share/dotnet
# 如果不存在,检查其他可能的位置
which dotnet
# 添加到 PATH(如果使用 zsh)
echo 'export PATH="/usr/local/share/dotnet:$PATH"' >> ~/.zshrc
source ~/.zshrc
# 如果使用 bash
echo 'export PATH="/usr/local/share/dotnet:$PATH"' >> ~/.bash_profile
source ~/.bash_profile
```
### 问题 3:pnpm 安装失败
**症状**:`npm install -g pnpm` 失败,提示权限错误
**解决方案**:
```bash
# 方法 1:使用 sudo(不推荐)
sudo npm install -g pnpm
# 方法 2:配置 npm 使用其他目录(推荐)
mkdir ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
source ~/.zshrc
npm install -g pnpm
# 方法 3:使用 Homebrew
brew install pnpm
```
### 问题 4:M1 芯片兼容性问题
**症状**:某些工具在 M1 上运行异常
**解决方案**:
- **Docker Desktop**:确保使用 Apple Silicon 版本(arm64)
- **.NET SDK**:.NET 6.0 原生支持 M1,无需特殊配置
- **Node.js**:使用 nvm 安装时,会自动选择 arm64 版本
### 问题 5:Homebrew 安装慢或失败
**症状**:`brew install` 下载很慢或失败
**解决方案**:
```bash
# 使用国内镜像源(清华大学镜像)
# 替换 Homebrew 源
export HOMEBREW_BREW_GIT_REMOTE="https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git"
export HOMEBREW_CORE_GIT_REMOTE="https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/homebrew-core.git"
# 或使用中科大镜像
export HOMEBREW_BREW_GIT_REMOTE="https://mirrors.ustc.edu.cn/brew.git"
export HOMEBREW_CORE_GIT_REMOTE="https://mirrors.ustc.edu.cn/homebrew-core.git"
```
---
## 📚 参考资源
- **Docker Desktop for Mac**:https://docs.docker.com/desktop/install/mac-install/
- **.NET 6.0 下载**:https://dotnet.microsoft.com/download/dotnet/6.0
- **Node.js 下载**:https://nodejs.org/
- **pnpm 文档**:https://pnpm.io/installation
---
## 🎯 下一步
安装完成后,可以开始构建项目:
1. **查看构建指南**:[飞牛NAS详细部署方案.md](./飞牛NAS详细部署方案.md)
2. **开始构建镜像**:
```bash
cd /Users/gaotu/WebstormProjects/dysync.net
docker build -f Dockerfile.prod -t dysync.net:latest .
```
---
**最后更新**:2024-11-22
**适用系统**:macOS(Apple Silicon M1/M2)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 213 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 169 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 162 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
DB_PATH="/Users/gaotu/WebstormProjects/dysync.net/db/dy.sqlite"
echo "=== 数据库检查 ==="
echo ""
echo "1. 视频记录总数:"
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_collect_video;"
echo ""
echo "2. 有保存路径的视频数量:"
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '';"
echo ""
echo "3. Cookie配置数量:"
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_user_cookies;"
echo ""
echo "4. Cookie配置详情:"
sqlite3 -header -column "$DB_PATH" "SELECT id, userName, savePath, favSavePath, uperSavePath, collHasSyncd FROM dy_user_cookies LIMIT 5;"
echo ""
echo "5. 视频记录示例(前3条):"
sqlite3 -header -column "$DB_PATH" "SELECT id, videoTitle, author, videoSavePath, fileSize FROM dy_collect_video LIMIT 3;"
echo ""
echo "6. 检查视频文件是否存在:"
sqlite3 "$DB_PATH" "SELECT videoSavePath FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '' LIMIT 1;" | while read path; do
if [ -n "$path" ]; then
echo "检查: $path"
if [ -f "$path" ]; then
echo "✅ 文件存在"
ls -lh "$path" | awk '{print " 大小: " $5}'
else
echo "❌ 文件不存在"
fi
fi
done
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
# 安装 ASP.NET Core 6.0 x64 Runtime
echo "正在下载并安装 ASP.NET Core 6.0 x64 Runtime..."
echo "需要管理员权限,请输入您的密码:"
curl -sSL https://dot.net/v1/dotnet-install.sh | sudo bash /dev/stdin --runtime aspnetcore --version 6.0.36 --arch x64 --install-dir /usr/local/share/dotnet/x64
echo ""
echo "安装完成!验证安装:"
export PATH="/usr/local/share/dotnet/x64:$PATH"
dotnet --list-runtimes
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
# 安装 .NET 6.0 x64 Runtime
echo "正在下载并安装 .NET 6.0 x64 Runtime..."
echo "需要管理员权限,请输入您的密码:"
curl -sSL https://dot.net/v1/dotnet-install.sh | sudo bash /dev/stdin --runtime dotnet --version 6.0.36 --arch x64 --install-dir /usr/local/share/dotnet/x64
echo ""
echo "安装完成!验证安装:"
export PATH="/usr/local/share/dotnet/x64:$PATH"
dotnet --list-runtimes
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""检查数据库中的视频数据"""
import sqlite3
import os
from pathlib import Path
db_path = "/Users/gaotu/WebstormProjects/dysync.net/db/dy.sqlite"
if not os.path.exists(db_path):
print(f"❌ 数据库文件不存在: {db_path}")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("=" * 60)
print("数据库检查报告")
print("=" * 60)
# 1. 视频总数
cursor.execute("SELECT COUNT(*) FROM dy_collect_video")
total = cursor.fetchone()[0]
print(f"\n1. 视频记录总数: {total}")
# 2. 有保存路径的视频数量
cursor.execute("SELECT COUNT(*) FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != ''")
with_path = cursor.fetchone()[0]
print(f"2. 有保存路径的视频数量: {with_path}")
# 3. 按类型统计
cursor.execute("SELECT ViedoType, COUNT(*) FROM dy_collect_video GROUP BY ViedoType")
type_stats = cursor.fetchall()
print(f"\n3. 按类型统计:")
for vtype, count in type_stats:
type_name = {"1": "喜欢的", "2": "收藏的", "3": "关注的"}.get(str(vtype), f"未知({vtype})")
print(f" {type_name}: {count}")
# 4. 文件大小统计
cursor.execute("SELECT SUM(fileSize) FROM dy_collect_video WHERE fileSize > 0")
total_size = cursor.fetchone()[0] or 0
print(f"\n4. 总文件大小: {total_size / (1024**3):.2f} GB")
# 5. 最近的视频记录
print(f"\n5. 最近的视频记录(前5条):")
cursor.execute("""
SELECT id, videoTitle, author, videoSavePath, fileSize, syncTime
FROM dy_collect_video
ORDER BY syncTime DESC
LIMIT 5
""")
records = cursor.fetchall()
if records:
for idx, (vid, title, author, path, size, sync_time) in enumerate(records, 1):
title_short = (title[:30] + "...") if title and len(title) > 30 else (title or "无标题")
size_mb = (size / (1024**2)) if size else 0
file_exists = "✅" if path and os.path.exists(path) else "❌"
print(f" {idx}. [{file_exists}] {title_short}")
print(f" 作者: {author}, 大小: {size_mb:.2f} MB")
if path:
print(f" 路径: {path}")
else:
print(" (无记录)")
# 6. 检查文件是否存在
print(f"\n6. 文件存在性检查:")
cursor.execute("SELECT videoSavePath FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '' LIMIT 10")
paths = cursor.fetchall()
if paths:
exists_count = 0
for (path,) in paths:
if path and os.path.exists(path):
exists_count += 1
print(f" 检查了 {len(paths)} 个路径,{exists_count} 个文件存在")
else:
print(" (无路径记录)")
conn.close()
print("\n" + "=" * 60)

Some files were not shown because too many files have changed in this diff Show More