chore: track root project files and subrepos

This commit is contained in:
hajimi
2026-05-23 13:22:03 +08:00
parent 1624f023a2
commit 648ed678f3
1035 changed files with 154418 additions and 1 deletions
+28
View File
@@ -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
View File
@@ -0,0 +1,399 @@
# Sport-Era 体育咨询AI平台 - 产品方案文档
> 版本:v1.1Demo版)
> 日期: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
View File
@@ -0,0 +1,678 @@
# Sport-Era 体育咨询AI平台 - 实现流程文档
> 版本:v1.1Demo版)
> 日期: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路由规划
### 前台APIapp/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 | 优化迭代 | 持续 | 性能优化+数据积累+模型调优 |
+104
View File
@@ -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_`
- 缓存:RedisThinkPHP 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)
开发或接手任务前,先查看该文档确认已完成事项、进行中事项、待办事项与避坑记录。
+21
View File
@@ -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.
+145
View File
@@ -0,0 +1,145 @@
<a href="https://www.likeshop.cn">![likeshop全开源商城](https://server.likeshop.cn/uploads/gitad/fastbuildAI.png)</a><br>
<a href="https://www.likeshop.cn">![likeshop全开源商城](https://server.likeshop.cn/uploads/gitad/likeshop.png)</a><br>
<a href="https://www.buildingai.cc">![BuildingAI](https://server.likeshop.cn/uploads/gitad/fastbuildAI.png)</a><br>
<h1 align="center">likeadminPHP
通用管理后台</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>
![](/server/public/readme/likeadmin.png)
## 🚀🚀🚀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")
### 🖥️ 界面预览
![](/server/public/readme/admin-1.png)
![](/server/public/readme/admin-2.png)
![](/server/public/readme/admin-3.png)
![](/server/public/readme/admin-4.png)
<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 端前台界面
![](/server/public/readme/pc-1.png)
![](/server/public/readme/pc-2.png)
![](/server/public/readme/pc-3.png)
## 👨‍💻‍ 简介
我们希望能够为开源社区做出更多的贡献,推出永久免费开源的 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
+2
View File
@@ -0,0 +1,2 @@
/data
/log
+31
View File
@@ -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;
}
}
+42
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
upload_max_filesize = 5M
+67
View File
@@ -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=不适用';
+53
View File
@@ -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());
+72
View File
@@ -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());
+2
View File
@@ -0,0 +1,2 @@
# 请求域名
NUXT_API_URL=
+17
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# 请求域名
NUXT_API_URL=
+46
View File
@@ -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'
}
}
+11
View File
@@ -0,0 +1,11 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.vite
.env
.env.development
.env.production
dist
+11
View File
@@ -0,0 +1,11 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 80,
"proseWrap": "preserve",
"bracketSameLine": false,
"endOfLine": "auto",
"tabWidth": 4,
"useTabs": false,
"trailingComma": "none"
}
+72
View File
@@ -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`
+42
View File
@@ -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.
+35
View File
@@ -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 })
}
+19
View File
@@ -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)
}
+57
View File
@@ -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 })
}
+4
View File
@@ -0,0 +1,4 @@
//首页数据
export function getIndex() {
return $request.get({ url: '/pc/index' })
}
+36
View File
@@ -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
View File
@@ -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

+137
View File
@@ -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);
}
}
+3
View File
@@ -0,0 +1,3 @@
@use 'element.scss';
@use 'var.css';
@use 'public.scss';
+56
View File
@@ -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};
}
+64
View File
@@ -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);
}
+73
View File
@@ -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>
+28
View File
@@ -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>
+38
View File
@@ -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>
+115
View File
@@ -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>
+168
View File
@@ -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 }}&nbsp;|&nbsp;
</span>
<span class="mr-5" v-if="showTime">{{ createTime }}</span>
<div v-if="showClick" class="flex items-center">
<ElIcon>
<View />
</ElIcon>
<span>&nbsp;{{ 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>
+137
View File
@@ -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>
+79
View File
@@ -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>
+19
View File
@@ -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
}
}
+26
View File
@@ -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 []
}
+55
View File
@@ -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'
}
]
}
]
}
]
+33
View File
@@ -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'
}
+8
View File
@@ -0,0 +1,8 @@
// 本地缓冲key
//token
export const TOKEN_KEY = 'token'
//账号
export const ACCOUNT_KEY = 'account'
//设置
export const SETTING_KEY = 'setting'
+7
View File
@@ -0,0 +1,7 @@
export enum PageEnum {
//登录页面
LOGIN = '/login',
//无权限页面
ERROR_403 = '/403',
INDEX = '/'
}
+19
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
import { Request } from '@/utils/http/request'
declare global {
const $request: Request
}
+6
View File
@@ -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>
+36
View File
@@ -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>
+310
View File
@@ -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>
+124
View File
@@ -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
}
}
+35
View File
@@ -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>
+24
View File
@@ -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>
+44
View File
@@ -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>
+11
View File
@@ -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>
+58
View File
@@ -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>
+58
View File
@@ -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>
+50
View File
@@ -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>
+67
View File
@@ -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>
+30
View File
@@ -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>
+42
View File
@@ -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>
+65
View File
@@ -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>
+28
View File
@@ -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>
+18
View File
@@ -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()
}
})
+33
View File
@@ -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)
}
})
+17
View File
@@ -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
})
+18
View File
@@ -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
}
+19589
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
}
+199
View File
@@ -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>
+85
View File
@@ -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>
+86
View File
@@ -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>
+141
View File
@@ -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 }}&nbsp;|&nbsp;
</span>
<span class="mr-5">{{ newsDetail.create_time }}</span>
<div class="flex items-center">
<Icon name="el-icon-View" />
<span>&nbsp;{{ 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>
+87
View File
@@ -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>
+25
View File
@@ -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>
+85
View File
@@ -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>
+202
View File
@@ -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>
+8
View File
@@ -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)
}
})
+18
View File
@@ -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
})
+23
View File
@@ -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)
}
})
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
+61
View File
@@ -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()
+28
View File
@@ -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
}
}
})
+43
View File
@@ -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
}
}
})
+73
View File
@@ -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'
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
// https://v3.nuxtjs.org/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}
+38
View File
@@ -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
}
}
View File
+8
View File
@@ -0,0 +1,8 @@
import 'vue-router'
declare module 'vue-router' {
// 扩展 RouteMeta
interface RouteMeta {
module?: string
activeMenu?: string
}
}
+27
View File
@@ -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