commit 60daff236581ee08ff4c80868d36ac91ca4c9ffd Author: Dev Date: Mon Nov 9 16:46:35 2026 +0800 搭建轻舟一期门户、后台和统一登录。 让分类、会员和示例工具共用同一账号,本地即可跑通登录与授权。 Co-authored-by: Cursor diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc8724f --- /dev/null +++ b/README.md @@ -0,0 +1,442 @@ +# 轻舟(Qingzhou)功能说明 + +状态:**一期已可在本地运行**。目录按现有工程分为 `backend`、`frontend`、`admin`。下文仍是功能边界;支付渠道、自动开云主机和团队席位仍留在后续阶段。 + +参考站点:[蜘蛛导航](https://www.zhizhudh.com/)。借鉴它的门户信息架构:顶栏检索、左侧分类、分区标题、工具卡片(图标、名称、简介、标签)。轻舟的产品是「实用工具与软件服务平台」:把多个在线工具汇到同一套账号下,用会员订阅、云端部署和增值服务盈利。 + +--- + +## 1. 产品一句话 + +轻舟提供统一账号和会员权益。用户在平台登录一次,即可带着同一身份、同一会员权限进入各个已接入工具;工具可以有独立网址,也可以用不同语言开发。 + +品牌名:**轻舟**。项目目录名 `qztool` 只作工程名,前台展示使用「轻舟」。 + +## 2. 谁在用、靠什么赚钱 + +### 2.1 用户 + +| 角色 | 能做什么 | +| --- | --- | +| 访客 | 浏览首页、分类、搜索、工具介绍、价格、帮助文章 | +| 注册用户 | 登录、管理资料与安全、打开已接入工具、使用免费额度 | +| 会员 | 在免费能力之上使用付费工具、更高额度、会员标识 | +| 企业/团队管理员(后期) | 管理席位、查看团队用量、为成员分配工具权限 | +| 平台运营 | 后台维护分类、工具、内容、会员、订单、接入应用 | +| 工具服务端 | 作为 OAuth 客户端,向账号中心换取用户身份和权益,不保存轻舟密码 | + +### 2.2 收入 + +1. **会员订阅**:按月/按年开通平台会员,解锁工具和额度。 +2. **增值服务**:额外额度、去广告、优先支持、定制二开、私有化部署咨询。 +3. **云端部署**:把开源软件二次开发后的工具,以共享 SaaS 或独立实例提供给用户。 +4. **二开交付**:用户提交需求,平台报价、排期、交付一个接入统一账号的工具或功能包。 + +首期把「能登录、能区分会员、能在后台配置工具和内容」做完整。自动开通云主机、在线支付渠道、团队席位放到后续阶段,但数据结构和页面入口按全文预留,避免返工。 + +## 3. 门户上借鉴参考站的部分 + +首页结构: + +- 顶部:品牌、主导航、搜索框、登录/注册或头像菜单。 +- 搜索框下方可用范围切换:**站内工具**、**我的常用**、**文档帮助**。常用和帮助在登录后更有意义;未登录时「我的常用」引导登录。 +- 左侧分类导航,可收起。分类、子分类、排序全部来自后台。 +- 主区域按分区排列工具卡片。分区示例:热门推荐、效率办公、文件处理、开发辅助、多媒体、学习笔记。具体分区由后台配置,不写死。 +- 卡片内容:图标、名称、一句话简介、标签、会员角标(免费 / 会员 / 企业)。点击进入工具详情,再「打开工具」。 +- 页脚:关于、协议、隐私、帮助、备案占位。 + +轻舟不做参考站里的「站外搜索引擎切换」和资源下载导航。工具分两类,后台可切换: + +| 类型 | 行为 | 登录 | +| --- | --- | --- | +| 接入工具 | 平台自己的工具,或二开后部署的工具 | 打开时走统一登录,携带账号和会员权益 | +| 收录链接 | 仅介绍并跳转到外部网站 | 只统计点击,不共享登录态 | + +盈利和权限只针对「接入工具」。收录链接用于内容完整度,不能冒充已打通账号。 + +## 4. 信息架构 + +### 4.1 前台页面 + +| 路径 | 页面 | 要点 | +| --- | --- | --- | +| `/` | 首页 | 搜索、分类、运营分区、工具卡片 | +| `/c/:slug` | 分类页 | 子分类、排序(推荐、最新、名称)、分页 | +| `/search` | 搜索页 | 名称、简介、标签;空结果给出热门工具 | +| `/t/:slug` | 工具详情 | 介绍、截图、使用说明、所需会员、额度说明、打开按钮、相关工具 | +| `/pricing` | 会员方案 | 方案对比、当前身份、开通/续费 | +| `/account` | 账号概览 | 身份、会员到期、最近使用的工具 | +| `/account/profile` | 资料 | 昵称、头像 | +| `/account/security` | 安全 | 绑定邮箱/手机、改密、登录方式 | +| `/account/sessions` | 登录设备 | 当前设备与各工具会话,可单独下线 | +| `/account/membership` | 会员与额度 | 方案、到期、各工具剩余额度 | +| `/account/orders` | 订单 | 订阅订单、增值服务订单、状态 | +| `/account/services` | 我的服务 | 云端实例、二开工单 | +| `/help` `/help/:slug` | 帮助中心 | 后台文章 | +| `/login` `/register` `/forgot` | 认证 | 也可做成首页上的对话框,地址仍可直达 | +| `/oauth/authorize` | 授权中转 | 工具跳回账号中心;已登录则直接签发授权码 | +| `/agreements/:type` | 协议 | 用户协议、隐私政策,后台可改 | + +打开工具的路径固定为:卡片或详情 → 账号中心确认登录 → 带授权码跳回该工具。未登录先完成登录,登录后回到刚才要打开的工具。 + +### 4.2 后台页面 + +后台入口 `/admin`,与前台同一 Vue 工程、独立布局。未登录或非运营角色不可进入。 + +| 模块 | 功能 | +| --- | --- | +| 概览 | 新增用户、有效会员、今日登录、工具打开次数、待处理工单 | +| 内容分区 | 首页分区的标题、排序、展示数量、手动置顶工具、上下架 | +| 分类 | 多级分类、图标、排序、是否在侧栏显示 | +| 工具 | 接入工具与收录链接的全部字段,见第 6 节 | +| 标签 | 标签库,工具多选 | +| 文章与公告 | 帮助文章、首页公告、协议正文,草稿/发布 | +| 导航与页脚 | 菜单项、外链、排序 | +| 轮播与推荐位 | 图片、链接、生效时间 | +| 用户 | 查询、禁用、重置登录失败次数、代开/延长会员、查看绑定身份 | +| 会员方案 | 名称、价格、周期、权益项、排序、上下架 | +| 订单 | 查询、备注、线下开通标记、退款状态(支付渠道接入前可手工改状态) | +| 工单与部署 | 二开需求、云端实例状态 | +| 应用接入 | 每个工具一个 OAuth 客户端:client_id、回调地址白名单、密钥重置 | +| 短信与邮件 | 模板、发送记录、失败原因。密钥只在环境变量,不在页面回显 | +| 系统设置 | 站点名、SEO、注册开关、验证码策略、会员到期宽限天数 | +| 审计 | 谁在何时改了工具、会员、用户状态 | + +列表统一具备:关键词、状态筛选、排序、分页。表单校验失败时停在当前页并指出字段。 + +## 5. 账号、登录与会话 + +### 5.1 身份 + +一个用户一条主账号,可同时绑定: + +- 一个邮箱 +- 一个中国大陆手机号 + +至少绑定其中一项才能完成注册。两项都绑定后,任一方式都能登录进同一个用户。解绑时必须还留有另一项,避免账号丢失。 + +昵称默认用脱敏手机号或邮箱前缀,用户可改。头像首期用内置色块字标,上传放到二期。 + +### 5.2 两种登录 + +**密码登录** + +- 账号框接受邮箱或手机号。 +- 密码不明文落库,也不在接口日志里出现。 +- 浏览器提交前,用账号中心下发的 RSA 公钥做 RSA-OAEP 加密,服务端私钥解密后再校验。 +- 库存 Argon2id 哈希。 +- 全程 HTTPS。应用层加密用来满足「加密登录」,避免口令以明文形式进入应用日志和转发链路。 + +**手机号 + 验证码登录** + +- 已注册手机号可直接登录。 +- 未注册手机号:验证码通过后创建账号并登录(免密注册)。是否允许「验证码即注册」默认开启,可在后台关掉,关掉后只允许已注册号码登录。 +- 验证码 6 位,5 分钟内有效,用完即废。同一号码 60 秒内不可重发。同一号码、同一 IP 有每日上限。 +- 验证码只存哈希,后台发送记录展示掩码手机号,不展示验证码本身。 + +### 5.3 注册、找回、绑定 + +- 邮箱注册:邮箱 + 邮箱验证码 + 密码。邮件通道未配置时,后台可暂时关闭邮箱注册,只留手机号。 +- 找回密码:已验证的邮箱或手机号收验证码,通过后设置新密码,并清掉该用户全部刷新令牌。 +- 修改密码:校验原密码(同样走公钥加密)。 +- 绑定新手机/新邮箱:验证码确认后写入;该手机或邮箱若属于别的账号,拒绝绑定并提示登录原账号。 + +### 5.4 安全策略 + +- 同一账号连续密码失败 5 次,锁定 15 分钟,并要求图形验证码。图形验证码在失败 3 次后提前出现。 +- 验证码错误单独计数,不与密码计数混用。 +- 登录成功写登录日志:时间、IP、UA、方式(密码/短信)、结果。 +- 用户可在「登录设备」看到账号中心会话,以及仍有效的工具会话,支持单端下线或全部下线。 +- 禁用用户:立即无法刷新令牌,已发出的访问令牌靠短有效期和权益版本号失效,见 5.6。 + +短信服务商、邮件服务商做成驱动接口。首期默认预留阿里云短信、SMTP 邮件,密钥放 `.env`。供应商未定时,本地环境用固定调试验证码(仅 `APP_DEBUG` 且非生产)。 + +## 6. 统一登录:一次登录,多工具共用 + +这是平台和其他导航站的分界。工具域名可以不同,语言可以是 PHP、Node、Python、Go 或静态前端加任意后端。账号中心不要求工具与主站同域,也不靠把密码同步给工具。 + +### 6.1 角色划分 + +- **账号中心(IdP)**:Laravel 应用。负责人、会话、会员、授权码、令牌、退出。 +- **门户**:Vue3。负责人找工具、发起「打开」。 +- **工具(客户端)**:独立部署的业务系统。只信任账号中心签发的授权码和令牌。 + +协议采用 **OAuth 2.0 授权码 + PKCE**,身份声明对齐 OpenID Connect 的最小子集(`iss`、`sub`、`aud`、`exp`、`nonce`)。不自造一套「把 token 放进 URL 里跳转」的私有协议。任意语言用标准库即可接入,平台再为 PHP 与 Node 提供一份参考示例(开发阶段编写)。 + +### 6.2 打开工具的过程 + +```text +用户点击「打开工具」 + → 门户把浏览器转到账号中心 /oauth/authorize + client_id、redirect_uri、scope、state、nonce、code_challenge + → 无轻舟会话:先完成第 5 节登录,登录后回到本次授权 + → 有会话:校验 client_id 与 redirect_uri 白名单 + → 签发一次性授权码(约 60 秒),重定向回工具 + → 工具服务端用授权码 + code_verifier + client_secret(机密客户端) + 向账号中心换 access_token、refresh_token、id_token + → 工具建立自己的本地会话,之后页面不再带令牌 +``` + +自有工具默认 **免确认页**(后台标记为内部应用)。第三方或尚未审核的客户端必须经用户点一次「同意」。 + +scope 首期只有: + +- `openid`:身份 +- `profile`:昵称、头像 +- `membership`:会员等级、到期时间、权益版本号、该工具可用的权益键和额度 + +工具拿不到密码,也拿不到其他工具的额度明细。手机号、邮箱默认不放进令牌;工具需要时走单独 scope,并在接入申请里写明理由,后台勾选后才签发。签发值仍可配置为掩码。 + +### 6.3 令牌与权限何时生效 + +| 令牌 | 建议寿命 | 存放 | +| --- | --- | --- | +| 授权码 | 60 秒,一次有效 | 服务端 | +| access_token | 15 分钟 | 工具服务端会话,不下发到前端脚本 | +| refresh_token | 14 天,轮换 | 只存哈希;用过即废,旧令牌重用则整串作废 | +| 账号中心会话 Cookie | 14 天,滑动 | `HttpOnly` + `Secure` + `SameSite=Lax`,主机仅限账号中心域名 | + +会员被升级、降级、禁用时,用户记录上的 `entitlement_version` 加一。access_token 内带上签发时的版本号。工具在关键操作前调用内省接口;版本不一致或用户已禁用,则拒绝并要求重新换票。15 分钟内的普通页面可以只信本地会话,避免每个请求都打账号中心。 + +额度扣减由**工具自己记账**,账号中心提供「本周期上限」和「重置时间」。跨工具的总账若要平台统一扣,使用后续的额度接口,首期不强制所有工具接入。 + +### 6.4 退出 + +- 只退出当前工具:工具销毁本地会话,账号中心会话保留,其他工具不受影响。 +- 退出轻舟:账号中心销毁会话、作废全部刷新令牌,并按每个客户端登记的退出回调地址做后端通知。用户下次打开任一工具都要重新登录。 +- 设备页「下线」与退出轻舟同一套作废逻辑,可针对单条会话。 + +### 6.5 同父域时的补充 + +若某些工具部署在 `tool.example.com`,主站在 `www.example.com`,仍然走授权码,不改用共享 Cookie 传递登录态。这样独立域名的工具与子域名工具是同一条接入路径。账号中心会话 Cookie 只存在于账号中心主机,不设到父域给工具读取。 + +### 6.6 工具侧必须实现的行为 + +1. 注册回调地址,只接受白名单上的 `redirect_uri`。 +2. 校验 `state`,防止跨站请求伪造。 +3. 用 PKCE 校验 `code_verifier`。 +4. 服务端换票,client_secret 不进浏览器。 +5. 纯前端、没有服务端的工具:使用公共客户端 + PKCE,access_token 只留在内存,并限制 scope 不含敏感字段。此类工具不能承接付费额度扣减。 +6. 展示会员不足时,跳回轻舟定价页,并带上 `return_to` 工具地址。登录与权限是两件事:登录始终成功,功能是否可用看权益。 + +## 7. 会员、订单与增值服务 + +### 7.1 方案 + +后台可配置,首期预置三档,价格上线前再填: + +| 方案 | 默认权益方向 | +| --- | --- | +| 免费 | 打开免费工具;付费工具可看介绍,功能内提示升级 | +| 会员 | 方案中勾选的工具与月度额度;去平台内广告位 | +| 企业 | 会员全部能力 + 更高额度 + 工单优先。团队席位二期再做 | + +权益项是键值,不把「能否使用」写死在代码分支里。例如: + +- `tool:{slug}:access` 是否可进入该工具的付费功能 +- `quota:{slug}:monthly` 月额度数字 +- `support:priority` 是否优先支持 +- `deploy:instance` 是否可申请独立实例 + +工具详情和定价页读取同一份配置。 + +### 7.2 订阅状态 + +```text +未开通 → 有效 → 到期宽限 → 已过期 + ↘ 续费成功回到有效 +有效 → 后台停用 → 已停用 +``` + +- 有效:按权益放行。 +- 宽限:默认 3 天,后台可改。宽限内保持权益,界面提示续费。 +- 已过期:付费功能关闭,账号和免费功能保留,数据不删。 +- 已停用:运营手工关闭,立即失效。 + +同一用户同一时刻只有一个生效订阅。升级按剩余天数折算差价的规则放到支付阶段;首期运营可在后台直接改等级和到期时间,并写审计。 + +### 7.3 订单 + +订单类型:`subscription`(订阅)、`addon`(增值,如加额度)、`service`(二开/部署)。 + +状态:`pending` 待支付、`paid` 已支付、`closed` 已关闭、`refunded` 已退款。 + +首期没有支付渠道时,用户提交订单后状态为待支付;运营在后台确认收款并标为已支付,系统同时延长订阅或创建工单。支付渠道(微信支付或支付宝)作为可替换驱动,接入时只补「下单、回调验签、关单」,不改会员状态机。 + +### 7.4 云端部署与二开 + +云端实例字段:所属用户、工具、规格、访问地址、状态(待开通、运行中、已暂停、已释放)、到期时间、备注。首期由运营在后台改状态和回填地址,不自动调用云厂商 API。 + +二开工单字段:需求说明、联系方式、关联工具、报价、状态(待受理、已报价、进行中、待验收、已完成、已关闭)、交付说明。用户在「我的服务」查看进度,运营在后台流转。 + +这两项是服务目录,用来接住「开源二开 + 云端部署」的收入,首期不做自动交付。 + +## 8. 工具与动态内容的字段 + +工具由后台创建,前台不提供用户投稿。 + +| 字段 | 说明 | +| --- | --- | +| 名称、slug、图标、简介、详细介绍 | 介绍支持安全的富文本(白名单标签) | +| 类型 | 接入工具 / 收录链接 | +| 分类、标签 | 多对多 | +| 打开地址 | 收录链接为外链;接入工具为该工具的启动地址 | +| 开发语言、仓库或备注 | 仅后台可见,便于识别二开项目 | +| 会员角标 | 免费 / 会员 / 企业,用于卡片展示 | +| 所需权益键 | 空表示登录即可用全部功能 | +| OAuth 客户端 | 仅接入工具;未配置客户端时「打开」按钮不可用 | +| 排序、是否推荐、上架状态、上架时间 | 首页分区另行挑选 | + +动态内容还包括:分类、标签、首页分区、轮播、公告、帮助文章、协议、导航、页脚、SEO 标题与描述。全部可排序、可下架。下架内容前台立即不可见,已打开的工具不因此被强制关闭。 + +搜索首期用 MySQL `FULLTEXT`(ngram 解析器,以支持中文)覆盖名称、简介、标签名。数据量上来后再换专门搜索引擎,接口保持「关键词进、工具列表出」。 + +## 9. 技术架构与约束 + +### 9.1 选型 + +| 层 | 选择 | +| --- | --- | +| 接口与账号中心 | PHP 8.4、Laravel 11(兼容 MySQL 5.7;不使用仅 MySQL 8 才有的窗口函数和 CTE) | +| 数据库 | MySQL 5.7,字符集 `utf8mb4`,排序规则 `utf8mb4_unicode_ci` | +| 缓存与会话 | Redis。生产环境账号会话、验证码计数、令牌黑名单放 Redis;本地可退回数据库驱动 | +| 前台与后台 | 一个 Vue 3 + TypeScript + Vite 工程,Vue Router、Pinia | +| 组件库 | Element Plus。门户用其表单、分页、对话框、消息;卡片和首页布局用自有样式,避免整个站点长成默认后台皮肤 | +| HTTP | 前台通过 Laravel API 取数,Laravel 提供 `/api` 与 `/oauth`。页面路由由 Vue 承接 | + +Laravel 11 同时支持 PHP 8.4 与 MySQL 5.7。业务 SQL 明确不依赖 MySQL 8 语法,避免以后迁库时才发现写死了版本特性。 + +### 9.2 目录 + +```text +qztool/ + backend/ Laravel 11:API、账号中心、示例工具 demo-tool + frontend/ Vue 3 门户 + admin/ Vue 3 后台 + README.md 本说明 +``` + +本地启动(MySQL 5.7 库名 `qztool`,连接写在 `backend/.env`): + +```text +cd backend && php artisan serve --host=127.0.0.1 --port=8000 +cd frontend && npm run dev +cd admin && npm run dev +cd backend/demo-tool && php -S 127.0.0.1:8090 index.php +``` + +门户 http://127.0.0.1:5173 ,后台 http://127.0.0.1:5174 。本地短信驱动是 `debug`,验证码为 `123456`。体验用户 `13900139000` / `User123456`,超级管理员 `13800138000` / `Admin123456`。示例工具的客户端密钥写在 `backend/demo-tool/config.php`,只用于本机演示。 + +工具仓库不放进本项目。每个工具独立部署,只按第 6 节接入。 + +### 9.3 接口风格 + +- 门户与后台:JSON API,`/api/v1/...`,后台加 `/api/v1/admin/...`。 +- 认证:账号中心会话 Cookie(同站)签发后,SPA 使用 HttpOnly 会话,CSRF 沿用 Laravel 的 SPA 方案(`/sanctum/csrf-cookie` 一类流程,实现时按所选官方包落地)。 +- 工具换票:走 OAuth 端点,不复用后台管理员令牌。 +- 错误体统一:`code`、`message`、`errors`(字段级)。业务失败用 4xx,不把堆栈返回给浏览器。 +- 时间统一存 UTC,接口返回 ISO 8601,前端按浏览器时区显示。 + +### 9.4 关键数据表(逻辑模型) + +实现时可以拆分或补索引,职责保持如下: + +- `users`:状态、昵称、权益版本、禁用原因 +- `user_identities`:类型(email/phone)、标识、是否已验证 +- `password_resets` / `verification_codes`:只存哈希、过期时间、场景(登录、注册、绑定、找回) +- `user_sessions`:账号中心会话、设备摘要、过期时间 +- `oauth_clients`、`oauth_auth_codes`、`oauth_access_tokens`、`oauth_refresh_tokens`:标准授权码流程所需数据;刷新令牌存哈希 +- `categories`、`tags`、`tools`、`tool_tag`、`tool_category` +- `home_sections`、`home_section_tools`、`banners`、`navigations` +- `articles`、`agreements` +- `plans`、`plan_entitlements`、`subscriptions` +- `orders`、`order_items` +- `service_tickets`、`deployments` +- `login_logs`、`audit_logs`、`message_logs` + +手机号若需满足更严的存储要求,可在 `user_identities` 对标识再做应用层加密,并另存用于登录查找的哈希。默认先明文存手机号(访问受权限控制),等你明确要做静态加密再加,避免首期无法按手机号查询。 + +## 10. 权限 + +| 能力 | 访客 | 用户 | 会员 | 运营 | 超级管理员 | +| --- | --- | --- | --- | --- | --- | +| 浏览上架内容 | 是 | 是 | 是 | 是 | 是 | +| 登录并打开免费工具 | 否 | 是 | 是 | 是 | 是 | +| 使用付费权益 | 否 | 否 | 按方案 | 可被代开 | 可被代开 | +| 改自己的资料与会话 | 否 | 是 | 是 | 是 | 是 | +| 后台内容与工具 | 否 | 否 | 否 | 是 | 是 | +| 改用户状态、代开会员、重置密钥 | 否 | 否 | 否 | 否 | 是 | + +运营与超级管理员是后台角色,用独立权限表,不把「是会员」当成后台权限。超级管理员账号由部署时的初始化命令创建,不在注册页开放。 + +## 11. 非功能要求 + +- 门户首页在常规宽带下可交互时间以 3 秒内为目标;工具列表接口分页,默认 24 条。 +- 验证码、登录、换票接口按 IP 与账号限流。 +- 管理操作写审计:操作者、对象、动作、前后差异摘要。 +- 富文本入库前过滤脚本与事件属性。 +- 外链卡片使用 `rel="noopener noreferrer"`。 +- 生产关闭调试、固定调试验证码和详细错误页。 +- 备份:MySQL 每日全量。上传文件若首期不用对象存储,则只允许后台上传图标和文章图片到 `storage`,并限制类型与大小(图片 2MB 内,png/jpg/webp/svg)。 + +## 12. 分期 + +### 一期(确认后立刻做) + +- Laravel API、Vue 门户与后台骨架、登录注册(密码加密提交 + 手机验证码)。 +- 分类、工具、标签、首页分区、文章、公告的动态配置。 +- 账号中心按第 6 节签发授权码;附一个最小示例工具(独立端口)验证「登录一次,两个站点识别为同一用户,并读到会员等级」。 +- 会员方案、订阅状态、后台代开通;订单可创建,支付标为线下确认。 +- 个人中心:资料、安全、会话、会员、订单。 + +### 二期 + +- 微信支付或支付宝(以你指定的渠道为准)、续费与升级差价。 +- 邮件验证码全流程、头像上传。 +- 额度查询接口、工具侧扣减约定。 +- 云端实例与二开工单的前台进度。 + +### 三期 + +- 团队席位。 +- 云厂商自动开通。 +- 更细的数据统计与搜索引擎替换。 + +## 13. 验收时怎么算做对 + +一期完成时,应能手工走通: + +1. 后台新建分类和两个接入工具,前台首页与分类页按排序出现,下架后消失。 +2. 用手机验证码注册,再用同一手机号密码登录,进入同一账号。 +3. 绑定邮箱后,邮箱+密码也能进入该账号。抓包可见密码字段为密文,数据库中密码为哈希。 +4. 未登录点击「打开工具」,登录后落到该工具,工具页显示同一用户编号和昵称。 +5. 再打开第二个工具,不再输入密码,仍是该用户。 +6. 后台把用户开通为会员,15 分钟内(或重新打开工具后)第二个工具能读到新等级。 +7. 在轻舟退出后,两个工具的刷新令牌失效,再次打开需要登录。 +8. 运营账号能改文章并在前台看到;普通用户访问 `/admin` 被拒绝。 + +## 14. 明确不做(除非你另提) + +- 用户自行提交导航、评论、社区、积分商城。 +- 把百度、Google 等搜索引擎嵌进首页。 +- 网盘、盗版资源、未授权软件下载类栏目。 +- 工具之间直接互通数据库。 +- 微信扫码等第三方登录。 +- 首期自动云主机开通、自动开票。 + +## 15. 请你确认的默认决定 + +下面按推荐方案写死。回复「按推荐开发」即按此开工;若要改,直接指出序号和新决定即可。 + +1. **品牌与范围**:前台品牌为「轻舟」。门户借鉴参考站的分类 + 搜索 + 卡片,产品主体是统一账号和可运营的工具服务。 +2. **工具形态**:同时支持「接入工具」和「收录链接」。只有接入工具共享登录与会员。 +3. **登录**:邮箱或手机号 + 密码(RSA-OAEP 后提交,Argon2id 存储);手机号 + 短信验证码。验证码登录默认可自动注册。 +4. **暂不接入**:微信登录、邮箱验证码可先留接口,SMTP 未配置时关闭邮箱注册。 +5. **单点登录**:OAuth 2.0 授权码 + PKCE。工具不同域名、不同语言都走这条协议。登录与「有没有会员」分开:没有权益也能登录,付费功能再拦截。 +6. **会员**:免费 / 会员 / 企业三档,价格与权益后台配置。首期支付用「待支付 + 后台确认收款」。 +7. **云部署与二开**:一期只做工单和实例记录,运营手工改状态。 +8. **工程**:`server`(Laravel 11、PHP 8.4、MySQL 5.7)+ `web`(Vue 3、TypeScript、Element Plus)。SQL 保持 5.7 兼容。 +9. **短信**:抽象成驱动,供应商与密钥你在开工时提供;未提供前仅本地调试码。 +10. **示例工具**:一期用一个独立的小型示例站点证明两个工具共用同一账号和会员信息。真实业务工具名单你后续给出再接入。 + +确认后下一步是:建库表与接口契约,再实现一期页面和示例工具,并用第 13 节的路径做验收。 diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..d70bb9c --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.DS_Store +*.local diff --git a/admin/env.d.ts b/admin/env.d.ts new file mode 100644 index 0000000..65c7311 --- /dev/null +++ b/admin/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..c9fa7c5 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,12 @@ + + + + + + 轻舟管理后台 + + +
+ + + diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..c10e0c7 --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,2056 @@ +{ + "name": "qztool-admin", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "qztool-admin", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.11.0", + "element-plus": "^2.11.0", + "pinia": "^3.0.3", + "vue": "^3.5.22", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "@vitejs/plugin-vue": "^6.0.1", + "typescript": "^5.9.2", + "vite": "^7.1.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.13", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz", + "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.3.0.tgz", + "integrity": "sha512-6V0+8vw/GmH92URQ/Ki12FPhoB3nqaQUJE+5xgbFbB5ok0OXYK5jCQsQQoyR3BzCddHsCpyCFRnIcI1PSOVYkQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.1.0.tgz", + "integrity": "sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.68.1.tgz", + "integrity": "sha512-67xgK/taQ7w18anfSxH4dy3RVwiu7GFT8aZglHVUnYisqIe4Qf1xQW7hWxe3lB7R5XSVfPJJKdKP31eGMID2zw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.68.1.tgz", + "integrity": "sha512-nGmSABuQDWUGPgGOy3VJygaIQl9/2SMPEwwR2+W+uTxit+MoZcve1y2ebaO9Dhg/MZXMeeoYL+HPGq8X5LTzgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-x64/-/rollup-android-x64-4.68.1.tgz", + "integrity": "sha512-Es4xgvucd6umxthid6WojNNYHLsfB6oySkLypUn4VXxR7IuK2TS2sdPB6+3U/mw/uU7ahQk9aw7fDUI6y2GkKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.68.1.tgz", + "integrity": "sha512-biPWTUPYDWejnysuD4A1kJ4mjT121duL6WJ8JJ0lyFWuLSnC91WF6C/X63CSz7z8yJIQEXixlHOpPK/VzPQ3Gw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.68.1.tgz", + "integrity": "sha512-EhxsI1TfSp3Lvyc9W6Y4o9W/zR43zaUgDsENK3hCix/OP/jrGc1CAxNhgmA28SJTsODpAv9ZBYYgP1rO27dfRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.68.1.tgz", + "integrity": "sha512-p6s+MBrmkj41sOH4no/z1g/5E446H9//kUblCvZY/uy+NWijHT+0/F6sAcRqI3l3RWLyeW6kHl0IqZ0+p3VjrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.68.1.tgz", + "integrity": "sha512-sm2K6aFA1tn+5gH59LLsm3SbNJ736cXOZ3IcpmxsUxuAEn6h04LFKWk3ji/Jjw1IEDdZ8QhgdxLPzGHnnOD4fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.68.1.tgz", + "integrity": "sha512-kirHmtPRCh5dVC6v8/9i/ldS60r6TYLiGunHCXpba/4ws1SPyvdW4PYkRLOxpAfYC+A9r2Km2zOLpm4cMty1Rg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.68.1.tgz", + "integrity": "sha512-TR1ec0+aZylxhD9/4UkelsSiCQ5EhtHjzty0LXKiMwwyWa4rgF55bIGExYqxp/+BwCH4GEV8F/EZB9JYwLDTdw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.68.1.tgz", + "integrity": "sha512-dxsv4BC45+TGKImva8fIgVdvuJ1/o2Bov+sGjPX/RFcDp/UtXNJ7K5/6UTS0fbAw0P7kTIvH5M0h6ViS5CP9xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.68.1.tgz", + "integrity": "sha512-/ATJPUYJUdlm1/Zil0IyU9x14zyBXicfoOvNbvHAUH4c8IjU5xNBCtnDteTD+xd08Ym6fdGVrdZLLThYRvsZOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.68.1.tgz", + "integrity": "sha512-Bp17x/tj1Fi45pNE4A654jkKAiSUgB6UjQuPltwT+e2jfYw7TfaYzQNGgbzx0MNH27eYQDNVUNxGk9m9L/xUaA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.68.1.tgz", + "integrity": "sha512-QvECQp0CyvIhiAv9mn1ouYZfIcDBIRdZek7M/ZYqRtiWzpiKAPu2gnGD13yNtI0KykSPk41szQitphAbHfHdvQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.68.1.tgz", + "integrity": "sha512-+ACzFhkXUiYvXLebVBb4ge8gr7EKM/xGDW+J8cmnwv8SSlYyoKXD8Es3OpOPFnHLV5vevmrqlQgCP/qQxr69JQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.68.1.tgz", + "integrity": "sha512-/wMEhoQ0Ithx4CrmhMogO57CKyt0LRB+2A7i97v+GjBDGLh+uhTNNSlIm1NixaTEwMXyiIIZ3bXVkSh4jfAhKg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.68.1.tgz", + "integrity": "sha512-N92PYV7Ulw6pOS16eE32Q/ykWSj2ly+ojYX/IHCsD9+FIj7n10qjeiPhdht7r8UTnCpYdmrGf7VeNonovOvuQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.68.1.tgz", + "integrity": "sha512-ab332+PwM7/sgbqRlE0BYb2eGNme8Rs1T/2Ob9mYSwrQqK7a1zawpLVYrXtnnRBPQn4TNGGtwaBy6toClPU7aQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.68.1.tgz", + "integrity": "sha512-zaQ2AHQP401CBAa+qhWLLTGU78MQ6nsB0RfWYbw5+AUWNLjp9juAokzZbjtyiXf7CBe8JfBNV7g2lNwbxmLKFg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.68.1.tgz", + "integrity": "sha512-J2KJofHPtGP7AZQUbRfylo7f1ZKdSjY2XG5VXjDwh+Mu2oM/bJN53mDWSjBGzNU3AGaXBqIxIhEAa0tSHQdO6w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.68.1.tgz", + "integrity": "sha512-WMXzHCmeAhFpAwP2w3ugGucdDp5jYJm8fwLO1e9uUtUQXhceWGfobu1E+seQ/zSixxGB+foAX8cs3iiebF+QjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.68.1.tgz", + "integrity": "sha512-PZadv8s8G/omPyl31lvtraKxlM//Ad0P32kLBBHGjImR31vCXZkiDNqnIQCZ6gXTZ5xN5GkTGhzZdJzMz3EOnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.68.1.tgz", + "integrity": "sha512-eXI1iou+f8g5iJFGnF9koInoopknJdulR51phO8ggVNGyTT2Ya68M7scDLYpIOTWaTN9cDPxRJ1o0d6ce/P2cQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.68.1.tgz", + "integrity": "sha512-la8czh2p2DlfCC+/XgVbxItS01f2/1ljPSy+U/Z1j5sWtlQv+Ammx04gP2w5MZWsGuKD/r85cTWudlASulQMEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.68.1.tgz", + "integrity": "sha512-WMc++sC1YObbiuITB73IYD6Neejvz0591glNh9kpJj2ZUA3YkwtYzVVXXkADyyGhttdJkJndR/dd4XKKxCHO+A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.68.1.tgz", + "integrity": "sha512-jDDAn+OoI7R63WRCsJDMLa9v4Qii/zTrnstftTy8+1kVZpZBJFSF6K6PDyot51Wh9wTcjh0zUU9dHztVNo1+uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.68.1.tgz", + "integrity": "sha512-kR8W3osyXy6mB3EaaTsC2FTAdgqZgRsOXXs31aIvC/KjbxeMqDon9l3Xyajo1ynQuS1JHlMhkYyqL10VKA3VZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.27", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.27.tgz", + "integrity": "sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.10", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25 || ^3.6.0-rc.10" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.45.tgz", + "integrity": "sha512-XGHJrM6ufZARAc/w+tyOswStw9tdKiqCKBScI4gGaNxD+aaGu/HQWgKJ+JItjRLPqenQ7iE7G8AJC7T5ew1p+g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.12", + "@vue/shared": "3.5.45", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.45.tgz", + "integrity": "sha512-LbP2d19GSOYGPmvMBBCVJVlv7TKtjM1Cfeyu3cYia720pknZP+1cLX7H5MVatvruT6MuJhiPuNbQBW/MDn/EwA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.45", + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.45.tgz", + "integrity": "sha512-MKFjb2UX0M/l/jhfzqMLrI8VM4pfvFKG3pqFZkSZFHRLtNld/O4RtUPZFWSwhro/14qP88UQEkWzHxAZAQ0tsw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.12", + "@vue/compiler-core": "3.5.45", + "@vue/compiler-dom": "3.5.45", + "@vue/compiler-ssr": "3.5.45", + "@vue/shared": "3.5.45", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.32", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.45.tgz", + "integrity": "sha512-RF5sbZOfYz5AK00tOJUn2PwFXR2fS1u2HVzI8D9VF9rGAFxuGBqg2bB1kJ3ZHMprqSltXnT9MZE1IsqjSM8oew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.45", + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.45.tgz", + "integrity": "sha512-WqkyubNWpI4OYBOipSrjIssMdJO8K1i9Zts/Wm8Er10/0msOEwCyWWWvZnAI7WW3kQsTong6DJp8Cbb+vd4cPA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.45.tgz", + "integrity": "sha512-bzIO3+FABjpj85uIKSc+Qnj36/sxDNI3tcH9i6w6xKA1k6e0ejv0G0jMCfeJBvK3yGL5XbJ3DMZj+ClqyCHFYw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.45", + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.45.tgz", + "integrity": "sha512-ZvRTFMDgfqTDYWvJfoVpQTJx1RN5M6l7Pyd6wfdY2NaXzbPQVh9ezeQYFbNPP6bIcRVn2SqSSR25N/jSW69BFw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.45", + "@vue/runtime-core": "3.5.45", + "@vue/shared": "3.5.45", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.45.tgz", + "integrity": "sha512-1fNXUHhhsWf4EVL4XixbGBU5mKdiIXJAB82feHRTyPPo6ZUEYWIJuEeUl3mVV6QDBp50HJOX4obLhxYXlrjrzA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.45", + "@vue/runtime-dom": "3.5.45", + "@vue/shared": "3.5.45" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.45.tgz", + "integrity": "sha512-ue9fgHmVNPlP7iW3+cxoBX/Cnlw53dHlVSw0JKGY6z1lObg8pHoj928M2nzG4/yVsMZ3NaRZ2s2z61uC16NDwQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "15.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-15.0.0.tgz", + "integrity": "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "15.0.0", + "@vueuse/shared": "15.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "15.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-15.0.0.tgz", + "integrity": "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "15.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-15.0.0.tgz", + "integrity": "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.21.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.21.0.tgz", + "integrity": "sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.1.5.tgz", + "integrity": "sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.8", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.8.tgz", + "integrity": "sha512-Z4Br45W9b8vWKvVHSTvbS6rRKmsLjmHCPXICJR6osl35+i+S2LCBs4NN8apLBkQiUh0pzS9labK5eCS7yzDhWw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.2", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "15.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.11" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.3.tgz", + "integrity": "sha512-toOSoqN6d51Wjf1aOFJYcoVv1aND9rj/CwjLU0Vz40oDuSpWDayCeKe4JpL5NL0WdFvoOOCI3BRo69HahjbR1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.68.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.68.1.tgz", + "integrity": "sha512-kjVU1iN2BbQeFPFTpmUtl1bR/DbMl2HPmiSLYpx6XLromTaMkidR+mlRMdS/Doovm6uKlwigyxMcHRhRe1vZ5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.68.1", + "@rollup/rollup-android-arm64": "4.68.1", + "@rollup/rollup-android-x64": "4.68.1", + "@rollup/rollup-darwin-arm64": "4.68.1", + "@rollup/rollup-darwin-x64": "4.68.1", + "@rollup/rollup-freebsd-arm64": "4.68.1", + "@rollup/rollup-freebsd-x64": "4.68.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.68.1", + "@rollup/rollup-linux-arm-musleabihf": "4.68.1", + "@rollup/rollup-linux-arm64-gnu": "4.68.1", + "@rollup/rollup-linux-arm64-musl": "4.68.1", + "@rollup/rollup-linux-loong64-gnu": "4.68.1", + "@rollup/rollup-linux-loong64-musl": "4.68.1", + "@rollup/rollup-linux-ppc64-gnu": "4.68.1", + "@rollup/rollup-linux-ppc64-musl": "4.68.1", + "@rollup/rollup-linux-riscv64-gnu": "4.68.1", + "@rollup/rollup-linux-riscv64-musl": "4.68.1", + "@rollup/rollup-linux-s390x-gnu": "4.68.1", + "@rollup/rollup-linux-x64-gnu": "4.68.1", + "@rollup/rollup-linux-x64-musl": "4.68.1", + "@rollup/rollup-openbsd-x64": "4.68.1", + "@rollup/rollup-openharmony-arm64": "4.68.1", + "@rollup/rollup-win32-arm64-msvc": "4.68.1", + "@rollup/rollup-win32-ia32-msvc": "4.68.1", + "@rollup/rollup-win32-x64-gnu": "4.68.1", + "@rollup/rollup-win32-x64-msvc": "4.68.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.45", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.45.tgz", + "integrity": "sha512-3cvsib8m9c0vB24+MDKsgcW4+Da4xaBl9Z/5Tbm2pmSIsRPxcil4cdXjXlVsmJ2g64XJo5h+IRd7rsdGT9pZaw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.45", + "@vue/compiler-sfc": "3.5.45", + "@vue/runtime-dom": "3.5.45", + "@vue/server-renderer": "3.5.45", + "@vue/shared": "3.5.45" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.14", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.14.tgz", + "integrity": "sha512-Pq3+OnbCiPCHk0R7KSZrx+j/XXaUn2kSQ1epxq60w8CPbByLjC1EJgV51n4WY851sTXUVzsNMuitywdkdVyKjA==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..8fbb5ca --- /dev/null +++ b/admin/package.json @@ -0,0 +1,24 @@ +{ + "name": "qztool-admin", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.11.0", + "element-plus": "^2.11.0", + "@element-plus/icons-vue": "^2.3.2", + "pinia": "^3.0.3", + "vue": "^3.5.22", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "@vitejs/plugin-vue": "^6.0.1", + "typescript": "^5.9.2", + "vite": "^7.1.5" + } +} diff --git a/admin/src/App.vue b/admin/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/admin/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/admin/src/api/crypto.ts b/admin/src/api/crypto.ts new file mode 100644 index 0000000..ebe5f7e --- /dev/null +++ b/admin/src/api/crypto.ts @@ -0,0 +1,11 @@ +export async function encryptPassword(pem: string, password: string) { + const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '') + const raw = Uint8Array.from(atob(b64), (char) => char.charCodeAt(0)) + const key = await crypto.subtle.importKey('spki', raw, { name: 'RSA-OAEP', hash: 'SHA-1' }, false, ['encrypt']) + const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, new TextEncoder().encode(password)) + let binary = '' + new Uint8Array(encrypted).forEach((item) => { + binary += String.fromCharCode(item) + }) + return btoa(binary) +} diff --git a/admin/src/api/http.ts b/admin/src/api/http.ts new file mode 100644 index 0000000..dc12d19 --- /dev/null +++ b/admin/src/api/http.ts @@ -0,0 +1,7 @@ +import axios from 'axios' + +export const http = axios.create({ baseURL: '/', withCredentials: true, timeout: 20000 }) + +export async function ensureCsrf() { + await http.get('/sanctum/csrf-cookie') +} diff --git a/admin/src/layouts/AdminLayout.vue b/admin/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..00ff2f4 --- /dev/null +++ b/admin/src/layouts/AdminLayout.vue @@ -0,0 +1,42 @@ + + + diff --git a/admin/src/main.ts b/admin/src/main.ts new file mode 100644 index 0000000..407e98b --- /dev/null +++ b/admin/src/main.ts @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import 'element-plus/dist/index.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) +app.mount('#app') diff --git a/admin/src/router/index.ts b/admin/src/router/index.ts new file mode 100644 index 0000000..6ba6040 --- /dev/null +++ b/admin/src/router/index.ts @@ -0,0 +1,38 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: () => import('@/views/LoginView.vue') }, + { + path: '/', + component: () => import('@/layouts/AdminLayout.vue'), + meta: { auth: true }, + children: [ + { path: '', component: () => import('@/views/DashboardView.vue') }, + { path: 'categories', component: () => import('@/views/CategoriesView.vue') }, + { path: 'tools', component: () => import('@/views/ToolsView.vue') }, + { path: 'sections', component: () => import('@/views/SectionsView.vue') }, + { path: 'content', component: () => import('@/views/ContentView.vue') }, + { path: 'users', component: () => import('@/views/UsersView.vue') }, + { path: 'plans', component: () => import('@/views/PlansView.vue') }, + { path: 'orders', component: () => import('@/views/OrdersView.vue') }, + { path: 'services', component: () => import('@/views/ServicesView.vue') }, + { path: 'clients', component: () => import('@/views/ClientsView.vue') }, + { path: 'settings', component: () => import('@/views/SettingsView.vue') }, + { path: 'audits', component: () => import('@/views/AuditsView.vue') }, + ], + }, + ], +}) + +router.beforeEach(async (to) => { + const auth = useAuthStore() + if (!auth.loaded) await auth.fetchMe() + const staff = auth.user && ['operator', 'super_admin'].includes(auth.user.role) + if (to.meta.auth && !staff) return '/login' + if (to.path === '/login' && staff) return '/' +}) + +export default router diff --git a/admin/src/stores/auth.ts b/admin/src/stores/auth.ts new file mode 100644 index 0000000..563435b --- /dev/null +++ b/admin/src/stores/auth.ts @@ -0,0 +1,22 @@ +import { defineStore } from 'pinia' +import { http } from '@/api/http' + +export const useAuthStore = defineStore('admin-auth', { + state: () => ({ user: null as null | { id: number; nickname: string; role: string }, loaded: false }), + actions: { + async fetchMe() { + try { + const { data } = await http.get('/api/v1/me') + this.user = data.data + } catch { + this.user = null + } finally { + this.loaded = true + } + }, + async logout() { + await http.post('/api/v1/auth/logout') + this.user = null + }, + }, +}) diff --git a/admin/src/views/AuditsView.vue b/admin/src/views/AuditsView.vue new file mode 100644 index 0000000..94d52a8 --- /dev/null +++ b/admin/src/views/AuditsView.vue @@ -0,0 +1,18 @@ + + + diff --git a/admin/src/views/CategoriesView.vue b/admin/src/views/CategoriesView.vue new file mode 100644 index 0000000..bdc9adf --- /dev/null +++ b/admin/src/views/CategoriesView.vue @@ -0,0 +1,65 @@ + + + diff --git a/admin/src/views/ClientsView.vue b/admin/src/views/ClientsView.vue new file mode 100644 index 0000000..1c30aa4 --- /dev/null +++ b/admin/src/views/ClientsView.vue @@ -0,0 +1,19 @@ + + + diff --git a/admin/src/views/ContentView.vue b/admin/src/views/ContentView.vue new file mode 100644 index 0000000..9345776 --- /dev/null +++ b/admin/src/views/ContentView.vue @@ -0,0 +1,79 @@ + + + diff --git a/admin/src/views/DashboardView.vue b/admin/src/views/DashboardView.vue new file mode 100644 index 0000000..76b6f60 --- /dev/null +++ b/admin/src/views/DashboardView.vue @@ -0,0 +1,25 @@ + + + diff --git a/admin/src/views/LoginView.vue b/admin/src/views/LoginView.vue new file mode 100644 index 0000000..95ebe4e --- /dev/null +++ b/admin/src/views/LoginView.vue @@ -0,0 +1,52 @@ + + + diff --git a/admin/src/views/OrdersView.vue b/admin/src/views/OrdersView.vue new file mode 100644 index 0000000..98dd825 --- /dev/null +++ b/admin/src/views/OrdersView.vue @@ -0,0 +1,38 @@ + + + diff --git a/admin/src/views/PlansView.vue b/admin/src/views/PlansView.vue new file mode 100644 index 0000000..7110fe9 --- /dev/null +++ b/admin/src/views/PlansView.vue @@ -0,0 +1,19 @@ + + + diff --git a/admin/src/views/SectionsView.vue b/admin/src/views/SectionsView.vue new file mode 100644 index 0000000..4d848fc --- /dev/null +++ b/admin/src/views/SectionsView.vue @@ -0,0 +1,56 @@ + + + diff --git a/admin/src/views/ServicesView.vue b/admin/src/views/ServicesView.vue new file mode 100644 index 0000000..a90c731 --- /dev/null +++ b/admin/src/views/ServicesView.vue @@ -0,0 +1,43 @@ + + + diff --git a/admin/src/views/SettingsView.vue b/admin/src/views/SettingsView.vue new file mode 100644 index 0000000..b624759 --- /dev/null +++ b/admin/src/views/SettingsView.vue @@ -0,0 +1,27 @@ + + + diff --git a/admin/src/views/ToolsView.vue b/admin/src/views/ToolsView.vue new file mode 100644 index 0000000..db3366c --- /dev/null +++ b/admin/src/views/ToolsView.vue @@ -0,0 +1,105 @@ + + + diff --git a/admin/src/views/UsersView.vue b/admin/src/views/UsersView.vue new file mode 100644 index 0000000..28e05fc --- /dev/null +++ b/admin/src/views/UsersView.vue @@ -0,0 +1,42 @@ + + + diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 0000000..d2c6c6a --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { "@/*": ["./src/*"] }, + "types": ["vite/client"] + }, + "include": ["env.d.ts", "src/**/*.ts", "src/**/*.vue"] +} diff --git a/admin/vite.config.ts b/admin/vite.config.ts new file mode 100644 index 0000000..7bafc49 --- /dev/null +++ b/admin/vite.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + host: '127.0.0.1', + port: 5174, + proxy: { + '/api': 'http://127.0.0.1:8000', + '/sanctum': 'http://127.0.0.1:8000', + }, + }, +}) diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..8f0de65 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7b49625 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,64 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_TIMEZONE=UTC +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +APP_MAINTENANCE_STORE=database + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..7fe978f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,19 @@ +/.phpunit.cache +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/vendor +.env +.env.backup +.env.production +.phpunit.result.cache +Homestead.json +Homestead.yaml +auth.json +npm-debug.log +yarn-error.log +/.fleet +/.idea +/.vscode diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1a4c26b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,66 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com/)** +- **[Tighten Co.](https://tighten.co)** +- **[WebReinvent](https://webreinvent.com/)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel/)** +- **[Cyber-Duck](https://cyber-duck.co.uk)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Jump24](https://jump24.co.uk)** +- **[Redberry](https://redberry.international/laravel/)** +- **[Active Logic](https://activelogic.com)** +- **[byte5](https://byte5.de)** +- **[OP.GG](https://op.gg)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/backend/app/Http/Controllers/Api/V1/AccountController.php b/backend/app/Http/Controllers/Api/V1/AccountController.php new file mode 100644 index 0000000..8d9dc0b --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/AccountController.php @@ -0,0 +1,187 @@ +user(); + $recent = UserToolOpen::query()->with('tool.tags')->where('user_id', $user->id)->orderByDesc('last_opened_at')->limit(8)->get() + ->filter(fn ($row) => $row->tool && $row->tool->status === 'published') + ->map(fn ($row) => Presenter::toolCard($row->tool))->values(); + + return $this->ok([ + 'user' => Presenter::user($user), + 'recent_tools' => $recent, + 'pending_orders' => Order::query()->where('user_id', $user->id)->where('status', 'pending')->count(), + ]); + } + + public function updateProfile(Request $request) + { + $data = $request->validate(['nickname' => ['required', 'string', 'min:2', 'max:40']]); + $request->user()->update(['nickname' => $data['nickname']]); + + return $this->ok(Presenter::user($request->user()->refresh()), '资料已更新'); + } + + public function updatePassword(Request $request) + { + $data = $request->validate([ + 'old_password' => ['nullable', 'string'], + 'password' => ['required', 'string'], + ]); + $this->auth->changePassword( + $request->user(), + isset($data['old_password']) ? $this->auth->decrypt($data['old_password']) : null, + $this->auth->decrypt($data['password']) + ); + + return $this->ok(null, '密码已更新,其他工具需要重新登录'); + } + + public function bind(Request $request) + { + $data = $request->validate([ + 'channel' => ['required', 'in:sms,email'], + 'account' => ['required', 'string'], + 'code' => ['required', 'string'], + ]); + $this->auth->bind($request->user(), $data['channel'], $data['account'], $data['code']); + + return $this->ok(Presenter::user($request->user()->refresh()), '绑定成功'); + } + + public function sessions(Request $request) + { + $current = $request->session()->getId(); + $rows = UserSession::query()->where('user_id', $request->user()->id)->whereNull('revoked_at')->orderByDesc('last_seen_at')->get(); + + return $this->ok($rows->map(fn (UserSession $row) => [ + 'id' => $row->id, + 'ip' => $row->ip, + 'user_agent' => $row->user_agent, + 'last_seen_at' => $row->last_seen_at?->toIso8601String(), + 'current' => $row->session_id === $current, + ])->values()); + } + + public function revokeSession(Request $request, UserSession $session) + { + $this->owns($request, $session); + $current = $session->session_id === $request->session()->getId(); + $session->update(['revoked_at' => now()]); + DB::table('sessions')->where('id', $session->session_id)->delete(); + if ($current) { + $this->auth->logout($request, false); + } + + return $this->ok(['logged_out' => $current], '会话已下线'); + } + + public function revokeAll(Request $request) + { + $this->auth->logout($request, true); + + return $this->ok(null, '已全部下线'); + } + + public function membership(Request $request) + { + return $this->ok($this->membership->snapshot($request->user())); + } + + public function orders(Request $request) + { + $rows = Order::query()->where('user_id', $request->user()->id)->latest()->limit(50)->get(); + + return $this->ok($rows->map(fn (Order $order) => $this->orderPayload($order))->values()); + } + + public function storeOrder(Request $request) + { + $data = $request->validate(['plan_id' => ['required', 'integer']]); + $plan = Plan::query()->where('status', 'published')->findOrFail($data['plan_id']); + if ($plan->price_cents <= 0) { + return response()->json(['message' => '免费方案无需下单', 'code' => 'free_plan'], 422); + } + $order = Order::query()->create([ + 'order_no' => 'QZ'.now()->format('YmdHis').random_int(1000, 9999), + 'user_id' => $request->user()->id, + 'type' => 'subscription', + 'status' => 'pending', + 'amount_cents' => $plan->price_cents, + 'title' => $plan->name, + ]); + $order->items()->create([ + 'plan_id' => $plan->id, + 'title' => $plan->name, + 'amount_cents' => $plan->price_cents, + ]); + + return $this->ok($this->orderPayload($order), '订单已提交,等待确认收款'); + } + + public function services(Request $request) + { + $userId = $request->user()->id; + + return $this->ok([ + 'tickets' => ServiceTicket::query()->with('tool:id,name')->where('user_id', $userId)->latest()->get(), + 'deployments' => Deployment::query()->with('tool:id,name')->where('user_id', $userId)->latest()->get(), + ]); + } + + public function storeTicket(Request $request) + { + $data = $request->validate([ + 'contact' => ['required', 'string', 'max:120'], + 'requirement' => ['required', 'string', 'max:5000'], + 'tool_id' => ['nullable', 'integer', 'exists:tools,id'], + ]); + $ticket = ServiceTicket::query()->create($data + [ + 'user_id' => $request->user()->id, + 'status' => 'pending', + ]); + + return $this->ok($ticket, '需求已提交'); + } + + private function owns(Request $request, UserSession $session): void + { + abort_unless($session->user_id === $request->user()->id && $session->revoked_at === null, 404); + } + + private function orderPayload(Order $order): array + { + return [ + 'id' => $order->id, + 'order_no' => $order->order_no, + 'type' => $order->type, + 'status' => $order->status, + 'amount_cents' => $order->amount_cents, + 'title' => $order->title, + 'paid_at' => $order->paid_at?->toIso8601String(), + 'created_at' => $order->created_at?->toIso8601String(), + ]; + } +} diff --git a/backend/app/Http/Controllers/Api/V1/Admin/CatalogController.php b/backend/app/Http/Controllers/Api/V1/Admin/CatalogController.php new file mode 100644 index 0000000..b9cec42 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/Admin/CatalogController.php @@ -0,0 +1,269 @@ +ok(Category::query()->orderByDesc('sort')->orderBy('id')->get()); + } + + public function storeCategory(Request $request) + { + $category = Category::query()->create($this->categoryData($request)); + $this->audit($request->user(), 'category.create', $category, '新建分类 '.$category->name); + + return $this->ok($category); + } + + public function updateCategory(Request $request, Category $category) + { + $category->update($this->categoryData($request, $category->id)); + $this->audit($request->user(), 'category.update', $category, '更新分类 '.$category->name); + + return $this->ok($category); + } + + public function destroyCategory(Request $request, Category $category) + { + if ($category->children()->exists()) { + return response()->json(['message' => '请先删除子分类', 'code' => 'has_children'], 422); + } + $category->tools()->detach(); + $category->delete(); + $this->audit($request->user(), 'category.delete', $category, '删除分类 '.$category->name); + + return $this->ok(null, '已删除'); + } + + public function tags() + { + return $this->ok(Tag::query()->orderBy('name')->get()); + } + + public function storeTag(Request $request) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:40'], + 'slug' => ['required', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', 'unique:tags,slug'], + ]); + $tag = Tag::query()->create($data); + $this->audit($request->user(), 'tag.create', $tag, '新建标签 '.$tag->name); + + return $this->ok($tag); + } + + public function updateTag(Request $request, Tag $tag) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:40'], + 'slug' => ['required', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('tags', 'slug')->ignore($tag->id)], + ]); + $tag->update($data); + $this->audit($request->user(), 'tag.update', $tag, '更新标签 '.$tag->name); + + return $this->ok($tag); + } + + public function destroyTag(Request $request, Tag $tag) + { + $tag->tools()->detach(); + $tag->delete(); + $this->audit($request->user(), 'tag.delete', $tag, '删除标签 '.$tag->name); + + return $this->ok(null, '已删除'); + } + + public function tools(Request $request) + { + $query = Tool::query()->with('categories:id,name', 'tags:id,name'); + if ($keyword = trim((string) $request->query('q', ''))) { + $query->where('name', 'like', '%'.addcslashes($keyword, '%_\\').'%'); + } + $page = $query->orderByDesc('sort')->orderByDesc('id')->paginate(15); + + return $this->ok([ + 'items' => collect($page->items())->map(fn (Tool $tool) => $this->toolPayload($tool))->values(), + 'total' => $page->total(), + 'page' => $page->currentPage(), + ]); + } + + public function storeTool(Request $request) + { + $data = $this->toolData($request); + if ($data['status'] === 'published') { + $data['published_at'] = now(); + } + $tool = Tool::query()->create($data); + $this->syncTool($tool, $request); + $this->audit($request->user(), 'tool.create', $tool, '新建工具 '.$tool->name); + + return $this->ok($this->toolPayload($tool->load('categories:id,name', 'tags:id,name'))); + } + + public function updateTool(Request $request, Tool $tool) + { + $data = $this->toolData($request, $tool->id); + if ($data['status'] === 'published' && ! $tool->published_at) { + $data['published_at'] = now(); + } + $tool->update($data); + $this->syncTool($tool, $request); + $this->audit($request->user(), 'tool.update', $tool, '更新工具 '.$tool->name); + + return $this->ok($this->toolPayload($tool->load('categories:id,name', 'tags:id,name'))); + } + + public function destroyTool(Request $request, Tool $tool) + { + if ($tool->oauthClients()->exists()) { + return response()->json(['message' => '工具仍被登录客户端引用', 'code' => 'in_use'], 422); + } + $tool->categories()->detach(); + $tool->tags()->detach(); + $tool->delete(); + $this->audit($request->user(), 'tool.delete', $tool, '删除工具 '.$tool->name); + + return $this->ok(null, '已删除'); + } + + public function sections() + { + return $this->ok(HomeSection::query()->with('tools:id,name')->orderByDesc('sort')->get()->map(function (HomeSection $section) { + return [ + 'id' => $section->id, + 'title' => $section->title, + 'slug' => $section->slug, + 'sort' => $section->sort, + 'display_limit' => $section->display_limit, + 'status' => $section->status, + 'tool_ids' => $section->tools->pluck('id')->values(), + 'tools' => $section->tools->map(fn ($tool) => ['id' => $tool->id, 'name' => $tool->name])->values(), + ]; + })); + } + + public function storeSection(Request $request) + { + $section = HomeSection::query()->create($this->sectionData($request)); + $this->syncSection($section, $request); + $this->audit($request->user(), 'section.create', $section, '新建分区 '.$section->title); + + return $this->ok($section->load('tools:id,name')); + } + + public function updateSection(Request $request, HomeSection $section) + { + $section->update($this->sectionData($request, $section->id)); + $this->syncSection($section, $request); + $this->audit($request->user(), 'section.update', $section, '更新分区 '.$section->title); + + return $this->ok($section->load('tools:id,name')); + } + + public function destroySection(Request $request, HomeSection $section) + { + $section->tools()->detach(); + $section->delete(); + $this->audit($request->user(), 'section.delete', $section, '删除分区 '.$section->title); + + return $this->ok(null, '已删除'); + } + + private function categoryData(Request $request, ?int $id = null): array + { + return $request->validate([ + 'parent_id' => ['nullable', 'integer', 'exists:categories,id'], + 'name' => ['required', 'string', 'max:80'], + 'slug' => ['required', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('categories', 'slug')->ignore($id)], + 'icon' => ['nullable', 'string', 'max:40'], + 'sort' => ['nullable', 'integer'], + 'show_in_nav' => ['required', 'boolean'], + 'status' => ['required', 'in:published,hidden'], + ]); + } + + private function toolData(Request $request, ?int $id = null): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:80'], + 'slug' => ['required', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('tools', 'slug')->ignore($id)], + 'icon' => ['nullable', 'string', 'max:255'], + 'summary' => ['required', 'string', 'max:255'], + 'content' => ['nullable', 'string'], + 'type' => ['required', 'in:integrated,link'], + 'open_url' => ['required', 'url', 'max:500'], + 'language' => ['nullable', 'string', 'max:40'], + 'internal_note' => ['nullable', 'string'], + 'badge' => ['required', 'in:free,member,enterprise'], + 'required_entitlement' => ['nullable', 'string', 'max:120'], + 'sort' => ['nullable', 'integer'], + 'is_recommended' => ['required', 'boolean'], + 'status' => ['required', 'in:draft,published'], + ]); + $data['content'] = HtmlSanitizer::clean($data['content'] ?? ''); + $data['sort'] = $data['sort'] ?? 0; + + return $data; + } + + private function syncTool(Tool $tool, Request $request): void + { + $ids = $request->validate([ + 'category_ids' => ['array'], + 'category_ids.*' => ['integer', 'exists:categories,id'], + 'tag_ids' => ['array'], + 'tag_ids.*' => ['integer', 'exists:tags,id'], + ]); + $tool->categories()->sync($ids['category_ids'] ?? []); + $tool->tags()->sync($ids['tag_ids'] ?? []); + } + + private function toolPayload(Tool $tool): array + { + return $tool->only([ + 'id', 'name', 'slug', 'icon', 'summary', 'content', 'type', 'open_url', 'language', + 'internal_note', 'badge', 'required_entitlement', 'sort', 'is_recommended', 'status', 'clicks', + ]) + [ + 'category_ids' => $tool->categories->pluck('id')->values(), + 'tag_ids' => $tool->tags->pluck('id')->values(), + 'categories' => $tool->categories->map(fn ($item) => ['id' => $item->id, 'name' => $item->name])->values(), + 'tags' => $tool->tags->map(fn ($item) => ['id' => $item->id, 'name' => $item->name])->values(), + ]; + } + + private function sectionData(Request $request, ?int $id = null): array + { + return $request->validate([ + 'title' => ['required', 'string', 'max:80'], + 'slug' => ['required', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('home_sections', 'slug')->ignore($id)], + 'sort' => ['nullable', 'integer'], + 'display_limit' => ['nullable', 'integer', 'min:1', 'max:24'], + 'status' => ['required', 'in:published,hidden'], + ]); + } + + private function syncSection(HomeSection $section, Request $request): void + { + $ids = $request->validate([ + 'tool_ids' => ['array'], + 'tool_ids.*' => ['integer', 'exists:tools,id'], + ])['tool_ids'] ?? []; + $sync = []; + foreach (array_values($ids) as $index => $toolId) { + $sync[$toolId] = ['sort' => $index]; + } + $section->tools()->sync($sync); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/Admin/CommerceController.php b/backend/app/Http/Controllers/Api/V1/Admin/CommerceController.php new file mode 100644 index 0000000..31a5e3d --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/Admin/CommerceController.php @@ -0,0 +1,176 @@ +ok(Plan::query()->with('entitlements')->orderByDesc('sort')->get()->map(fn (Plan $plan) => Presenter::plan($plan))); + } + + public function storePlan(Request $request) + { + $plan = DB::transaction(function () use ($request) { + $plan = Plan::query()->create($this->planData($request)); + $this->syncEntitlements($plan, $request); + + return $plan->load('entitlements'); + }); + $this->audit($request->user(), 'plan.create', $plan, '新建方案 '.$plan->name); + + return $this->ok(Presenter::plan($plan)); + } + + public function updatePlan(Request $request, Plan $plan) + { + DB::transaction(function () use ($request, $plan) { + $plan->update($this->planData($request, $plan->id)); + $this->syncEntitlements($plan, $request); + }); + $this->audit($request->user(), 'plan.update', $plan, '更新方案 '.$plan->name); + + return $this->ok(Presenter::plan($plan->load('entitlements'))); + } + + public function orders(Request $request) + { + $query = Order::query()->with('user:id,nickname')->latest(); + if ($status = $request->query('status')) { + $query->where('status', $status); + } + + return $this->ok($query->limit(100)->get()->map(fn (Order $order) => [ + 'id' => $order->id, + 'order_no' => $order->order_no, + 'title' => $order->title, + 'type' => $order->type, + 'status' => $order->status, + 'amount_cents' => $order->amount_cents, + 'user' => $order->user?->nickname, + 'remark' => $order->remark, + 'created_at' => $order->created_at?->toIso8601String(), + ])); + } + + public function confirmOrder(Request $request, Order $order) + { + $data = $request->validate(['remark' => ['nullable', 'string', 'max:255']]); + if (isset($data['remark'])) { + $order->remark = $data['remark']; + $order->save(); + } + $this->membership->confirmOrder($order->fresh()); + $this->audit($request->user(), 'order.confirm', $order, '确认收款 '.$order->order_no); + + return $this->ok(null, '已确认收款'); + } + + public function closeOrder(Request $request, Order $order) + { + if ($order->status !== 'pending') { + return response()->json(['message' => '只能关闭待支付订单', 'code' => 'invalid_status'], 422); + } + $order->update(['status' => 'closed']); + $this->audit($request->user(), 'order.close', $order, '关闭订单 '.$order->order_no); + + return $this->ok(null, '订单已关闭'); + } + + public function tickets() + { + return $this->ok(ServiceTicket::query()->with('user:id,nickname', 'tool:id,name')->latest()->limit(100)->get()); + } + + public function updateTicket(Request $request, ServiceTicket $ticket) + { + $data = $request->validate([ + 'status' => ['required', 'in:pending,quoted,doing,checking,done,closed'], + 'quote_cents' => ['nullable', 'integer', 'min:0'], + 'delivery_note' => ['nullable', 'string'], + ]); + $ticket->update($data); + $this->audit($request->user(), 'ticket.update', $ticket, '更新工单 #'.$ticket->id); + + return $this->ok($ticket); + } + + public function deployments() + { + return $this->ok(Deployment::query()->with('user:id,nickname', 'tool:id,name')->latest()->limit(100)->get()); + } + + public function storeDeployment(Request $request) + { + $data = $this->deploymentData($request); + $deployment = Deployment::query()->create($data); + $this->audit($request->user(), 'deployment.create', $deployment, '新建云端实例 #'.$deployment->id); + + return $this->ok($deployment); + } + + public function updateDeployment(Request $request, Deployment $deployment) + { + $deployment->update($this->deploymentData($request)); + $this->audit($request->user(), 'deployment.update', $deployment, '更新云端实例 #'.$deployment->id); + + return $this->ok($deployment); + } + + private function planData(Request $request, ?int $id = null): array + { + return $request->validate([ + 'code' => ['required', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('plans', 'code')->ignore($id)], + 'name' => ['required', 'string', 'max:80'], + 'price_cents' => ['required', 'integer', 'min:0'], + 'period' => ['required', 'in:month,year,lifetime'], + 'description' => ['nullable', 'string', 'max:255'], + 'sort' => ['nullable', 'integer'], + 'status' => ['required', 'in:published,hidden'], + ]); + } + + private function syncEntitlements(Plan $plan, Request $request): void + { + $items = $request->validate([ + 'entitlements' => ['array'], + 'entitlements.*.key' => ['required', 'string', 'max:120'], + 'entitlements.*.value' => ['required', 'string', 'max:120'], + ])['entitlements'] ?? []; + $plan->entitlements()->delete(); + foreach ($items as $item) { + PlanEntitlement::query()->create([ + 'plan_id' => $plan->id, + 'entitlement_key' => $item['key'], + 'entitlement_value' => $item['value'], + ]); + } + } + + private function deploymentData(Request $request): array + { + return $request->validate([ + 'user_id' => ['required', 'integer', 'exists:users,id'], + 'tool_id' => ['nullable', 'integer', 'exists:tools,id'], + 'spec' => ['nullable', 'string', 'max:120'], + 'access_url' => ['nullable', 'string', 'max:500'], + 'status' => ['required', 'in:pending,running,paused,released'], + 'ends_at' => ['nullable', 'date'], + 'remark' => ['nullable', 'string', 'max:255'], + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/Admin/ContentController.php b/backend/app/Http/Controllers/Api/V1/Admin/ContentController.php new file mode 100644 index 0000000..935b6ba --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/Admin/ContentController.php @@ -0,0 +1,166 @@ +ok(Article::query()->latest('id')->limit(100)->get()); + } + + public function storeArticle(Request $request) + { + $article = Article::query()->create($this->articleData($request)); + $this->audit($request->user(), 'article.create', $article, '新建文章 '.$article->title); + + return $this->ok($article); + } + + public function updateArticle(Request $request, Article $article) + { + $data = $this->articleData($request, $article->id); + if ($data['status'] === 'published' && ! $article->published_at) { + $data['published_at'] = now(); + } + $article->update($data); + $this->audit($request->user(), 'article.update', $article, '更新文章 '.$article->title); + + return $this->ok($article); + } + + public function destroyArticle(Request $request, Article $article) + { + $article->delete(); + $this->audit($request->user(), 'article.delete', $article, '删除文章 '.$article->title); + + return $this->ok(null, '已删除'); + } + + public function banners() + { + return $this->ok(Banner::query()->orderByDesc('sort')->get()); + } + + public function storeBanner(Request $request) + { + $banner = Banner::query()->create($this->bannerData($request)); + $this->audit($request->user(), 'banner.create', $banner, '新建轮播 '.$banner->title); + + return $this->ok($banner); + } + + public function updateBanner(Request $request, Banner $banner) + { + $banner->update($this->bannerData($request)); + $this->audit($request->user(), 'banner.update', $banner, '更新轮播 '.$banner->title); + + return $this->ok($banner); + } + + public function destroyBanner(Request $request, Banner $banner) + { + $banner->delete(); + $this->audit($request->user(), 'banner.delete', $banner, '删除轮播 '.$banner->title); + + return $this->ok(null, '已删除'); + } + + public function navigations() + { + return $this->ok(Navigation::query()->orderBy('group_name')->orderByDesc('sort')->get()); + } + + public function storeNavigation(Request $request) + { + $item = Navigation::query()->create($this->navData($request)); + $this->audit($request->user(), 'nav.create', $item, '新建导航 '.$item->title); + + return $this->ok($item); + } + + public function updateNavigation(Request $request, Navigation $navigation) + { + $navigation->update($this->navData($request)); + $this->audit($request->user(), 'nav.update', $navigation, '更新导航 '.$navigation->title); + + return $this->ok($navigation); + } + + public function destroyNavigation(Request $request, Navigation $navigation) + { + $navigation->delete(); + $this->audit($request->user(), 'nav.delete', $navigation, '删除导航 '.$navigation->title); + + return $this->ok(null, '已删除'); + } + + public function agreements() + { + return $this->ok(Agreement::query()->orderBy('type')->get()); + } + + public function updateAgreement(Request $request, Agreement $agreement) + { + $data = $request->validate([ + 'title' => ['required', 'string', 'max:120'], + 'body' => ['required', 'string'], + ]); + $data['body'] = HtmlSanitizer::clean($data['body']); + $agreement->update($data); + $this->audit($request->user(), 'agreement.update', $agreement, '更新协议 '.$agreement->title); + + return $this->ok($agreement); + } + + private function articleData(Request $request, ?int $id = null): array + { + $data = $request->validate([ + 'type' => ['required', 'in:help,announcement,page'], + 'title' => ['required', 'string', 'max:160'], + 'slug' => ['required', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('articles', 'slug')->ignore($id)], + 'summary' => ['nullable', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'status' => ['required', 'in:draft,published'], + ]); + $data['body'] = HtmlSanitizer::clean($data['body']); + if ($data['status'] === 'published') { + $data['published_at'] = now(); + } + + return $data; + } + + private function bannerData(Request $request): array + { + return $request->validate([ + 'title' => ['required', 'string', 'max:120'], + 'image' => ['nullable', 'string', 'max:500'], + 'link' => ['nullable', 'string', 'max:500'], + 'sort' => ['nullable', 'integer'], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date'], + 'status' => ['required', 'in:published,hidden'], + ]); + } + + private function navData(Request $request): array + { + return $request->validate([ + 'group_name' => ['required', 'in:header,footer'], + 'title' => ['required', 'string', 'max:80'], + 'link' => ['required', 'string', 'max:500'], + 'sort' => ['nullable', 'integer'], + 'status' => ['required', 'in:published,hidden'], + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/Admin/PeopleController.php b/backend/app/Http/Controllers/Api/V1/Admin/PeopleController.php new file mode 100644 index 0000000..c60af41 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/Admin/PeopleController.php @@ -0,0 +1,89 @@ +with('identities', 'subscription.plan'); + if ($keyword = trim((string) $request->query('q', ''))) { + $like = '%'.addcslashes($keyword, '%_\\').'%'; + $query->where(function ($q) use ($like) { + $q->where('nickname', 'like', $like) + ->orWhereHas('identities', fn ($identity) => $identity->where('identifier', 'like', $like)); + }); + } + $page = $query->latest()->paginate(15); + + return $this->ok([ + 'items' => collect($page->items())->map(fn (User $user) => Presenter::user($user, true))->values(), + 'total' => $page->total(), + 'page' => $page->currentPage(), + ]); + } + + public function updateUser(Request $request, User $user) + { + $data = $request->validate([ + 'status' => ['required', 'in:active,disabled'], + 'disabled_reason' => ['nullable', 'string', 'max:255'], + ]); + if ($user->id === $request->user()->id && $data['status'] !== 'active') { + return response()->json(['message' => '不能停用当前登录账号', 'code' => 'self'], 422); + } + $user->update($data); + if ($data['status'] !== 'active') { + $this->auth->revokeTokens($user); + } + $this->audit($request->user(), 'user.update', $user, '更新用户状态 '.$user->nickname); + + return $this->ok(Presenter::user($user->refresh(), true)); + } + + public function grant(Request $request, User $user) + { + $data = $request->validate([ + 'plan_code' => ['required', 'string'], + 'days' => ['required', 'integer', 'min:0', 'max:3650'], + ]); + if ($data['days'] === 0) { + $this->membership->stop($user); + $this->audit($request->user(), 'membership.stop', $user, '停用会员 '.$user->nickname); + + return $this->ok(Presenter::user($user->refresh(), true), '会员已停用'); + } + $plan = Plan::query()->where('code', $data['plan_code'])->firstOrFail(); + $this->membership->grant($user, $plan, now()->addDays($data['days'])); + $this->audit($request->user(), 'membership.grant', $user, '开通 '.$plan->name.' '.$data['days'].' 天'); + + return $this->ok(Presenter::user($user->refresh(), true), '会员已开通'); + } + + public function audits() + { + $rows = AuditLog::query()->with('actor:id,nickname')->latest()->limit(100)->get(); + + return $this->ok($rows->map(fn (AuditLog $row) => [ + 'id' => $row->id, + 'action' => $row->action, + 'summary' => $row->summary, + 'actor' => $row->actor?->nickname, + 'created_at' => $row->created_at?->toIso8601String(), + ])); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/Admin/SystemController.php b/backend/app/Http/Controllers/Api/V1/Admin/SystemController.php new file mode 100644 index 0000000..487d771 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/Admin/SystemController.php @@ -0,0 +1,123 @@ +ok([ + 'users' => User::query()->count(), + 'members' => Subscription::query()->where('status', 'active')->where('ends_at', '>', now())->count(), + 'logins_today' => LoginLog::query()->where('success', true)->where('created_at', '>=', today())->count(), + 'opens_today' => ToolOpenLog::query()->where('created_at', '>=', today())->count(), + 'pending_orders' => Order::query()->where('status', 'pending')->count(), + 'open_tickets' => ServiceTicket::query()->whereIn('status', ['pending', 'quoted', 'doing', 'checking'])->count(), + ]); + } + + public function clients() + { + return $this->ok(OauthClient::query()->with('tool:id,name')->latest()->get()->map(fn (OauthClient $client) => $this->clientPayload($client))); + } + + public function storeClient(Request $request) + { + $data = $this->clientData($request); + $secret = Str::random(32); + $client = OauthClient::query()->create($data + ['secret_hash' => hash('sha256', $secret)]); + $this->audit($request->user(), 'client.create', $client, '新建客户端 '.$client->name); + + return $this->ok($this->clientPayload($client) + ['secret' => $secret], '密钥只显示一次'); + } + + public function updateClient(Request $request, OauthClient $client) + { + $client->update($this->clientData($request, $client->id)); + $this->audit($request->user(), 'client.update', $client, '更新客户端 '.$client->name); + + return $this->ok($this->clientPayload($client)); + } + + public function resetSecret(Request $request, OauthClient $client) + { + $secret = Str::random(32); + $client->update(['secret_hash' => hash('sha256', $secret)]); + $this->audit($request->user(), 'client.secret', $client, '重置客户端密钥 '.$client->name); + + return $this->ok(['secret' => $secret], '新密钥只显示一次'); + } + + public function settings() + { + return $this->ok(Setting::query()->orderBy('key')->get()); + } + + public function updateSettings(Request $request) + { + $data = $request->validate([ + 'items' => ['required', 'array'], + 'items.site_name' => ['nullable', 'string', 'max:40'], + 'items.site_description' => ['nullable', 'string', 'max:255'], + 'items.icp' => ['nullable', 'string', 'max:80'], + 'items.grace_days' => ['nullable', 'integer', 'min:0', 'max:30'], + 'items.sms_register_creates_user' => ['nullable', 'in:0,1'], + 'items.mail_enabled' => ['nullable', 'in:0,1'], + ]); + foreach ($data['items'] as $key => $value) { + Setting::query()->updateOrCreate(['key' => $key], ['value' => (string) $value]); + } + Settings::flush(); + $this->audit($request->user(), 'settings.update', null, '更新系统设置'); + + return $this->ok(Setting::query()->orderBy('key')->get(), '设置已保存'); + } + + private function clientData(Request $request, ?int $id = null): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:80'], + 'client_id' => ['required', 'regex:/^[a-z0-9_\-]+$/', Rule::unique('oauth_clients', 'client_id')->ignore($id)], + 'redirect_uris' => ['required', 'array', 'min:1'], + 'redirect_uris.*' => ['url', 'max:500'], + 'logout_uris' => ['nullable', 'array'], + 'logout_uris.*' => ['url', 'max:500'], + 'tool_id' => ['nullable', 'integer', 'exists:tools,id'], + 'is_first_party' => ['required', 'boolean'], + 'is_public' => ['required', 'boolean'], + 'status' => ['required', 'in:active,disabled'], + ]); + + return $data; + } + + private function clientPayload(OauthClient $client): array + { + return [ + 'id' => $client->id, + 'name' => $client->name, + 'client_id' => $client->client_id, + 'redirect_uris' => $client->redirect_uris, + 'logout_uris' => $client->logout_uris ?? [], + 'tool_id' => $client->tool_id, + 'tool_name' => $client->tool?->name, + 'is_first_party' => $client->is_first_party, + 'is_public' => $client->is_public, + 'status' => $client->status, + ]; + } +} diff --git a/backend/app/Http/Controllers/Api/V1/AuthController.php b/backend/app/Http/Controllers/Api/V1/AuthController.php new file mode 100644 index 0000000..801f2cc --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/AuthController.php @@ -0,0 +1,116 @@ +ok(['pem' => $this->auth->publicKeyPem()]); + } + + public function captcha() + { + return $this->ok($this->auth->captcha()); + } + + public function sendCode(Request $request) + { + $data = $request->validate([ + 'scene' => ['required', 'in:register,login,reset,bind'], + 'channel' => ['required', 'in:sms,email'], + 'account' => ['required', 'string', 'max:191'], + ]); + + return $this->ok($this->auth->sendCode($data['scene'], $data['channel'], $data['account'], (string) $request->ip())); + } + + public function register(Request $request) + { + $data = $request->validate([ + 'channel' => ['required', 'in:sms,email'], + 'account' => ['required', 'string'], + 'code' => ['required', 'string'], + 'password' => ['required', 'string'], + 'nickname' => ['nullable', 'string', 'max:40'], + ]); + $user = $this->auth->register( + $data['channel'], + $data['account'], + $data['code'], + $this->auth->decrypt($data['password']), + $data['nickname'] ?? null, + $request + ); + + return $this->ok(Presenter::user($user), '注册成功'); + } + + public function login(Request $request) + { + $data = $request->validate([ + 'account' => ['required', 'string'], + 'password' => ['required', 'string'], + 'captcha_key' => ['nullable', 'string'], + 'captcha_code' => ['nullable', 'string'], + ]); + $user = $this->auth->loginWithPassword( + $data['account'], + $this->auth->decrypt($data['password']), + $data['captcha_key'] ?? null, + $data['captcha_code'] ?? null, + $request + ); + + return $this->ok(Presenter::user($user), '登录成功'); + } + + public function loginSms(Request $request) + { + $data = $request->validate([ + 'account' => ['required', 'string'], + 'code' => ['required', 'string'], + ]); + $user = $this->auth->loginWithSms($data['account'], $data['code'], $request); + + return $this->ok(Presenter::user($user), '登录成功'); + } + + public function forgot(Request $request) + { + $data = $request->validate([ + 'account' => ['required', 'string'], + 'code' => ['required', 'string'], + 'password' => ['required', 'string'], + ]); + $this->auth->resetPassword($data['account'], $data['code'], $this->auth->decrypt($data['password'])); + + return $this->ok(null, '密码已重置'); + } + + public function logout(Request $request) + { + $this->auth->logout($request, true); + + return $this->ok(null, '已退出'); + } + + public function me(Request $request) + { + $user = $request->user(); + if (! $user) { + return response()->json(['message' => '未登录', 'code' => 'unauthenticated', 'data' => null], 401); + } + $session = $user->sessions()->where('session_id', $request->session()->getId())->first(); + $session?->update(['last_seen_at' => now()]); + + return $this->ok(Presenter::user($user)); + } +} diff --git a/backend/app/Http/Controllers/Api/V1/PortalController.php b/backend/app/Http/Controllers/Api/V1/PortalController.php new file mode 100644 index 0000000..085893a --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/PortalController.php @@ -0,0 +1,255 @@ +with(['children' => fn ($q) => $q->where('status', 'published')]) + ->whereNull('parent_id') + ->where('status', 'published') + ->where('show_in_nav', true) + ->orderByDesc('sort') + ->orderBy('id') + ->get(); + + return $this->ok([ + 'site_name' => Settings::get('site_name', '轻舟'), + 'description' => Settings::get('site_description', ''), + 'icp' => Settings::get('icp', ''), + 'mail_enabled' => Settings::bool('mail_enabled'), + 'nav' => $this->nav('header'), + 'footer' => $this->nav('footer'), + 'categories' => $categories->map(fn (Category $category) => $this->categoryNode($category)), + ]); + } + + public function home() + { + $sections = HomeSection::query() + ->with(['tools' => fn ($q) => $q->published()->with('tags')]) + ->where('status', 'published') + ->orderByDesc('sort') + ->orderBy('id') + ->get(); + $banners = Banner::query()->where('status', 'published') + ->where(function ($q) { + $q->whereNull('starts_at')->orWhere('starts_at', '<=', now()); + }) + ->where(function ($q) { + $q->whereNull('ends_at')->orWhere('ends_at', '>=', now()); + }) + ->orderByDesc('sort')->get(); + $announcement = Article::query()->published()->where('type', 'announcement')->latest('published_at')->first(); + + return $this->ok([ + 'sections' => $sections->map(fn (HomeSection $section) => [ + 'id' => $section->id, + 'title' => $section->title, + 'slug' => $section->slug, + 'tools' => $section->tools->take($section->display_limit)->map(fn (Tool $tool) => Presenter::toolCard($tool))->values(), + ]), + 'banners' => $banners->map(fn (Banner $banner) => [ + 'id' => $banner->id, + 'title' => $banner->title, + 'image' => $banner->image, + 'link' => $banner->link, + ]), + 'announcement' => $announcement ? [ + 'title' => $announcement->title, + 'slug' => $announcement->slug, + 'summary' => $announcement->summary, + ] : null, + ]); + } + + public function category(Request $request, string $slug) + { + $category = Category::query()->with('children')->where('slug', $slug)->where('status', 'published')->firstOrFail(); + $ids = $category->children->pluck('id')->push($category->id); + $query = Tool::query()->published()->with('tags')->whereHas('categories', fn ($q) => $q->whereIn('categories.id', $ids)); + $this->applySort($query, (string) $request->query('sort', 'recommend')); + $page = $query->paginate(24); + + return $this->ok([ + 'category' => $this->categoryNode($category), + 'items' => collect($page->items())->map(fn (Tool $tool) => Presenter::toolCard($tool))->values(), + 'total' => $page->total(), + 'page' => $page->currentPage(), + 'per_page' => $page->perPage(), + ]); + } + + public function tool(string $slug) + { + $tool = Tool::query()->published()->with('tags', 'categories')->where('slug', $slug)->firstOrFail(); + $related = Tool::query()->published()->with('tags') + ->whereKeyNot($tool->id) + ->whereHas('categories', fn ($q) => $q->whereIn('categories.id', $tool->categories->pluck('id'))) + ->limit(4) + ->get(); + + return $this->ok(Presenter::toolDetail($tool) + [ + 'related' => $related->map(fn (Tool $item) => Presenter::toolCard($item))->values(), + ]); + } + + public function go(string $slug) + { + $tool = Tool::query()->published()->where('slug', $slug)->where('type', 'link')->firstOrFail(); + $tool->increment('clicks'); + + return redirect()->away($tool->open_url); + } + + public function search(Request $request) + { + $keyword = trim((string) $request->query('q', '')); + $scope = (string) $request->query('scope', 'tools'); + $like = '%'.addcslashes($keyword, '%_\\').'%'; + if ($scope === 'help') { + $query = Article::query()->published()->where('type', 'help'); + if ($keyword !== '') { + $query->where(fn ($q) => $q->where('title', 'like', $like)->orWhere('summary', 'like', $like)); + } + $page = $query->latest('published_at')->paginate(24); + + return $this->ok([ + 'items' => collect($page->items())->map(fn (Article $article) => [ + 'title' => $article->title, + 'slug' => $article->slug, + 'summary' => $article->summary, + ])->values(), + 'total' => $page->total(), + 'page' => $page->currentPage(), + 'per_page' => $page->perPage(), + ]); + } + if ($scope === 'recent') { + $user = $request->user(); + if (! $user) { + return response()->json(['message' => '请先登录', 'code' => 'unauthenticated'], 401); + } + $opens = UserToolOpen::query()->with('tool.tags')->where('user_id', $user->id)->orderByDesc('last_opened_at')->get(); + $items = $opens->filter(function (UserToolOpen $open) use ($keyword) { + return $open->tool && $open->tool->status === 'published' + && ($keyword === '' || str_contains($open->tool->name, $keyword) || str_contains($open->tool->summary, $keyword)); + })->map(fn (UserToolOpen $open) => Presenter::toolCard($open->tool))->values(); + + return $this->ok(['items' => $items, 'total' => $items->count(), 'page' => 1, 'per_page' => $items->count()]); + } + + $query = Tool::query()->published()->with('tags'); + if ($keyword !== '') { + $query->where(function ($q) use ($like) { + $q->where('name', 'like', $like) + ->orWhere('summary', 'like', $like) + ->orWhereHas('tags', fn ($tag) => $tag->where('name', 'like', $like)); + }); + } + $page = $query->orderByDesc('is_recommended')->orderByDesc('sort')->paginate(24); + $hot = $page->total() === 0 + ? Tool::query()->published()->with('tags')->orderByDesc('is_recommended')->limit(8)->get() + : collect(); + + return $this->ok([ + 'items' => collect($page->items())->map(fn (Tool $tool) => Presenter::toolCard($tool))->values(), + 'hot' => $hot->map(fn (Tool $tool) => Presenter::toolCard($tool))->values(), + 'total' => $page->total(), + 'page' => $page->currentPage(), + 'per_page' => $page->perPage(), + ]); + } + + public function plans() + { + $plans = Plan::query()->with('entitlements')->where('status', 'published')->orderByDesc('sort')->orderBy('id')->get(); + + return $this->ok($plans->map(fn (Plan $plan) => Presenter::plan($plan))->values()); + } + + public function articles(Request $request) + { + $type = (string) $request->query('type', 'help'); + $items = Article::query()->published()->where('type', $type)->latest('published_at')->get(); + + return $this->ok($items->map(fn (Article $article) => [ + 'title' => $article->title, + 'slug' => $article->slug, + 'summary' => $article->summary, + 'published_at' => $article->published_at?->toIso8601String(), + ])->values()); + } + + public function article(string $slug) + { + $article = Article::query()->published()->where('slug', $slug)->firstOrFail(); + + return $this->ok([ + 'title' => $article->title, + 'slug' => $article->slug, + 'summary' => $article->summary, + 'body' => $article->body, + 'type' => $article->type, + 'published_at' => $article->published_at?->toIso8601String(), + ]); + } + + public function agreement(string $type) + { + $agreement = Agreement::query()->where('type', $type)->firstOrFail(); + + return $this->ok([ + 'type' => $agreement->type, + 'title' => $agreement->title, + 'body' => $agreement->body, + ]); + } + + private function nav(string $group): array + { + return Navigation::query()->where('group_name', $group)->where('status', 'published')->orderByDesc('sort')->get() + ->map(fn (Navigation $item) => ['title' => $item->title, 'link' => $item->link])->all(); + } + + private function categoryNode(Category $category): array + { + return [ + 'id' => $category->id, + 'name' => $category->name, + 'slug' => $category->slug, + 'icon' => $category->icon, + 'children' => $category->children->map(fn (Category $child) => [ + 'id' => $child->id, + 'name' => $child->name, + 'slug' => $child->slug, + 'icon' => $child->icon, + ])->values(), + ]; + } + + private function applySort($query, string $sort): void + { + match ($sort) { + 'latest' => $query->orderByDesc('published_at'), + 'name' => $query->orderBy('name'), + default => $query->orderByDesc('is_recommended')->orderByDesc('sort'), + }; + } +} diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..81591f6 --- /dev/null +++ b/backend/app/Http/Controllers/Controller.php @@ -0,0 +1,25 @@ +json(['message' => $message, 'data' => $data], $status); + } + + protected function audit($actor, string $action, ?object $model, string $summary): void + { + AuditLog::query()->create([ + 'actor_id' => $actor?->id, + 'action' => $action, + 'subject_type' => $model ? $model::class : null, + 'subject_id' => $model->id ?? null, + 'summary' => $summary, + ]); + } +} diff --git a/backend/app/Http/Controllers/OauthController.php b/backend/app/Http/Controllers/OauthController.php new file mode 100644 index 0000000..c2e5dc9 --- /dev/null +++ b/backend/app/Http/Controllers/OauthController.php @@ -0,0 +1,123 @@ +validate([ + 'response_type' => ['required', 'in:code'], + 'client_id' => ['required', 'string'], + 'redirect_uri' => ['required', 'string'], + 'scope' => ['nullable', 'string'], + 'state' => ['required', 'string', 'max:200'], + 'nonce' => ['nullable', 'string', 'max:200'], + 'code_challenge' => ['required', 'string', 'max:200'], + 'code_challenge_method' => ['required', 'in:S256'], + 'confirm' => ['nullable'], + ]); + $client = OauthClient::query()->where('client_id', $data['client_id'])->where('status', 'active')->first(); + if (! $client || ! $client->allowsRedirect($data['redirect_uri'])) { + abort(400, '客户端或回调地址无效'); + } + if (! Auth::check()) { + $login = rtrim((string) config('qz.frontend_url'), '/').'/login?redirect='.urlencode($request->fullUrl()); + + return redirect()->away($login); + } + $user = $request->user(); + if ($user->status !== 'active') { + return redirect()->away($this->withQuery($data['redirect_uri'], [ + 'error' => 'access_denied', + 'state' => $data['state'], + ])); + } + $scopes = $this->oauth->scopes($data['scope'] ?? 'openid'); + if (! $client->is_first_party && ! $request->boolean('confirm')) { + return view('oauth.consent', [ + 'client' => $client, + 'scopes' => $scopes, + 'query' => $request->except(['_token', 'confirm']), + ]); + } + $code = $this->oauth->issueCode($client, $user, $data, $scopes); + + return redirect()->away($this->withQuery($data['redirect_uri'], [ + 'code' => $code, + 'state' => $data['state'], + ])); + } + + public function token(Request $request) + { + $client = $this->oauth->clientFromRequest($request); + if (! $client) { + return response()->json(['error' => 'invalid_client'], 401); + } + $result = match ($request->input('grant_type')) { + 'authorization_code' => $this->oauth->exchange($request, $client), + 'refresh_token' => $this->oauth->refresh($request, $client), + default => ['error' => 'unsupported_grant_type'], + }; + $status = isset($result['error']) ? 400 : 200; + + return response()->json($result, $status); + } + + public function userinfo(Request $request) + { + $token = $this->bearer($request); + $info = $token ? $this->oauth->userinfo($token) : null; + if (! $info) { + return response()->json(['error' => 'invalid_token'], 401); + } + + return response()->json($info); + } + + public function introspect(Request $request) + { + $client = $this->oauth->clientFromRequest($request); + if (! $client) { + return response()->json(['active' => false], 401); + } + + return response()->json($this->oauth->introspect((string) $request->input('token'))); + } + + public function revoke(Request $request) + { + $client = $this->oauth->clientFromRequest($request); + if (! $client) { + return response()->json(['error' => 'invalid_client'], 401); + } + $this->oauth->revoke((string) $request->input('token')); + + return response()->noContent(); + } + + private function bearer(Request $request): ?string + { + $header = (string) $request->header('Authorization'); + if (! str_starts_with($header, 'Bearer ')) { + return null; + } + + return trim(substr($header, 7)); + } + + private function withQuery(string $uri, array $params): string + { + $joiner = str_contains($uri, '?') ? '&' : '?'; + + return $uri.$joiner.http_build_query($params); + } +} diff --git a/backend/app/Http/Middleware/EnsureActive.php b/backend/app/Http/Middleware/EnsureActive.php new file mode 100644 index 0000000..550bbad --- /dev/null +++ b/backend/app/Http/Middleware/EnsureActive.php @@ -0,0 +1,23 @@ +user(); + if ($user && $user->status !== 'active') { + Auth::logout(); + + return response()->json(['message' => '账号已停用', 'code' => 'disabled'], 403); + } + + return $next($request); + } +} diff --git a/backend/app/Http/Middleware/EnsureAdmin.php b/backend/app/Http/Middleware/EnsureAdmin.php new file mode 100644 index 0000000..233138b --- /dev/null +++ b/backend/app/Http/Middleware/EnsureAdmin.php @@ -0,0 +1,21 @@ +user(); + $allowed = $roles !== [] ? $roles : ['operator', 'super_admin']; + if (! $user || ! in_array($user->role, $allowed, true)) { + return response()->json(['message' => '无权访问后台', 'code' => 'forbidden'], 403); + } + + return $next($request); + } +} diff --git a/backend/app/Models/Agreement.php b/backend/app/Models/Agreement.php new file mode 100644 index 0000000..9f90056 --- /dev/null +++ b/backend/app/Models/Agreement.php @@ -0,0 +1,10 @@ + 'datetime']; + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published'); + } +} diff --git a/backend/app/Models/AuditLog.php b/backend/app/Models/AuditLog.php new file mode 100644 index 0000000..bd07c16 --- /dev/null +++ b/backend/app/Models/AuditLog.php @@ -0,0 +1,16 @@ +belongsTo(User::class, 'actor_id'); + } +} diff --git a/backend/app/Models/Banner.php b/backend/app/Models/Banner.php new file mode 100644 index 0000000..7400b5a --- /dev/null +++ b/backend/app/Models/Banner.php @@ -0,0 +1,19 @@ + 'integer', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } +} diff --git a/backend/app/Models/Category.php b/backend/app/Models/Category.php new file mode 100644 index 0000000..088d9cb --- /dev/null +++ b/backend/app/Models/Category.php @@ -0,0 +1,33 @@ + 'boolean', 'sort' => 'integer']; + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Category::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(Category::class, 'parent_id')->orderByDesc('sort')->orderBy('id'); + } + + public function tools(): BelongsToMany + { + return $this->belongsToMany(Tool::class, 'category_tool'); + } +} diff --git a/backend/app/Models/Deployment.php b/backend/app/Models/Deployment.php new file mode 100644 index 0000000..102797d --- /dev/null +++ b/backend/app/Models/Deployment.php @@ -0,0 +1,26 @@ + 'datetime']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function tool(): BelongsTo + { + return $this->belongsTo(Tool::class); + } +} diff --git a/backend/app/Models/HomeSection.php b/backend/app/Models/HomeSection.php new file mode 100644 index 0000000..9aaa809 --- /dev/null +++ b/backend/app/Models/HomeSection.php @@ -0,0 +1,23 @@ + 'integer', 'display_limit' => 'integer']; + } + + public function tools(): BelongsToMany + { + return $this->belongsToMany(Tool::class, 'home_section_tool') + ->withPivot('sort') + ->orderByPivot('sort'); + } +} diff --git a/backend/app/Models/LoginLog.php b/backend/app/Models/LoginLog.php new file mode 100644 index 0000000..2899c0e --- /dev/null +++ b/backend/app/Models/LoginLog.php @@ -0,0 +1,15 @@ + 'boolean']; + } +} diff --git a/backend/app/Models/MessageLog.php b/backend/app/Models/MessageLog.php new file mode 100644 index 0000000..c18790c --- /dev/null +++ b/backend/app/Models/MessageLog.php @@ -0,0 +1,10 @@ + 'integer']; + } +} diff --git a/backend/app/Models/OauthAccessToken.php b/backend/app/Models/OauthAccessToken.php new file mode 100644 index 0000000..8666cf1 --- /dev/null +++ b/backend/app/Models/OauthAccessToken.php @@ -0,0 +1,33 @@ + 'array', + 'entitlement_version' => 'integer', + 'expires_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + public function client(): BelongsTo + { + return $this->belongsTo(OauthClient::class, 'oauth_client_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/OauthAuthCode.php b/backend/app/Models/OauthAuthCode.php new file mode 100644 index 0000000..b7a28c2 --- /dev/null +++ b/backend/app/Models/OauthAuthCode.php @@ -0,0 +1,33 @@ + 'array', + 'expires_at' => 'datetime', + 'consumed_at' => 'datetime', + ]; + } + + public function client(): BelongsTo + { + return $this->belongsTo(OauthClient::class, 'oauth_client_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/OauthClient.php b/backend/app/Models/OauthClient.php new file mode 100644 index 0000000..54e08be --- /dev/null +++ b/backend/app/Models/OauthClient.php @@ -0,0 +1,36 @@ + 'array', + 'logout_uris' => 'array', + 'is_first_party' => 'boolean', + 'is_public' => 'boolean', + ]; + } + + public function tool(): BelongsTo + { + return $this->belongsTo(Tool::class); + } + + public function allowsRedirect(string $uri): bool + { + return in_array($uri, $this->redirect_uris ?? [], true); + } +} diff --git a/backend/app/Models/OauthRefreshToken.php b/backend/app/Models/OauthRefreshToken.php new file mode 100644 index 0000000..6a183bf --- /dev/null +++ b/backend/app/Models/OauthRefreshToken.php @@ -0,0 +1,36 @@ + 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + public function accessToken(): BelongsTo + { + return $this->belongsTo(OauthAccessToken::class, 'oauth_access_token_id'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OauthClient::class, 'oauth_client_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/Order.php b/backend/app/Models/Order.php new file mode 100644 index 0000000..8e74703 --- /dev/null +++ b/backend/app/Models/Order.php @@ -0,0 +1,32 @@ + 'integer', + 'paid_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function items(): HasMany + { + return $this->hasMany(OrderItem::class); + } +} diff --git a/backend/app/Models/OrderItem.php b/backend/app/Models/OrderItem.php new file mode 100644 index 0000000..f0ab6bb --- /dev/null +++ b/backend/app/Models/OrderItem.php @@ -0,0 +1,26 @@ + 'integer']; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } +} diff --git a/backend/app/Models/Plan.php b/backend/app/Models/Plan.php new file mode 100644 index 0000000..0c1bf8d --- /dev/null +++ b/backend/app/Models/Plan.php @@ -0,0 +1,21 @@ + 'integer', 'sort' => 'integer']; + } + + public function entitlements(): HasMany + { + return $this->hasMany(PlanEntitlement::class); + } +} diff --git a/backend/app/Models/PlanEntitlement.php b/backend/app/Models/PlanEntitlement.php new file mode 100644 index 0000000..1d66244 --- /dev/null +++ b/backend/app/Models/PlanEntitlement.php @@ -0,0 +1,16 @@ +belongsTo(Plan::class); + } +} diff --git a/backend/app/Models/ServiceTicket.php b/backend/app/Models/ServiceTicket.php new file mode 100644 index 0000000..5fa9249 --- /dev/null +++ b/backend/app/Models/ServiceTicket.php @@ -0,0 +1,28 @@ + 'integer']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function tool(): BelongsTo + { + return $this->belongsTo(Tool::class); + } +} diff --git a/backend/app/Models/Setting.php b/backend/app/Models/Setting.php new file mode 100644 index 0000000..a71cb8c --- /dev/null +++ b/backend/app/Models/Setting.php @@ -0,0 +1,16 @@ + 'datetime', + 'ends_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } +} diff --git a/backend/app/Models/Tag.php b/backend/app/Models/Tag.php new file mode 100644 index 0000000..426de5b --- /dev/null +++ b/backend/app/Models/Tag.php @@ -0,0 +1,16 @@ +belongsToMany(Tool::class, 'tool_tag'); + } +} diff --git a/backend/app/Models/Tool.php b/backend/app/Models/Tool.php new file mode 100644 index 0000000..3062fe0 --- /dev/null +++ b/backend/app/Models/Tool.php @@ -0,0 +1,47 @@ + 'boolean', + 'sort' => 'integer', + 'clicks' => 'integer', + 'published_at' => 'datetime', + ]; + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published'); + } + + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class, 'category_tool'); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'tool_tag'); + } + + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } +} diff --git a/backend/app/Models/ToolOpenLog.php b/backend/app/Models/ToolOpenLog.php new file mode 100644 index 0000000..51004d4 --- /dev/null +++ b/backend/app/Models/ToolOpenLog.php @@ -0,0 +1,10 @@ + 'hashed', + 'entitlement_version' => 'integer', + ]; + } + + public function identities(): HasMany + { + return $this->hasMany(UserIdentity::class); + } + + public function sessions(): HasMany + { + return $this->hasMany(UserSession::class); + } + + public function subscription(): HasOne + { + return $this->hasOne(Subscription::class); + } + + public function isStaff(): bool + { + return in_array($this->role, ['operator', 'super_admin'], true); + } +} diff --git a/backend/app/Models/UserIdentity.php b/backend/app/Models/UserIdentity.php new file mode 100644 index 0000000..c0d6cd5 --- /dev/null +++ b/backend/app/Models/UserIdentity.php @@ -0,0 +1,21 @@ + 'datetime']; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/UserSession.php b/backend/app/Models/UserSession.php new file mode 100644 index 0000000..2e6bf94 --- /dev/null +++ b/backend/app/Models/UserSession.php @@ -0,0 +1,24 @@ + 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/UserToolOpen.php b/backend/app/Models/UserToolOpen.php new file mode 100644 index 0000000..6c5a201 --- /dev/null +++ b/backend/app/Models/UserToolOpen.php @@ -0,0 +1,24 @@ + 'integer', + 'last_opened_at' => 'datetime', + ]; + } + + public function tool(): BelongsTo + { + return $this->belongsTo(Tool::class); + } +} diff --git a/backend/app/Models/VerificationCode.php b/backend/app/Models/VerificationCode.php new file mode 100644 index 0000000..0a3f438 --- /dev/null +++ b/backend/app/Models/VerificationCode.php @@ -0,0 +1,18 @@ + 'datetime', + 'consumed_at' => 'datetime', + ]; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/backend/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ +privateKey())); + + return $details['key']; + } + + public function decrypt(string $cipher): string + { + $raw = base64_decode($cipher, true); + if ($raw === false) { + throw ValidationException::withMessages(['password' => '密码格式无效']); + } + $ok = openssl_private_decrypt($raw, $plain, $this->privateKey(), OPENSSL_PKCS1_OAEP_PADDING); + if (! $ok || ! is_string($plain) || $plain === '') { + throw ValidationException::withMessages(['password' => '密码无法解密']); + } + + return $plain; + } + + public function assertPassword(string $plain): void + { + $longEnough = mb_strlen($plain) >= 8; + $hasLetter = preg_match('/\p{L}/u', $plain) === 1; + $hasDigit = preg_match('/\d/', $plain) === 1; + if (! $longEnough || ! $hasLetter || ! $hasDigit) { + throw ValidationException::withMessages(['password' => '密码至少 8 位,并同时包含字母和数字']); + } + } + + public function captcha(): array + { + $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + $code = ''; + for ($i = 0; $i < 4; $i++) { + $code .= $alphabet[random_int(0, strlen($alphabet) - 1)]; + } + $key = Str::random(40); + Cache::put('captcha:'.$key, strtolower($code), now()->addMinutes(5)); + $svg = '' + .'' + .''.$code.'' + .''; + + return ['key' => $key, 'image' => 'data:image/svg+xml;base64,'.base64_encode($svg)]; + } + + public function sendCode(string $scene, string $channel, string $target, string $ip): array + { + $target = $this->normalize($channel === 'email' ? 'email' : 'phone', $target); + $this->assertTarget($channel, $target); + if ($channel === 'email' && ! Settings::bool('mail_enabled')) { + throw ValidationException::withMessages(['account' => '邮箱通道未开启']); + } + if (config('qz.sms_driver') !== 'debug') { + throw new HttpException(503, '短信通道尚未配置'); + } + + $exists = UserIdentity::query()->where('type', $channel === 'email' ? 'email' : 'phone')->where('identifier', $target)->exists(); + if ($scene === 'register' && $exists) { + throw ValidationException::withMessages(['account' => '该账号已注册']); + } + if (in_array($scene, ['login', 'reset'], true) && ! $exists && ! ($scene === 'login' && Settings::bool('sms_register_creates_user', true) && $channel === 'sms')) { + throw ValidationException::withMessages(['account' => '账号不存在']); + } + if ($scene === 'bind' && $exists) { + throw ValidationException::withMessages(['account' => '该账号已绑定其他用户']); + } + + $dayKey = 'code-day:'.$target.':'.now()->toDateString(); + $ipKey = 'code-ip:'.$ip.':'.now()->toDateString(); + if ((int) Cache::get($dayKey, 0) >= 10 || (int) Cache::get($ipKey, 0) >= 30) { + throw ValidationException::withMessages(['account' => '发送次数已达今日上限']); + } + if (! Cache::add('code-wait:'.$target, 1, 60)) { + throw ValidationException::withMessages(['account' => '请稍后再获取验证码']); + } + + $code = (string) config('qz.debug_sms_code'); + VerificationCode::query()->create([ + 'scene' => $scene, + 'channel' => $channel, + 'target' => $target, + 'code_hash' => hash('sha256', $code), + 'expires_at' => now()->addMinutes(5), + 'ip' => $ip, + ]); + Cache::put($dayKey, (int) Cache::get($dayKey, 0) + 1, now()->endOfDay()); + Cache::put($ipKey, (int) Cache::get($ipKey, 0) + 1, now()->endOfDay()); + MessageLog::query()->create([ + 'channel' => $channel, + 'target_mask' => Presenter::mask($channel === 'email' ? 'email' : 'phone', $target), + 'scene' => $scene, + 'status' => 'debug', + ]); + + $payload = ['sent' => true]; + if (app()->environment('local')) { + $payload['debug_code'] = $code; + } + + return $payload; + } + + public function verifyCode(string $scene, string $target, string $code): void + { + $failKey = 'code-fail:'.$scene.':'.$target; + if ((int) Cache::get($failKey, 0) >= 5) { + throw ValidationException::withMessages(['code' => '验证码错误次数过多,请稍后再试']); + } + $row = VerificationCode::query() + ->where('scene', $scene) + ->where('target', $target) + ->whereNull('consumed_at') + ->latest('id') + ->first(); + $valid = $row && $row->expires_at->isFuture() && hash_equals($row->code_hash, hash('sha256', $code)); + if (! $valid) { + Cache::put($failKey, (int) Cache::get($failKey, 0) + 1, now()->addMinutes(15)); + throw ValidationException::withMessages(['code' => '验证码错误或已过期']); + } + $row->update(['consumed_at' => now()]); + Cache::forget($failKey); + } + + public function register(string $channel, string $account, string $code, string $password, ?string $nickname, Request $request): User + { + $type = $channel === 'email' ? 'email' : 'phone'; + $account = $this->normalize($type, $account); + $this->verifyCode('register', $account, $code); + $this->assertPassword($password); + $user = DB::transaction(function () use ($type, $account, $password, $nickname) { + $user = User::query()->create([ + 'nickname' => $nickname ?: $this->defaultNickname($type, $account), + 'password' => $password, + 'role' => 'user', + 'status' => 'active', + ]); + $user->identities()->create([ + 'type' => $type, + 'identifier' => $account, + 'verified_at' => now(), + ]); + + return $user; + }); + $this->establish($user, $request, $account, 'password'); + + return $user; + } + + public function loginWithPassword(string $account, string $password, ?string $captchaKey, ?string $captchaCode, Request $request): User + { + $account = trim($account); + $normalized = str_contains($account, '@') ? $this->normalize('email', $account) : $this->normalize('phone', $account); + $this->assertNotLocked($normalized); + $this->assertCaptcha($normalized, $captchaKey, $captchaCode); + $identity = UserIdentity::query()->where('identifier', $normalized)->first(); + $user = $identity?->user; + if (! $user || ! $user->password || ! Hash::check($password, $user->password)) { + $this->recordFailure($normalized); + $this->log($user, $normalized, 'password', false, $request, '账号或密码错误'); + throw ValidationException::withMessages(['account' => '账号或密码错误']); + } + $this->assertActive($user); + Cache::forget($this->failKey($normalized)); + $this->establish($user, $request, $normalized, 'password'); + + return $user; + } + + public function loginWithSms(string $phone, string $code, Request $request): User + { + $phone = $this->normalize('phone', $phone); + $this->verifyCode('login', $phone, $code); + $identity = UserIdentity::query()->where('type', 'phone')->where('identifier', $phone)->first(); + $user = $identity?->user; + if (! $user) { + if (! Settings::bool('sms_register_creates_user', true)) { + throw ValidationException::withMessages(['account' => '账号不存在']); + } + $user = DB::transaction(function () use ($phone) { + $user = User::query()->create([ + 'nickname' => $this->defaultNickname('phone', $phone), + 'role' => 'user', + 'status' => 'active', + ]); + $user->identities()->create([ + 'type' => 'phone', + 'identifier' => $phone, + 'verified_at' => now(), + ]); + + return $user; + }); + } + $this->assertActive($user); + $this->establish($user, $request, $phone, 'sms'); + + return $user; + } + + public function resetPassword(string $phone, string $code, string $password): void + { + $phone = $this->normalize('phone', $phone); + $this->verifyCode('reset', $phone, $code); + $this->assertPassword($password); + $user = UserIdentity::query()->where('type', 'phone')->where('identifier', $phone)->firstOrFail()->user; + $user->password = $password; + $user->save(); + $this->revokeTokens($user); + } + + public function bind(User $user, string $channel, string $account, string $code): void + { + $type = $channel === 'email' ? 'email' : 'phone'; + $account = $this->normalize($type, $account); + if ($channel === 'email' && ! Settings::bool('mail_enabled')) { + throw ValidationException::withMessages(['account' => '邮箱通道未开启']); + } + if ($user->identities()->where('type', $type)->exists()) { + throw ValidationException::withMessages(['account' => '已绑定该类型账号,如需更换请先联系运营']); + } + $this->verifyCode('bind', $account, $code); + $user->identities()->create([ + 'type' => $type, + 'identifier' => $account, + 'verified_at' => now(), + ]); + } + + public function changePassword(User $user, ?string $oldPassword, string $newPassword): void + { + $this->assertPassword($newPassword); + if ($user->password) { + if (! $oldPassword || ! Hash::check($oldPassword, $user->password)) { + throw ValidationException::withMessages(['old_password' => '原密码不正确']); + } + } + $user->password = $newPassword; + $user->save(); + $this->revokeTokens($user); + } + + public function logout(Request $request, bool $everywhere = true): void + { + $user = $request->user(); + if ($user && $everywhere) { + $this->revokeTokens($user); + UserSession::query()->where('user_id', $user->id)->whereNull('revoked_at')->update(['revoked_at' => now()]); + DB::table('sessions')->where('user_id', $user->id)->delete(); + $this->notifyLogout($user); + } + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + public function revokeTokens(User $user): void + { + OauthAccessToken::query()->where('user_id', $user->id)->whereNull('revoked_at')->update(['revoked_at' => now()]); + OauthRefreshToken::query()->where('user_id', $user->id)->whereNull('revoked_at')->update(['revoked_at' => now()]); + } + + public function normalize(string $type, string $value): string + { + $value = trim($value); + if ($type === 'email') { + $value = strtolower($value); + if (! filter_var($value, FILTER_VALIDATE_EMAIL)) { + throw ValidationException::withMessages(['account' => '邮箱格式不正确']); + } + + return $value; + } + if (! preg_match('/^1[3-9]\d{9}$/', $value)) { + throw ValidationException::withMessages(['account' => '请输入中国大陆手机号']); + } + + return $value; + } + + private function establish(User $user, Request $request, string $account, string $method): void + { + Auth::login($user); + $request->session()->regenerate(); + UserSession::query()->create([ + 'user_id' => $user->id, + 'session_id' => $request->session()->getId(), + 'ip' => $request->ip(), + 'user_agent' => Str::limit((string) $request->userAgent(), 255, ''), + 'last_seen_at' => now(), + ]); + $this->log($user, $account, $method, true, $request, '登录成功'); + } + + private function assertActive(User $user): void + { + if ($user->status !== 'active') { + throw ValidationException::withMessages(['account' => '账号已停用']); + } + } + + private function assertNotLocked(string $account): void + { + if (Cache::has('login-lock:'.$account)) { + throw ValidationException::withMessages(['account' => '账号已临时锁定,请 15 分钟后再试']); + } + } + + private function assertCaptcha(string $account, ?string $key, ?string $code): void + { + if ((int) Cache::get($this->failKey($account), 0) < 3) { + return; + } + $expected = $key ? Cache::pull('captcha:'.$key) : null; + if (! $expected || ! $code || ! hash_equals($expected, strtolower($code))) { + throw new \Illuminate\Http\Exceptions\HttpResponseException(response()->json([ + 'message' => '请完成图形验证码', + 'code' => 'captcha_required', + ], 422)); + } + } + + private function recordFailure(string $account): void + { + $count = (int) Cache::get($this->failKey($account), 0) + 1; + Cache::put($this->failKey($account), $count, now()->addMinutes(15)); + if ($count >= 5) { + Cache::put('login-lock:'.$account, 1, now()->addMinutes(15)); + Cache::forget($this->failKey($account)); + } + } + + private function failKey(string $account): string + { + return 'login-fail:'.$account; + } + + private function assertTarget(string $channel, string $target): void + { + $this->normalize($channel === 'email' ? 'email' : 'phone', $target); + } + + private function defaultNickname(string $type, string $account): string + { + return $type === 'email' ? strstr($account, '@', true) : Presenter::mask('phone', $account); + } + + private function log(?User $user, string $account, string $method, bool $success, Request $request, string $message): void + { + LoginLog::query()->create([ + 'user_id' => $user?->id, + 'account' => Presenter::mask(str_contains($account, '@') ? 'email' : 'phone', $account), + 'method' => $method, + 'success' => $success, + 'ip' => $request->ip(), + 'user_agent' => Str::limit((string) $request->userAgent(), 255, ''), + 'message' => $message, + ]); + } + + private function notifyLogout(User $user): void + { + $clients = \App\Models\OauthClient::query()->where('status', 'active')->get(); + foreach ($clients as $client) { + foreach ($client->logout_uris ?? [] as $uri) { + try { + \Illuminate\Support\Facades\Http::timeout(2)->asJson()->post($uri, [ + 'sub' => (string) $user->id, + 'client_id' => $client->client_id, + ]); + } catch (\Throwable) { + // 工具退出回调失败不阻断平台退出。 + } + } + } + } + + private function privateKey(): string + { + $path = storage_path('app/keys/login-private.pem'); + if (! is_file($path)) { + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0700, true); + } + $config = [ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + $cnf = PHP_BINDIR.DIRECTORY_SEPARATOR.'extras'.DIRECTORY_SEPARATOR.'ssl'.DIRECTORY_SEPARATOR.'openssl.cnf'; + if (is_file($cnf)) { + $config['config'] = $cnf; + } + $resource = openssl_pkey_new($config); + if ($resource === false) { + throw new \RuntimeException('无法生成登录密钥:'.openssl_error_string()); + } + openssl_pkey_export($resource, $pem, null, $config); + file_put_contents($path, $pem); + } + + return (string) file_get_contents($path); + } +} diff --git a/backend/app/Services/MembershipService.php b/backend/app/Services/MembershipService.php new file mode 100644 index 0000000..8ba7e93 --- /dev/null +++ b/backend/app/Services/MembershipService.php @@ -0,0 +1,129 @@ +loadMissing('subscription.plan.entitlements'); + $subscription = $user->subscription; + $status = $this->effectiveStatus($subscription); + $plan = in_array($status, ['active', 'grace'], true) + ? $subscription?->plan + : Plan::query()->where('code', 'free')->with('entitlements')->first(); + + $entitlements = []; + foreach ($plan?->entitlements ?? [] as $item) { + $entitlements[$item->entitlement_key] = $this->castValue($item->entitlement_value); + } + if ($toolSlug) { + $entitlements = array_filter( + $entitlements, + fn ($value, $key) => str_contains($key, ':'.$toolSlug.':') + || (! str_starts_with($key, 'tool:') && ! str_starts_with($key, 'quota:')), + ARRAY_FILTER_USE_BOTH + ); + } + + return [ + 'plan_code' => $plan->code ?? 'free', + 'plan_name' => $plan->name ?? '免费', + 'status' => $status, + 'starts_at' => $subscription?->starts_at?->toIso8601String(), + 'ends_at' => $subscription?->ends_at?->toIso8601String(), + 'entitlement_version' => $user->entitlement_version, + 'entitlements' => $entitlements, + ]; + } + + public function effectiveStatus(?Subscription $subscription): string + { + if (! $subscription || $subscription->status === 'stopped') { + return $subscription?->status === 'stopped' ? 'stopped' : 'none'; + } + if (now()->lt($subscription->ends_at)) { + return 'active'; + } + $graceDays = (int) Settings::get('grace_days', '3'); + if (now()->lt($subscription->ends_at->copy()->addDays($graceDays))) { + return 'grace'; + } + + return 'expired'; + } + + public function grant(User $user, Plan $plan, Carbon $endsAt): Subscription + { + $subscription = Subscription::query()->updateOrCreate( + ['user_id' => $user->id], + [ + 'plan_id' => $plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'ends_at' => $endsAt, + ] + ); + $user->increment('entitlement_version'); + $user->refresh(); + + return $subscription; + } + + public function extendWithOrder(User $user, Plan $plan): Subscription + { + $current = $user->subscription; + $base = ($current && $current->ends_at && $current->ends_at->isFuture() && $current->status !== 'stopped') + ? $current->ends_at->copy() + : now(); + $ends = match ($plan->period) { + 'year' => $base->addYear(), + 'lifetime' => $base->addYears(100), + default => $base->addMonth(), + }; + + return $this->grant($user, $plan, $ends); + } + + public function stop(User $user): void + { + $user->subscription?->update(['status' => 'stopped']); + $user->increment('entitlement_version'); + } + + public function confirmOrder(Order $order): void + { + if ($order->status !== 'pending') { + abort(422, '只有待支付订单可以确认收款'); + } + $order->update(['status' => 'paid', 'paid_at' => now()]); + if ($order->type === 'subscription') { + $item = $order->items()->with('plan')->first(); + if ($item?->plan) { + $this->extendWithOrder($order->user, $item->plan); + } + } + } + + private function castValue(string $value): bool|int|string + { + if ($value === 'true') { + return true; + } + if ($value === 'false') { + return false; + } + if (preg_match('/^-?\d+$/', $value)) { + return (int) $value; + } + + return $value; + } +} diff --git a/backend/app/Services/OAuthService.php b/backend/app/Services/OAuthService.php new file mode 100644 index 0000000..612522b --- /dev/null +++ b/backend/app/Services/OAuthService.php @@ -0,0 +1,288 @@ +input('client_id'); + $client = OauthClient::query()->where('client_id', $clientId)->where('status', 'active')->first(); + if (! $client) { + return null; + } + if ($client->is_public) { + return $client; + } + $secret = (string) $request->input('client_secret'); + if ($secret === '' || ! hash_equals((string) $client->secret_hash, hash('sha256', $secret))) { + return null; + } + + return $client; + } + + /** @return list */ + public function scopes(string $scope): array + { + $allowed = ['openid', 'profile', 'membership']; + $items = array_values(array_filter(explode(' ', trim($scope)))); + if ($items === []) { + $items = ['openid']; + } + foreach ($items as $item) { + if (! in_array($item, $allowed, true)) { + abort(400, '不支持的 scope'); + } + } + if (! in_array('openid', $items, true)) { + array_unshift($items, 'openid'); + } + + return $items; + } + + public function issueCode(OauthClient $client, User $user, array $input, array $scopes): string + { + $code = $this->random(); + OauthAuthCode::query()->create([ + 'code_hash' => hash('sha256', $code), + 'oauth_client_id' => $client->id, + 'user_id' => $user->id, + 'redirect_uri' => $input['redirect_uri'], + 'scopes' => $scopes, + 'nonce' => $input['nonce'] ?? null, + 'code_challenge' => $input['code_challenge'], + 'code_challenge_method' => 'S256', + 'expires_at' => now()->addSeconds((int) config('qz.code_ttl')), + ]); + if ($client->tool_id) { + $this->recordOpen($user, $client->tool_id); + } + + return $code; + } + + public function exchange(Request $request, OauthClient $client): array + { + $code = OauthAuthCode::query()->where('code_hash', hash('sha256', (string) $request->input('code')))->first(); + if (! $code || $code->oauth_client_id !== $client->id) { + return $this->error('invalid_grant', '授权码无效'); + } + if ($code->consumed_at) { + $this->revokeClientUser($client->id, $code->user_id); + + return $this->error('invalid_grant', '授权码已使用'); + } + if ($code->expires_at->isPast() || ! hash_equals($code->redirect_uri, (string) $request->input('redirect_uri'))) { + return $this->error('invalid_grant', '授权码已过期或回调不匹配'); + } + if (! $this->pkce($code->code_challenge, (string) $request->input('code_verifier'))) { + return $this->error('invalid_grant', 'PKCE 校验失败'); + } + $code->update(['consumed_at' => now()]); + $user = $code->user; + if (! $user || $user->status !== 'active') { + return $this->error('access_denied', '用户不可用'); + } + + return $this->mint($client, $user, $code->scopes ?? [], $code->nonce); + } + + public function refresh(Request $request, OauthClient $client): array + { + $row = OauthRefreshToken::query()->where('token_hash', hash('sha256', (string) $request->input('refresh_token')))->first(); + if (! $row || $row->oauth_client_id !== $client->id) { + return $this->error('invalid_grant', '刷新令牌无效'); + } + if ($row->revoked_at) { + $this->revokeClientUser($client->id, $row->user_id); + + return $this->error('invalid_grant', '刷新令牌已失效'); + } + if ($row->expires_at->isPast()) { + return $this->error('invalid_grant', '刷新令牌已过期'); + } + $user = $row->user; + if (! $user || $user->status !== 'active') { + return $this->error('access_denied', '用户不可用'); + } + $scopes = $row->accessToken?->scopes ?? ['openid']; + $row->update(['revoked_at' => now()]); + $row->accessToken?->update(['revoked_at' => now()]); + + return $this->mint($client, $user, $scopes, null); + } + + public function userinfo(string $token): ?array + { + $access = $this->access($token); + if (! $access) { + return null; + } + $user = $access->user; + $client = $access->client; + $scopes = $access->scopes ?? []; + $slug = $client?->tool?->slug; + $payload = ['sub' => (string) $user->id]; + if (in_array('profile', $scopes, true)) { + $payload['name'] = $user->nickname; + $payload['picture'] = null; + } + if (in_array('membership', $scopes, true)) { + $payload['membership'] = $this->membership->snapshot($user, $slug); + } + + return $payload; + } + + public function introspect(string $token): array + { + $access = $this->access($token); + if (! $access) { + return ['active' => false]; + } + $user = $access->user; + + return [ + 'active' => true, + 'sub' => (string) $user->id, + 'entitlement_version' => $access->entitlement_version, + 'current_entitlement_version' => $user->entitlement_version, + 'stale' => $access->entitlement_version !== $user->entitlement_version, + 'exp' => $access->expires_at->timestamp, + ]; + } + + public function revoke(string $token): void + { + $hash = hash('sha256', $token); + OauthAccessToken::query()->where('token_hash', $hash)->update(['revoked_at' => now()]); + $refresh = OauthRefreshToken::query()->where('token_hash', $hash)->first(); + if ($refresh) { + $refresh->update(['revoked_at' => now()]); + $refresh->accessToken?->update(['revoked_at' => now()]); + } + } + + private function mint(OauthClient $client, User $user, array $scopes, ?string $nonce): array + { + $accessPlain = $this->random(); + $refreshPlain = $this->random(); + $access = OauthAccessToken::query()->create([ + 'token_hash' => hash('sha256', $accessPlain), + 'oauth_client_id' => $client->id, + 'user_id' => $user->id, + 'scopes' => $scopes, + 'entitlement_version' => $user->entitlement_version, + 'expires_at' => now()->addMinutes((int) config('qz.access_ttl')), + ]); + OauthRefreshToken::query()->create([ + 'token_hash' => hash('sha256', $refreshPlain), + 'oauth_access_token_id' => $access->id, + 'oauth_client_id' => $client->id, + 'user_id' => $user->id, + 'expires_at' => now()->addDays((int) config('qz.refresh_ttl_days')), + ]); + $now = time(); + $idPayload = [ + 'iss' => config('app.url'), + 'sub' => (string) $user->id, + 'aud' => $client->client_id, + 'exp' => $now + ((int) config('qz.access_ttl') * 60), + 'iat' => $now, + 'nonce' => $nonce, + 'name' => in_array('profile', $scopes, true) ? $user->nickname : null, + ]; + + return [ + 'token_type' => 'Bearer', + 'access_token' => $accessPlain, + 'expires_in' => (int) config('qz.access_ttl') * 60, + 'refresh_token' => $refreshPlain, + 'id_token' => $this->jwt($idPayload), + 'scope' => implode(' ', $scopes), + ]; + } + + private function access(string $token): ?OauthAccessToken + { + $row = OauthAccessToken::query()->with(['user', 'client.tool'])->where('token_hash', hash('sha256', $token))->first(); + if (! $row || $row->revoked_at || $row->expires_at->isPast() || ! $row->user || $row->user->status !== 'active') { + return null; + } + + return $row; + } + + private function pkce(string $challenge, string $verifier): bool + { + if ($verifier === '') { + return false; + } + $computed = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + return hash_equals($challenge, $computed); + } + + private function revokeClientUser(int $clientId, int $userId): void + { + OauthAccessToken::query()->where('oauth_client_id', $clientId)->where('user_id', $userId)->update(['revoked_at' => now()]); + OauthRefreshToken::query()->where('oauth_client_id', $clientId)->where('user_id', $userId)->update(['revoked_at' => now()]); + } + + private function recordOpen(User $user, int $toolId): void + { + DB::transaction(function () use ($user, $toolId) { + $open = UserToolOpen::query()->firstOrNew(['user_id' => $user->id, 'tool_id' => $toolId]); + $open->open_count = ($open->open_count ?? 0) + 1; + $open->last_opened_at = now(); + $open->save(); + ToolOpenLog::query()->create(['user_id' => $user->id, 'tool_id' => $toolId]); + Tool::query()->whereKey($toolId)->increment('clicks'); + }); + } + + private function jwt(array $payload): string + { + $header = $this->b64(json_encode(['alg' => 'RS256', 'typ' => 'JWT'], JSON_UNESCAPED_UNICODE)); + $body = $this->b64(json_encode($payload, JSON_UNESCAPED_UNICODE)); + $data = $header.'.'.$body; + $private = file_get_contents(storage_path('app/keys/login-private.pem')); + if (! is_string($private) || $private === '') { + app(AuthService::class)->publicKeyPem(); + $private = (string) file_get_contents(storage_path('app/keys/login-private.pem')); + } + openssl_sign($data, $signature, $private, OPENSSL_ALGO_SHA256); + + return $data.'.'.$this->b64($signature); + } + + private function b64(string $raw): string + { + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + private function random(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } + + private function error(string $error, string $description): array + { + return ['error' => $error, 'error_description' => $description]; + } +} diff --git a/backend/app/Support/HtmlSanitizer.php b/backend/app/Support/HtmlSanitizer.php new file mode 100644 index 0000000..5e5638e --- /dev/null +++ b/backend/app/Support/HtmlSanitizer.php @@ -0,0 +1,22 @@ +