chore: track root project files and subrepos
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Build and Release Folders
|
||||||
|
bin-debug/
|
||||||
|
bin-release/
|
||||||
|
[Oo]bj/
|
||||||
|
[Bb]in/
|
||||||
|
|
||||||
|
# Other files and folders
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# Executables
|
||||||
|
*.swf
|
||||||
|
*.air
|
||||||
|
*.ipa
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
|
||||||
|
# should NOT be excluded as they contain compiler settings and other important
|
||||||
|
# information for Eclipse / Flash Builder.
|
||||||
|
|
||||||
|
/.idea
|
||||||
|
/.vscode/
|
||||||
|
/.trae/
|
||||||
|
/.windsurf/
|
||||||
|
/python/workspace/
|
||||||
|
/release-*.zip
|
||||||
|
/tmp_*
|
||||||
|
**/.hbuilderx/
|
||||||
|
**/unpackage/
|
||||||
+399
@@ -0,0 +1,399 @@
|
|||||||
|
# Sport-Era 体育咨询AI平台 - 产品方案文档
|
||||||
|
|
||||||
|
> 版本:v1.1(Demo版)
|
||||||
|
> 日期:2026-03-14
|
||||||
|
> 技术栈:ThinkPHP 8 + Vue 3 + UniApp + DeepSeek AI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 〇、当前阶段说明
|
||||||
|
|
||||||
|
**当前目标:构建一个无限接近真实的 Demo 版本**
|
||||||
|
|
||||||
|
| 维度 | Demo阶段 | 正式上线 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 赛事数据 | 网络爬取真实数据(免费源) | 接入付费第三方API(稳定+实时) |
|
||||||
|
| 赔率/盘口 | 网络爬取真实赔率数据 | 接入实时赔率API |
|
||||||
|
| 资讯文章 | 爬取体育新闻 + AI生成分析 | 运营团队 + AI生成 + 聚合 |
|
||||||
|
| AI分析 | DeepSeek真实接入(Key后台配置) | 同左 |
|
||||||
|
| 用户/社区 | 种子用户 + AI生成社区内容 + 真实注册流程 | 真实用户 |
|
||||||
|
| 钱包/提现 | 完整流程(虚拟金额) | 接入真实支付 |
|
||||||
|
| 分销 | 完整逻辑(虚拟数据验证) | 真实运营 |
|
||||||
|
|
||||||
|
**核心原则**:所有功能逻辑、UI交互、API接口与正式版完全一致。赛事/赔率/资讯等数据通过爬取网络真实数据填充,接入付费API后切换数据源即可发布。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、平台定位
|
||||||
|
|
||||||
|
以 **数据深度** 为核心竞争力的体育咨询AI平台,通过 AI论坛/赛事评估分析 提供智能化体育资讯服务,采用 **流量共享分润模式** 实现商业化。
|
||||||
|
|
||||||
|
**目标用户**:体育爱好者、足球/篮球球迷、体育数据分析爱好者
|
||||||
|
|
||||||
|
**平台特色**:
|
||||||
|
- AI驱动的赛事分析与预测
|
||||||
|
- 社区化的跟单与方案分享
|
||||||
|
- 可信度模型保障内容质量
|
||||||
|
- 多端覆盖(iOS / Android / 鸿蒙 / H5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、功能模块总览
|
||||||
|
|
||||||
|
| 模块 | 核心功能 | AI介入 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| 平台首页 | 聚合展示/推荐流 | ✅ 个性化推荐 |
|
||||||
|
| 足球/篮球模块 | 赛程/比分/盘口/赔率 | ✅ 赛事预测 |
|
||||||
|
| 头条资讯 | 分类/列表/详情 | ✅ AI导读分析 |
|
||||||
|
| 社区系统 | 发帖/跟单/标签 | ✅ 可信度分析 |
|
||||||
|
| AI智能模块 | 预测/摘要/分析 | ✅ 核心模块 |
|
||||||
|
| 个人中心 | 资料/钱包/分销 | ❌ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、模块详细设计
|
||||||
|
|
||||||
|
### 3.1 平台首页
|
||||||
|
|
||||||
|
#### 功能清单
|
||||||
|
- **今日赛事/开奖聚合**:展示当日重要赛事和开奖结果
|
||||||
|
- **热门推荐**
|
||||||
|
- 赛事推荐:根据热度和AI分析推荐赛事
|
||||||
|
- 彩票推荐:AI概率模型推荐
|
||||||
|
- 新闻资讯推荐:基于用户偏好推荐
|
||||||
|
- **论坛帖子盈利榜**:展示近期盈利最多的社区推手
|
||||||
|
- **AI精选分析**:AI自动生成的赛事分析精选
|
||||||
|
- **风险提示**:平台风险提醒和免责声明
|
||||||
|
- **个性化推荐流(AI驱动)**:基于用户行为的个性化内容推荐
|
||||||
|
|
||||||
|
#### 数据来源
|
||||||
|
- **Demo阶段**:首页数据通过爬取网络真实数据填充(赛事/资讯/排行榜等)
|
||||||
|
- **正式上线**:付费数据API + DeepSeek AI推荐算法 + 社区实时统计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 足球/篮球模块
|
||||||
|
|
||||||
|
#### 赛程
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 实时比分 | 赛事进行中实时更新比分 |
|
||||||
|
| 盘口数据 | 亚盘/欧赔/大小球盘口展示 |
|
||||||
|
| 赔率变化 | 赔率走势图,记录各时间点赔率变动 |
|
||||||
|
| 历史交锋 | 两队历史对阵记录和统计 |
|
||||||
|
|
||||||
|
#### 主流赛事覆盖
|
||||||
|
- 足球:英超、西甲、德甲、意甲、法甲、欧冠、中超等
|
||||||
|
- 篮球:NBA、CBA、欧洲篮球联赛等
|
||||||
|
|
||||||
|
#### AI协助分析
|
||||||
|
| 模型 | 输出 |
|
||||||
|
|------|------|
|
||||||
|
| 球队攻防模型 | 球队攻防能力评估图表 |
|
||||||
|
| 进球期望值模型 | xG(预期进球)分析 |
|
||||||
|
| 盘口盈亏模型 | 盘口价值分析 |
|
||||||
|
| 伤停影响模型 | 伤停对比赛结果的影响评估 |
|
||||||
|
| 胜平负概率 | AI预测胜/平/负概率 |
|
||||||
|
| 大小球概率 | AI预测大/小球概率 |
|
||||||
|
| 让球赢盘概率 | AI预测让球盘赢盘概率 |
|
||||||
|
| 风险等级评估 | 综合风险等级(低/中/高) |
|
||||||
|
|
||||||
|
#### 数据来源方案
|
||||||
|
|
||||||
|
**Demo阶段(爬取网络真实数据)**:
|
||||||
|
- 爬取公开体育网站的真实赛事数据(联赛/球队/赛程/比分/赔率/历史交锋)
|
||||||
|
- 数据源:懒球帝、雷速体育、球探网、TheSportsDB等公开页面
|
||||||
|
- API-Football 免费版(100次/天,补充数据)
|
||||||
|
- 后端定时任务爬取 → 清洗入库 → 前端读取本地数据
|
||||||
|
- 覆盖足球(英超/西甲/德甲等)和篮球(NBA/CBA)
|
||||||
|
|
||||||
|
**正式上线**:
|
||||||
|
- 切换为付费第三方API(纳米数据 / 雷速体育 / API-Football)
|
||||||
|
- 数据表结构与Demo阶段一致,仅切换数据源适配器即可
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 头条资讯
|
||||||
|
|
||||||
|
#### 功能清单
|
||||||
|
- **资讯分类**:赛事新闻、转会动态、球队资讯、数据分析等
|
||||||
|
- **热点导读**:编辑精选/AI推荐的热门资讯
|
||||||
|
- **资讯列表**:分类浏览、搜索
|
||||||
|
- **资讯详情**:文章阅读
|
||||||
|
- **AI导读分析**:对资讯内容进行AI智能分析
|
||||||
|
|
||||||
|
#### AI导读分析功能
|
||||||
|
- 自动生成文章摘要
|
||||||
|
- 重点信息提取
|
||||||
|
- 风险提示标注
|
||||||
|
- 反向观点对比
|
||||||
|
- 情绪倾向分析(正面/中性/负面)
|
||||||
|
|
||||||
|
#### 内容来源方案
|
||||||
|
|
||||||
|
**Demo阶段**:
|
||||||
|
- 爬取体育新闻网站的真实资讯文章(懒球帝/虎扑/腾讯体育等)
|
||||||
|
- AI基于爬取的真实赛事数据自动生成分析文章
|
||||||
|
- 后台可手动发布新文章
|
||||||
|
- AI导读分析功能接入真实 DeepSeek
|
||||||
|
|
||||||
|
**正式上线**:
|
||||||
|
| 来源 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 后台手动发布 | 运营/编辑发布原创内容 |
|
||||||
|
| AI生成 | DeepSeek基于赛事数据自动生成赛前/赛后分析 |
|
||||||
|
| 新闻聚合API | 接入新闻聚合服务获取体育资讯 |
|
||||||
|
| 社区UGC精选 | 社区优质内容升级为资讯 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 社区系统
|
||||||
|
|
||||||
|
#### 发帖系统
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 图文发布 | 支持图文混排帖子 |
|
||||||
|
| 战绩截图上传 | 用户上传投注战绩截图 |
|
||||||
|
| 推荐方案发布 | 发布赛事推荐方案(关联赛事) |
|
||||||
|
| 标签系统 | 帖子打标签便于分类和搜索 |
|
||||||
|
|
||||||
|
#### 跟单系统
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 一键跟单 | 用户可一键跟随推手方案 |
|
||||||
|
| 盈利统计 | 跟单盈利汇总展示 |
|
||||||
|
| 盈亏曲线图 | 可视化盈亏走势 |
|
||||||
|
|
||||||
|
#### 用户体系
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 等级体系 | 经验值升级系统 |
|
||||||
|
| VIP订阅 | 付费会员特权 |
|
||||||
|
| 专家认证 | 推手资质认证 |
|
||||||
|
| 信誉评分 | AI驱动的可信度评估 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 AI智能模块(DeepSeek接入)
|
||||||
|
|
||||||
|
#### 3.5.1 AI资讯阅读助手
|
||||||
|
| 功能 | 实现方式 |
|
||||||
|
|------|----------|
|
||||||
|
| 自动摘要 | 文章全文 → DeepSeek → 结构化摘要(3-5句话) |
|
||||||
|
| 重点提取 | 提取关键数据点、核心观点 |
|
||||||
|
| 风险提示 | 识别文章中的风险因素并标注 |
|
||||||
|
| 反向观点对比 | 自动搜索相反立场的观点对比 |
|
||||||
|
| 情绪倾向分析 | 分析文章情绪:正面/中性/负面,置信度百分比 |
|
||||||
|
|
||||||
|
#### 3.5.2 社区可信度分析模型
|
||||||
|
| 维度 | 权重 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 历史命中率 | 30% | 历史推荐的准确率 |
|
||||||
|
| 盈亏波动率 | 15% | 盈亏的稳定程度 |
|
||||||
|
| 发帖稳定性 | 15% | 发帖频率的稳定性 |
|
||||||
|
| 预测合理性 | 15% | 推荐理由是否合理(AI判断) |
|
||||||
|
| 是否事后编辑 | 15% | 检测赛后修改行为 |
|
||||||
|
| 与赛果偏离度 | 10% | 预测结果与实际结果的差距 |
|
||||||
|
|
||||||
|
**输出**:可信度评分(0-100)、风险等级、是否疑似"马后炮"标记
|
||||||
|
|
||||||
|
**更新策略**:每日凌晨AI批量计算更新
|
||||||
|
|
||||||
|
#### 3.5.3 赛事预测模型
|
||||||
|
| 输入数据 | 输出结果 |
|
||||||
|
|----------|----------|
|
||||||
|
| 历史战绩 | 胜平负概率区间 |
|
||||||
|
| xG数据 | 大小球概率 |
|
||||||
|
| 盘口变化 | 让球赢盘概率 |
|
||||||
|
| 伤停信息 | 盈利期望值 |
|
||||||
|
| 赔率走势 | 风险等级(低/中/高) |
|
||||||
|
|
||||||
|
#### 3.5.4 彩票概率模型
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 统计分布分析 | 历史开奖数据的统计特征分析 |
|
||||||
|
| 冷热号分析 | 号码出现频率和遗漏值分析 |
|
||||||
|
| 概率可视化 | 图表展示各号码出现概率 |
|
||||||
|
| 随机模拟 | 蒙特卡洛模拟生成推荐组合 |
|
||||||
|
|
||||||
|
#### AI技术架构
|
||||||
|
```
|
||||||
|
用户请求 → PHP后端(AIService) → DeepSeek API
|
||||||
|
↓
|
||||||
|
结果缓存(Redis)
|
||||||
|
↓
|
||||||
|
存储(MySQL) → 前端展示
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI配置管理(通过后台管理系统)**:
|
||||||
|
- DeepSeek API Key:后台「AI配置」页面录入,存储在 la_ai_config 表
|
||||||
|
- 模型选择:deepseek-chat / deepseek-reasoner,后台可切换
|
||||||
|
- 调用参数:temperature、max_tokens 等,后台可调整
|
||||||
|
- 调用限制:每日API调用上限、单用户调用频次,后台可配置
|
||||||
|
- 调用日志:所有AI调用记录可在后台查看
|
||||||
|
|
||||||
|
**调用策略**:
|
||||||
|
- 缓存优先:相同输入在缓存有效期内直接返回
|
||||||
|
- 异步处理:大量分析任务通过队列异步执行
|
||||||
|
- 成本控制:设置每日API调用上限,VIP用户优先
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 个人中心
|
||||||
|
|
||||||
|
#### 个人资料
|
||||||
|
- 头像设置
|
||||||
|
- 昵称修改
|
||||||
|
- 手机号注册/绑定
|
||||||
|
|
||||||
|
#### 分销管理
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 邀请 | 邀请链接/二维码生成、邀请记录查看 |
|
||||||
|
| 团队管理 | 团队列表、用户设置 |
|
||||||
|
| 结算报表 | 佣金统计和结算明细 |
|
||||||
|
|
||||||
|
**分销模式**:二级分销
|
||||||
|
- 一级佣金:被邀请人消费金额的 15%
|
||||||
|
- 二级佣金:被邀请人的邀请人消费金额的 8%
|
||||||
|
- 佣金来源:VIP订阅、付费方案购买
|
||||||
|
- 结算周期:月结(每月1日结算上月佣金)
|
||||||
|
- **Demo阶段**:完整分销逻辑,使用虚拟金额验证流程
|
||||||
|
|
||||||
|
#### 钱包管理
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 钱包统计 | 总收入、可用余额 |
|
||||||
|
| 账单记录 | 收入记录、提现记录 |
|
||||||
|
| 钱包地址管理 | USTD钱包地址绑定 |
|
||||||
|
| 提现 | USTD提现(支持TRC20) |
|
||||||
|
|
||||||
|
**提现流程**:
|
||||||
|
1. 用户发起提现申请
|
||||||
|
2. 系统校验余额和地址
|
||||||
|
3. 管理员后台审核
|
||||||
|
4. 审核通过后执行打款
|
||||||
|
5. 到账通知
|
||||||
|
|
||||||
|
**Demo阶段**:完整提现流程UI和逻辑,后台审核页面,但不接入真实支付(审核通过仅更新状态)
|
||||||
|
|
||||||
|
#### 关于平台
|
||||||
|
- 平台协议(用户协议、隐私政策、免责声明)
|
||||||
|
- 平台版本信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、用户等级体系
|
||||||
|
|
||||||
|
### 等级设定
|
||||||
|
|
||||||
|
| 等级 | 名称 | 经验值 | 权益 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| Lv1 | 新手 | 0 | 基础浏览、查看资讯 |
|
||||||
|
| Lv2 | 球迷 | 100 | 发帖、评论、收藏 |
|
||||||
|
| Lv3 | 达人 | 500 | 查看AI基础分析报告 |
|
||||||
|
| Lv4 | 专家 | 2000 | 发布推荐方案、申请专家认证 |
|
||||||
|
| Lv5 | 大师 | 5000 | 查看AI高级分析、历史数据导出 |
|
||||||
|
|
||||||
|
### 经验值获取
|
||||||
|
|
||||||
|
| 行为 | 经验值 | 每日上限 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| 每日签到 | +5 | 1次 |
|
||||||
|
| 发帖 | +10 | 5次 |
|
||||||
|
| 评论 | +3 | 10次 |
|
||||||
|
| 被点赞 | +2 | 无上限 |
|
||||||
|
| 被跟单 | +5 | 无上限 |
|
||||||
|
| 推荐命中 | +20 | 无上限 |
|
||||||
|
| 邀请新用户 | +50 | 无上限 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、VIP订阅
|
||||||
|
|
||||||
|
| VIP等级 | 价格 | 权益 |
|
||||||
|
|---------|------|------|
|
||||||
|
| 普通用户 | 免费 | 基础浏览、社区发帖 |
|
||||||
|
| VIP月卡 | ¥28/月 | AI分析报告、跟单提醒、去广告 |
|
||||||
|
| VIP季卡 | ¥68/季 | 月卡权益 + 专家方案优先查看 |
|
||||||
|
| VIP年卡 | ¥198/年 | 季卡权益 + 专属客服 + 数据导出 |
|
||||||
|
|
||||||
|
**Demo阶段**:VIP订阅页面和权限逻辑完整实现,可通过后台手动设置用户VIP状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、专家认证
|
||||||
|
|
||||||
|
### 认证条件
|
||||||
|
- 近30天发帖 ≥ 10篇
|
||||||
|
- 推荐方案命中率 ≥ 60%
|
||||||
|
- 信誉评分 ≥ 80分
|
||||||
|
- 提交实名认证资料
|
||||||
|
|
||||||
|
### 专家权益
|
||||||
|
- 专属认证标识
|
||||||
|
- 方案可设置收费查看
|
||||||
|
- 参与分销分成
|
||||||
|
- 优先展示在推荐流中
|
||||||
|
|
||||||
|
### 认证流程
|
||||||
|
1. 用户提交认证申请 + 实名资料
|
||||||
|
2. 系统自动校验数据条件
|
||||||
|
3. 管理员人工审核
|
||||||
|
4. 审核通过/拒绝通知
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、Demo阶段数据采集方案
|
||||||
|
|
||||||
|
### 7.1 爬取真实数据(定时任务)
|
||||||
|
|
||||||
|
| 数据类型 | 数据源 | 爬取策略 |
|
||||||
|
|----------|--------|----------|
|
||||||
|
| 联赛/球队基础数据 | TheSportsDB(API) / 球探网 | 初始化爬取一次,定期更新 |
|
||||||
|
| 赛程/比分 | 雷速体育 / 懒球帝公开页 | 每日定时爬取当日赛程 |
|
||||||
|
| 赔率/盘口 | 球探网 / 雷速体育 | 赛前定时爬取赔率变化 |
|
||||||
|
| 历史交锋 | TheSportsDB(API) / 球探网 | 查询时拉取并缓存 |
|
||||||
|
| 球员/伤停 | 懒球帝 / 转会市场 | 每日定时爬取 |
|
||||||
|
| 体育资讯 | 懒球帝 / 虎扑 / 腾讯体育 | 每日定时爬取最新资讯 |
|
||||||
|
|
||||||
|
### 7.2 AI生成内容
|
||||||
|
|
||||||
|
| 内容类型 | 生成方式 |
|
||||||
|
|----------|----------|
|
||||||
|
| 赛事分析文章 | DeepSeek基于爬取的真实赛事数据自动生成 |
|
||||||
|
| 社区帖子 | AI基于真实赛事生成推荐方案/分析帖 |
|
||||||
|
| 用户评论 | AI生成多样化的评论内容 |
|
||||||
|
|
||||||
|
### 7.3 种子数据(脚本创建)
|
||||||
|
|
||||||
|
| 数据类型 | 数量 | 说明 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 种子用户 | 20个 | 含普通用户/VIP/专家,各种等级 |
|
||||||
|
| 跟单方案 | 20个 | 基于真实赛事AI生成,含命中/未命中 |
|
||||||
|
|
||||||
|
### 7.4 爬取架构设计
|
||||||
|
|
||||||
|
```
|
||||||
|
后端定时任务 → 爬虫服务(SportCrawlerService)
|
||||||
|
├── 联赛球队Crawler → la_league / la_team
|
||||||
|
├── 赛事比分Crawler → la_match / la_match_stats
|
||||||
|
├── 赔率盘口Crawler → la_match_odds
|
||||||
|
├── 球员伤停Crawler → la_player / la_player_injury
|
||||||
|
├── 资讯Crawler → la_article
|
||||||
|
└── 数据清洗器 → 统一格式入库
|
||||||
|
```
|
||||||
|
|
||||||
|
**架构优势**:爬虫服务和正式上线的第三方API服务共用同一套数据清洗和入库逻辑,切换时仅需替换数据源适配器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、正式上线待对接事项
|
||||||
|
|
||||||
|
| # | 事项 | 说明 | 状态 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 赛事数据API | 纳米数据/雷速体育,替换爬虫数据源 | 上线前对接 |
|
||||||
|
| 2 | DeepSeek API Key | 后台配置页面已就绪,录入Key即可 | 上线前配置 |
|
||||||
|
| 3 | USTD提现链路 | TRC20,接入真实链上转账 | 上线前对接 |
|
||||||
|
| 4 | 短信服务 | 后台已有短信配置,录入服务商Key | 上线前配置 |
|
||||||
|
| 5 | 支付对接 | VIP订阅的真实支付渠道 | 上线前对接 |
|
||||||
+678
@@ -0,0 +1,678 @@
|
|||||||
|
# Sport-Era 体育咨询AI平台 - 实现流程文档
|
||||||
|
|
||||||
|
> 版本:v1.1(Demo版)
|
||||||
|
> 日期:2026-03-14
|
||||||
|
> 技术栈:ThinkPHP 8 + Vue 3 + UniApp + DeepSeek AI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、技术架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 客户端 │
|
||||||
|
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ iOS │ │Android│ │ 鸿蒙 │ │ H5 │ │
|
||||||
|
│ └──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ │
|
||||||
|
│ └─────────┴─────────┴─────────┘ │
|
||||||
|
│ UniApp (Vue 3) │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 管理后台: Vue 3 + Element Plus │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ API层: ThinkPHP 8 │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ adminapi │ │ api │ │ AI Service│ │
|
||||||
|
│ └──────────┘ └──────────┘ └─────┬────┘ │
|
||||||
|
├──────────────────────────────────┼──────────────┤
|
||||||
|
│ 数据层 │ │
|
||||||
|
│ ┌──────┐ ┌──────┐ ┌──────┐ │ │
|
||||||
|
│ │MySQL │ │Redis │ │ 队列 │ │ │
|
||||||
|
│ └──────┘ └──────┘ └──────┘ │ │
|
||||||
|
├────────────────────────────────┼──────────────┤
|
||||||
|
│ 外部服务 │ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ │ │
|
||||||
|
│ │体育数据API│ │DeepSeek AI│◄──┘ │
|
||||||
|
│ └──────────┘ └──────────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、开发阶段规划
|
||||||
|
|
||||||
|
### 阶段一:基础架构(预计 2-3 周)
|
||||||
|
|
||||||
|
#### 1.1 数据库建表 + 爬虫服务
|
||||||
|
- 创建所有业务表(约40张新表)
|
||||||
|
- 执行SQL迁移脚本
|
||||||
|
- 验证表结构和关联关系
|
||||||
|
- **开发爬虫服务(SportCrawlerService)**,爬取网络真实数据填充数据库
|
||||||
|
- 爬虫服务与正式上线API服务共用数据清洗/入库逻辑,切换时仅替换数据源适配器
|
||||||
|
|
||||||
|
#### 1.2 用户体系扩展
|
||||||
|
**后端(server/app/)**
|
||||||
|
```
|
||||||
|
app/common/model/user/
|
||||||
|
├── UserProfile.php # 用户扩展资料模型
|
||||||
|
├── UserLevel.php # 用户等级模型
|
||||||
|
├── UserVip.php # VIP订阅模型
|
||||||
|
├── UserExpert.php # 专家认证模型
|
||||||
|
└── UserCredit.php # 信誉评分模型
|
||||||
|
|
||||||
|
app/api/controller/user/
|
||||||
|
├── UserProfileController.php
|
||||||
|
├── UserLevelController.php
|
||||||
|
└── UserVipController.php
|
||||||
|
|
||||||
|
app/api/logic/user/
|
||||||
|
├── UserProfileLogic.php
|
||||||
|
├── UserLevelLogic.php
|
||||||
|
└── UserVipLogic.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端(uniapp/src/)**
|
||||||
|
```
|
||||||
|
pages/user/
|
||||||
|
├── profile/index.vue # 个人资料页
|
||||||
|
├── level/index.vue # 等级详情页
|
||||||
|
└── vip/index.vue # VIP订阅页
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. 创建用户扩展表 → Model → Logic → Controller → API路由
|
||||||
|
2. 前端个人资料页面
|
||||||
|
3. 等级经验值计算逻辑
|
||||||
|
4. VIP订阅流程
|
||||||
|
|
||||||
|
#### 1.3 头条资讯模块
|
||||||
|
**后端开发**
|
||||||
|
```
|
||||||
|
app/api/controller/article/
|
||||||
|
├── ArticleController.php # 复用已有,扩展AI分析接口
|
||||||
|
└── ArticleCateController.php # 复用已有
|
||||||
|
|
||||||
|
app/api/logic/article/
|
||||||
|
└── ArticleAiLogic.php # 新增 - AI导读分析逻辑
|
||||||
|
|
||||||
|
app/common/model/article/
|
||||||
|
└── ArticleAiAnalysis.php # 新增 - AI分析结果模型
|
||||||
|
|
||||||
|
app/adminapi/controller/article/
|
||||||
|
└── ArticleController.php # 复用已有 - 后台资讯管理
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端开发**
|
||||||
|
```
|
||||||
|
uniapp/src/pages/
|
||||||
|
├── news/
|
||||||
|
│ ├── index.vue # 资讯列表
|
||||||
|
│ ├── detail.vue # 资讯详情
|
||||||
|
│ └── components/
|
||||||
|
│ ├── NewsCard.vue # 资讯卡片组件
|
||||||
|
│ └── AiAnalysis.vue # AI导读分析组件
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. 扩展资讯分类(体育相关分类)
|
||||||
|
2. 资讯列表/详情前端页面
|
||||||
|
3. AI导读分析接口(阶段三实现AI逻辑,此处先预留接口)
|
||||||
|
|
||||||
|
#### 1.4 赛事基础模块
|
||||||
|
**后端开发**
|
||||||
|
```
|
||||||
|
app/common/model/sport/
|
||||||
|
├── SportType.php # 运动类型
|
||||||
|
├── League.php # 联赛
|
||||||
|
├── Team.php # 球队
|
||||||
|
├── Match.php # 赛事
|
||||||
|
├── MatchOdds.php # 赔率
|
||||||
|
├── MatchStats.php # 赛事统计
|
||||||
|
├── MatchHistory.php # 历史交锋
|
||||||
|
├── Player.php # 球员
|
||||||
|
└── PlayerInjury.php # 伤停
|
||||||
|
|
||||||
|
app/api/controller/sport/
|
||||||
|
├── MatchController.php # 赛事接口
|
||||||
|
├── LeagueController.php # 联赛接口
|
||||||
|
└── TeamController.php # 球队接口
|
||||||
|
|
||||||
|
app/api/logic/sport/
|
||||||
|
├── MatchLogic.php
|
||||||
|
├── LeagueLogic.php
|
||||||
|
└── TeamLogic.php
|
||||||
|
|
||||||
|
app/common/service/sport/
|
||||||
|
├── SportDataService.php # 数据源统一接口(爬虫/API切换)
|
||||||
|
├── SportCrawlerService.php # 爬虫服务(Demo阶段主要数据源)
|
||||||
|
└── DataCleanerService.php # 数据清洗器(爬虫/API共用)
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端开发**
|
||||||
|
```
|
||||||
|
uniapp/src/pages/
|
||||||
|
├── match/
|
||||||
|
│ ├── index.vue # 赛事列表(按日期)
|
||||||
|
│ ├── detail.vue # 赛事详情
|
||||||
|
│ └── components/
|
||||||
|
│ ├── MatchCard.vue # 赛事卡片
|
||||||
|
│ ├── OddsPanel.vue # 赔率面板
|
||||||
|
│ ├── ScoreBoard.vue # 比分板
|
||||||
|
│ └── HistoryTable.vue # 历史交锋表
|
||||||
|
├── league/
|
||||||
|
│ ├── index.vue # 联赛列表
|
||||||
|
│ └── detail.vue # 联赛详情(排名/赛程)
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. 运动类型/联赛/球队基础数据表和模型
|
||||||
|
2. 赛事CRUD接口
|
||||||
|
3. 爬虫服务开发(SportCrawlerService)爬取网络真实赛事数据
|
||||||
|
4. 数据清洗器(DataCleanerService)统一入库格式
|
||||||
|
5. 前端赛事列表/详情页面
|
||||||
|
6. 赔率/盘口展示
|
||||||
|
7. SportDataService 统一接口(正式上线时切换数据源适配器)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段二:社区与互动(预计 2-3 周)
|
||||||
|
|
||||||
|
#### 2.1 社区发帖系统
|
||||||
|
**后端开发**
|
||||||
|
```
|
||||||
|
app/common/model/community/
|
||||||
|
├── CommunityPost.php # 帖子
|
||||||
|
├── CommunityPostImage.php # 帖子图片
|
||||||
|
├── CommunityComment.php # 评论
|
||||||
|
├── CommunityLike.php # 点赞
|
||||||
|
├── CommunityTag.php # 标签
|
||||||
|
├── CommunityPostTag.php # 帖子标签关联
|
||||||
|
└── CommunityFollow.php # 关注
|
||||||
|
|
||||||
|
app/api/controller/community/
|
||||||
|
├── PostController.php # 帖子接口
|
||||||
|
├── CommentController.php # 评论接口
|
||||||
|
├── FollowController.php # 关注接口
|
||||||
|
└── TagController.php # 标签接口
|
||||||
|
|
||||||
|
app/api/logic/community/
|
||||||
|
├── PostLogic.php
|
||||||
|
├── CommentLogic.php
|
||||||
|
└── FollowLogic.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端开发**
|
||||||
|
```
|
||||||
|
uniapp/src/pages/
|
||||||
|
├── community/
|
||||||
|
│ ├── index.vue # 社区首页(帖子列表)
|
||||||
|
│ ├── post_detail.vue # 帖子详情
|
||||||
|
│ ├── publish.vue # 发帖页面
|
||||||
|
│ └── components/
|
||||||
|
│ ├── PostCard.vue # 帖子卡片
|
||||||
|
│ ├── CommentList.vue # 评论列表
|
||||||
|
│ ├── ImageUpload.vue # 图片上传
|
||||||
|
│ └── TagSelector.vue # 标签选择器
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. 帖子CRUD → 评论 → 点赞 → 标签
|
||||||
|
2. 关注系统
|
||||||
|
3. 图片上传(战绩截图)
|
||||||
|
4. 推荐方案发布(关联赛事)
|
||||||
|
|
||||||
|
#### 2.2 跟单系统
|
||||||
|
**后端开发**
|
||||||
|
```
|
||||||
|
app/common/model/tipster/
|
||||||
|
├── TipsterPlan.php # 推荐方案
|
||||||
|
├── TipsterFollow.php # 跟单记录
|
||||||
|
├── TipsterStats.php # 推手统计
|
||||||
|
└── TipsterProfitLog.php # 盈亏日志
|
||||||
|
|
||||||
|
app/api/controller/tipster/
|
||||||
|
├── PlanController.php # 方案接口
|
||||||
|
├── FollowController.php # 跟单接口
|
||||||
|
└── StatsController.php # 统计接口
|
||||||
|
|
||||||
|
app/api/logic/tipster/
|
||||||
|
├── PlanLogic.php
|
||||||
|
├── FollowLogic.php
|
||||||
|
└── StatsLogic.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端开发**
|
||||||
|
```
|
||||||
|
uniapp/src/pages/
|
||||||
|
├── tipster/
|
||||||
|
│ ├── index.vue # 推手列表/排行榜
|
||||||
|
│ ├── detail.vue # 推手详情(历史方案/盈亏曲线)
|
||||||
|
│ ├── plan_detail.vue # 方案详情
|
||||||
|
│ └── components/
|
||||||
|
│ ├── TipsterCard.vue # 推手卡片
|
||||||
|
│ ├── PlanCard.vue # 方案卡片
|
||||||
|
│ ├── ProfitChart.vue # 盈亏曲线图
|
||||||
|
│ └── FollowButton.vue # 跟单按钮
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. 推荐方案发布/展示
|
||||||
|
2. 跟单功能
|
||||||
|
3. 盈利统计和排行榜
|
||||||
|
4. 盈亏曲线图表
|
||||||
|
|
||||||
|
#### 2.3 专家认证
|
||||||
|
**后端**:UserExpert 模型 + 认证申请/审核接口
|
||||||
|
**管理后台**:认证审核页面
|
||||||
|
**开发顺序**:认证申请接口 → 后台审核页面 → 前端申请页面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段三:AI智能模块(预计 3-4 周)
|
||||||
|
|
||||||
|
#### 3.1 AI基础服务层
|
||||||
|
**后端开发**
|
||||||
|
```
|
||||||
|
app/common/service/ai/
|
||||||
|
├── AiService.php # AI服务基类
|
||||||
|
├── DeepSeekClient.php # DeepSeek API客户端
|
||||||
|
├── AiCacheService.php # AI结果缓存服务
|
||||||
|
└── AiQueueService.php # AI异步队列服务
|
||||||
|
|
||||||
|
app/common/enum/
|
||||||
|
└── AiEnum.php # AI相关枚举(分析类型/状态等)
|
||||||
|
|
||||||
|
config/
|
||||||
|
└── ai.php # AI配置文件(从 la_ai_config 表读取,后台可配置)
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心类设计**:
|
||||||
|
```php
|
||||||
|
// DeepSeekClient.php - 核心调用类
|
||||||
|
class DeepSeekClient
|
||||||
|
{
|
||||||
|
private string $apiKey; // 从 la_ai_config 表读取,后台可配置
|
||||||
|
private string $baseUrl; // 从 la_ai_config 表读取
|
||||||
|
private string $model; // deepseek-chat / deepseek-reasoner,后台可切换
|
||||||
|
|
||||||
|
public function chat(string $systemPrompt, string $userMessage): array
|
||||||
|
public function chatStream(string $systemPrompt, string $userMessage): Generator
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiService.php - 业务封装
|
||||||
|
class AiService
|
||||||
|
{
|
||||||
|
public static function analyzeArticle(int $articleId): array // 资讯AI分析
|
||||||
|
public static function predictMatch(int $matchId): array // 赛事预测
|
||||||
|
public static function analyzeCredibility(int $userId): array // 可信度分析
|
||||||
|
public static function analyzeLottery(array $params): array // 彩票概率分析
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 AI资讯阅读助手
|
||||||
|
**Prompt设计**:
|
||||||
|
```
|
||||||
|
系统角色:你是一位专业的体育资讯分析师。
|
||||||
|
|
||||||
|
任务:对以下体育资讯文章进行深度分析,输出JSON格式结果:
|
||||||
|
{
|
||||||
|
"summary": "3-5句话的摘要",
|
||||||
|
"key_points": ["重点1", "重点2", ...],
|
||||||
|
"risk_warnings": ["风险提示1", ...],
|
||||||
|
"opposite_views": ["反向观点1", ...],
|
||||||
|
"sentiment": {
|
||||||
|
"label": "positive/neutral/negative",
|
||||||
|
"confidence": 0.85
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. DeepSeekClient 封装(从 la_ai_config 读取配置)
|
||||||
|
2. 后台AI配置页面(API Key / 模型选择 / 参数调整 / 调用限制)
|
||||||
|
3. 资讯分析Prompt设计和调试
|
||||||
|
4. 结果缓存策略
|
||||||
|
5. 前端AI分析展示组件
|
||||||
|
6. 后台AI调用日志查看页面
|
||||||
|
|
||||||
|
#### 3.3 社区可信度分析模型
|
||||||
|
**数据采集**:
|
||||||
|
```
|
||||||
|
用户ID →
|
||||||
|
├── 历史推荐方案列表 → 命中率计算
|
||||||
|
├── 盈亏记录 → 波动率计算
|
||||||
|
├── 发帖时间序列 → 稳定性计算
|
||||||
|
├── 帖子编辑记录 → 事后编辑检测
|
||||||
|
└── 赛果对比 → 偏离程度计算
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prompt设计**:
|
||||||
|
```
|
||||||
|
系统角色:你是体育社区内容审核AI。
|
||||||
|
|
||||||
|
输入数据:用户历史数据摘要(JSON格式)
|
||||||
|
任务:评估该用户的可信度,输出JSON:
|
||||||
|
{
|
||||||
|
"credibility_score": 75,
|
||||||
|
"risk_level": "medium",
|
||||||
|
"is_suspicious": false,
|
||||||
|
"analysis": "分析说明",
|
||||||
|
"dimension_scores": {
|
||||||
|
"hit_rate": 80,
|
||||||
|
"volatility": 70,
|
||||||
|
"stability": 85,
|
||||||
|
"rationality": 65,
|
||||||
|
"edit_integrity": 90,
|
||||||
|
"deviation": 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**定时任务**:每日凌晨3点执行批量分析
|
||||||
|
**开发顺序**:
|
||||||
|
1. 数据采集逻辑
|
||||||
|
2. Prompt设计和调优
|
||||||
|
3. 定时任务配置
|
||||||
|
4. 前端可信度标识展示
|
||||||
|
|
||||||
|
#### 3.4 赛事预测模型
|
||||||
|
**数据输入组装**:
|
||||||
|
```php
|
||||||
|
// 组装赛事分析数据
|
||||||
|
$matchData = [
|
||||||
|
'home_team' => [...], // 主队近期数据
|
||||||
|
'away_team' => [...], // 客队近期数据
|
||||||
|
'head_to_head' => [...], // 历史交锋
|
||||||
|
'odds' => [...], // 当前赔率
|
||||||
|
'injuries' => [...], // 伤停信息
|
||||||
|
'xg_data' => [...], // xG数据
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. 赛事数据组装逻辑
|
||||||
|
2. 预测Prompt设计
|
||||||
|
3. 预测结果存储和展示
|
||||||
|
4. 前端预测报告页面
|
||||||
|
|
||||||
|
#### 3.5 彩票概率模型
|
||||||
|
**实现方式**:统计算法为主 + DeepSeek辅助分析
|
||||||
|
```
|
||||||
|
历史开奖数据 →
|
||||||
|
├── PHP统计计算(频率/遗漏值/冷热号)
|
||||||
|
├── DeepSeek分析趋势和异常模式
|
||||||
|
└── 蒙特卡洛模拟生成推荐组合
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.6 个性化推荐流
|
||||||
|
**推荐因子**:
|
||||||
|
- 用户浏览历史(赛事/资讯偏好)
|
||||||
|
- 关注的推手和球队
|
||||||
|
- AI分析结果质量评分
|
||||||
|
- 时效性(优先最新内容)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段四:变现与运营(预计 2-3 周)
|
||||||
|
|
||||||
|
#### 4.1 钱包管理
|
||||||
|
**后端开发**
|
||||||
|
```
|
||||||
|
app/common/model/wallet/
|
||||||
|
├── Wallet.php # 钱包
|
||||||
|
├── WalletLog.php # 钱包流水
|
||||||
|
├── WalletWithdraw.php # 提现记录
|
||||||
|
└── WalletAddress.php # 钱包地址
|
||||||
|
|
||||||
|
app/api/controller/wallet/
|
||||||
|
├── WalletController.php # 钱包接口
|
||||||
|
└── WithdrawController.php # 提现接口
|
||||||
|
|
||||||
|
app/api/logic/wallet/
|
||||||
|
├── WalletLogic.php
|
||||||
|
└── WithdrawLogic.php
|
||||||
|
|
||||||
|
app/adminapi/controller/wallet/
|
||||||
|
└── WithdrawController.php # 后台提现审核
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端开发**
|
||||||
|
```
|
||||||
|
uniapp/src/pages/
|
||||||
|
├── wallet/
|
||||||
|
│ ├── index.vue # 钱包首页
|
||||||
|
│ ├── bill.vue # 账单记录
|
||||||
|
│ ├── withdraw.vue # 提现页面
|
||||||
|
│ └── address.vue # 地址管理
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. 钱包余额管理
|
||||||
|
2. 流水记录
|
||||||
|
3. 提现申请和审核流程
|
||||||
|
4. USTD地址管理
|
||||||
|
|
||||||
|
#### 4.2 分销管理
|
||||||
|
**后端开发**
|
||||||
|
```
|
||||||
|
app/common/model/distribution/
|
||||||
|
├── DistributionConfig.php # 分销配置
|
||||||
|
├── DistributionInvite.php # 邀请记录
|
||||||
|
├── DistributionTeam.php # 团队关系
|
||||||
|
└── DistributionSettlement.php # 结算记录
|
||||||
|
|
||||||
|
app/api/controller/distribution/
|
||||||
|
├── InviteController.php # 邀请接口
|
||||||
|
├── TeamController.php # 团队接口
|
||||||
|
└── SettlementController.php # 结算接口
|
||||||
|
|
||||||
|
app/api/logic/distribution/
|
||||||
|
├── InviteLogic.php # 邀请码生成/绑定逻辑
|
||||||
|
├── TeamLogic.php # 团队树形结构逻辑
|
||||||
|
└── SettlementLogic.php # 佣金计算/结算逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端开发**
|
||||||
|
```
|
||||||
|
uniapp/src/pages/
|
||||||
|
├── distribution/
|
||||||
|
│ ├── index.vue # 分销中心
|
||||||
|
│ ├── invite.vue # 邀请页面(含二维码)
|
||||||
|
│ ├── team.vue # 团队列表
|
||||||
|
│ └── settlement.vue # 结算报表
|
||||||
|
```
|
||||||
|
|
||||||
|
**开发顺序**:
|
||||||
|
1. 邀请码/链接生成
|
||||||
|
2. 注册绑定上级关系
|
||||||
|
3. 团队树形管理
|
||||||
|
4. 佣金计算逻辑
|
||||||
|
5. 月度自动结算定时任务
|
||||||
|
|
||||||
|
#### 4.3 平台首页聚合
|
||||||
|
**开发顺序**:
|
||||||
|
1. 今日赛事聚合接口
|
||||||
|
2. 热门推荐接口
|
||||||
|
3. 盈利排行榜接口
|
||||||
|
4. AI精选分析展示
|
||||||
|
5. 首页UI整合
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、后台管理扩展
|
||||||
|
|
||||||
|
### 需要新增的管理后台页面
|
||||||
|
|
||||||
|
```
|
||||||
|
admin/src/views/
|
||||||
|
├── sport/ # 赛事管理
|
||||||
|
│ ├── type/index.vue # 运动类型管理
|
||||||
|
│ ├── league/index.vue # 联赛管理
|
||||||
|
│ ├── team/index.vue # 球队管理
|
||||||
|
│ ├── match/index.vue # 赛事管理
|
||||||
|
│ └── data_sync.vue # 数据同步管理
|
||||||
|
├── community/ # 社区管理
|
||||||
|
│ ├── post/index.vue # 帖子管理(审核/删除)
|
||||||
|
│ ├── comment/index.vue # 评论管理
|
||||||
|
│ ├── tag/index.vue # 标签管理
|
||||||
|
│ └── report.vue # 举报处理
|
||||||
|
├── tipster/ # 跟单管理
|
||||||
|
│ ├── plan/index.vue # 方案管理
|
||||||
|
│ ├── expert/index.vue # 专家认证审核
|
||||||
|
│ └── ranking.vue # 排行榜管理
|
||||||
|
├── ai/ # AI管理
|
||||||
|
│ ├── config.vue # AI配置(API Key/参数)
|
||||||
|
│ ├── analysis_log.vue # 分析日志
|
||||||
|
│ └── model_config.vue # 模型参数配置
|
||||||
|
├── wallet/ # 钱包管理
|
||||||
|
│ ├── withdraw/index.vue # 提现审核
|
||||||
|
│ └── log.vue # 流水查看
|
||||||
|
├── distribution/ # 分销管理
|
||||||
|
│ ├── config.vue # 分销配置
|
||||||
|
│ ├── team.vue # 团队查看
|
||||||
|
│ └── settlement.vue # 结算管理
|
||||||
|
└── user_ext/ # 用户扩展管理
|
||||||
|
├── level_config.vue # 等级配置
|
||||||
|
├── vip_config.vue # VIP配置
|
||||||
|
└── credit.vue # 信誉评分查看
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、API路由规划
|
||||||
|
|
||||||
|
### 前台API(app/api)
|
||||||
|
|
||||||
|
```
|
||||||
|
// 赛事模块
|
||||||
|
Route::group('sport', function () {
|
||||||
|
Route::get('match/list', 'sport.Match/list');
|
||||||
|
Route::get('match/detail', 'sport.Match/detail');
|
||||||
|
Route::get('match/odds', 'sport.Match/odds');
|
||||||
|
Route::get('match/history', 'sport.Match/history');
|
||||||
|
Route::get('league/list', 'sport.League/list');
|
||||||
|
Route::get('team/detail', 'sport.Team/detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 社区模块
|
||||||
|
Route::group('community', function () {
|
||||||
|
Route::get('post/list', 'community.Post/list');
|
||||||
|
Route::get('post/detail', 'community.Post/detail');
|
||||||
|
Route::post('post/publish', 'community.Post/publish');
|
||||||
|
Route::post('post/like', 'community.Post/like');
|
||||||
|
Route::post('comment/add', 'community.Comment/add');
|
||||||
|
Route::post('follow/toggle', 'community.Follow/toggle');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 跟单模块
|
||||||
|
Route::group('tipster', function () {
|
||||||
|
Route::get('plan/list', 'tipster.Plan/list');
|
||||||
|
Route::get('plan/detail', 'tipster.Plan/detail');
|
||||||
|
Route::post('plan/publish', 'tipster.Plan/publish');
|
||||||
|
Route::post('plan/follow', 'tipster.Follow/follow');
|
||||||
|
Route::get('stats/ranking', 'tipster.Stats/ranking');
|
||||||
|
});
|
||||||
|
|
||||||
|
// AI模块
|
||||||
|
Route::group('ai', function () {
|
||||||
|
Route::get('article/analysis', 'ai.Article/analysis');
|
||||||
|
Route::get('match/prediction', 'ai.Match/prediction');
|
||||||
|
Route::get('user/credibility', 'ai.User/credibility');
|
||||||
|
Route::get('lottery/analysis', 'ai.Lottery/analysis');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 钱包模块
|
||||||
|
Route::group('wallet', function () {
|
||||||
|
Route::get('info', 'wallet.Wallet/info');
|
||||||
|
Route::get('log/list', 'wallet.Wallet/logList');
|
||||||
|
Route::post('withdraw/apply', 'wallet.Withdraw/apply');
|
||||||
|
Route::get('withdraw/list', 'wallet.Withdraw/list');
|
||||||
|
Route::post('address/save', 'wallet.Wallet/saveAddress');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分销模块
|
||||||
|
Route::group('distribution', function () {
|
||||||
|
Route::get('invite/code', 'distribution.Invite/code');
|
||||||
|
Route::get('invite/list', 'distribution.Invite/list');
|
||||||
|
Route::get('team/list', 'distribution.Team/list');
|
||||||
|
Route::get('settlement/list', 'distribution.Settlement/list');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、定时任务清单
|
||||||
|
|
||||||
|
| 任务 | Cron表达式 | Demo阶段 | 说明 |
|
||||||
|
|------|-----------|----------|------|
|
||||||
|
| 赛事数据爬取 | */30 * * * * | ✅ 爬虫服务 | 每30分钟爬取赛事数据(Demo用爬虫,正式用API) |
|
||||||
|
| 赔率数据爬取 | 0 */2 * * * | ✅ 爬虫服务 | 每2小时爬取赔率变化 |
|
||||||
|
| 赛果更新 | 0 */1 * * * | ✅ 爬虫服务 | 每小时检查并更新赛果 |
|
||||||
|
| 资讯爬取 | 0 */4 * * * | ✅ 爬虫服务 | 每4小时爬取最新体育资讯 |
|
||||||
|
| 推荐方案结算 | 0 */1 * * * | ✅ 需要 | 每小时结算已结束赛事的方案 |
|
||||||
|
| AI可信度计算 | 0 3 * * * | ✅ 需要 | 每日凌晨3点批量计算信誉评分 |
|
||||||
|
| 用户等级计算 | 0 4 * * * | ✅ 需要 | 每日凌晨4点更新用户等级 |
|
||||||
|
| 分销佣金结算 | 0 2 1 * * | ✅ 需要 | 每月1日凌晨2点结算上月佣金 |
|
||||||
|
| AI分析报告生成 | 0 8 * * * | ✅ 需要 | 每日早8点生成当日赛事AI分析 |
|
||||||
|
| 盈利排行榜更新 | 0 */2 * * * | ✅ 需要 | 每2小时更新盈利排行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、第三方服务集成
|
||||||
|
|
||||||
|
| 服务 | 用途 | Demo阶段 | 正式上线 |
|
||||||
|
|------|------|----------|----------|
|
||||||
|
| DeepSeek API | AI分析核心 | ✅ 真实接入(Key后台配置) | 同左 |
|
||||||
|
| 体育数据API | 赛事/赔率/比分 | ✅ 爬虫爬取真实数据 | 切换为付费API |
|
||||||
|
| 短信服务 | 注册/登录验证码 | ✅ 通用验证码0000 | 接入短信服务商 |
|
||||||
|
| 文件存储 | 图片/截图上传 | ✅ likeadmin已集成 | 同左(OSS) |
|
||||||
|
| 微信SDK | 小程序/公众号登录 | ✅ likeadmin已集成 | 同左 |
|
||||||
|
| 支付服务 | VIP订阅/方案购买 | ❌ 后台手动设置 | 接入支付渠道 |
|
||||||
|
| USTD提现 | 钱包提现 | ❌ 仅状态更新 | 接入链上转账 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、部署与同步流程
|
||||||
|
|
||||||
|
### 开发流程
|
||||||
|
```
|
||||||
|
本地开发(IDE) → Git Push(Gitee) → 服务器 Git Pull → 生效
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git 部署(主要方式)
|
||||||
|
- 远程仓库:`https://gitee.com/phperbylyh/sport-era-service.git`
|
||||||
|
- 分支:`develop`
|
||||||
|
- 服务器更新命令(宝塔定时任务):
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/sportera.gs-z.cn && chattr -i public/.user.ini 2>/dev/null && git -c safe.directory=/www/wwwroot/sportera.gs-z.cn fetch --all && echo "----- 更新内容 -----" && git -c safe.directory=/www/wwwroot/sportera.gs-z.cn log HEAD..origin/develop --oneline 2>&1 && echo "----- 变更文件 -----" && git -c safe.directory=/www/wwwroot/sportera.gs-z.cn diff --stat HEAD origin/develop 2>&1 && git -c safe.directory=/www/wwwroot/sportera.gs-z.cn reset --hard origin/develop 2>&1 && chattr +i public/.user.ini 2>/dev/null && echo "===== 部署完成: $(date) ====="
|
||||||
|
```
|
||||||
|
|
||||||
|
### SFTP 配置(辅助方式)
|
||||||
|
- 配置文件:`.winsurf/sftp.json`
|
||||||
|
- 自动上传:保存即同步
|
||||||
|
- 忽略文件:.git, .env, runtime, vendor
|
||||||
|
|
||||||
|
### 前端构建部署
|
||||||
|
```bash
|
||||||
|
# Admin后台
|
||||||
|
cd admin && npm run build # 输出到 server/public/admin/
|
||||||
|
|
||||||
|
# UniApp H5
|
||||||
|
cd uniapp && npm run build:h5 # 输出到 server/public/mobile/
|
||||||
|
|
||||||
|
# UniApp App
|
||||||
|
cd uniapp && npm run build:app # 生成原生App包
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、里程碑节点
|
||||||
|
|
||||||
|
| 里程碑 | 阶段 | 预计完成 | 交付物 |
|
||||||
|
|--------|------|----------|--------|
|
||||||
|
| M1 | 基础架构 | 第3周 | 数据库+爬虫服务+用户体系+资讯+赛事基础数据 |
|
||||||
|
| M2 | 社区上线 | 第6周 | 社区发帖+跟单+专家认证 |
|
||||||
|
| M3 | AI上线 | 第10周 | AI分析全功能+后台AI配置管理 |
|
||||||
|
| M4 | 商业闭环 | 第13周 | 钱包+分销+VIP+首页聚合 |
|
||||||
|
| **Demo交付** | **全部** | **第13周** | **完整功能Demo + 虚拟数据** |
|
||||||
|
| M5 | 正式上线 | 第15周 | 接入真实数据API+支付+USTD |
|
||||||
|
| M6 | 优化迭代 | 持续 | 性能优化+数据积累+模型调优 |
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Sport Era 项目说明
|
||||||
|
|
||||||
|
## 思考语言
|
||||||
|
|
||||||
|
思考过程请使用中文。
|
||||||
|
|
||||||
|
## 项目信息
|
||||||
|
|
||||||
|
- 基于 likeadmin 的体育资讯 AI 分析 App / 流量平台
|
||||||
|
- 后端:PHP 8.x + ThinkPHP / likeadmin
|
||||||
|
- 管理后台:Vue 3 + Vite + Element Plus + TypeScript
|
||||||
|
- 手机端:uni-app + Vue 3
|
||||||
|
- PC 前台:Nuxt 3 + Vue 3
|
||||||
|
- 数据库:MySQL(`sbnews`,表前缀 `la_`)
|
||||||
|
- 缓存:Redis(ThinkPHP Cache facade)
|
||||||
|
|
||||||
|
## 子项目开发规范
|
||||||
|
|
||||||
|
修改到哪个子项目,则需要遵循对应子项目的 Agent 说明:
|
||||||
|
|
||||||
|
| 模块 | Agent 文件 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 后端 | `server/AGENTS.md` | API 接口、数据库建模、业务逻辑、定时任务 |
|
||||||
|
| 管理后台 | `admin/AGENTS.md` | 消费 adminapi 接口,构建管理页面 |
|
||||||
|
| 手机端 | `uniapp/AGENTS.md` | 消费 api 接口,构建 H5/App/小程序页面 |
|
||||||
|
| PC 前台 | `pc/AGENTS.md` | 消费 api 接口,构建 Nuxt PC 页面 |
|
||||||
|
| 测试 | `qa/AGENTS.md` | 后端 API 验证与前端页面验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编程准则
|
||||||
|
|
||||||
|
1. 做新功能之前必须先检查现有代码是否已有相关功能,避免重复开发。
|
||||||
|
2. 创建新数据表时,用到的字典、枚举值、状态码等必须配置在现有的数据表或配置表内统一管理,不能硬编码。
|
||||||
|
3. 相同或相似的功能必须封装为可复用的方法,避免重复代码。
|
||||||
|
4. 查询字段、路由、菜单、配置前,优先以当前项目代码和 `sbnews.sql` / `docs/sql/` 中结构为准;如需线上核对,再按用户要求使用可用 MCP 或指定连接方式。
|
||||||
|
5. 不通过 WSL sshpass/scp 上传代码到服务器;项目代码更新由用户自行通过 SFTP 等方式处理。
|
||||||
|
6. 项目级文档(如协作规范、业务进度、设计稿映射、产品方案、实现流程、爬虫说明等)统一存放在项目根目录;仅子项目专属文档放在对应子目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用命令速查
|
||||||
|
|
||||||
|
| 模块 | 开发命令 | 构建命令 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| server | `php think run` | — |
|
||||||
|
| admin | `npm run dev` | `npm run build` |
|
||||||
|
| uniapp | `npm run dev:h5` | `npm run build:h5` |
|
||||||
|
| pc | `npm run dev` | `npm run generate` / `npm run build` |
|
||||||
|
|
||||||
|
## 数据库结构基准
|
||||||
|
|
||||||
|
- 数据库表前缀:`la_`。
|
||||||
|
- 配置存储在 `la_config` 表,通过 `ConfigService::get/set` 读写。
|
||||||
|
- 菜单存储在 `la_system_menu` 表,type: M=目录、C=页面、A=按钮权限。
|
||||||
|
- 定时任务注册在 `la_dev_crontab`,通过 `php think crontab` 统一调度。
|
||||||
|
- MySQL 保留字如 `system`、`order`、`group`、`key` 等写原生 SQL 时必须用反引号包裹。
|
||||||
|
|
||||||
|
## 代码修改后验证与评审
|
||||||
|
|
||||||
|
除非用户特别说明不需要验证,代码修改完成后按改动类型选择验证方式:
|
||||||
|
|
||||||
|
### 后端接口修改
|
||||||
|
|
||||||
|
1. PHP 语法检查:`php -l <modified_file.php>`。
|
||||||
|
2. 涉及接口时确认控制器、逻辑层、验证器、列表类、模型字段一致。
|
||||||
|
3. 涉及数据库字段时确认表结构、索引、字典或配置存在。
|
||||||
|
4. 检查前端调用方是否需要同步更新。
|
||||||
|
|
||||||
|
### 前端页面修改
|
||||||
|
|
||||||
|
1. 检查接口请求模块、页面路由、组件引用是否一致。
|
||||||
|
2. 新增 uniapp 页面必须检查 `pages.json`。
|
||||||
|
3. 涉及条件编译时确认 H5/App/小程序差异。
|
||||||
|
4. 检查控制台、网络请求和页面渲染问题。
|
||||||
|
|
||||||
|
### 通用代码评审清单
|
||||||
|
|
||||||
|
| 序号 | 检查项 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 改动范围 | 确认改动文件列表,是否有遗漏或多余文件 |
|
||||||
|
| 2 | 跨文件依赖 | 修改 model/controller/API 后,检查引用方是否同步 |
|
||||||
|
| 3 | 硬编码检查 | 业务枚举、分类、状态优先走字典或配置 |
|
||||||
|
| 4 | 未使用引入 | 检查多余 import/use |
|
||||||
|
| 5 | 命名一致性 | 字段、方法、路由命名与现有风格一致 |
|
||||||
|
| 6 | 安全性 | SQL 拼接、用户输入、权限校验是否安全 |
|
||||||
|
| 7 | 幂等性 | SQL 初始化必须可重复执行 |
|
||||||
|
| 8 | 文档同步 | 功能完成后更新 `业务进度管理.md` |
|
||||||
|
|
||||||
|
## 设计稿映射
|
||||||
|
|
||||||
|
设计稿与页面/组件映射统一维护在:
|
||||||
|
|
||||||
|
- [设计稿映射.md](./设计稿映射.md)
|
||||||
|
|
||||||
|
开发或还原视觉前,先确认设计稿名称、页面路径、实现状态与备注。
|
||||||
|
|
||||||
|
## 业务进度管理
|
||||||
|
|
||||||
|
当前业务进度、待办事项、避坑记录统一维护在:
|
||||||
|
|
||||||
|
- [业务进度管理.md](./业务进度管理.md)
|
||||||
|
|
||||||
|
开发或接手任务前,先查看该文档确认已完成事项、进行中事项、待办事项与避坑记录。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 likeshop技术社区
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<a href="https://www.likeshop.cn"></a><br>
|
||||||
|
<a href="https://www.likeshop.cn"></a><br>
|
||||||
|
<a href="https://www.buildingai.cc"></a><br>
|
||||||
|
<h1 align="center">likeadmin(PHP)
|
||||||
|
通用管理后台</h1>
|
||||||
|
<h4 align="center">🚀快速开发 🛠️ 一键生成代码 ✅后台多端自适应 📱手机端 🖥️PC(电脑)端前台</h4>
|
||||||
|
<p align="center">
|
||||||
|
<p align="center"><a href="https://gitee.com/likeadmin/likeadmin_php_saas">⬇⬇SaaS版点击这里⬇⬇</a></p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.php.net/"><img src="https://img.shields.io/badge/PHP-8-8892bf"></a>
|
||||||
|
<a href="https://www.tslang.cn/"><img src="https://img.shields.io/badge/TypeScript-5-294e80"></a>
|
||||||
|
<a href="#"><img src="https://img.shields.io/badge/ThinkPHP-8.0-6fb737"></a>
|
||||||
|
<a href="#"><img src="https://img.shields.io/badge/Vue.js-3.5-4eb883"></a>
|
||||||
|
<a href="#"><img src="https://img.shields.io/badge/vite-5-ffc018"></a>
|
||||||
|
<a href="#"><img src="https://img.shields.io/badge/Element Plus-2.8-409eff"></a>
|
||||||
|
<a href="https://uniapp.dcloud.io/"><img src="https://img.shields.io/badge/uniapp-3-d85806"></a>
|
||||||
|
<a href="https://www.nuxtjs.cn/"><img src="https://img.shields.io/badge/Nuxt.js-3-18bc78"></a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://mp.weixin.qq.com/"><img src="https://img.shields.io/badge/微信-公众号-05ce66"></a>
|
||||||
|
<a href="https://mp.weixin.qq.com/"><img src="https://img.shields.io/badge/微信-小程序-05ce66"></a>
|
||||||
|
<a href="https://open.weixin.qq.com/"><img src="https://img.shields.io/badge/微信-开放平台-05ce66"></a>
|
||||||
|
<a href="https://pay.weixin.qq.com/"><img src="https://img.shields.io/badge/微信-支付API3-05ce66"></a>
|
||||||
|
<a href="https://cloud.tencent.com/"><img src="https://img.shields.io/badge/腾讯云-COS-00a3ff"></a>
|
||||||
|
<a href="https://cloud.tencent.com/"><img src="https://img.shields.io/badge/腾讯云-短信-00a3ff"></a>
|
||||||
|
<a href="https://www.alipay.com"><img src="https://img.shields.io/badge/支付宝-支付-00a0eb"></a>
|
||||||
|
<a href="https://www.aliyun.com/"><img src="https://img.shields.io/badge/阿里云-OSS-ff6a00"></a>
|
||||||
|
<a href="https://www.aliyun.com/"><img src="https://img.shields.io/badge/阿里云-短信-ff6a00"></a>
|
||||||
|
<a href="https://www.qiniu.com/"><img src="https://img.shields.io/badge/七牛云-OSS-07beff"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 🚀🚀🚀docker 本地一句命令快速部署体验
|
||||||
|
|
||||||
|
### 🐳 快速部署
|
||||||
|
|
||||||
|
安装启动 [docker](https://www.docker.com/) 之后,在终端运行以下命令即可体验。<br>
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -d --name likeadmin_php -p 20221:80 -e MYSQL_ROOT_PASSWORD=root registry.cn-guangzhou.aliyuncs.com/likeadmin/likeadmin_php:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
如果需要自定义参数永久挂载数据,在终端运行以下命令,其中整个"[]"改成自定义参数。
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -d --name likeadmin_php \
|
||||||
|
-v 【主机存储数据库路径】:/var/lib/mysql \
|
||||||
|
-v 【主机存储项目代码文件路径】:/var/www/html/likeadmin_php \
|
||||||
|
-p 【访问端口】:80 \
|
||||||
|
-e MYSQL_ROOT_PASSWORD=【Mysql密码】 \
|
||||||
|
registry.cn-guangzhou.aliyuncs.com/likeadmin/likeadmin_php:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ 注意
|
||||||
|
|
||||||
|
1.如果出现无法访问,请等待 1 分钟,docker 部署每次会下载最新源码。<br> 2.安装的时候,数据默认帐号为 root,默认密码为 root。<br>
|
||||||
|
3.docker 快速部署只适合本地部署体验,不熟悉 docker 请勿用于生产环境,可能造成数据丢失等问题。
|
||||||
|
|
||||||
|
### 🛜 访问
|
||||||
|
|
||||||
|
访问安装程序:http://127.0.0.1:20221
|
||||||
|
<br>PC 端管理后台:http://127.0.0.1:20221/admin/login
|
||||||
|
<br>PC 端前台:http://127.0.0.1:20221/pc/
|
||||||
|
<br>手机端前台:http://127.0.0.1:20221/mobile/
|
||||||
|
|
||||||
|
## 👀 体验后台
|
||||||
|
|
||||||
|
### 管理后台
|
||||||
|
|
||||||
|
**地址:https://php.likeadmin.cn/admin <br>
|
||||||
|
账号:admin 密码:123456**
|
||||||
|
|
||||||
|
### 微信小程序前台
|
||||||
|
|
||||||
|
<img width="40%" src="/server/public/readme/mnp.jpg" /><br>
|
||||||
|
|
||||||
|
### 手机端网页前台
|
||||||
|
|
||||||
|
https://php.likeadmin.cn/mobile
|
||||||
|
|
||||||
|
### PC(电脑)端网页前台
|
||||||
|
|
||||||
|
https://php.likeadmin.cn/pc/
|
||||||
|
|
||||||
|
### 开发文档
|
||||||
|
|
||||||
|
地址:[https://www.likeadmin.cn](https://www.likeadmin.cn "https://www.likeadmin.cn")
|
||||||
|
|
||||||
|
### 🖥️ 界面预览
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
<br>
|
||||||
|
|
||||||
|
### 📱 手机端前台界面
|
||||||
|
|
||||||
|
<center class="half">
|
||||||
|
<img width="23%" src="/server/public/readme/mobile-1.png" />
|
||||||
|
<img width="23%" src="/server/public/readme/mobile-2.png" />
|
||||||
|
<img width="23%" src="/server/public/readme/mobile-3.png" />
|
||||||
|
<img width="23%" src="/server/public/readme/mobile-4.png" />
|
||||||
|
</center> <br>
|
||||||
|
|
||||||
|
### 🖥️PC 端前台界面
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 👨💻 简介
|
||||||
|
|
||||||
|
我们希望能够为开源社区做出更多的贡献,推出永久免费开源的 likeadmin 通用前后端分离管理后台系统。遵循 MIT 开源许可协议,您可以免费使用,甚至允许把你基于 likeadmin 开发的软件应用开源、发布、销售。
|
||||||
|
<br>
|
||||||
|
|
||||||
|
## 🧐 进一步了解
|
||||||
|
|
||||||
|
### 🧰 场景介绍
|
||||||
|
|
||||||
|
1.likeadmin 已经搭建好前后端分离的底层,包含程序安装、登录、登出、工作台、菜单权限控制、角色、管理员、部门管理、岗位管理、素材管理、网站设置、图库管理等基础功能,无需重复造轮子。更有开发者工具功能,一键生成代码,大大节省开发时间。<br> 2.可视化系统程序安装界面,可自定义安装数据,开发者可快速扩展发行自己的软件产品。<br>
|
||||||
|
3.likeadmin 定位为通用的软件系统管理后台,方便开发者快速开发软件系统,文档清晰、代码易懂、简单易用。<br> 4.集成通用的微信支付(API3)、微信小程序公众号登录、阿里云、腾讯云短信,阿里云 OSS、腾讯云 COS、七牛云 OSS 等通用模块。
|
||||||
|
|
||||||
|
### 🐙 后端架构方面
|
||||||
|
|
||||||
|
1.服务端使用 PHP8.0 开发,性能有突破性的提升。<br> 2.使用最新 ThinkPHP8 框架,目前国内最流行的 PHP 框架,高性能、简单易用、文档齐全、支持 composer 快速安装组件、支持 Redis、支持 PSR 多种规范等。
|
||||||
|
|
||||||
|
### 🐹 前端架构方面
|
||||||
|
|
||||||
|
#### 后台
|
||||||
|
|
||||||
|
1.使用最流性的前后端分离方案 typescript、vue3、vite 开发,保持了代码的简洁、一致和规范。<br> 2.后台界面使用 element-plus UI 框架,简单精美的后台界面,丰富的组件库,方便快速开发,满足各种后台交互。
|
||||||
|
|
||||||
|
#### 手机前台
|
||||||
|
|
||||||
|
手机端 uniapp 前台,可以编译成手机 H5 网页、微信小程序、安卓 App,苹果 App 等客户端。
|
||||||
|
|
||||||
|
#### PC 端前台
|
||||||
|
|
||||||
|
PC 端前台使用 Nuxt.js,支持服务器端渲染(SSR),对 SEO 友好。
|
||||||
|
|
||||||
|
### 🛠️ 代码生成器
|
||||||
|
|
||||||
|
一键生成前后端业务代码,大大提升开发效率。
|
||||||
Submodule
+1
Submodule admin added at 87f6b56054
@@ -0,0 +1,2 @@
|
|||||||
|
/data
|
||||||
|
/log
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Copyright (c) 2014, 2016, Oracle and/or its affiliates. All rights reserved.
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; version 2 of the License.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
|
||||||
|
#
|
||||||
|
# The MySQL Server configuration file.
|
||||||
|
#
|
||||||
|
# For explanations see
|
||||||
|
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html
|
||||||
|
|
||||||
|
[mysqld]
|
||||||
|
pid-file = /var/run/mysqld/mysqld.pid
|
||||||
|
socket = /var/run/mysqld/mysqld.sock
|
||||||
|
datadir = /var/lib/mysql
|
||||||
|
log-error = /var/log/mysql/error.log
|
||||||
|
# By default we only accept connections from localhost
|
||||||
|
#bind-address = 127.0.0.1
|
||||||
|
# Disabling symbolic-links is recommended to prevent assorted security risks
|
||||||
|
symbolic-links=0
|
||||||
|
max_connections=1000
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name www.likeadmin.host;
|
||||||
|
access_log /logs/www.likeadmin.host_access_nginx.log;
|
||||||
|
error_log /logs/www.likeadmin.host_error_nginx.log;
|
||||||
|
client_max_body_size 5M;
|
||||||
|
location / {
|
||||||
|
root /likeadmin_php/server/public;
|
||||||
|
index index.html index.htm index.php;
|
||||||
|
if (!-e $request_filename)
|
||||||
|
{
|
||||||
|
rewrite ^/(.*)$ /index.php?s=$1 last;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
location ~ /.*\.php/ {
|
||||||
|
rewrite ^(.*?/?)(.*\.php)(.*)$ /$2?s=$3 last;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass likeadmin-php:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /likeadmin_php/server/public$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
location = /favicon.ico {
|
||||||
|
log_not_found off;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
user nginx;
|
||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
#gzip on;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/company/*.conf;
|
||||||
|
include /etc/nginx/conf.d/my/*.conf;
|
||||||
|
include /etc/nginx/conf.d/product/*.conf;
|
||||||
|
include /etc/nginx/conf.d/project/*.conf;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
upload_max_filesize = 5M
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
likeadmin:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
container_name: likeadmin-nginx
|
||||||
|
image: registry.cn-guangzhou.aliyuncs.com/likeadmin/nginx:1.23.1
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- "php"
|
||||||
|
volumes:
|
||||||
|
- ../server:/likeadmin_php/server
|
||||||
|
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
||||||
|
- ./log/nginx/logs:/logs
|
||||||
|
networks:
|
||||||
|
- likeadmin
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
php:
|
||||||
|
container_name: likeadmin-php
|
||||||
|
image: registry.cn-guangzhou.aliyuncs.com/likeadmin/php:8.0.30.3-fpm
|
||||||
|
restart: always
|
||||||
|
working_dir: /likeadmin_php/server
|
||||||
|
volumes:
|
||||||
|
- ../server:/likeadmin_php/server
|
||||||
|
networks:
|
||||||
|
- likeadmin
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
#user: "1000:1000"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
container_name: likeadmin-mysql
|
||||||
|
image: registry.cn-guangzhou.aliyuncs.com/likeadmin/mysql:5.7.44 #X86架构
|
||||||
|
#image: registry.cn-guangzhou.aliyuncs.com/likeadmin/mysql:5.7.29-amd64 #arm架构
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
volumes:
|
||||||
|
- ./data/mysql5.7.29/lib:/var/lib/mysql
|
||||||
|
- ./config/mysql/mysqld.cnf:/etc/mysql/my.cnf
|
||||||
|
networks:
|
||||||
|
- likeadmin
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
|
||||||
|
|
||||||
|
redis:
|
||||||
|
container_name: likeadmin-redis
|
||||||
|
image: registry.cn-guangzhou.aliyuncs.com/likeadmin/redis:7.4.0
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./data/redis:/data
|
||||||
|
networks:
|
||||||
|
- likeadmin
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- la_sms_upload 扩展来电记录字段
|
||||||
|
-- 说明:
|
||||||
|
-- 1. 保持短信验证码链路继续使用 type=1/2、status=0/1
|
||||||
|
-- 2. 来电记录复用同表,type 使用 11/13/15 表示已接/未接/拒接
|
||||||
|
-- 3. status=2 表示来电记录“不适用验证码状态”
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
ALTER TABLE `la_sms_upload`
|
||||||
|
MODIFY COLUMN `status` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '0=待验证 1=已使用 2=不适用';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- APP 版本管理:建表 + 菜单 + 权限节点
|
||||||
|
-- 表前缀:la_
|
||||||
|
-- 说明:使用项目本身管理 APP 版本(替代 uni-admin 升级中心)
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- 1. 创建版本管理表
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS `la_app_version`;
|
||||||
|
CREATE TABLE `la_app_version` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`version_name` varchar(32) NOT NULL DEFAULT '' COMMENT '版本号,如 1.0.12',
|
||||||
|
`version_code` int unsigned NOT NULL DEFAULT 0 COMMENT '版本数字,如 1012',
|
||||||
|
`min_version_code` int unsigned NOT NULL DEFAULT 0 COMMENT '原生最低版本号(wgt时使用,低于此值的整包不允许热更)',
|
||||||
|
`platform` tinyint NOT NULL DEFAULT 1 COMMENT '平台 1=Android 2=iOS',
|
||||||
|
`package_type` tinyint NOT NULL DEFAULT 1 COMMENT '包类型 1=wgt热更 2=apk整包 3=ipa/AppStore',
|
||||||
|
`update_type` tinyint NOT NULL DEFAULT 1 COMMENT '升级类型 1=普通(可跳过) 2=强制 3=静默',
|
||||||
|
`download_url` varchar(500) NOT NULL DEFAULT '' COMMENT '下载地址(wgt/apk链接 或 AppStore链接)',
|
||||||
|
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '更新标题',
|
||||||
|
`update_content` text COMMENT '更新内容(支持换行)',
|
||||||
|
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态 0=禁用 1=启用',
|
||||||
|
`create_time` int unsigned DEFAULT NULL,
|
||||||
|
`update_time` int unsigned DEFAULT NULL,
|
||||||
|
`delete_time` int unsigned DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_platform_status` (`platform`, `status`),
|
||||||
|
KEY `idx_version_code` (`version_code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='APP版本管理';
|
||||||
|
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- 2. 注册后台菜单(pid=158 应用管理)
|
||||||
|
-- ----------------------------
|
||||||
|
-- 父菜单:版本管理 (M)
|
||||||
|
INSERT INTO `la_system_menu` (`id`, `pid`, `type`, `name`, `icon`, `paths`, `perms`, `component`, `selected`, `params`, `sort`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
VALUES (542, 158, 'M', '版本管理', 'el-icon-Coin', 'version', '', '', '', '', 110, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||||
|
|
||||||
|
-- 子菜单:版本列表 (C)
|
||||||
|
INSERT INTO `la_system_menu` (`id`, `pid`, `type`, `name`, `icon`, `paths`, `perms`, `component`, `selected`, `params`, `sort`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
VALUES (543, 542, 'C', '版本列表', 'el-icon-Document', 'lists', 'app.appVersion/lists', 'app/version/lists/index', '', '', 0, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||||
|
|
||||||
|
-- 子菜单:版本添加/编辑 (C)(隐藏菜单,仅做路由)
|
||||||
|
INSERT INTO `la_system_menu` (`id`, `pid`, `type`, `name`, `icon`, `paths`, `perms`, `component`, `selected`, `params`, `sort`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
VALUES (544, 542, 'C', '版本添加/编辑', 'el-icon-Edit', 'lists/edit', 'app.appVersion/add:edit', 'app/version/lists/edit', 'version/lists', '', 0, 0, 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||||
|
|
||||||
|
-- 权限节点:新增 / 编辑 / 删除 / 详情 / 修改状态
|
||||||
|
INSERT INTO `la_system_menu` (`id`, `pid`, `type`, `name`, `icon`, `paths`, `perms`, `component`, `selected`, `params`, `sort`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) VALUES
|
||||||
|
(545, 543, 'A', '新增', '', '', 'app.appVersion/add', '', '', '', 0, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(546, 543, 'A', '编辑', '', '', 'app.appVersion/edit', '', '', '', 0, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(547, 543, 'A', '删除', '', '', 'app.appVersion/delete', '', '', '', 0, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(548, 543, 'A', '详情', '', '', 'app.appVersion/detail', '', '', '', 0, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(549, 543, 'A', '修改状态', '', '', 'app.appVersion/updateStatus', '', '', '', 0, 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 联赛配置表 la_league
|
||||||
|
-- 来源: 懂球帝 API /data/tab/list 接口
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `la_league`;
|
||||||
|
|
||||||
|
CREATE TABLE `la_league` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
|
||||||
|
`tab_id` int NOT NULL DEFAULT 0 COMMENT '懂球帝 tab ID(原始 id)',
|
||||||
|
`league_id` int NOT NULL DEFAULT 0 COMMENT '懂球帝联赛 ID',
|
||||||
|
`label` varchar(50) NOT NULL DEFAULT '' COMMENT '联赛显示名称',
|
||||||
|
`type` varchar(50) NOT NULL DEFAULT '' COMMENT '类型标识: league/important/lottery',
|
||||||
|
`sport_type` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '运动类型: 1=足球 2=篮球 3=网球 4=电竞 5=综合',
|
||||||
|
`api` varchar(500) NOT NULL DEFAULT '' COMMENT '懂球帝 API 地址',
|
||||||
|
`icon` varchar(500) NOT NULL DEFAULT '' COMMENT '联赛图标 URL',
|
||||||
|
`sort` int NOT NULL DEFAULT 0 COMMENT '排序权重(越小越靠前)',
|
||||||
|
`is_show` tinyint unsigned NOT NULL DEFAULT 1 COMMENT '是否显示: 0=隐藏 1=显示',
|
||||||
|
`extra_data` json DEFAULT NULL COMMENT '额外数据(如 calendar 等)',
|
||||||
|
`create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`update_time` int unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
|
||||||
|
`delete_time` int unsigned DEFAULT NULL COMMENT '删除时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_tab_id` (`tab_id`),
|
||||||
|
KEY `idx_league_id` (`league_id`),
|
||||||
|
KEY `idx_type` (`type`),
|
||||||
|
KEY `idx_sort` (`sort`),
|
||||||
|
KEY `idx_sport_type` (`sport_type`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='联赛配置表';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 插入数据
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
INSERT INTO `la_league` (`tab_id`, `league_id`, `label`, `type`, `sport_type`, `api`, `sort`, `is_show`, `extra_data`, `create_time`, `update_time`) VALUES
|
||||||
|
-- === 聚合分类 ===
|
||||||
|
(10, 0, '重要', 'important', 5, 'https://api.dongqiudi.com/data/tab/new/important', 278, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(12, 0, '全球', 'lottery', 5, 'https://api.dongqiudi.com/data/tab/new/lottery', 279, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(70, 0, '足球', 'important', 1, 'https://api.dongqiudi.com/data/tab/new/soccer', 281, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(69, 0, '篮球', 'important', 2, 'https://api.dongqiudi.com/data/tab/new/basketball', 282, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(107, 0, '网球', 'important', 3, 'https://api.dongqiudi.com/data/tab/new/tennis', 283, 1, '{"calendar":{"icon":"https://img1.dongqiudi.com/fastdfs7/M00/35/44/rBUC6GhrdLGANtZqAAAmY4Lwmkg879.png","scheme":"dongqiudi:///url/https://topic.dongqiudi.com/webapp/tennisCalendar/tennisCalendar.html"}}', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(65, 0, '电竞', 'important', 4, 'https://api.dongqiudi.com/data/tab/new/esport', 284, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(106, 0, '体坛', 'important', 5, 'https://api.dongqiudi.com/data/tab/new/sport', 285, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
|
||||||
|
-- === 足球联赛 ===
|
||||||
|
(5, 43, '中超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/43', 286, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(6, 4, '英超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/4', 287, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(8, 5, '德甲', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/5', 288, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(7, 3, '西甲', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/3', 289, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(9, 9, '意甲', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/9', 290, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(30, 12, '法甲', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/12', 291, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(31, 6, '欧冠', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/6', 292, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(34, 14, '欧联', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/14', 293, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(84, 3904, '欧协联', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/3904', 294, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(105, 226, '亚冠精英', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/226', 297, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(98, 257, '澳超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/257', 298, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(99, 197, '沙特联', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/197', 299, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(100, 129, '中甲', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/129', 300, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(101, 1535, '中乙', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/1535', 301, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(111, 258, '世俱杯', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/258', 302, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(113, 9930, '川超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/9930', 303, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(118, 10225,'滇超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/10225', 304, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(112, 8647, '苏超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/8647', 305, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(115, 8817, '赣超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/8817', 306, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(114, 9640, '湘超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/9640', 307, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(116, 9576, '内蒙超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/9576', 308, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(117, 9687, '齐鲁超', 'league', 1, 'https://api.dongqiudi.com/data/tab/league/new/9687', 309, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
|
||||||
|
-- === 篮球联赛 ===
|
||||||
|
(68, 3010, 'NBA', 'league', 2, 'https://api.dongqiudi.com/data/tab/league/new/3010', 295, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||||
|
(67, 3027, 'CBA', 'league', 2, 'https://api.dongqiudi.com/data/tab/league/new/3027', 296, 1, NULL, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 请求域名
|
||||||
|
NUXT_API_URL=
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 版本号
|
||||||
|
NUXT_VERSION=1.9.0
|
||||||
|
|
||||||
|
# 接口默认前缀
|
||||||
|
NUXT_API_PREFIX=/api
|
||||||
|
|
||||||
|
# 客户端类型
|
||||||
|
NUXT_CLIENT=4
|
||||||
|
|
||||||
|
# 基础路径
|
||||||
|
NUXT_BASE_URL=/pc/
|
||||||
|
|
||||||
|
# 是否开启ssr,填些任意值开启, 开启的话运行 npm run build:ssr命令打包
|
||||||
|
NUXT_SSR=
|
||||||
|
|
||||||
|
# 端口号
|
||||||
|
NITRO_PORT=3000
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
# 请求域名
|
||||||
|
NUXT_API_URL=
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: [
|
||||||
|
'plugin:nuxt/recommended',
|
||||||
|
'plugin:vue/vue3-essential',
|
||||||
|
'eslint:recommended',
|
||||||
|
'@vue/eslint-config-typescript/recommended',
|
||||||
|
'@vue/eslint-config-prettier'
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
sourceType: 'module'
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint'],
|
||||||
|
rules: {
|
||||||
|
'prettier/prettier': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
semi: false,
|
||||||
|
singleQuote: true,
|
||||||
|
printWidth: 80,
|
||||||
|
proseWrap: 'preserve',
|
||||||
|
bracketSameLine: false,
|
||||||
|
endOfLine: 'auto',
|
||||||
|
tabWidth: 4,
|
||||||
|
useTabs: false,
|
||||||
|
trailingComma: 'none'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'no-useless-escape': 'off',
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
|
'no-undef': 'off',
|
||||||
|
'vue/prefer-import-from-vue': 'off',
|
||||||
|
'no-prototype-builtins': 'off',
|
||||||
|
'prefer-spread': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off'
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
module: 'readonly'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
*.log*
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
.output
|
||||||
|
.vite
|
||||||
|
.env
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
dist
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"endOfLine": "auto",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# PC Agent — Sport Era PC 前台
|
||||||
|
|
||||||
|
Nuxt 3 / Vue 3 PC 前台站点。**消费 `server/app/api/controller/` 的接口(与 uniapp 共享 `/api/*`)。**
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **Nuxt** 3 + **Vue** 3
|
||||||
|
- **TypeScript**
|
||||||
|
- **Pinia** 状态管理
|
||||||
|
- **Tailwind CSS** / SCSS
|
||||||
|
- **Axios / ofetch** HTTP 请求
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
pc/
|
||||||
|
├── nuxt.config.ts # Nuxt 配置
|
||||||
|
├── app.vue # 根布局
|
||||||
|
├── pages/ # 文件路由
|
||||||
|
├── components/ # 公共组件
|
||||||
|
├── composables/ # 组合式函数
|
||||||
|
├── stores/ # Pinia Store
|
||||||
|
├── api/ # 接口请求模块(→ 后端 /api/*)
|
||||||
|
├── plugins/ # Nuxt 插件
|
||||||
|
├── middleware/ # 路由中间件
|
||||||
|
├── layouts/ # 页面布局
|
||||||
|
├── public/ # 静态资源
|
||||||
|
└── assets/ # 编译资源
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run generate
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 并行协作协议
|
||||||
|
|
||||||
|
### 新功能开发时
|
||||||
|
|
||||||
|
#### Step 1: 等待或查阅后端 API 契约
|
||||||
|
|
||||||
|
查看 `server/app/api/controller/` 下的 controller,确认接口签名、请求参数和响应结构。
|
||||||
|
|
||||||
|
#### Step 2: 并行实现
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. api/ 下新增或复用请求模块
|
||||||
|
2. pages/ 下新增页面(文件路由)
|
||||||
|
3. components/ 下复用或新增组件
|
||||||
|
4. stores/ 处理跨页面状态
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: 构建联调
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码约定
|
||||||
|
|
||||||
|
- 接口请求通过 `composables` 或 `api/` 模块统一封装。
|
||||||
|
- SEO 关键页面使用 `useHead` 配置 `<title>` / `<meta>`。
|
||||||
|
- 接口 baseURL 由 `runtimeConfig` 或环境配置控制,不要写死。
|
||||||
|
- 页面级数据获取优先使用 `useAsyncData` / `useFetch`。
|
||||||
|
- 与 uniapp 共享前台接口契约,避免同一业务前台两套字段命名。
|
||||||
|
- 设计稿与页面映射维护在根目录 `设计稿映射.md`。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Nuxt 3 Minimal Starter
|
||||||
|
|
||||||
|
Look at the [nuxt 3 documentation](https://v3.nuxtjs.org) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install --shamefully-hoist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on http://localhost:3000
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { getClient } from '~~/utils/env'
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
export function login(params: any) {
|
||||||
|
return $request.post({
|
||||||
|
url: '/login/account',
|
||||||
|
params: { ...params, terminal: getClient() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 登录
|
||||||
|
export function logout() {
|
||||||
|
return $request.post({ url: '/login/logout' })
|
||||||
|
}
|
||||||
|
|
||||||
|
//注册
|
||||||
|
export function register(params: any) {
|
||||||
|
return $request.post({
|
||||||
|
url: '/login/register',
|
||||||
|
params: { ...params, channel: getClient() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//向微信请求code的链接
|
||||||
|
export function getWxCodeUrl() {
|
||||||
|
return $request.get({
|
||||||
|
url: '/login/getScanCode',
|
||||||
|
params: {
|
||||||
|
url: location.href
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wxLogin(params: any) {
|
||||||
|
return $request.post({ url: '/login/scanLogin', params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//发送短信
|
||||||
|
export function smsSend(params: any) {
|
||||||
|
return $request.post({ url: '/sms/sendCode', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
export function getConfig() {
|
||||||
|
return $request.get({ url: '/pc/config' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取协议
|
||||||
|
export function getPolicy(params: any) {
|
||||||
|
return $request.get({ url: '/index/policy', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
export function uploadImage(params: any) {
|
||||||
|
return $request.uploadFile({ url: '/upload/image' }, params)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @description 获取文章分类
|
||||||
|
* @return { Promise }
|
||||||
|
*/
|
||||||
|
export function getArticleCate() {
|
||||||
|
return $request.get({ url: '/article/cate' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取文章列表
|
||||||
|
* @return { Promise }
|
||||||
|
*/
|
||||||
|
export function getArticleList(params) {
|
||||||
|
return $request.get({ url: '/article/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取资讯中心
|
||||||
|
* @return { Promise }
|
||||||
|
*/
|
||||||
|
export function getArticleCenter() {
|
||||||
|
return $request.get({ url: '/pc/infoCenter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 文章详情
|
||||||
|
* @return { Promise }
|
||||||
|
*/
|
||||||
|
export function getArticleDetail(params) {
|
||||||
|
return $request.get({ url: '/pc/articleDetail', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 加入收藏
|
||||||
|
* @param { number } id
|
||||||
|
* @return { Promise }
|
||||||
|
*/
|
||||||
|
export function addCollect(params) {
|
||||||
|
return $request.post({ url: '/article/addCollect', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 取消收藏
|
||||||
|
* @param { number } id
|
||||||
|
* @return { Promise }
|
||||||
|
*/
|
||||||
|
export function cancelCollect(params) {
|
||||||
|
return $request.post({ url: '/article/cancelCollect', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取收藏列表
|
||||||
|
* @return { Promise }
|
||||||
|
*/
|
||||||
|
export function getCollect(params) {
|
||||||
|
return $request.get({ url: '/article/collect', params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//首页数据
|
||||||
|
export function getIndex() {
|
||||||
|
return $request.get({ url: '/pc/index' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export function getUserCenter(headers?: any) {
|
||||||
|
return $request.get({ url: '/user/center', headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 个人信息
|
||||||
|
export function getUserInfo() {
|
||||||
|
return $request.get({ url: '/user/info' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 个人编辑
|
||||||
|
export function userEdit(params: any) {
|
||||||
|
return $request.post({ url: '/user/setInfo', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定手机
|
||||||
|
export function userBindMobile(params: any, headers?: any) {
|
||||||
|
return $request.post(
|
||||||
|
{ url: '/user/bindMobile', params, headers },
|
||||||
|
{ withToken: !headers?.token }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信电话
|
||||||
|
export function userMnpMobile(params: any) {
|
||||||
|
return $request.post({ url: '/user/getMobileByMnp', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更改密码
|
||||||
|
export function userChangePwd(params: any) {
|
||||||
|
return $request.post({ url: '/user/changePassword', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
//忘记密码
|
||||||
|
export function forgotPassword(params: Record<string, any>) {
|
||||||
|
return $request.post({ url: '/user/resetPassword', params })
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ID_INJECTION_KEY, ElConfigProvider } from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import { useAppStore } from './stores/app'
|
||||||
|
|
||||||
|
provide(ID_INJECTION_KEY, {
|
||||||
|
prefix: 100,
|
||||||
|
current: 0
|
||||||
|
})
|
||||||
|
const config = {
|
||||||
|
locale: zhCn
|
||||||
|
}
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const { pc_title, pc_ico, pc_keywords, pc_desc } = appStore.getWebsiteConfig
|
||||||
|
const { clarity_code } = appStore.getSiteStatistics
|
||||||
|
const headOptions: any = {
|
||||||
|
title: pc_title,
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: pc_desc },
|
||||||
|
{ name: 'keywords', content: pc_keywords }
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
href: pc_ico
|
||||||
|
}
|
||||||
|
],
|
||||||
|
script: []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clarity_code) {
|
||||||
|
headOptions.script.push({
|
||||||
|
type: 'text/javascript',
|
||||||
|
innerHTML: `
|
||||||
|
(function(c,l,a,r,i,t,y){
|
||||||
|
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||||
|
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||||
|
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||||
|
})(window, document, "clarity", "script", "${clarity_code}");
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
useHead(headOptions)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ElConfigProvider v-bind="config">
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtLoadingIndicator color="#4a5dff" :height="2" />
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</ElConfigProvider>
|
||||||
|
</template>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,137 @@
|
|||||||
|
@import "element-plus/theme-chalk/index.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// 弹窗居中
|
||||||
|
.el-overlay-dialog {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100%;
|
||||||
|
position: static;
|
||||||
|
|
||||||
|
.el-dialog {
|
||||||
|
--el-dialog-content-font-size: var(--el-font-size-base);
|
||||||
|
--el-dialog-margin-top: 50px;
|
||||||
|
max-width: calc(100vw - 30px);
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
&.body-padding .el-dialog__body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog__body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 15px 20px;
|
||||||
|
}
|
||||||
|
.el-dialog__header {
|
||||||
|
font-size: var(--el-font-size-large);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-drawer {
|
||||||
|
--el-drawer-padding-primary: 16px;
|
||||||
|
&__header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
&__title {
|
||||||
|
@apply text-tx-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
--el-table-header-text-color: var(--el-text-color-primary);
|
||||||
|
--el-table-header-bg-color: var(--table-header-bg-color);
|
||||||
|
font-size: var(--el-font-size-base);
|
||||||
|
|
||||||
|
thead {
|
||||||
|
th {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input-group__prepend {
|
||||||
|
background-color: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-checkbox {
|
||||||
|
--el-checkbox-font-size: var(--el-font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box {
|
||||||
|
--el-messagebox-width: 350px;
|
||||||
|
}
|
||||||
|
.el-date-editor {
|
||||||
|
--el-date-editor-datetimerange-width: 380px;
|
||||||
|
.el-range-input {
|
||||||
|
font-size: var(--el-font-size-small);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
--el-button-hover-link-text-color: var(--el-color-primary-light-3);
|
||||||
|
--el-button-text-color: var(--color-btn-text);
|
||||||
|
--el-button-hover-text-color:var(--color-btn-text);
|
||||||
|
}
|
||||||
|
.el-button--success {
|
||||||
|
--el-button-hover-link-text-color: var(--el-color-success-light-3);
|
||||||
|
}
|
||||||
|
.el-button--info {
|
||||||
|
--el-button-hover-link-text-color: var(--el-color-info-light-3);
|
||||||
|
}
|
||||||
|
.el-button--warning {
|
||||||
|
--el-button-hover-link-text-color: var(--el-color-warning-light-3);
|
||||||
|
}
|
||||||
|
.el-button--danger {
|
||||||
|
--el-button-hover-link-text-color: var(--el-color-danger-light-3);
|
||||||
|
}
|
||||||
|
.el-button--large {
|
||||||
|
--el-button-size: 38px;
|
||||||
|
padding: 11px 20px;
|
||||||
|
}
|
||||||
|
.el-image__error {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.el-tabs__nav-wrap::after {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.el-page-header {
|
||||||
|
&__breadcrumb {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-card {
|
||||||
|
--el-card-border-radius: 8px;
|
||||||
|
}
|
||||||
|
.el-menu {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.el-pagination.is-background .btn-next.is-active,
|
||||||
|
.el-pagination.is-background .btn-prev.is-active,
|
||||||
|
.el-pagination.is-background .el-pager li.is-active{
|
||||||
|
color: var(--color-btn-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
// 防止被tailwindcss默认样式覆盖
|
||||||
|
background-color: var(--el-button-bg-color, var(--el-color-white));
|
||||||
|
|
||||||
|
//覆盖el-button的点击样式
|
||||||
|
&:focus {
|
||||||
|
color: var(--el-button-text-color);
|
||||||
|
border-color: var(--el-button-border-color);
|
||||||
|
background-color: var(--el-button-bg-color);
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-button-hover-text-color);
|
||||||
|
border-color: var(--el-button-hover-border-color);
|
||||||
|
background-color: var(--el-button-hover-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@use 'element.scss';
|
||||||
|
@use 'var.css';
|
||||||
|
@use 'public.scss';
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
body {
|
||||||
|
@apply text-base text-tx-primary bg-page;
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
// width: 100vw;
|
||||||
|
}
|
||||||
|
.form-tips {
|
||||||
|
@apply text-tx-secondary text-xs leading-6 mt-1;
|
||||||
|
}
|
||||||
|
.el-button {
|
||||||
|
background-color: var(--el-button-bg-color, var(--el-color-white));
|
||||||
|
}
|
||||||
|
.clearfix:after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
clear: both;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-html {
|
||||||
|
ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.17em;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: 0.83em;
|
||||||
|
}
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5 {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NProgress */
|
||||||
|
#nprogress .bar {
|
||||||
|
@apply bg-primary #{!important};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
:root body {
|
||||||
|
--el-font-family: theme(fontFamily.sans);
|
||||||
|
--color-primary: #4153ff;
|
||||||
|
--el-color-primary: var(--color-primary);
|
||||||
|
--color-minor: #7583ff;
|
||||||
|
--color-btn-text: white;
|
||||||
|
--el-color-primary-dark-2: rgb(19, 153, 166);
|
||||||
|
--el-color-primary-light-3: rgb(93, 210, 222);
|
||||||
|
--el-color-primary-light-5: rgb(140, 223, 232);
|
||||||
|
--el-color-primary-light-7: rgb(186, 236, 241);
|
||||||
|
--el-color-primary-light-8: rgb(209, 242, 246);
|
||||||
|
--el-color-primary-light-9: rgb(232, 249, 250);
|
||||||
|
--el-font-weight-primary: 400;
|
||||||
|
--el-menu-item-height: 46px;
|
||||||
|
--el-menu-sub-item-height: var(--el-menu-item-height);
|
||||||
|
--el-menu-icon-width: 18px;
|
||||||
|
--sidebar-width: 140px;
|
||||||
|
--aside-bg-color: #fff;
|
||||||
|
--header-height: 60px;
|
||||||
|
--color-white: #ffffff;
|
||||||
|
--table-header-bg-color: #f8f8f8;
|
||||||
|
--el-font-size-extra-large: 18px;
|
||||||
|
--el-menu-horizontal-height: 44px;
|
||||||
|
--el-menu-base-level-padding: 16px;
|
||||||
|
--el-menu-level-padding: 26px;
|
||||||
|
--el-font-size-large: 16px;
|
||||||
|
--el-font-size-medium: 15px;
|
||||||
|
--el-font-size-base: 14px;
|
||||||
|
--el-font-size-small: 13px;
|
||||||
|
--el-font-size-extra-small: 12px;
|
||||||
|
|
||||||
|
--gradient-1: var(--color-primary);
|
||||||
|
--gradient-2: var(--color-minor);
|
||||||
|
|
||||||
|
--el-bg-color: var(--color-white);
|
||||||
|
--el-bg-color-page: #f7f7f7;
|
||||||
|
--el-bg-color-overlay: #ffffff;
|
||||||
|
--el-text-color-primary: #101010;
|
||||||
|
--el-text-color-regular: #666666;
|
||||||
|
--el-text-color-secondary: #999999;
|
||||||
|
--el-text-color-placeholder: #a8abb2;
|
||||||
|
--el-text-color-disabled: #c0c4cc;
|
||||||
|
--el-border-color: #dcdfe6;
|
||||||
|
--el-border-color-light: #e4e7ed;
|
||||||
|
--el-border-color-lighter: #ebeef5;
|
||||||
|
--el-border-color-extra-light: #f2f2f2;
|
||||||
|
--el-border-color-dark: #d4d7de;
|
||||||
|
--el-border-color-darker: #cdd0d6;
|
||||||
|
--el-fill-color: #f0f2f5;
|
||||||
|
--el-fill-color-light: #f5f7fa;
|
||||||
|
--el-fill-color-lighter: #fafafa;
|
||||||
|
--el-fill-color-extra-light: #fafcff;
|
||||||
|
--el-fill-color-dark: #ebedf0;
|
||||||
|
--el-fill-color-darker: #e6e8eb;
|
||||||
|
--el-fill-color-blank: #ffffff;
|
||||||
|
--el-mask-color: rgba(255, 255, 255, 0.9);
|
||||||
|
--el-mask-color-extra-light: rgba(255, 255, 255, 0.3);
|
||||||
|
-el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04),
|
||||||
|
0px 8px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08),
|
||||||
|
0px 12px 32px rgba(0, 0, 0, 0.12), 0px 8px 16px -8px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<div>
|
||||||
|
<ElUpload
|
||||||
|
ref="uploadRef"
|
||||||
|
:show-file-list="false"
|
||||||
|
:limit="1"
|
||||||
|
:on-change="handleChange"
|
||||||
|
:auto-upload="false"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ElUpload>
|
||||||
|
<ElDialog
|
||||||
|
v-model="state.cropperVisible"
|
||||||
|
:append-to-body="true"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:width="600"
|
||||||
|
@close="state.cropperVisible = false"
|
||||||
|
>
|
||||||
|
<div class="h-[400px]">
|
||||||
|
<VueCropper
|
||||||
|
ref="vueCropperRef"
|
||||||
|
:img="state.imagePath"
|
||||||
|
:autoCrop="true"
|
||||||
|
:auto-crop-height="200"
|
||||||
|
:auto-crop-width="200"
|
||||||
|
output-type="png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<ElButton @click="handleConfirmCropper">
|
||||||
|
确认裁剪
|
||||||
|
</ElButton>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElUpload, ElDialog, ElButton } from 'element-plus'
|
||||||
|
import 'vue-cropper/dist/index.css'
|
||||||
|
import { VueCropper } from 'vue-cropper'
|
||||||
|
import { uploadImage } from '~~/api/app'
|
||||||
|
const emit = defineEmits(['change'])
|
||||||
|
const vueCropperRef = shallowRef()
|
||||||
|
const uploadRef = shallowRef<InstanceType<typeof ElUpload>>()
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
cropperVisible: false,
|
||||||
|
imagePath: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleChange = (rawFile) => {
|
||||||
|
const URL = window.URL || window.webkitURL
|
||||||
|
state.imagePath = URL.createObjectURL(rawFile.raw)
|
||||||
|
state.cropperVisible = true
|
||||||
|
}
|
||||||
|
const handleConfirmCropper = () => {
|
||||||
|
vueCropperRef.value?.getCropBlob(async (file) => {
|
||||||
|
const fileName = `file.${file.type.split('/')[1]}`
|
||||||
|
const imgFile = new window.File([file], fileName, {
|
||||||
|
type: file.type
|
||||||
|
})
|
||||||
|
const data = await uploadImage({ file: imgFile })
|
||||||
|
state.cropperVisible = false
|
||||||
|
emit('change', data.uri)
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<ElIcon v-bind="props" v-if="name.includes(EL_ICON_PREFIX)">
|
||||||
|
<component :is="name" />
|
||||||
|
</ElIcon>
|
||||||
|
<span v-if="name.includes(LOCAL_ICON_PREFIX)" class="local-icon">
|
||||||
|
<SvgIcon v-bind="props" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElIcon } from 'element-plus'
|
||||||
|
import { EL_ICON_PREFIX, LOCAL_ICON_PREFIX } from '~~/plugins/icons'
|
||||||
|
import SvgIcon from './svg-icon.vue'
|
||||||
|
const props = defineProps({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '14px'
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'inherit'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<svg aria-hidden="true" :style="styles">
|
||||||
|
<use :xlink:href="symbolId" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { addUnit } from '@/utils/util'
|
||||||
|
import type { CSSProperties } from 'vue'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 16
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'inherit'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const symbolId = computed(() => `#${props.name}`)
|
||||||
|
const styles = computed<CSSProperties>(() => {
|
||||||
|
return {
|
||||||
|
width: addUnit(props.size),
|
||||||
|
height: addUnit(props.size),
|
||||||
|
color: props.color
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { symbolId, styles }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-[8px]">
|
||||||
|
<div class="flex items-center h-[60px] border-b border-br ml-5 pr-5">
|
||||||
|
<div class="flex-1 flex min-w-0 mr-4 h-full">
|
||||||
|
<span
|
||||||
|
class="text-2xl truncate font-medium h-full border-b-2 border-tx-primary mt-[1px] flex items-center"
|
||||||
|
>
|
||||||
|
{{ header }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ElButton class="button" link v-if="link">
|
||||||
|
<NuxtLink :to="link" class="flex">
|
||||||
|
更多
|
||||||
|
<ElIcon><ArrowRight /></ElIcon>
|
||||||
|
</NuxtLink>
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<slot name="content" :data="data" v-if="data.length">
|
||||||
|
<div class="px-5 pb-5">
|
||||||
|
<template v-for="(item, index) in data" :key="item.id">
|
||||||
|
<slot name="item" :item="item" :index="index">
|
||||||
|
<InformationItems
|
||||||
|
:index="index"
|
||||||
|
:show-sort="showSort"
|
||||||
|
:id="item.id"
|
||||||
|
:title="item.title"
|
||||||
|
:desc="item.desc"
|
||||||
|
:click="item.click"
|
||||||
|
:author="item.author"
|
||||||
|
:create-time="item.create_time"
|
||||||
|
:image="item.image"
|
||||||
|
:only-title="onlyTitle"
|
||||||
|
:image-size="imageSize"
|
||||||
|
:show-author="showAuthor"
|
||||||
|
:show-desc="showDesc"
|
||||||
|
:show-click="showClick"
|
||||||
|
:border="border"
|
||||||
|
:title-line="titleLine"
|
||||||
|
:show-time="showTime"
|
||||||
|
:source="source"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
<div v-else>
|
||||||
|
<el-empty
|
||||||
|
:image="empty_news"
|
||||||
|
description="暂无资讯"
|
||||||
|
:image-size="250"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElButton, ElIcon, ElEmpty } from 'element-plus'
|
||||||
|
import empty_news from '@/assets/images/empty_news.png'
|
||||||
|
import { ArrowRight } from '@element-plus/icons-vue'
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
defineProps({
|
||||||
|
header: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Array as PropType<any[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
},
|
||||||
|
onlyTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
titleLine: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
imageSize: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
},
|
||||||
|
showAuthor: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showDesc: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showClick: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showTime: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink :to="`/information/detail/${id}`">
|
||||||
|
<div
|
||||||
|
v-if="onlyTitle"
|
||||||
|
class="before:w-[6px] mt-4 before:h-[6px] before:bg-primary before:block flex items-center before:rounded-[6px] before:mr-2.5 before:flex-none"
|
||||||
|
>
|
||||||
|
<slot name="title" :title="title">
|
||||||
|
<span class="line-clamp-1 flex-1 font-medium">{{ title }}</span>
|
||||||
|
</slot>
|
||||||
|
<span class="text-tx-secondary ml-4" v-if="showTime">
|
||||||
|
{{ createTime }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
:class="{
|
||||||
|
'border-b border-br pb-4': border,
|
||||||
|
'flex pt-4 items-center': !isHorizontal
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex relative">
|
||||||
|
<ElImage
|
||||||
|
v-if="image"
|
||||||
|
class="flex-none"
|
||||||
|
:class="{
|
||||||
|
'mr-4': !isHorizontal
|
||||||
|
}"
|
||||||
|
:src="image"
|
||||||
|
fit="cover"
|
||||||
|
:style="getImageStyle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex-1"
|
||||||
|
:class="{
|
||||||
|
'p-2': isHorizontal
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot name="title" :title="title">
|
||||||
|
<div
|
||||||
|
class="text-lg font-medium"
|
||||||
|
:class="`line-clamp-${titleLine}`"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showDesc && desc"
|
||||||
|
class="text-tx-regular line-clamp-2 mt-4"
|
||||||
|
>
|
||||||
|
{{ desc }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showAuthor || showTime || showClick"
|
||||||
|
class="mt-5 text-tx-secondary flex items-center flex-wrap"
|
||||||
|
>
|
||||||
|
<span v-if="showAuthor && author">
|
||||||
|
{{ author }} |
|
||||||
|
</span>
|
||||||
|
<span class="mr-5" v-if="showTime">{{ createTime }}</span>
|
||||||
|
<div v-if="showClick" class="flex items-center">
|
||||||
|
<ElIcon>
|
||||||
|
<View />
|
||||||
|
</ElIcon>
|
||||||
|
<span> {{ click }}人浏览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElImage, ElIcon } from 'element-plus'
|
||||||
|
import { View } from '@element-plus/icons-vue'
|
||||||
|
const props = defineProps({
|
||||||
|
index: {
|
||||||
|
type: Number
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: Number
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
desc: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
click: {
|
||||||
|
type: Number
|
||||||
|
},
|
||||||
|
createTime: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
onlyTitle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
isHorizontal: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
titleLine: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
},
|
||||||
|
imageSize: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
},
|
||||||
|
showAuthor: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showDesc: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showClick: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showTime: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const getImageStyle = computed(() => {
|
||||||
|
switch (props.imageSize) {
|
||||||
|
case 'default':
|
||||||
|
return {
|
||||||
|
width: '180px',
|
||||||
|
height: '135px'
|
||||||
|
}
|
||||||
|
case 'mini':
|
||||||
|
return {
|
||||||
|
width: '120px',
|
||||||
|
height: '90px'
|
||||||
|
}
|
||||||
|
case 'large':
|
||||||
|
return {
|
||||||
|
width: '260px',
|
||||||
|
height: '195px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<div @mouseenter="inPopover = true" @mouseleave="inPopover = false">
|
||||||
|
<el-popover
|
||||||
|
placement="top"
|
||||||
|
v-model:visible="visible"
|
||||||
|
:width="width"
|
||||||
|
trigger="contextmenu"
|
||||||
|
class="popover-input"
|
||||||
|
:teleported="teleported"
|
||||||
|
:persistent="false"
|
||||||
|
popper-class="!p-0"
|
||||||
|
>
|
||||||
|
<div class="flex p-3" @click.stop="">
|
||||||
|
<div class="popover-input__input mr-[10px] flex-1">
|
||||||
|
<el-select
|
||||||
|
class="flex-1"
|
||||||
|
:size="size"
|
||||||
|
v-if="type == 'select'"
|
||||||
|
v-model="inputValue"
|
||||||
|
:teleported="teleported"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in options"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
></el-option>
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
v-model.trim="inputValue"
|
||||||
|
:maxlength="limit"
|
||||||
|
:show-word-limit="showLimit"
|
||||||
|
:type="type"
|
||||||
|
:size="size"
|
||||||
|
clearable
|
||||||
|
:placeholder="placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="popover-input__btns flex-none">
|
||||||
|
<el-button link @click="close">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:size="size"
|
||||||
|
@click="handleConfirm"
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #reference>
|
||||||
|
<div class="inline" @click.stop="handleOpen">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
import { ElPopover, ElButton, ElSelect, ElOption, ElInput } from 'element-plus'
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: '300px'
|
||||||
|
},
|
||||||
|
placeholder: String,
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array as PropType<any[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String as PropType<'default' | 'small' | 'large'>,
|
||||||
|
default: 'default'
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: Number,
|
||||||
|
default: 200
|
||||||
|
},
|
||||||
|
showLimit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
teleported: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['confirm'])
|
||||||
|
const visible = ref(false)
|
||||||
|
const inPopover = ref(false)
|
||||||
|
const inputValue = ref()
|
||||||
|
const handleConfirm = () => {
|
||||||
|
close()
|
||||||
|
emit('confirm', inputValue.value)
|
||||||
|
}
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
(value) => {
|
||||||
|
inputValue.value = value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
useEventListener(document.documentElement, 'click', () => {
|
||||||
|
if (inPopover.value) return
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<ElButton v-if="!isStart" @click="handlStart" link>
|
||||||
|
{{ isRetry ? endText : startText }}
|
||||||
|
</ElButton>
|
||||||
|
<VueCountdown
|
||||||
|
v-else
|
||||||
|
ref="vueCountdownRef"
|
||||||
|
:time="seconds * 1000"
|
||||||
|
v-slot="{ totalSeconds }"
|
||||||
|
@end="handleEnd"
|
||||||
|
>
|
||||||
|
{{ getChangeText(totalSeconds) }}
|
||||||
|
</VueCountdown>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import VueCountdown from '@chenfengyuan/vue-countdown'
|
||||||
|
import { useThrottleFn } from '@vueuse/core'
|
||||||
|
import { ElButton } from 'element-plus'
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
VueCountdown,
|
||||||
|
ElButton
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
// 倒计时总秒数
|
||||||
|
seconds: {
|
||||||
|
type: Number,
|
||||||
|
default: 60
|
||||||
|
},
|
||||||
|
// 尚未开始时提示
|
||||||
|
startText: {
|
||||||
|
type: String,
|
||||||
|
default: '获取验证码'
|
||||||
|
},
|
||||||
|
// 正在倒计时中的提示
|
||||||
|
changeText: {
|
||||||
|
type: String,
|
||||||
|
default: 'x秒重新获取'
|
||||||
|
},
|
||||||
|
// 倒计时结束时的提示
|
||||||
|
endText: {
|
||||||
|
type: String,
|
||||||
|
default: '重新获取'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['click-get'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const isStart = ref(false)
|
||||||
|
const isRetry = ref(false)
|
||||||
|
const start = async () => {
|
||||||
|
isStart.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChangeText = (second) => {
|
||||||
|
return props.changeText.replace('x', second)
|
||||||
|
}
|
||||||
|
const handleEnd = () => {
|
||||||
|
isStart.value = false
|
||||||
|
isRetry.value = true
|
||||||
|
}
|
||||||
|
const handlStart = useThrottleFn(
|
||||||
|
() => {
|
||||||
|
emit('click-get')
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
getChangeText,
|
||||||
|
isStart,
|
||||||
|
start,
|
||||||
|
isRetry,
|
||||||
|
handleEnd,
|
||||||
|
handlStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function useLockFn(fn: (...args: any[]) => Promise<any>) {
|
||||||
|
const isLock = ref(false)
|
||||||
|
const lockFn = async (...args: any[]) => {
|
||||||
|
if (isLock.value) return
|
||||||
|
isLock.value = true
|
||||||
|
try {
|
||||||
|
const res = await fn(...args)
|
||||||
|
isLock.value = false
|
||||||
|
return res
|
||||||
|
} catch (e) {
|
||||||
|
isLock.value = false
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isLock,
|
||||||
|
lockFn
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NAVBAR, SIDEBAR } from '@/constants/menu'
|
||||||
|
export default function useMenu() {
|
||||||
|
const menu = useState(() => NAVBAR)
|
||||||
|
const route = useRoute()
|
||||||
|
const sidebar = computed(() => getSidebar(route.meta.module))
|
||||||
|
const hasSidebar = computed(() => sidebar.value.length)
|
||||||
|
return {
|
||||||
|
menu,
|
||||||
|
sidebar,
|
||||||
|
hasSidebar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSidebar(module?: string): any[] {
|
||||||
|
const queue: any[] = []
|
||||||
|
SIDEBAR.forEach((item) => queue.push(item))
|
||||||
|
while (queue.length) {
|
||||||
|
const item = queue.shift()
|
||||||
|
if (item.module && item.module == module) {
|
||||||
|
return item.children
|
||||||
|
}
|
||||||
|
item.children &&
|
||||||
|
item.children.forEach((child: any) => queue.push(child))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export const NAVBAR = [
|
||||||
|
{
|
||||||
|
name: '首页',
|
||||||
|
path: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '资讯中心',
|
||||||
|
path: '/information',
|
||||||
|
component: 'information'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '移动端',
|
||||||
|
path: '/mobile',
|
||||||
|
component: 'mobile'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '管理后台',
|
||||||
|
path: '/admin',
|
||||||
|
component: 'admin'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SIDEBAR = [
|
||||||
|
{
|
||||||
|
module: 'personal',
|
||||||
|
hidden: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: '个人中心',
|
||||||
|
path: '/user',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: '个人信息',
|
||||||
|
path: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '我的收藏',
|
||||||
|
path: 'collection'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: '账户设置',
|
||||||
|
path: '/account',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: '账户安全',
|
||||||
|
path: 'security'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//菜单主题类型
|
||||||
|
export enum ThemeEnum {
|
||||||
|
LIGHT = 'light',
|
||||||
|
DARK = 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单类型
|
||||||
|
export enum MenuEnum {
|
||||||
|
CATALOGUE = 'M',
|
||||||
|
MENU = 'C',
|
||||||
|
BUTTON = 'A'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 屏幕
|
||||||
|
export enum ScreenEnum {
|
||||||
|
SM = 640,
|
||||||
|
MD = 768,
|
||||||
|
LG = 1024,
|
||||||
|
XL = 1280,
|
||||||
|
'2XL' = 1536
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SMSEnum {
|
||||||
|
LOGIN = 'YZMDL',
|
||||||
|
BIND_MOBILE = 'BDSJHM',
|
||||||
|
CHANGE_MOBILE = 'BGSJHM',
|
||||||
|
FIND_PASSWORD = 'ZHDLMM'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PolicyAgreementEnum {
|
||||||
|
SERVICE = 'service',
|
||||||
|
PRIVACY = 'privacy'
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 本地缓冲key
|
||||||
|
|
||||||
|
//token
|
||||||
|
export const TOKEN_KEY = 'token'
|
||||||
|
//账号
|
||||||
|
export const ACCOUNT_KEY = 'account'
|
||||||
|
//设置
|
||||||
|
export const SETTING_KEY = 'setting'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export enum PageEnum {
|
||||||
|
//登录页面
|
||||||
|
LOGIN = '/login',
|
||||||
|
//无权限页面
|
||||||
|
ERROR_403 = '/403',
|
||||||
|
INDEX = '/'
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export enum ContentTypeEnum {
|
||||||
|
// json
|
||||||
|
JSON = 'application/json;charset=UTF-8',
|
||||||
|
// form-data 上传资源(图片,视频)
|
||||||
|
FORM_DATA = 'multipart/form-data'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RequestMethodsEnum {
|
||||||
|
GET = 'GET',
|
||||||
|
POST = 'POST'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RequestCodeEnum {
|
||||||
|
NOT_INSTALL = -2,
|
||||||
|
LOGIN_FAILURE = -1,
|
||||||
|
FAIL = 0,
|
||||||
|
SUCCESS = 1,
|
||||||
|
OPEN_NEW_PAGE = 2
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
import { Request } from '@/utils/http/request'
|
||||||
|
declare global {
|
||||||
|
const $request: Request
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<section class="layout-blank">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup></script>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-4xl">
|
||||||
|
{{ hasMobile ? '更换手机号' : '绑定手机号' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
class="mt-[35px]"
|
||||||
|
size="large"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
>
|
||||||
|
<ElFormItem prop="mobile">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.mobile"
|
||||||
|
placeholder="请输入手机号码"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="code">
|
||||||
|
<ElInput v-model="formData.code" placeholder="请输入验证码">
|
||||||
|
<template #suffix>
|
||||||
|
<div
|
||||||
|
class="flex justify-center leading-5 w-[90px] pl-2.5 border-l border-br"
|
||||||
|
>
|
||||||
|
<VerificationCode
|
||||||
|
ref="verificationCodeRef"
|
||||||
|
@click-get="sendSms"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem class="mt-[60px]">
|
||||||
|
<ElButton
|
||||||
|
class="w-full"
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirmLock"
|
||||||
|
:loading="isLock"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElButton,
|
||||||
|
FormInstance,
|
||||||
|
FormRules
|
||||||
|
} from 'element-plus'
|
||||||
|
import { smsSend } from '~~/api/app'
|
||||||
|
import { userBindMobile } from '~~/api/user'
|
||||||
|
import { SMSEnum } from '~~/enums/appEnums'
|
||||||
|
import { useAccount } from './useAccount'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
const { toggleShowPopup } = useAccount()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const verificationCodeRef = shallowRef()
|
||||||
|
const formRules: FormRules = {
|
||||||
|
mobile: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入手机号码',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
code: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入验证码',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const hasMobile = computed(() => !!userStore.userInfo.mobile)
|
||||||
|
const formData = reactive({
|
||||||
|
type: hasMobile.value ? 'change' : 'bind',
|
||||||
|
mobile: '',
|
||||||
|
code: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendSms = async () => {
|
||||||
|
await formRef.value?.validateField(['mobile'])
|
||||||
|
await smsSend({
|
||||||
|
scene: hasMobile.value ? SMSEnum.CHANGE_MOBILE : SMSEnum.BIND_MOBILE,
|
||||||
|
mobile: formData.mobile
|
||||||
|
})
|
||||||
|
verificationCodeRef.value?.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
if (userStore.isLogin) {
|
||||||
|
await userBindMobile(formData)
|
||||||
|
} else {
|
||||||
|
await userBindMobile(formData, { token: userStore.temToken })
|
||||||
|
userStore.login(userStore.temToken)
|
||||||
|
await userStore.getUser()
|
||||||
|
}
|
||||||
|
toggleShowPopup(false)
|
||||||
|
}
|
||||||
|
const { lockFn: handleConfirmLock, isLock } = useLockFn(handleConfirm)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-4xl">忘记登录密码</span>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="setPopupType(PopupTypeEnum.LOGIN)"
|
||||||
|
v-if="!userStore.isLogin"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
class="mt-[35px]"
|
||||||
|
size="large"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
>
|
||||||
|
<ElFormItem prop="mobile">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.mobile"
|
||||||
|
placeholder="请输入手机号码"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="code">
|
||||||
|
<ElInput v-model="formData.code" placeholder="请输入验证码">
|
||||||
|
<template #suffix>
|
||||||
|
<div
|
||||||
|
class="flex justify-center leading-5 w-[90px] pl-2.5 border-l border-br"
|
||||||
|
>
|
||||||
|
<VerificationCode
|
||||||
|
ref="verificationCodeRef"
|
||||||
|
@click-get="sendSms"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="password">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.password"
|
||||||
|
placeholder="请输入6-20位数字+字母或符号组合"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="password_confirm">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.password_confirm"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem class="mt-[60px]">
|
||||||
|
<ElButton
|
||||||
|
class="w-full"
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirmLock"
|
||||||
|
:loading="isLock"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElButton,
|
||||||
|
FormInstance,
|
||||||
|
FormRules
|
||||||
|
} from 'element-plus'
|
||||||
|
import { smsSend } from '~~/api/app'
|
||||||
|
import { forgotPassword } from '~~/api/user'
|
||||||
|
import { SMSEnum } from '~~/enums/appEnums'
|
||||||
|
import { useUserStore } from '~~/stores/user'
|
||||||
|
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { setPopupType, toggleShowPopup } = useAccount()
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const verificationCodeRef = shallowRef()
|
||||||
|
const formRules: FormRules = {
|
||||||
|
mobile: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入手机号码',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: 3,
|
||||||
|
max: 12,
|
||||||
|
message: '账号长度应为3-12',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
code: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入验证码',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入6-20位数字+字母或符号组合',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: 6,
|
||||||
|
max: 20,
|
||||||
|
message: '密码长度应为6-20',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
password_confirm: [
|
||||||
|
{
|
||||||
|
validator(rule: any, value: any, callback: any) {
|
||||||
|
if (value === '') {
|
||||||
|
callback(new Error('请再次输入密码'))
|
||||||
|
} else if (value !== formData.password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const formData = reactive({
|
||||||
|
mobile: '',
|
||||||
|
password: '',
|
||||||
|
code: '',
|
||||||
|
password_confirm: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendSms = async () => {
|
||||||
|
await formRef.value?.validateField(['mobile'])
|
||||||
|
await smsSend({
|
||||||
|
scene: SMSEnum.FIND_PASSWORD,
|
||||||
|
mobile: formData.mobile
|
||||||
|
})
|
||||||
|
verificationCodeRef.value?.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
await forgotPassword(formData)
|
||||||
|
userStore.logout()
|
||||||
|
setPopupType(PopupTypeEnum.LOGIN)
|
||||||
|
}
|
||||||
|
const { lockFn: handleConfirmLock, isLock } = useLockFn(handleConfirm)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="account" v-if="showPopup">
|
||||||
|
<ClientOnly>
|
||||||
|
<ElDialog
|
||||||
|
v-model="showPopup"
|
||||||
|
:width="400"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div class="px-5 text-tx-primary">
|
||||||
|
<Login v-show="popupType == PopupTypeEnum.LOGIN" />
|
||||||
|
<Register v-show="popupType == PopupTypeEnum.REGISTER" />
|
||||||
|
<ForgotPwd v-show="popupType == PopupTypeEnum.FORGOT_PWD" />
|
||||||
|
<BindMobile
|
||||||
|
v-show="popupType == PopupTypeEnum.BIND_MOBILE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElDialog>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElDialog } from 'element-plus'
|
||||||
|
import Login from './login.vue'
|
||||||
|
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||||
|
import Register from './register.vue'
|
||||||
|
import ForgotPwd from './forgot-pwd.vue'
|
||||||
|
import BindMobile from './bind-mobile.vue'
|
||||||
|
import { useUserStore } from '~~/stores/user'
|
||||||
|
const { popupType, showPopup } = useAccount()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
watch(showPopup, (value) => {
|
||||||
|
if (!value) userStore.temToken = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login">
|
||||||
|
<div class="text-4xl">欢迎登录</div>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
class="mt-[35px]"
|
||||||
|
size="large"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-if="isAccountLogin && includeLoginWay(LoginWayEnum.ACCOUNT)"
|
||||||
|
>
|
||||||
|
<ElFormItem prop="account">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.account"
|
||||||
|
placeholder="请输入账号/手机号"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="password">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入密码"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-if="isMobileLogin && includeLoginWay(LoginWayEnum.MOBILE)"
|
||||||
|
>
|
||||||
|
<ElFormItem prop="account">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.account"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="code">
|
||||||
|
<ElInput v-model="formData.code" placeholder="请输入验证码">
|
||||||
|
<template #suffix>
|
||||||
|
<div
|
||||||
|
class="flex justify-center leading-5 w-[90px] pl-2.5 border-l border-br"
|
||||||
|
>
|
||||||
|
<VerificationCode
|
||||||
|
ref="verificationCodeRef"
|
||||||
|
@click-get="sendSms"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</ElFormItem>
|
||||||
|
</template>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElButton
|
||||||
|
v-if="
|
||||||
|
isAccountLogin &&
|
||||||
|
includeLoginWay(LoginWayEnum.MOBILE)
|
||||||
|
"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="changeLoginWay"
|
||||||
|
>
|
||||||
|
手机验证码登录
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="
|
||||||
|
isMobileLogin &&
|
||||||
|
includeLoginWay(LoginWayEnum.ACCOUNT)
|
||||||
|
"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="changeLoginWay"
|
||||||
|
>
|
||||||
|
账号密码登录
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElButton
|
||||||
|
v-if="isAccountLogin"
|
||||||
|
link
|
||||||
|
@click="setPopupType(PopupTypeEnum.FORGOT_PWD)"
|
||||||
|
>
|
||||||
|
忘记密码?
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<ElFormItem class="mt-[30px]">
|
||||||
|
<ElButton
|
||||||
|
class="w-full"
|
||||||
|
type="primary"
|
||||||
|
:loading="isLock"
|
||||||
|
@click="loginLock"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
<div class="mt-[40px]" v-if="isOpenOtherAuth">
|
||||||
|
<ElDivider>
|
||||||
|
<span class="text-tx-secondary font-normal">
|
||||||
|
第三方登录
|
||||||
|
</span>
|
||||||
|
</ElDivider>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ElButton link @click="getWxCodeLock" v-if="inWxAuth">
|
||||||
|
<img
|
||||||
|
class="w-[48px] h-[48px]"
|
||||||
|
src="@/assets/images/icon/icon_wx.png"
|
||||||
|
/>
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mb-[-15px] mx-[-40px] mt-[30px] bg-primary-light-9 rounded-b-md px-[15px] flex leading-10"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElCheckbox v-if="isOpenAgreement" v-model="isAgreement">
|
||||||
|
<span class="text-tx-secondary text-sm">
|
||||||
|
已阅读并同意
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/policy/${PolicyAgreementEnum.SERVICE}`"
|
||||||
|
custom
|
||||||
|
v-slot="{ href }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="text-tx-primary"
|
||||||
|
:href="href"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
《服务协议》
|
||||||
|
</a>
|
||||||
|
</NuxtLink>
|
||||||
|
和
|
||||||
|
<NuxtLink
|
||||||
|
class="text-tx-primary"
|
||||||
|
:to="`/policy/${PolicyAgreementEnum.PRIVACY}`"
|
||||||
|
custom
|
||||||
|
v-slot="{ href }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="text-tx-primary"
|
||||||
|
:href="href"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
《隐私政策》
|
||||||
|
</a>
|
||||||
|
</NuxtLink>
|
||||||
|
</span>
|
||||||
|
</ElCheckbox>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="setPopupType(PopupTypeEnum.REGISTER)"
|
||||||
|
>
|
||||||
|
<span class="text-sm">注册账号</span>
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElButton,
|
||||||
|
ElDivider,
|
||||||
|
ElCheckbox,
|
||||||
|
FormInstance,
|
||||||
|
FormRules
|
||||||
|
} from 'element-plus'
|
||||||
|
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||||
|
import { getWxCodeUrl, login } from '@/api/account'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { smsSend } from '~~/api/app'
|
||||||
|
import { PolicyAgreementEnum, SMSEnum } from '~~/enums/appEnums'
|
||||||
|
import feedback from '~~/utils/feedback'
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { setPopupType, toggleShowPopup } = useAccount()
|
||||||
|
enum LoginWayEnum {
|
||||||
|
ACCOUNT = 1,
|
||||||
|
MOBILE = 2
|
||||||
|
}
|
||||||
|
const isAgreement = ref(false)
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const formRules: FormRules = {
|
||||||
|
account: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator(rule: any, value: any, callback: any) {
|
||||||
|
if (value === '') {
|
||||||
|
callback(
|
||||||
|
new Error(
|
||||||
|
formData.scene == LoginWayEnum.ACCOUNT
|
||||||
|
? '请输入账号/手机号'
|
||||||
|
: '请输入手机号'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入密码',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
code: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入验证码',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const formData = reactive({
|
||||||
|
code: '',
|
||||||
|
account: '',
|
||||||
|
password: '',
|
||||||
|
scene: 0
|
||||||
|
})
|
||||||
|
const isAccountLogin = computed(() => formData.scene == LoginWayEnum.ACCOUNT)
|
||||||
|
const isMobileLogin = computed(() => formData.scene == LoginWayEnum.MOBILE)
|
||||||
|
const includeLoginWay = (way: LoginWayEnum) =>
|
||||||
|
appStore.getLoginConfig.login_way?.includes(String(way))
|
||||||
|
|
||||||
|
const inWxAuth = computed(() => {
|
||||||
|
return appStore.getLoginConfig.wechat_auth
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOpenAgreement = computed(
|
||||||
|
() => appStore.getLoginConfig.login_agreement == 1
|
||||||
|
)
|
||||||
|
const isOpenOtherAuth = computed(() => appStore.getLoginConfig.third_auth == 1)
|
||||||
|
const isForceBindMobile = computed(
|
||||||
|
() => appStore.getLoginConfig.coerce_mobile == 1
|
||||||
|
)
|
||||||
|
const changeLoginWay = () => {
|
||||||
|
if (formData.scene == LoginWayEnum.ACCOUNT) {
|
||||||
|
formData.scene = LoginWayEnum.MOBILE
|
||||||
|
} else {
|
||||||
|
formData.scene = LoginWayEnum.ACCOUNT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const verificationCodeRef = shallowRef()
|
||||||
|
const sendSms = async () => {
|
||||||
|
await formRef.value?.validateField(['account'])
|
||||||
|
await smsSend({
|
||||||
|
scene: SMSEnum.LOGIN,
|
||||||
|
mobile: formData.account
|
||||||
|
})
|
||||||
|
|
||||||
|
verificationCodeRef.value?.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
const data = await login(formData)
|
||||||
|
if (isForceBindMobile.value && !data.mobile) {
|
||||||
|
userStore.temToken = data.token
|
||||||
|
setPopupType(PopupTypeEnum.BIND_MOBILE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userStore.login(data.token)
|
||||||
|
await userStore.getUser()
|
||||||
|
toggleShowPopup(false)
|
||||||
|
}
|
||||||
|
const { lockFn: handleLoginLock, isLock } = useLockFn(handleLogin)
|
||||||
|
const agreementConfirm = async () => {
|
||||||
|
if (isAgreement.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await feedback.confirm('确认已阅读并同意《服务协议》和《隐私政策》')
|
||||||
|
isAgreement.value = true
|
||||||
|
}
|
||||||
|
const loginLock = async () => {
|
||||||
|
await agreementConfirm()
|
||||||
|
await handleLoginLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWxCode = async () => {
|
||||||
|
await agreementConfirm()
|
||||||
|
const { url } = await getWxCodeUrl()
|
||||||
|
window.location.href = url
|
||||||
|
}
|
||||||
|
const { lockFn: getWxCodeLock } = useLockFn(getWxCode)
|
||||||
|
watch(
|
||||||
|
() => appStore.getLoginConfig,
|
||||||
|
(value) => {
|
||||||
|
const { login_way } = value
|
||||||
|
if (login_way && login_way.length) {
|
||||||
|
formData.scene = login_way.at(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-4xl">注册账号</span>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="setPopupType(PopupTypeEnum.LOGIN)"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
class="mt-[35px]"
|
||||||
|
size="large"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
>
|
||||||
|
<ElFormItem prop="account">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.account"
|
||||||
|
placeholder="请输入创建的账号"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="password">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入6-20位数字+字母或符号组合"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="password_confirm">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.password_confirm"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem class="mt-[60px]">
|
||||||
|
<ElButton
|
||||||
|
class="w-full"
|
||||||
|
type="primary"
|
||||||
|
:loading="isLock"
|
||||||
|
@click="handleConfirmLock"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElButton,
|
||||||
|
FormInstance,
|
||||||
|
FormRules
|
||||||
|
} from 'element-plus'
|
||||||
|
import { register } from '~~/api/account'
|
||||||
|
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||||
|
const { setPopupType } = useAccount()
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const formRules: FormRules = {
|
||||||
|
account: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入创建的账号',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: 3,
|
||||||
|
max: 12,
|
||||||
|
message: '账号长度应为3-12',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入6-20位数字+字母或符号组合',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: 6,
|
||||||
|
max: 20,
|
||||||
|
message: '密码长度应为6-20',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
password_confirm: [
|
||||||
|
{
|
||||||
|
validator(rule: any, value: any, callback: any) {
|
||||||
|
if (value === '') {
|
||||||
|
callback(new Error('请再次输入密码'))
|
||||||
|
} else if (value !== formData.password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const formData = reactive({
|
||||||
|
account: '',
|
||||||
|
password: '',
|
||||||
|
password_confirm: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
await register(formData)
|
||||||
|
setPopupType(PopupTypeEnum.LOGIN)
|
||||||
|
}
|
||||||
|
const { lockFn: handleConfirmLock, isLock } = useLockFn(handleConfirm)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col justify-center items-center">
|
||||||
|
<div class="text-tx-regular mb-4">您还未登录,请先登录</div>
|
||||||
|
<ElButton @click="toLogin">登录</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useAccount, PopupTypeEnum } from './useAccount'
|
||||||
|
import { ElButton } from 'element-plus'
|
||||||
|
const { setPopupType, toggleShowPopup } = useAccount()
|
||||||
|
const toLogin = () => {
|
||||||
|
setPopupType(PopupTypeEnum.LOGIN)
|
||||||
|
toggleShowPopup(true)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export enum PopupTypeEnum {
|
||||||
|
LOGIN,
|
||||||
|
FORGOT_PWD,
|
||||||
|
REGISTER,
|
||||||
|
BIND_MOBILE
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAccount = () => {
|
||||||
|
const popupType = useState<PopupTypeEnum>(() => PopupTypeEnum.LOGIN)
|
||||||
|
const setPopupType = (type: PopupTypeEnum = PopupTypeEnum.LOGIN) => {
|
||||||
|
popupType.value = type
|
||||||
|
}
|
||||||
|
const showPopup = useState(() => false)
|
||||||
|
const toggleShowPopup = (toggle: boolean) => {
|
||||||
|
showPopup.value = toggle ?? !showPopup.value
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
popupType,
|
||||||
|
setPopupType,
|
||||||
|
showPopup,
|
||||||
|
toggleShowPopup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="layout-footer text-center bg-[#222222] py-[30px]">
|
||||||
|
<div class="text-[#bebebe]">
|
||||||
|
<!-- <NuxtLink> 关于我们 </NuxtLink>
|
||||||
|
| -->
|
||||||
|
<NuxtLink :to="`/policy/${PolicyAgreementEnum.SERVICE}`">
|
||||||
|
用户协议
|
||||||
|
</NuxtLink>
|
||||||
|
|
|
||||||
|
<NuxtLink :to="`/policy/${PolicyAgreementEnum.PRIVACY}`">
|
||||||
|
隐私政策
|
||||||
|
</NuxtLink>
|
||||||
|
|
|
||||||
|
<NuxtLink to="/user/info"> 会员中心 </NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-tx-secondary">
|
||||||
|
<a
|
||||||
|
class="mx-1 hover:underline"
|
||||||
|
:href="item.value"
|
||||||
|
target="_blank"
|
||||||
|
v-for="item in appStore.getCopyrightConfig"
|
||||||
|
:key="item.key"
|
||||||
|
>
|
||||||
|
{{ item.key }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { PolicyAgreementEnum } from '@/enums/appEnums'
|
||||||
|
const appStore = useAppStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink :to="appStore.getAdminUrl" target="_blank">
|
||||||
|
<ElMenuItem :index="menuItem.path">
|
||||||
|
<template #title>
|
||||||
|
<span>
|
||||||
|
{{ menuItem.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElMenuItem>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElMenuItem } from 'element-plus'
|
||||||
|
import { useAppStore } from '~~/stores/app'
|
||||||
|
defineProps({
|
||||||
|
menuItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const appStore = useAppStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<header class="layout-header text-white bg-primary">
|
||||||
|
<div class="header-contain">
|
||||||
|
<Logo class="flex-none mr-4" />
|
||||||
|
<Navbar class="w-[600px]" />
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<Search class="mr-[40px] flex-none" />
|
||||||
|
<User class="flex-none" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import User from './user.vue'
|
||||||
|
import Search from './search.vue'
|
||||||
|
import Logo from './logo.vue'
|
||||||
|
import Navbar from './navbar.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.layout-header {
|
||||||
|
height: var(--header-height);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1999;
|
||||||
|
.header-contain {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
.navbar {
|
||||||
|
--el-menu-item-font-size: var(--el-font-size-large);
|
||||||
|
--el-menu-bg-color: var(--el-color-primary);
|
||||||
|
--el-menu-active-color: var(--color-white);
|
||||||
|
--el-menu-text-color: var(--color-white);
|
||||||
|
--el-menu-item-hover-fill: var(--el-color-primary);
|
||||||
|
--el-menu-hover-text-color: var(--color-white);
|
||||||
|
--el-menu-hover-bg-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<el-dropdown :max-height="200" :disabled="!hasData">
|
||||||
|
<span class="flex items-center text-white">
|
||||||
|
<MenuItem :menu-item="menuItem" :route-path="menuItem.path" />
|
||||||
|
<span class="ml-[-10px]" v-if="hasData">
|
||||||
|
<Icon name="el-icon-ArrowDown" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<NuxtLink
|
||||||
|
:to="{
|
||||||
|
path: '/information/default',
|
||||||
|
query: {
|
||||||
|
cid: item.id,
|
||||||
|
name: item.name
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
v-for="item in data"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<el-dropdown-item> {{ item.name }} </el-dropdown-item>
|
||||||
|
</NuxtLink>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus'
|
||||||
|
import { getArticleCate } from '~~/api/news'
|
||||||
|
import MenuItem from '../menu/menu-item.vue'
|
||||||
|
defineProps({
|
||||||
|
menuItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data } = await useAsyncData(() => getArticleCate())
|
||||||
|
const hasData = computed(() => {
|
||||||
|
return data.value && data.value.length
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink v-if="appStore.getWebsiteConfig.pc_logo" class="flex" to="/">
|
||||||
|
<img :src="appStore.getWebsiteConfig.pc_logo" class="h-[26px]" />
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useAppStore } from '~~/stores/app'
|
||||||
|
const appStore = useAppStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ElMenuItem :index="menuItem.path" @click="showMobilePopup = true">
|
||||||
|
<template #title>
|
||||||
|
<span>
|
||||||
|
{{ menuItem.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElMenuItem>
|
||||||
|
<ClientOnly>
|
||||||
|
<ElDialog
|
||||||
|
v-model="showMobilePopup"
|
||||||
|
@close="showMobilePopup = false"
|
||||||
|
:width="700"
|
||||||
|
>
|
||||||
|
<div class="text-center text-tx-primary">
|
||||||
|
<div class="text-4xl font-medium">移动端演示</div>
|
||||||
|
<div class="flex my-[40px] justify-around">
|
||||||
|
<div v-if="oa">
|
||||||
|
<img :src="oa" class="w-[180px] h-[180px]" alt="" />
|
||||||
|
<div class="mt-2.5">微信公众号演示</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="mnp">
|
||||||
|
<img
|
||||||
|
:src="mnp"
|
||||||
|
class="w-[180px] h-[180px]"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="mt-2.5">微信小程序演示</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!mnp && !oa"
|
||||||
|
class="w-[180px] h-[180px] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
暂无演示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElDialog>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElMenuItem, ElDialog } from 'element-plus'
|
||||||
|
import { useAppStore } from '~~/stores/app'
|
||||||
|
defineProps({
|
||||||
|
menuItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const mnp = computed(() => appStore.getQrcodeConfig.mnp)
|
||||||
|
const oa = computed(() => appStore.getQrcodeConfig.oa)
|
||||||
|
const showMobilePopup = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<nav>
|
||||||
|
<Menu
|
||||||
|
class="navbar"
|
||||||
|
:menu="menu"
|
||||||
|
:default-active="activeMenu"
|
||||||
|
mode="horizontal"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<MenuItem
|
||||||
|
v-if="!item.component"
|
||||||
|
:menu-item="item"
|
||||||
|
:route-path="item.path"
|
||||||
|
/>
|
||||||
|
<div v-else>
|
||||||
|
<template v-if="item.component == 'information'">
|
||||||
|
<Information :menu-item="item" />
|
||||||
|
</template>
|
||||||
|
<template v-if="item.component == 'mobile'">
|
||||||
|
<Mobile :menu-item="item" />
|
||||||
|
</template>
|
||||||
|
<template v-if="item.component == 'admin'">
|
||||||
|
<Admin :menu-item="item" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Menu>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Menu from '../menu/index.vue'
|
||||||
|
import MenuItem from '../menu/menu-item.vue'
|
||||||
|
import Admin from './admin.vue'
|
||||||
|
import Information from './information.vue'
|
||||||
|
import Mobile from './mobile.vue'
|
||||||
|
const route = useRoute()
|
||||||
|
const activeMenu = computed<string>(() => route.path)
|
||||||
|
const { menu } = useMenu()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.navbar {
|
||||||
|
--el-menu-item-font-size: var(--el-font-size-large);
|
||||||
|
--el-menu-bg-color: var(--el-color-primary);
|
||||||
|
--el-menu-active-color: var(--color-white);
|
||||||
|
--el-menu-text-color: var(--color-white);
|
||||||
|
--el-menu-item-hover-fill: var(--el-color-primary);
|
||||||
|
--el-menu-hover-text-color: var(--color-white);
|
||||||
|
--el-menu-hover-bg-color: var(--el-color-primary);
|
||||||
|
:deep() {
|
||||||
|
& > .el-sub-menu {
|
||||||
|
.el-sub-menu__title:hover {
|
||||||
|
background-color: var(--el-menu-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-[250px] search">
|
||||||
|
<ElInput
|
||||||
|
v-model.trim="searchKeyword"
|
||||||
|
placeholder="请输入关键词"
|
||||||
|
:suffix-icon="Search"
|
||||||
|
@keyup.enter="handleToSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElInput } from 'element-plus'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import feedback from '~~/utils/feedback'
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const searchKeyword = ref()
|
||||||
|
const handleToSearch = () => {
|
||||||
|
if (!searchKeyword.value) return feedback.msgError('请输入关键词')
|
||||||
|
router.push({
|
||||||
|
path: '/information/search',
|
||||||
|
query: {
|
||||||
|
keywords: searchKeyword.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
route,
|
||||||
|
(routeNew) => {
|
||||||
|
if (routeNew.path == '/information/search') {
|
||||||
|
searchKeyword.value = routeNew.query.keywords
|
||||||
|
} else {
|
||||||
|
searchKeyword.value = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search {
|
||||||
|
:deep(.el-input) {
|
||||||
|
.el-input__wrapper {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ElDropdown v-if="userStore.isLogin" @command="handleCommand">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ElAvatar :size="25" :src="userStore.userInfo.avatar" />
|
||||||
|
<div class="ml-1 text-white text-lg flex">
|
||||||
|
<span class="mr-2">个人中心</span>
|
||||||
|
<ElIcon><ArrowDown /></ElIcon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #dropdown>
|
||||||
|
<ElDropdownMenu>
|
||||||
|
<NuxtLink to="/user/info">
|
||||||
|
<ElDropdownItem command="user">个人信息</ElDropdownItem>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/user/collection">
|
||||||
|
<ElDropdownItem command="collect">
|
||||||
|
我的收藏
|
||||||
|
</ElDropdownItem>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/account/security">
|
||||||
|
<ElDropdownItem command="account">
|
||||||
|
账号安全
|
||||||
|
</ElDropdownItem>
|
||||||
|
</NuxtLink>
|
||||||
|
<ElDropdownItem command="logout">退出登录</ElDropdownItem>
|
||||||
|
</ElDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</ElDropdown>
|
||||||
|
|
||||||
|
<div v-else class="cursor-pointer text-lg" @click="handleToLogin">
|
||||||
|
登录/注册
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
ElAvatar,
|
||||||
|
ElDropdown,
|
||||||
|
ElDropdownMenu,
|
||||||
|
ElDropdownItem,
|
||||||
|
ElIcon
|
||||||
|
} from 'element-plus'
|
||||||
|
import { ArrowDown } from '@element-plus/icons-vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { PopupTypeEnum, useAccount } from '../account/useAccount'
|
||||||
|
import feedback from '~~/utils/feedback'
|
||||||
|
import { logout } from '~~/api/account'
|
||||||
|
const { setPopupType, toggleShowPopup } = useAccount()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const handleToLogin = () => {
|
||||||
|
setPopupType(PopupTypeEnum.LOGIN)
|
||||||
|
toggleShowPopup(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCommand = async (command: string) => {
|
||||||
|
switch (command) {
|
||||||
|
case 'logout':
|
||||||
|
await feedback.confirm('确定退出登录吗?')
|
||||||
|
await logout()
|
||||||
|
userStore.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<main class="mx-auto w-[1200px] py-4">
|
||||||
|
<div
|
||||||
|
v-if="sidebar.length"
|
||||||
|
class="mr-4 bg-white rounded-[8px] overflow-hidden"
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
:menu="sidebar"
|
||||||
|
:default-active="activeMenu"
|
||||||
|
mode="vertical"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'layout-page flex-1 min-w-0 rounded-[8px]',
|
||||||
|
{
|
||||||
|
'bg-body': hasSidebar
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Menu from '../menu/index.vue'
|
||||||
|
const route = useRoute()
|
||||||
|
const activeMenu = computed<string>(() => route.meta.activeMenu ?? route.path)
|
||||||
|
const { sidebar, hasSidebar } = useMenu()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<ElMenu class="menu" v-bind="$props" :ellipsis="true">
|
||||||
|
<div v-for="item in menu" :key="item.path">
|
||||||
|
<slot name="item" :item="item">
|
||||||
|
<MenuItem :menu-item="item" :route-path="item.path" />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</ElMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElMenu, menuProps } from 'element-plus'
|
||||||
|
import { PropType } from 'vue'
|
||||||
|
import MenuItem from './menu-item.vue'
|
||||||
|
defineProps({
|
||||||
|
menu: {
|
||||||
|
type: Array as PropType<any[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
...menuProps
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.menu {
|
||||||
|
&.el-menu--horizontal {
|
||||||
|
--el-menu-item-height: 40px;
|
||||||
|
border-bottom: none;
|
||||||
|
:deep(.el-menu-item) {
|
||||||
|
span {
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
&.is-active > span {
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.el-menu--vertical:not(.el-menu--collapse) {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="!menuItem?.hidden">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="!hasShowChild"
|
||||||
|
:to="routePath"
|
||||||
|
class="flex items-center w-full"
|
||||||
|
:custom="menuItem.type == 'custom'"
|
||||||
|
:external="isExternal(routePath)"
|
||||||
|
:target="isExternal(routePath) ? '_blank' : ''"
|
||||||
|
>
|
||||||
|
<ElMenuItem class="w-full" :index="routePath">
|
||||||
|
<template #title>
|
||||||
|
<span>
|
||||||
|
{{ menuItem.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElMenuItem>
|
||||||
|
</NuxtLink>
|
||||||
|
<ElSubMenu v-else :index="routePath" :popper-offset="12">
|
||||||
|
<template #title>
|
||||||
|
<!-- <Icon
|
||||||
|
v-if="menuItem.icon"
|
||||||
|
class="menu-item-icon"
|
||||||
|
:size="16"
|
||||||
|
:name="menuItem.icon"
|
||||||
|
/> -->
|
||||||
|
<span>{{ menuItem.name }}</span>
|
||||||
|
</template>
|
||||||
|
<MenuItem
|
||||||
|
v-for="item in menuItem.children"
|
||||||
|
:key="resolvePath(item.path)"
|
||||||
|
:menu-item="item"
|
||||||
|
:route-path="resolvePath(item.path)"
|
||||||
|
/>
|
||||||
|
</ElSubMenu>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElMenuItem, ElSubMenu } from 'element-plus'
|
||||||
|
import { getNormalPath } from '@/utils/util'
|
||||||
|
import { isExternal } from '@/utils/validate'
|
||||||
|
const props = defineProps({
|
||||||
|
menuItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
routePath: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const hasShowChild = computed(() => {
|
||||||
|
const children = props.menuItem.children ?? []
|
||||||
|
return !!children.filter((item: any) => !item?.hidden).length
|
||||||
|
})
|
||||||
|
const resolvePath = (path: string) => {
|
||||||
|
if (isExternal(path)) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
const newPath = getNormalPath(`${props.routePath}/${path}`)
|
||||||
|
return newPath
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<section class="layout-default min-w-[1200px]">
|
||||||
|
<LayoutHeader />
|
||||||
|
<div class="main-contain">
|
||||||
|
<LayoutMain class="flex-1 min-h-0 flex">
|
||||||
|
<slot v-if="userStore.isLogin || !$route.meta.auth" />
|
||||||
|
<ToLogin class="h-full" v-else />
|
||||||
|
</LayoutMain>
|
||||||
|
<LayoutFooter />
|
||||||
|
</div>
|
||||||
|
<Account />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import LayoutHeader from "./components/header/index.vue";
|
||||||
|
import LayoutMain from "./components/main/index.vue";
|
||||||
|
import LayoutFooter from "./components/footer/index.vue";
|
||||||
|
import Account from "./components/account/index.vue";
|
||||||
|
import { useUserStore } from "~~/stores/user";
|
||||||
|
import ToLogin from "./components/account/to-login.vue";
|
||||||
|
const userStore = useUserStore();
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.main-contain {
|
||||||
|
min-height: calc(100vh - var(--header-height));
|
||||||
|
@apply flex flex-col;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useAppStore } from '~~/stores/app'
|
||||||
|
import { useUserStore } from '~~/stores/user'
|
||||||
|
import { isEmptyObject } from '~~/utils/validate'
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
try {
|
||||||
|
if (isEmptyObject(appStore.config)) {
|
||||||
|
await appStore.getConfig()
|
||||||
|
}
|
||||||
|
if (userStore.isLogin && isEmptyObject(userStore.userInfo)) {
|
||||||
|
await userStore.getUser()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
userStore.$reset()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { wxLogin } from '~~/api/account'
|
||||||
|
import {
|
||||||
|
PopupTypeEnum,
|
||||||
|
useAccount
|
||||||
|
} from '~~/layouts/components/account/useAccount'
|
||||||
|
import { useAppStore } from '~~/stores/app'
|
||||||
|
import { useUserStore } from '~~/stores/user'
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { setPopupType, toggleShowPopup } = useAccount()
|
||||||
|
const isForceBindMobile = appStore.getLoginConfig.coerce_mobile
|
||||||
|
const { code, state } = to.query
|
||||||
|
delete to.query.code
|
||||||
|
delete to.query.state
|
||||||
|
try {
|
||||||
|
if (code && state) {
|
||||||
|
const data = await wxLogin({ code, state })
|
||||||
|
if (isForceBindMobile && !data.mobile) {
|
||||||
|
userStore.temToken = data.token
|
||||||
|
setPopupType(PopupTypeEnum.BIND_MOBILE)
|
||||||
|
toggleShowPopup(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userStore.login(data.token)
|
||||||
|
await userStore.getUser()
|
||||||
|
return navigateTo(to)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return navigateTo(to)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// https://v3.nuxtjs.org/api/configuration/nuxt.config
|
||||||
|
|
||||||
|
import { getEnvConfig } from './nuxt/env'
|
||||||
|
const envConfig = getEnvConfig()
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
css: ['@/assets/styles/index.scss'],
|
||||||
|
modules: ['@pinia/nuxt', '@nuxtjs/tailwindcss', '@element-plus/nuxt'],
|
||||||
|
app: {
|
||||||
|
baseURL: envConfig.baseUrl
|
||||||
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
...envConfig
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ssr: !!envConfig.ssr
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import dotenv from 'dotenv'
|
||||||
|
dotenv.config({ path: `.env.${process.env.NODE_ENV}` })
|
||||||
|
const ENV_PREFIX = 'NUXT_'
|
||||||
|
export const getEnvConfig = () => {
|
||||||
|
const config: Record<string, any> = {}
|
||||||
|
Object.keys(process.env).forEach((evnKey) => {
|
||||||
|
if (evnKey.includes(ENV_PREFIX)) {
|
||||||
|
const key = evnKey
|
||||||
|
.replace(ENV_PREFIX, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\_([A-Za-z])/g, function (all, $1) {
|
||||||
|
return $1.toUpperCase()
|
||||||
|
})
|
||||||
|
config[key] = process.env[evnKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return config
|
||||||
|
}
|
||||||
Generated
+19589
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt generate && node scripts/build.mjs",
|
||||||
|
"build:ssr": "nuxt build && node scripts/build.mjs",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"start": "nuxt start",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@element-plus/nuxt": "1.0.5",
|
||||||
|
"@nuxt/webpack-builder": "3.10.0",
|
||||||
|
"@nuxtjs/tailwindcss": "6.8.0",
|
||||||
|
"@pinia/nuxt": "0.4.11",
|
||||||
|
"@vue/eslint-config-prettier": "9.0.0",
|
||||||
|
"@vue/eslint-config-typescript": "12.0.0",
|
||||||
|
"eslint": "8.56.0",
|
||||||
|
"eslint-plugin-nuxt": "4.0.0",
|
||||||
|
"eslint-plugin-vue": "9.18.1",
|
||||||
|
"eslint-plugin-import": "2.27.5",
|
||||||
|
"nuxt": "3.6.5",
|
||||||
|
"nuxt-lodash": "2.5.0",
|
||||||
|
"prettier": "3.2.4",
|
||||||
|
"vue": "3.3.4",
|
||||||
|
"sass": "1.62.1",
|
||||||
|
"sass-loader": "14.1.0",
|
||||||
|
"typescript": "4.9.3",
|
||||||
|
"vite-plugin-svg-icons": "2.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@chenfengyuan/vue-countdown": "2.1.1",
|
||||||
|
"element-plus": "2.5.2",
|
||||||
|
"pinia": "2.0.3",
|
||||||
|
"vue-cropper": "1.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-[30px] py-5 user-info">
|
||||||
|
<div class="border-b border-br pb-5">
|
||||||
|
<span class="text-2xl font-medium">账号安全</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="info-item leading-10 flex justify-between">
|
||||||
|
<div class="item-name">登录密码</div>
|
||||||
|
<div>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="showMobilePopup = true"
|
||||||
|
>
|
||||||
|
{{ userInfo.has_password ? '点击修改' : '点击设置' }}
|
||||||
|
<Icon name="el-icon-ArrowRight" />
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item leading-10 flex justify-between">
|
||||||
|
<div class="item-name">绑定微信</div>
|
||||||
|
<div>
|
||||||
|
{{ userInfo.has_auth ? '已绑定' : '未绑定' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ClientOnly>
|
||||||
|
<ElDialog
|
||||||
|
v-model="showMobilePopup"
|
||||||
|
:width="400"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div class="px-5">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-4xl">
|
||||||
|
{{
|
||||||
|
userInfo.has_password
|
||||||
|
? '修改登录密码'
|
||||||
|
: '设置登录密码'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="toForgetPwd"
|
||||||
|
v-if="userInfo.has_password"
|
||||||
|
>
|
||||||
|
忘记原密码
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
class="mt-[35px]"
|
||||||
|
size="large"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
>
|
||||||
|
<ElFormItem
|
||||||
|
prop="old_password"
|
||||||
|
v-if="userInfo.has_password"
|
||||||
|
>
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.old_password"
|
||||||
|
placeholder="请输入原密码"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="password">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.password"
|
||||||
|
placeholder="请输入6-20位数字+字母或符号组合"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem prop="password_confirm">
|
||||||
|
<ElInput
|
||||||
|
v-model="formData.password_confirm"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem class="mt-[60px]">
|
||||||
|
<ElButton
|
||||||
|
class="w-full"
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirmLock"
|
||||||
|
:loading="isLock"
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</ElDialog>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { getUserInfo, userChangePwd } from '@/api/user'
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElButton,
|
||||||
|
FormInstance,
|
||||||
|
FormRules,
|
||||||
|
ElDialog
|
||||||
|
} from 'element-plus'
|
||||||
|
import {
|
||||||
|
PopupTypeEnum,
|
||||||
|
useAccount
|
||||||
|
} from '~~/layouts/components/account/useAccount'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
const { data: userInfo, refresh } = await useAsyncData(() => getUserInfo(), {
|
||||||
|
default: () => ({}),
|
||||||
|
initialCache: false
|
||||||
|
})
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const showMobilePopup = ref(false)
|
||||||
|
const { setPopupType, toggleShowPopup } = useAccount()
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const formRules: FormRules = {
|
||||||
|
old_password: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入原密码',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入6-20位数字+字母或符号组合',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
min: 6,
|
||||||
|
max: 20,
|
||||||
|
message: '密码长度应为6-20',
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
password_confirm: [
|
||||||
|
{
|
||||||
|
validator(rule: any, value: any, callback: any) {
|
||||||
|
if (value === '') {
|
||||||
|
callback(new Error('请再次输入密码'))
|
||||||
|
} else if (value !== formData.password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: ['change', 'blur']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const formData = reactive({
|
||||||
|
old_password: '',
|
||||||
|
password: '',
|
||||||
|
password_confirm: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toForgetPwd = () => {
|
||||||
|
showMobilePopup.value = false
|
||||||
|
setPopupType(PopupTypeEnum.FORGOT_PWD)
|
||||||
|
toggleShowPopup(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
await userChangePwd(formData)
|
||||||
|
userStore.logout()
|
||||||
|
showMobilePopup.value = false
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
const { lockFn: handleConfirmLock, isLock } = useLockFn(handleConfirm)
|
||||||
|
definePageMeta({
|
||||||
|
module: 'personal',
|
||||||
|
auth: true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.user-info {
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
|
padding: 10px 0;
|
||||||
|
.item-name {
|
||||||
|
width: 80px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="index">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-[750px] h-[340px] flex-none mr-5">
|
||||||
|
<ElCarousel
|
||||||
|
v-if="getSwiperData.enabled"
|
||||||
|
class="w-full"
|
||||||
|
trigger="click"
|
||||||
|
height="340px"
|
||||||
|
>
|
||||||
|
<ElCarouselItem v-for="item in showList" :key="item">
|
||||||
|
<NuxtLink :to="item.link.path" target="_blank">
|
||||||
|
<ElImage
|
||||||
|
class="w-full h-full rounded-[8px] bg-white overflow-hidden"
|
||||||
|
:src="appStore.getImageUrl(item.image)"
|
||||||
|
fit="contain"
|
||||||
|
/>
|
||||||
|
</NuxtLink>
|
||||||
|
</ElCarouselItem>
|
||||||
|
</ElCarousel>
|
||||||
|
</div>
|
||||||
|
<InformationCard
|
||||||
|
link="/information/new"
|
||||||
|
class="flex-1 min-w-0"
|
||||||
|
header="最新资讯"
|
||||||
|
:data="pageData.new"
|
||||||
|
:show-time="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex">
|
||||||
|
<InformationCard
|
||||||
|
link="/information"
|
||||||
|
class="w-[750px] flex-none mr-5"
|
||||||
|
header="全部资讯"
|
||||||
|
:data="pageData.all"
|
||||||
|
:only-title="false"
|
||||||
|
/>
|
||||||
|
<InformationCard
|
||||||
|
link="/information/hot"
|
||||||
|
class="flex-1"
|
||||||
|
header="热门资讯"
|
||||||
|
:data="pageData.hot"
|
||||||
|
:only-title="false"
|
||||||
|
image-size="mini"
|
||||||
|
:show-author="false"
|
||||||
|
:show-desc="false"
|
||||||
|
:show-click="false"
|
||||||
|
:border="false"
|
||||||
|
:title-line="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElCarousel, ElCarouselItem, ElImage } from 'element-plus'
|
||||||
|
import { getIndex } from '@/api/shop'
|
||||||
|
import { useAppStore } from '~~/stores/app'
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const { data: pageData } = await useAsyncData(() => getIndex(), {
|
||||||
|
default: () => ({
|
||||||
|
all: [],
|
||||||
|
hot: [],
|
||||||
|
new: [],
|
||||||
|
page: {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSwiperData = computed(() => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(pageData.value.page.data)
|
||||||
|
console.log(data)
|
||||||
|
return data.find((item) => item.name === 'pc-banner')?.content
|
||||||
|
} catch (error) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const showList = computed(() => {
|
||||||
|
return (
|
||||||
|
getSwiperData.value?.data ||
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-full flex flex-col">
|
||||||
|
<div class="text-4xl mb-5">
|
||||||
|
<span v-if="route.query.keywords">
|
||||||
|
查找"{{ route.query.keywords }}"
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ route.query.name || getSourceText }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-loading="pending">
|
||||||
|
<div
|
||||||
|
class="bg-white px-5 rounded overflow-hidden"
|
||||||
|
v-if="data.lists.length"
|
||||||
|
>
|
||||||
|
<div class="pt-5 text-tx-secondary" v-if="route.query.keywords">
|
||||||
|
为您找到相关结果 {{ data.count }}个
|
||||||
|
</div>
|
||||||
|
<InformationItems
|
||||||
|
v-for="item in data.lists"
|
||||||
|
:key="item.id"
|
||||||
|
:id="item.id"
|
||||||
|
:title="item.title"
|
||||||
|
:desc="item.desc"
|
||||||
|
:click="item.click"
|
||||||
|
:author="item.author"
|
||||||
|
:create-time="item.create_time"
|
||||||
|
:image="item.image"
|
||||||
|
:only-title="false"
|
||||||
|
/>
|
||||||
|
<div class="py-4 flex justify-end">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="params.page_no"
|
||||||
|
:total="data.count"
|
||||||
|
:page-size="params.page_size"
|
||||||
|
hide-on-single-page
|
||||||
|
@current-change="refresh()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex-1 flex justify-center items-center">
|
||||||
|
<el-empty
|
||||||
|
:image="empty_news"
|
||||||
|
description="暂无资讯"
|
||||||
|
:image-size="250"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElPagination, ElEmpty } from 'element-plus'
|
||||||
|
import empty_news from '@/assets/images/empty_news.png'
|
||||||
|
import { getArticleList } from '~~/api/news'
|
||||||
|
const route = useRoute()
|
||||||
|
const sort = computed(() => route.params.source)
|
||||||
|
const keyword = computed(() => route.query.keywords || '')
|
||||||
|
const cid = computed(() => route.query.cid || '')
|
||||||
|
const params = reactive({
|
||||||
|
page_no: 1,
|
||||||
|
page_size: 15,
|
||||||
|
keyword,
|
||||||
|
cid,
|
||||||
|
sort
|
||||||
|
})
|
||||||
|
const { data, refresh, pending } = await useAsyncData(
|
||||||
|
() => getArticleList(params),
|
||||||
|
{
|
||||||
|
initialCache: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const getSourceText = computed(() => {
|
||||||
|
switch (route.params.source) {
|
||||||
|
case 'hot':
|
||||||
|
return '热门资讯'
|
||||||
|
case 'new':
|
||||||
|
return ' 最新资讯'
|
||||||
|
default:
|
||||||
|
return '全部资讯'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([() => route.query.keywords, () => route.query.cid], () => {
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
当前位置:
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item :to="{ path: '/information' }">
|
||||||
|
资讯中心
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item
|
||||||
|
:to="{
|
||||||
|
path: `/information/default`,
|
||||||
|
query: {
|
||||||
|
cid: newsDetail.cid,
|
||||||
|
name: newsDetail.cate_name
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ newsDetail.cate_name }}
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item>文章详情</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 mt-5">
|
||||||
|
<div class="w-[750px] bg-body rounded-[8px] flex-none p-5">
|
||||||
|
<div class="border-b border-br pb-4">
|
||||||
|
<span class="font-medium text-[22px]">
|
||||||
|
{{ newsDetail.title }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="mt-3 text-tx-secondary flex items-center flex-wrap"
|
||||||
|
>
|
||||||
|
<span v-if="newsDetail.author">
|
||||||
|
{{ newsDetail.author }} |
|
||||||
|
</span>
|
||||||
|
<span class="mr-5">{{ newsDetail.create_time }}</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Icon name="el-icon-View" />
|
||||||
|
<span> {{ newsDetail.click }}人浏览</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="newsDetail.abstract"
|
||||||
|
class="bg-page mt-4 p-3 rounded-lg"
|
||||||
|
>
|
||||||
|
摘要:{{ newsDetail.abstract }}
|
||||||
|
</div>
|
||||||
|
<div class="py-4" v-html="newsDetail.content"></div>
|
||||||
|
<div class="flex justify-center mt-[40px]">
|
||||||
|
<ElButton size="large" round @click="handelCollectLock">
|
||||||
|
<Icon
|
||||||
|
:name="`el-icon-${
|
||||||
|
newsDetail.collect ? 'StarFilled' : 'Star'
|
||||||
|
}`"
|
||||||
|
:size="newsDetail.collect ? 20 : 16"
|
||||||
|
:color="newsDetail.collect ? '#FF2C2F' : 'inherit'"
|
||||||
|
/>
|
||||||
|
{{ newsDetail.collect ? '取消收藏' : '点击收藏' }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-br mt-[30px]">
|
||||||
|
<div class="mt-5 flex">
|
||||||
|
<span class="text-tx-regular">上一篇:</span>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="newsDetail.last.id"
|
||||||
|
class="flex-1 hover:underline"
|
||||||
|
:to="`/information/detail/${newsDetail.last?.id}`"
|
||||||
|
>
|
||||||
|
{{ newsDetail.last?.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else> 暂无相关文章 </span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex">
|
||||||
|
<span class="text-tx-regular">下一篇:</span>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="newsDetail.next.id"
|
||||||
|
class="flex-1 hover:underline"
|
||||||
|
:to="`/information/detail/${newsDetail.next?.id}`"
|
||||||
|
>
|
||||||
|
{{ newsDetail.next?.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else> 暂无相关文章 </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InformationCard
|
||||||
|
class="flex-1"
|
||||||
|
header="相关资讯"
|
||||||
|
:data="newsDetail.new"
|
||||||
|
:only-title="false"
|
||||||
|
image-size="mini"
|
||||||
|
:show-author="false"
|
||||||
|
:show-desc="false"
|
||||||
|
:show-click="false"
|
||||||
|
:border="false"
|
||||||
|
:title-line="2"
|
||||||
|
source="new"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElBreadcrumb, ElBreadcrumbItem, ElButton } from 'element-plus'
|
||||||
|
import { addCollect, cancelCollect, getArticleDetail } from '~~/api/news'
|
||||||
|
import feedback from '~~/utils/feedback'
|
||||||
|
const route = useRoute()
|
||||||
|
const { data: newsDetail, refresh } = await useAsyncData(
|
||||||
|
() =>
|
||||||
|
getArticleDetail({
|
||||||
|
id: route.params.id,
|
||||||
|
source: route.params.source
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
initialCache: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const getSourceText = computed(() => {
|
||||||
|
switch (route.params.source) {
|
||||||
|
case 'hot':
|
||||||
|
return '热门资讯'
|
||||||
|
case 'new':
|
||||||
|
return ' 最新资讯'
|
||||||
|
default:
|
||||||
|
return '全部资讯'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handelCollect = async () => {
|
||||||
|
const id = route.params.id
|
||||||
|
if (newsDetail.value.collect) {
|
||||||
|
await cancelCollect({ id })
|
||||||
|
feedback.msgSuccess('已取消收藏')
|
||||||
|
} else {
|
||||||
|
await addCollect({ id })
|
||||||
|
feedback.msgSuccess('收藏成功')
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
const { lockFn: handelCollectLock } = useLockFn(handelCollect)
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-4xl mb-5">资讯中心</div>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<InformationCard
|
||||||
|
v-for="item in newsLists"
|
||||||
|
style="width: calc(50% - 8px)"
|
||||||
|
:key="item.id"
|
||||||
|
:header="item.name"
|
||||||
|
:data="item.article"
|
||||||
|
:link="`/information/default?cid=${item.id}&name=${item.name}`"
|
||||||
|
>
|
||||||
|
<template #content="{ data }">
|
||||||
|
<div class="px-4 py-5">
|
||||||
|
<div class="flex gap-2.5">
|
||||||
|
<div
|
||||||
|
class="w-[180px] bg-page rounded overflow-hidden"
|
||||||
|
v-for="(item, index) in splitData(data)
|
||||||
|
.topThree"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<InformationItems
|
||||||
|
:index="index"
|
||||||
|
:id="item.id"
|
||||||
|
:title="item.title"
|
||||||
|
:desc="item.desc"
|
||||||
|
:click="item.click"
|
||||||
|
:author="item.author"
|
||||||
|
:create-time="item.create_time"
|
||||||
|
:image="item.image || placeholder"
|
||||||
|
:only-title="false"
|
||||||
|
:border="false"
|
||||||
|
:show-author="false"
|
||||||
|
:show-desc="false"
|
||||||
|
:show-time="false"
|
||||||
|
:show-click="false"
|
||||||
|
:is-horizontal="true"
|
||||||
|
>
|
||||||
|
<template #title="{ title }">
|
||||||
|
<span class="line-clamp-2">{{
|
||||||
|
title
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</InformationItems>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="item in splitData(data).remain"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<InformationItems
|
||||||
|
:id="item.id"
|
||||||
|
:title="item.title"
|
||||||
|
:desc="item.desc"
|
||||||
|
:click="item.click"
|
||||||
|
:author="item.author"
|
||||||
|
:create-time="item.create_time"
|
||||||
|
:image="item.image"
|
||||||
|
:only-title="true"
|
||||||
|
:show-time="false"
|
||||||
|
>
|
||||||
|
<template #title="{ title }">
|
||||||
|
<span class="line-clamp-1">{{
|
||||||
|
title
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</InformationItems>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</InformationCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { getArticleCenter } from '~~/api/news'
|
||||||
|
import placeholder from '@/assets/images/placeholder.png'
|
||||||
|
const { data: newsLists } = await useAsyncData(() => getArticleCenter())
|
||||||
|
const splitData = (data) => {
|
||||||
|
const size = 3
|
||||||
|
return {
|
||||||
|
topThree: data.slice(0, size),
|
||||||
|
remain: data.slice(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white render-html p-[30px] w-[1200px] mx-auto min-h-screen">
|
||||||
|
<h1 class="text-center">{{ data.title }}</h1>
|
||||||
|
<div class="mx-auto" v-html="data.content"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { getPolicy } from '~~/api/app'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { data } = await useAsyncData(
|
||||||
|
() =>
|
||||||
|
getPolicy({
|
||||||
|
type: route.params.type
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
initialCache: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'blank'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-[30px] py-5 user-info min-h-full flex flex-col">
|
||||||
|
<div class="border-b border-br pb-5">
|
||||||
|
<span class="text-2xl font-medium">我的收藏</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.lists.length">
|
||||||
|
<div
|
||||||
|
class="cursor-pointer"
|
||||||
|
v-for="item in data.lists"
|
||||||
|
:key="item.id"
|
||||||
|
@click="$router.push(`/information/detail/${item.article_id}`)"
|
||||||
|
>
|
||||||
|
<div class="border-b border-br py-4 flex items-center">
|
||||||
|
<ElImage
|
||||||
|
v-if="item.image"
|
||||||
|
class="flex-none w-[180px] h-[135px] mr-4"
|
||||||
|
:src="item.image"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-lg font-medium line-clamp-1">
|
||||||
|
{{ item.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-tx-regular line-clamp-2 mt-4">
|
||||||
|
{{ item.desc }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-5 text-tx-secondary flex justify-between"
|
||||||
|
>
|
||||||
|
<div>收藏于{{ item.collect_time }}</div>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
@click.stop="handelCollect(item.article_id)"
|
||||||
|
>
|
||||||
|
取消收藏
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="py-4 flex justify-end">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="params.page_no"
|
||||||
|
:total="data.count"
|
||||||
|
:page-size="params.page_size"
|
||||||
|
hide-on-single-page
|
||||||
|
layout="total, prev, pager, next, jumper"
|
||||||
|
@current-change="refresh()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 justify-center items-center" v-else>
|
||||||
|
<el-empty
|
||||||
|
:image="empty_news"
|
||||||
|
description="暂无收藏"
|
||||||
|
:image-size="250"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { cancelCollect, getCollect } from '~~/api/news'
|
||||||
|
import empty_news from '@/assets/images/empty_news.png'
|
||||||
|
import { ElImage, ElButton, ElPagination, ElEmpty } from 'element-plus'
|
||||||
|
import feedback from '~~/utils/feedback'
|
||||||
|
const params = reactive({
|
||||||
|
page_no: 1,
|
||||||
|
page_size: 15
|
||||||
|
})
|
||||||
|
const { data, refresh } = await useAsyncData(() => getCollect(params), {
|
||||||
|
initialCache: false
|
||||||
|
})
|
||||||
|
const handelCollect = async (id) => {
|
||||||
|
await cancelCollect({ id })
|
||||||
|
feedback.msgSuccess('已取消收藏')
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
definePageMeta({
|
||||||
|
module: 'personal',
|
||||||
|
auth: true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-[30px] py-5 user-info">
|
||||||
|
<div class="border-b border-br pb-5">
|
||||||
|
<span class="text-2xl font-medium">个人信息</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="item-name">头像</div>
|
||||||
|
<div class="avatar">
|
||||||
|
<ElAvatar :size="60" :src="userInfo.avatar"></ElAvatar>
|
||||||
|
<div class="change-btn">
|
||||||
|
<CropperUpload
|
||||||
|
@change="setUserInfo($event, UserFieldEnum.AVATAR)"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-white">修改</span>
|
||||||
|
</CropperUpload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item leading-10">
|
||||||
|
<div class="item-name">账号</div>
|
||||||
|
<div>
|
||||||
|
{{ userInfo.account }}
|
||||||
|
<ClientOnly>
|
||||||
|
<PopoverInput
|
||||||
|
class="inline-block"
|
||||||
|
@confirm="
|
||||||
|
setUserInfo($event, UserFieldEnum.USERNAME)
|
||||||
|
"
|
||||||
|
:limit="30"
|
||||||
|
show-limit
|
||||||
|
>
|
||||||
|
<ElButton link>
|
||||||
|
<Icon name="el-icon-Edit" :size="16" />
|
||||||
|
</ElButton>
|
||||||
|
</PopoverInput>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item leading-10">
|
||||||
|
<div class="item-name">昵称</div>
|
||||||
|
<div>
|
||||||
|
{{ userInfo.nickname }}
|
||||||
|
<ClientOnly>
|
||||||
|
<PopoverInput
|
||||||
|
class="inline-block"
|
||||||
|
@confirm="
|
||||||
|
setUserInfo($event, UserFieldEnum.NICKNAME)
|
||||||
|
"
|
||||||
|
:limit="30"
|
||||||
|
show-limit
|
||||||
|
>
|
||||||
|
<ElButton link>
|
||||||
|
<Icon name="el-icon-Edit" :size="16" />
|
||||||
|
</ElButton>
|
||||||
|
</PopoverInput>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item leading-10">
|
||||||
|
<div class="item-name">性别</div>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
{{ userInfo.sex }}
|
||||||
|
</span>
|
||||||
|
<ClientOnly>
|
||||||
|
<PopoverInput
|
||||||
|
class="inline-block"
|
||||||
|
type="select"
|
||||||
|
:teleported="false"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
label: '未知',
|
||||||
|
value: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '男',
|
||||||
|
value: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '女',
|
||||||
|
value: 2
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
@confirm="setUserInfo($event, UserFieldEnum.SEX)"
|
||||||
|
>
|
||||||
|
<ElButton link>
|
||||||
|
<Icon name="el-icon-Edit" :size="16" />
|
||||||
|
</ElButton>
|
||||||
|
</PopoverInput>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item leading-10">
|
||||||
|
<div class="item-name">手机号</div>
|
||||||
|
<div v-if="userInfo.mobile">
|
||||||
|
{{ userInfo.mobile }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElButton link type="primary" @click="changeMobile">
|
||||||
|
{{ userInfo.mobile ? '更换手机号' : '绑定手机号' }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<div class="info-item leading-10">
|
||||||
|
<div class="item-name">注册时间</div>
|
||||||
|
<div>
|
||||||
|
{{ userInfo.create_time }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-[60px] flex justify-center">
|
||||||
|
<ElButton type="primary" @click="handleLogut">退出登录</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElAvatar, ElButton } from 'element-plus'
|
||||||
|
import { getUserInfo, userEdit } from '@/api/user'
|
||||||
|
import CropperUpload from '@/components/cropper-upload/index.vue'
|
||||||
|
import PopoverInput from '@/components/popover-input/index.vue'
|
||||||
|
import {
|
||||||
|
useAccount,
|
||||||
|
PopupTypeEnum
|
||||||
|
} from '@/layouts/components/account/useAccount'
|
||||||
|
import { logout } from '~~/api/account'
|
||||||
|
import feedback from '~~/utils/feedback'
|
||||||
|
import { useUserStore } from '~~/stores/user'
|
||||||
|
const { setPopupType, toggleShowPopup, showPopup } = useAccount()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
// 用户资料
|
||||||
|
enum UserFieldEnum {
|
||||||
|
NONE = '',
|
||||||
|
AVATAR = 'avatar',
|
||||||
|
USERNAME = 'account',
|
||||||
|
NICKNAME = 'nickname',
|
||||||
|
SEX = 'sex'
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: userInfo, refresh } = await useAsyncData(() => getUserInfo(), {
|
||||||
|
initialCache: false
|
||||||
|
})
|
||||||
|
const setUserInfo = async (
|
||||||
|
value: string,
|
||||||
|
type: UserFieldEnum
|
||||||
|
): Promise<void> => {
|
||||||
|
await userEdit({
|
||||||
|
field: type,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeMobile = () => {
|
||||||
|
setPopupType(PopupTypeEnum.BIND_MOBILE)
|
||||||
|
toggleShowPopup(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showPopup, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleLogut = async () => {
|
||||||
|
await feedback.confirm('确定退出登录吗?')
|
||||||
|
await logout()
|
||||||
|
userStore.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
module: 'personal',
|
||||||
|
auth: true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.user-info {
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
|
padding: 10px 0;
|
||||||
|
.item-name {
|
||||||
|
width: 80px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
@apply relative flex cursor-pointer;
|
||||||
|
.change-btn {
|
||||||
|
display: none;
|
||||||
|
height: 50%;
|
||||||
|
line-height: 30px;
|
||||||
|
@apply absolute bg-[rgba(0,0,0,0.5)] w-full text-center bottom-0 rounded-b-full;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
.change-btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ElLoading } from 'element-plus'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const plugins = [ElLoading]
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
nuxtApp.vueApp.use(plugin)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createRequest } from '~~/utils/http'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const request = createRequest()
|
||||||
|
//@ts-ignore 添加
|
||||||
|
globalThis.$request = request
|
||||||
|
|
||||||
|
const $fetchOriginal = globalThis.$fetch
|
||||||
|
const $fetch: any = (url: string, opts?: any) => {
|
||||||
|
opts = opts ?? {}
|
||||||
|
opts.url = url
|
||||||
|
return request.request(opts, opts.requestOptions)
|
||||||
|
}
|
||||||
|
$fetch.raw = $fetchOriginal.raw
|
||||||
|
$fetch.create = $fetchOriginal.create
|
||||||
|
//@ts-ignore 重写$fetch
|
||||||
|
globalThis.$fetch = $fetch
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as ElementPlusIcons from '@element-plus/icons-vue'
|
||||||
|
//@ts-ignore
|
||||||
|
const localIconsName: string[] = []
|
||||||
|
|
||||||
|
export const LOCAL_ICON_PREFIX = 'local-icon-'
|
||||||
|
export const EL_ICON_PREFIX = 'el-icon-'
|
||||||
|
|
||||||
|
const elIconsName: string[] = []
|
||||||
|
|
||||||
|
export function getElementPlusIconNames() {
|
||||||
|
return elIconsName
|
||||||
|
}
|
||||||
|
export function getLocalIconNames() {
|
||||||
|
return localIconsName
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
for (const [iconName, component] of Object.entries(ElementPlusIcons)) {
|
||||||
|
const componenName = `${EL_ICON_PREFIX}${iconName}`
|
||||||
|
elIconsName.push(componenName)
|
||||||
|
nuxtApp.vueApp.component(componenName, component)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import fsExtra from 'fs-extra'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
const { existsSync, remove, copy } = fsExtra
|
||||||
|
const cwd = process.cwd()
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
const isSSR = Boolean(process.env.NUXT_SSR)
|
||||||
|
//打包发布路径,可能会覆盖重要文件,请谨慎改动
|
||||||
|
const releaseRelativePath = '../server/public/pc'
|
||||||
|
const distRelativePath = isSSR ? '' : './.output/public'
|
||||||
|
const distPath = path.resolve(cwd, distRelativePath)
|
||||||
|
const distSSRFilter = ['.output', 'public', 'static', 'package.json', 'package-lock.json']
|
||||||
|
const distNotSSRFilter = ['']
|
||||||
|
const distFilter = joinPath(isSSR ? distSSRFilter : distNotSSRFilter)
|
||||||
|
|
||||||
|
function joinPath(target, res = []) {
|
||||||
|
target.forEach((src) => {
|
||||||
|
res.push(path.join(distPath, src))
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const releasePath = path.resolve(cwd, releaseRelativePath)
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
if (existsSync(releasePath)) {
|
||||||
|
await remove(releasePath)
|
||||||
|
}
|
||||||
|
console.log(`文件正在复制 ==> ${releaseRelativePath}`)
|
||||||
|
try {
|
||||||
|
await copyFile(distPath, releasePath)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`\n ${error}`)
|
||||||
|
}
|
||||||
|
console.log(`文件已复制 ==> ${releaseRelativePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFile(sourceDir, targetDir) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
copy(
|
||||||
|
sourceDir,
|
||||||
|
targetDir,
|
||||||
|
{
|
||||||
|
filter(src) {
|
||||||
|
if (src === distPath) return true
|
||||||
|
return distFilter.some((item) => src.includes(item))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
build()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { getConfig } from '~~/api/app'
|
||||||
|
|
||||||
|
interface AppSate {
|
||||||
|
config: Record<string, any>
|
||||||
|
}
|
||||||
|
export const useAppStore = defineStore({
|
||||||
|
id: 'appStore',
|
||||||
|
state: (): AppSate => ({
|
||||||
|
config: {}
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
getImageUrl: (state) => (url: string) =>
|
||||||
|
url ? `${state.config.domain}${url}` : '',
|
||||||
|
getWebsiteConfig: (state) => state.config.website || {},
|
||||||
|
getLoginConfig: (state) => state.config.login || {},
|
||||||
|
getCopyrightConfig: (state) => state.config.copyright || [],
|
||||||
|
getQrcodeConfig: (state) => state.config.qrcode || {},
|
||||||
|
getAdminUrl: (state) => state.config.admin_url,
|
||||||
|
getSiteStatistics: (state) => state.config.siteStatistics || {}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async getConfig() {
|
||||||
|
const config = await getConfig()
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { getUserCenter } from '@/api/user'
|
||||||
|
import { TOKEN_KEY } from '@/enums/cacheEnums'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
interface UserSate {
|
||||||
|
userInfo: Record<string, any>
|
||||||
|
token: string | null
|
||||||
|
temToken: string | null
|
||||||
|
}
|
||||||
|
export const useUserStore = defineStore({
|
||||||
|
id: 'userStore',
|
||||||
|
state: (): UserSate => {
|
||||||
|
const TOKEN = useCookie(TOKEN_KEY)
|
||||||
|
return {
|
||||||
|
userInfo: {},
|
||||||
|
token: TOKEN.value || null,
|
||||||
|
temToken: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
isLogin: (state) => !!state.token
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async getUser() {
|
||||||
|
const data = await getUserCenter()
|
||||||
|
this.userInfo = data
|
||||||
|
},
|
||||||
|
setUser(userInfo: any) {
|
||||||
|
this.userInfo = userInfo
|
||||||
|
},
|
||||||
|
login(token: string) {
|
||||||
|
const TOKEN = useCookie(TOKEN_KEY)
|
||||||
|
this.token = token
|
||||||
|
TOKEN.value = token
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
const TOKEN = useCookie(TOKEN_KEY)
|
||||||
|
this.token = null
|
||||||
|
this.userInfo = {}
|
||||||
|
TOKEN.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./components/**/*.{vue,js}',
|
||||||
|
'./layouts/**/*.vue',
|
||||||
|
'./pages/**/*.vue',
|
||||||
|
'./plugins/**/*.{js,ts}'
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
colors: {
|
||||||
|
white: 'var(--color-white)',
|
||||||
|
black: 'var(--el-color-black)',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'var(--el-color-primary)',
|
||||||
|
'light-3': 'var(--el-color-primary-light-3)',
|
||||||
|
'light-5': 'var(--el-color-primary-light-5)',
|
||||||
|
'light-7': 'var(--el-color-primary-light-7)',
|
||||||
|
'light-8': 'var(--el-color-primary-light-8)',
|
||||||
|
'light-9': 'var(--el-color-primary-light-9)',
|
||||||
|
'dark-2': 'var(--el-color-primary-dark-2)'
|
||||||
|
},
|
||||||
|
success: 'var(--el-color-success)',
|
||||||
|
warning: 'var(--el-color-warning)',
|
||||||
|
danger: 'var(--el-color-danger)',
|
||||||
|
error: 'var(--el-color-error)',
|
||||||
|
info: 'var(--el-color-info)',
|
||||||
|
body: 'var(--el-bg-color)',
|
||||||
|
page: 'var(--el-bg-color-page)',
|
||||||
|
'tx-primary': 'var(--el-text-color-primary)',
|
||||||
|
'tx-regular': 'var(--el-text-color-regular)',
|
||||||
|
'tx-secondary': 'var(--el-text-color-secondary)',
|
||||||
|
'tx-placeholder': 'var(--el-text-color-placeholder)',
|
||||||
|
'tx-disabled': 'var(--el-text-color-disabled)',
|
||||||
|
br: 'var(--el-border-color)',
|
||||||
|
'br-light': 'var(--el-border-color-light)',
|
||||||
|
'br-extra-light': 'var(--el-border-color-extra-light)',
|
||||||
|
'br-dark': 'var( --el-border-color-dark)',
|
||||||
|
fill: 'var(--el-fill-color)',
|
||||||
|
mask: 'var(--el-mask-color)',
|
||||||
|
overlay: 'var(--el-overlay-color-light)'
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
'PingFang SC',
|
||||||
|
'Arial',
|
||||||
|
'Hiragino Sans GB',
|
||||||
|
'Microsoft YaHei',
|
||||||
|
'sans-serif'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
DEFAULT: 'var(--el-box-shadow)',
|
||||||
|
light: 'var(--el-box-shadow-light)',
|
||||||
|
lighter: 'var(--el-box-shadow-lighter)',
|
||||||
|
dark: 'var(--el-box-shadow-dark)'
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: 'var(--el-font-size-extra-small)',
|
||||||
|
sm: 'var( --el-font-size-small)',
|
||||||
|
base: 'var( --el-font-size-base)',
|
||||||
|
lg: 'var( --el-font-size-medium)',
|
||||||
|
xl: 'var( --el-font-size-large)',
|
||||||
|
'2xl': 'var( --el-font-size-extra-large)',
|
||||||
|
'3xl': '20px',
|
||||||
|
'4xl': '24px',
|
||||||
|
'5xl': '28px',
|
||||||
|
'6xl': '30px',
|
||||||
|
'7xl': '36px',
|
||||||
|
'8xl': '48px',
|
||||||
|
'9xl': '60px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// https://v3.nuxtjs.org/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import 'ofetch'
|
||||||
|
import { FetchResponse, FetchOptions } from 'ofetch'
|
||||||
|
|
||||||
|
declare module 'ofetch' {
|
||||||
|
interface FetchOptions {
|
||||||
|
url?: string
|
||||||
|
requestOptions?: RequestOptions
|
||||||
|
}
|
||||||
|
interface RequestOptions {
|
||||||
|
// 请求接口前缀
|
||||||
|
apiPrefix?: string
|
||||||
|
// 需要对返回数据进行处理
|
||||||
|
isTransformResponse?: boolean
|
||||||
|
// 是否返回默认数据
|
||||||
|
isReturnDefaultResponse?: boolean
|
||||||
|
//POST请求下如果无data,则将params视为data
|
||||||
|
isParamsToData?: boolean
|
||||||
|
// 是否自动携带token
|
||||||
|
withToken?: boolean
|
||||||
|
requestInterceptorsHook?(options: FetchOptions): FetchOptions
|
||||||
|
responseInterceptorsHook?(
|
||||||
|
response: FetchResponse<any>,
|
||||||
|
options: FetchOptions
|
||||||
|
): any
|
||||||
|
responseInterceptorsCatchHook?: (error: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestEventStreamOptions extends Partial<RequestOptions> {
|
||||||
|
onstart?: (reader: ReadableStreamDefaultReader<Uint8Array>) => void
|
||||||
|
onmessage?: (value: string) => void
|
||||||
|
onclose?: () => void
|
||||||
|
}
|
||||||
|
interface FileParams {
|
||||||
|
name?: string
|
||||||
|
file: File
|
||||||
|
data?: any
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import 'vue-router'
|
||||||
|
declare module 'vue-router' {
|
||||||
|
// 扩展 RouteMeta
|
||||||
|
interface RouteMeta {
|
||||||
|
module?: string
|
||||||
|
activeMenu?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @description 获取客户端类型
|
||||||
|
*/
|
||||||
|
export function getClient() {
|
||||||
|
return useRuntimeConfig().public.client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取版本号
|
||||||
|
*/
|
||||||
|
export function getVersion() {
|
||||||
|
return useRuntimeConfig().public.version
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取请求域名
|
||||||
|
*/
|
||||||
|
export function getApiUrl() {
|
||||||
|
return useRuntimeConfig().public.apiUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取请求前缀
|
||||||
|
*/
|
||||||
|
export function getApiPrefix() {
|
||||||
|
return useRuntimeConfig().public.apiPrefix
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user