Merge remote-tracking branch 'origin/huangzhenbao-admin'

This commit is contained in:
not-like-juvenile
2026-03-18 17:14:05 +08:00
676 changed files with 25158 additions and 46646 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -1,393 +0,0 @@
# Admin管理系统融合方案 - 文档总索引
> 📚 完整分析文档导航和内容总结
---
## 📑 生成的文档清单
### 🎯 核心分析文档4份
| 文档 | 文件名 | 内容摘要 | 推荐阅读 |
| ------------ | --------------------------------------------- | ---------------------------------------------------------------------- | --------------- |
| **综合分析** | `ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md` | 📊 四端功能分析、60+功能融合方案、15个新角色设计、权限体系、实施路线图 | ⭐⭐⭐ 必读 |
| **快速参考** | `ADMIN_INTEGRATION_QUICK_REFERENCE.md` | 🚀 一页纸快速了解、菜单对照、角色速查表、优先级排序 | ⭐⭐ 快速入门 |
| **菜单结构** | `ADMIN_MENU_STRUCTURE_COMPARISON.md` | 📋 18个菜单详细结构、5个新菜单的100+页面配置、菜单互联关系 | ⭐⭐⭐ 实施指南 |
| **实施检查** | `ADMIN_IMPLEMENTATION_CHECKLIST.md` | ✅ Phase 1-5的完整检查清单、15周甘特图、成功指标 | ⭐⭐ 项目管理 |
**总计**: 4份文档共约15000字包含图表、表格、代码示例
---
## 🎯 各角色推荐阅读顺序
### 管理层CEO/CTO/产品总监)
1. 📄 [ADMIN_INTEGRATION_QUICK_REFERENCE.md](ADMIN_INTEGRATION_QUICK_REFERENCE.md) - 5分钟了解全景
2. 📊 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md) 第一部分 - 理解融合价值10分钟
3. ✅ [ADMIN_IMPLEMENTATION_CHECKLIST.md](ADMIN_IMPLEMENTATION_CHECKLIST.md) - 了解实施成本5分钟
**总计**: 20分钟掌握全局
### 产品经理
1. 📋 [ADMIN_MENU_STRUCTURE_COMPARISON.md](ADMIN_MENU_STRUCTURE_COMPARISON.md) - 理解菜单结构
2. 📊 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md) - 完整功能需求
3. 📄 [ADMIN_INTEGRATION_QUICK_REFERENCE.md](ADMIN_INTEGRATION_QUICK_REFERENCE.md) - 15个角色和权限
**总计**: 1小时深入理解
### 技术主管 / 架构师
1. 📊 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md) 第四、六部分 - 前端架构、数据权限设计
2. 📋 [ADMIN_MENU_STRUCTURE_COMPARISON.md](ADMIN_MENU_STRUCTURE_COMPARISON.md) - 理解菜单和页面结构
3. ✅ [ADMIN_IMPLEMENTATION_CHECKLIST.md](ADMIN_IMPLEMENTATION_CHECKLIST.md) - 技术实施清单
4. 📄 [ADMIN_INTEGRATION_QUICK_REFERENCE.md](ADMIN_INTEGRATION_QUICK_REFERENCE.md) - 快速参考
**总计**: 2小时完全掌握技术方案
### 前端开发
1. 📊 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md) 第四部分 - 前端实现架构
2. 📋 [ADMIN_MENU_STRUCTURE_COMPARISON.md](ADMIN_MENU_STRUCTURE_COMPARISON.md) - 页面结构和布局
3. ✅ [ADMIN_IMPLEMENTATION_CHECKLIST.md](ADMIN_IMPLEMENTATION_CHECKLIST.md) Phase 1-2 - 权限库和菜单
**总计**: 1.5小时技术方案理解 + 开始编码
### 后端开发
1. 📊 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md) 第六部分 - 数据权限设计
2. 📋 [ADMIN_MENU_STRUCTURE_COMPARISON.md](ADMIN_MENU_STRUCTURE_COMPARISON.md) - 理解功能端点
3. ✅ [ADMIN_IMPLEMENTATION_CHECKLIST.md](ADMIN_IMPLEMENTATION_CHECKLIST.md) Phase 1 - 数据库和API开发
**总计**: 1.5小时技术方案 + 开始编码
### 业务运营
1. 📄 [ADMIN_INTEGRATION_QUICK_REFERENCE.md](ADMIN_INTEGRATION_QUICK_REFERENCE.md) - 快速了解
2. 📋 [ADMIN_MENU_STRUCTURE_COMPARISON.md](ADMIN_MENU_STRUCTURE_COMPARISON.md) 新增菜单部分 - 了解你的工作菜单
3. 📊 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md) 第三部分 - 了解你的角色和权限
**总计**: 30分钟了解新系统
---
## 📊 文档内容详细索引
### ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md
**第一部分:四端功能现状分析** (第1-65页)
- Analytics数据分析端分析 ✅
- 当前功能和可融合管理功能表
- Consumer消费者端分析 ✅
- 订单风险、退款审核、用户行为等12条管理功能
- Delivery配送端分析 ✅
- 配送员管理、绩效考核、费用结算等12条管理功能
- Merchant商户端分析 ✅
- 商户审核、佣金管理、经营分析等11条管理功能
**第二部分:功能融合方案** (第66-140页)
- 融合核心原则
- 18个菜单调整方案
- 5个新增菜单详细功能清单
- 数据分析菜单12页
- 配送管理菜单25页
- 商户管理菜单22页
- 行为分析菜单17页
- 审核管理菜单16页
**第三部分:权限和角色体系设计** (第141-200页)
- 当前7个角色简述
- 推荐新增15个角色的详细权限定义
- 管理层3个
- 运营经理6个
- 执行专员4个
- 专项角色2个
- 权限矩阵总览表18 × 15
- 多商户数据隔离设计
- 地区级权限设计
- 时间范围权限设计
**第四部分:前端实现架构** (第201-250页)
- 首页看板动态适配
- 侧边栏菜单权限隐藏
- 页面级权限验证
- 数据权限隔离
- 左侧菜单 + 主内容区布局
- 响应式设计方案
- 代码示例
**第五部分:实施路线图** (第251-280页)
- Phase 1-5详细时间表
- 每个阶段的具体任务
- 周期15周
**第六部分:附录** (第281-300页)
- 检查清单
- 权限设计检查
- 功能完整性检查
- 测试场景
---
### ADMIN_INTEGRATION_QUICK_REFERENCE.md
**融合概览**
- 四端融合表
- 新增菜单统计
**15个角色体系一览**
- 简明版本的角色定义
- 每个角色职责一句话总结
**5个新菜单详解**
- 每个菜单的分组、页面数、关键特性、适用人员
**按功能模块看权限**
- 快速查找"谁能做什么"
**实施优先级**
- 三个优先级的任务清单
**关键权限规则**
- 数据隔离、操作限制、审计记录、权限检查
**页面展示差异示例**
- 各角色看到的首页不同
**成功指标**
- 5个关键指标
**常见问题FAQ**
- 4个常见问题
---
### ADMIN_MENU_STRUCTURE_COMPARISON.md
**菜单进化图**
- 融合前13菜单全树形展示
- 融合后18菜单全树形展示
- 标注新增菜单
**5个新菜单详细结构**
- 📈 数据分析菜单完整树形结构4层20+页面)
- 🚚 配送管理菜单完整树形结构6层25页面
- 🏪 商户管理菜单完整树形结构5层22页面
- 📊 行为分析菜单完整树形结构3层17页面
- ⚖️ 审核管理菜单完整树形结构4层16页面
**菜单统计对比**
- 一级菜单、管理页面、管理角色、权限维度对比
**菜单的互联关系**
- 菜单间的数据流和操作流关系
---
### ADMIN_IMPLEMENTATION_CHECKLIST.md
**Phase 0方案评审**
- 方案理解、需求确认、资源规划
**Phase 1技术基础建设**
- 数据库设计、后端API开发、前端权限库、测试编写
**Phase 2菜单和首页重构**
- 菜单树数据结构、侧边栏组件、首页看板、页面权限守卫
**Phase 3新增菜单实现**
- 数据分析第4-5周
- 配送管理第6-8周
- 商户管理第9-10周
- 行为分析第11周
- 审核管理第12周
- 详细检查项和交付成果
**Phase 4验收和优化**
- UAT、培训、性能优化、安全审计、上线准备
**Phase 5上线和运维**
- 灰度发布、线上监控、Bug修复、持续优化
**甘特图**
- 15周项目时间表
**成功指标和验收标准**
- 功能、性能、安全、业务4个维度
---
## 🔍 关键数据速查
### 菜单数量
- 融合前13个一级菜单100+页面
- 融合后18个一级菜单160+页面
- 新增5个一级菜单60+页面
### 角色数量
- 当前7个角色
- 推荐15个角色
- 新增8个角色
### 功能融合
- Analytics贡献8条管理功能
- Consumer贡献12条管理功能
- Delivery贡献12条管理功能
- Merchant贡献11条管理功能
- **合计**60+条管理功能融合
### 实施周期
- Phase 1基础2周
- Phase 2菜单1周
- Phase 3新菜单8周
- Phase 4验收2周
- Phase 5上线2周
- **总计**15周3个半月
### 权限矩阵
- 18个菜单 × 15个角色
- 共270个权限点18×15
---
## 💡 核心概念速查
### 15个新角色的分类
| 分类 | 角色数 | 代表 |
| -------- | ------ | ------------------------------------------ |
| 管理层 | 3个 | 超级管理员、总经理、副总 |
| 运营经理 | 6个 | 用户、商品、订单、营销、配送、商户运营经理 |
| 执行专员 | 4个 | 客服、财务、数据分析、审核专员 |
| 专项角色 | 2个 | 内容编辑、系统维护员 |
### 5个新菜单的定位
| 菜单 | 定位 | 适用场景 |
| -------- | ---------------------------------- | ----------------- |
| 数据分析 | 统一的数据看板和报表管理 | 所有场景 |
| 配送管理 | 配送员、任务、绩效的完整管理 | O2O或自建配送 |
| 商户管理 | 商户入驻、费用、经营的完整管理 | 平台模式B2B2C |
| 行为分析 | 用户行为、订单风险、退款的智能识别 | 所有场景 |
| 审核管理 | 统一的多维度审核和风控中心 | 所有场景 |
### 权限三层隔离
| 层级 | 说明 | 例子 |
| ------ | -------------------- | ---------------------------- |
| 菜单级 | 用户能否看到某个菜单 | 商户运营看不到财务菜单 |
| 页面级 | 用户能否访问某个页面 | 财务专员只能访问提现审核页 |
| 数据级 | 用户能否看到某些数据 | 商户运营只能看自己商户的数据 |
---
## ⚡ 快速决策表
### "我应该选择这个方案吗?"
| 考虑因素 | 答案 | 说明 |
| ---------------------------- | --------------- | ------------------------- |
| 需要统一的运营管理后台? | ✅ 是 | 这是核心价值 |
| 有10+ 管理人员需要不同权限? | ✅ 是 | 15角色体系很合适 |
| 当前管理功能散布在多个端? | ✅ 是 | 融合是最好解决方案 |
| 有自建配送或平台商户? | ⚠️ 部分 | 配送/商户管理菜单可选 |
| 需要高级的数据权限控制? | ✅ 是 | 我们有详细的数据隔离方案 |
| 团队有15周完整开发周期 | ✅ 是 | 保证最好的质量 |
| **建议**: | ✅ **采纳方案** | 1周评审 + 15周实施 = 16周 |
---
## 📞 文档更新日志
| 日期 | 版本 | 更新内容 |
| ---------- | ---- | ------------------------- |
| 2026-02-04 | v1.0 | 初版发布包含4份核心文档 |
---
## 🎬 下一步行动
### 立即行动24小时
1. [ ] 项目负责人阅读 [QUICK_REFERENCE.md](ADMIN_INTEGRATION_QUICK_REFERENCE.md)
2. [ ] 组织管理层评审会30分钟演讲
3. [ ] 决策是否采纳方案
### 1周内
1. [ ] 详细评审方案中的每个部分
2. [ ] 确认是否需要调整(比如菜单名称、角色定义)
3. [ ] 启动Phase 0方案评审
### 2周内
1. [ ] 组建项目团队
2. [ ] 启动Phase 1技术基础建设
3. [ ] 确定具体的上线时间表
---
## 🏆 预期收益
### 运营效率提升
- ✅ 所有管理功能集中在一个平台,无需在多个端之间切换
- ✅ 统一的数据看板,实时掌握业务全景
- ✅ 完善的权限管理,提升团队协作效率
### 数据和安全
- ✅ 强大的数据隔离机制,确保数据安全
- ✅ 完整的审计日志,所有操作可追踪
- ✅ 细粒度的权限控制,防止权限滥用
### 成本节省
- ✅ 减少重复开发(不用在多个端重复实现管理功能)
- ✅ 降低系统维护成本(统一平台、统一权限、统一审计)
- ✅ 加快新功能上线速度(已有的权限框架复用)
### 用户体验
- ✅ 所有人都能看到适合自己角色的功能和数据
- ✅ 统一的UI/UX设计学习成本低
- ✅ 响应式设计,支持桌面和平板访问
---
**文档完成日期**: 2026年2月4日
**文档维护者**: AI助手
**文档状态**: ✅ 可用于评审和实施

View File

@@ -1,739 +0,0 @@
# Admin管理系统融合方案 - 实施检查清单
> 📋 从0到1实施admin端融合方案的完整检查清单
---
## Phase 0: 方案评审第0-1周
### 方案理解
- [ ] 管理层理解融合方案的目标和收益
- [ ] 理解新增5个菜单的功能和必要性
- [ ] 确认15个新角色是否满足公司组织结构
- [ ] 确认是否需要删除或合并某些菜单
- [ ] 确认优先级排序(哪些菜单先做)
### 需求确认
- [ ] 确认是否支持多商户模式(若不支持,商户管理菜单不必做)
- [ ] 确认是否有自建配送(若不支持,配送管理菜单不必做)
- [ ] 确认是否需要用户行为追踪(资源密集)
- [ ] 确认是否需要智能风控需要ML团队支持
- [ ] 确认权限细度是否需要到按钮级
### 资源规划
- [ ] 确认开发团队规模和技能
- [ ] 规划测试团队资源
- [ ] 规划UAT参与者业务人员
- [ ] 规划项目经理和设计师
- [ ] 规划后续维护团队
---
## Phase 1: 技术基础建设第1-2周
### 数据库设计
- [ ] 设计 `roles` 表结构id、name、description、status
- [ ] 设计 `permissions` 表结构id、code、name、resource、action
- [ ] 设计 `role_permissions` 表结构role_id、permission_id
- [ ] 设计 `user_roles` 表结构user_id、role_id、start_date、end_date
- [ ] 设计 `permission_audit_log` 表(谁在何时修改了什么权限)
- [ ] 设计 `operation_audit_log` 表(所有操作记录)
- [ ] 添加索引优化查询
- [ ] 创建RLS策略保证数据隔离
### 后端API开发
- [ ] 开发 `/api/auth/user-roles` - 获取当前用户的所有角色
- [ ] 开发 `/api/auth/permissions` - 获取当前用户的所有权限
- [ ] 开发 `/api/roles` - CRUD角色
- [ ] 开发 `/api/permissions` - CRUD权限
- [ ] 开发 `/api/role-permissions` - 角色权限分配
- [ ] 开发 `/api/user-audit-log` - 用户操作审计日志查询
- [ ] 开发权限验证中间件
- [ ] 开发数据权限过滤中间件
### 前端权限库开发
- [ ] 创建 `usePermission()` hook
- [ ] 创建 `hasPermission(code)` 函数
- [ ] 创建 `hasRole(roleId)` 函数
- [ ] 创建菜单过滤函数
- [ ] 创建按钮权限隐藏指令 `v-permission`
- [ ] 创建权限检查中间件(路由守卫)
### 测试编写
- [ ] 编写权限查询单元测试
- [ ] 编写数据隔离测试A用户不能看B用户数据
- [ ] 编写权限提升防护测试
- [ ] 编写API权限验证测试
**交付成果**: 基础权限框架就位所有API都有权限验证
---
## Phase 2: 菜单和首页重构第2-3周
### 菜单树数据结构
- [ ] 定义菜单树TypeScript类型
- [ ] 创建菜单树配置文件JSON
- [ ] 为每个菜单项添加 `requiredRoles``requiredPermissions` 字段
- [ ] 实现菜单权限过滤函数
### 侧边栏组件重构
- [ ] 重构 `Sidebar` 组件支持动态菜单
- [ ] 实现菜单展开/折叠
- [ ] 实现菜单搜索
- [ ] 实现菜单高亮当前页面
- [ ] 添加菜单权限不足时的提示
### 首页看板动态化
- [ ] 创建 `DashboardConfig` 数据结构(看板配置)
- [ ] 实现看板选择器用户选择要看哪些KPI
- [ ] 实现7个角色特定的看板模板
- [ ] 超级管理员看板
- [ ] 总经理看板
- [ ] 用户运营看板
- [ ] 商品运营看板
- [ ] 订单管理看板
- [ ] 营销运营看板
- [ ] 数据分析师看板
- [ ] 实现看板主题切换
- [ ] 实现看板配置保存/加载
### 页面级权限守卫
- [ ] 为每个现有页面添加权限验证
- [ ] 无权限时重定向到首页+提示
- [ ] 添加权限变化时的动态更新
**交付成果**: 用户登录后看到的菜单和首页完全根据角色定制化
---
## Phase 3: 新增菜单实现第4-12周
### 14. 数据分析菜单第4-5周
#### 看板管理子菜单
- [ ] 看板配置页面
- [ ] 看板列表(查看、编辑、删除、新建)
- [ ] 看板编辑界面(拖拽配置)
- [ ] KPI指标选择器30+个指标)
- [ ] 图表类型选择器
- [ ] 预览功能
- [ ] 看板样式页面
- [ ] 颜色方案选择
- [ ] 布局选择
- [ ] 字体大小调整
- [ ] 看板权限页面
- [ ] 选择哪些角色可见
- [ ] 选择哪些用户可见
- [ ] 权限历史查看
#### 报表管理子菜单
- [ ] 报表模板库
- [ ] 预设模板列表
- [ ] 模板详情查看
- [ ] 一键应用模板
- [ ] 自定义模板保存
- [ ] 定时报表
- [ ] 创建定时报表向导
- [ ] 报表参数配置
- [ ] 发送时间设置(日/周/月)
- [ ] 接收人列表
- [ ] 发送历史
- [ ] 报表审计
- [ ] 谁查看了哪些报表
- [ ] 何时导出了什么数据
- [ ] 筛选和搜索
- [ ] 下载审计日志
- [ ] 数据权限
- [ ] 按角色配置数据可见范围
- [ ] 按地区/商户配置
- [ ] 权限生效测试
#### 异常告警子菜单
- [ ] 告警规则配置
- [ ] 选择告警类型KPI、库存、用户、成本
- [ ] 设置阈值和触发条件
- [ ] 配置生效时间
- [ ] 规则列表管理
- [ ] 告警频道配置
- [ ] 启用/禁用各种通知渠道
- [ ] 配置收件人
- [ ] 配置通知模板
- [ ] 告警历史
- [ ] 告警日志查看
- [ ] 标记已处理
- [ ] 处理统计
#### 对标管理子菜单
- [ ] 目标设置
- [ ] 设置月度目标(销售、利润、新客等)
- [ ] 部门目标分配
- [ ] 目标历史查看
- [ ] 对标查询
- [ ] 选择对标对象(去年同期、上月、行业均值)
- [ ] 显示对比图表
- [ ] 导出对标数据
- [ ] 达成分析
- [ ] 完成度进度条
- [ ] 驱动因素分析
- [ ] 月度复盘报告
#### 数据库和RPC
- [ ] 创建 analytics_dashboards 表
- [ ] 创建 analytics_reports 表
- [ ] 创建 analytics_alerts 表
- [ ] 创建 analytics_audit_log 表
- [ ] 实现 RPCget_dashboard_data
- [ ] 实现 RPCget_report_data
- [ ] 实现 RPCcheck_alert_conditions
#### 测试
- [ ] 测试权限验证(只有指定角色能编辑)
- [ ] 测试报表生成和发送
- [ ] 测试告警触发和通知
- [ ] 性能测试(大数据量报表查询)
**第4-5周交付成果**: 完整的数据分析菜单就位
---
### 15. 配送管理菜单第6-8周仅O2O模式
#### 配送统计子菜单
- [ ] 今日数据看板
- [ ] 待接单数、配送中数、已完成数
- [ ] 总收入、平均送达时间
- [ ] 实时更新
- [ ] 配送员排行
- [ ] 今日/周/月排行
- [ ] 按收入、订单数、评分排行
- [ ] 导出排行榜
- [ ] 路线分析
- [ ] 配送费用效率(总费用/订单数)
- [ ] 完成率分析
- [ ] 里程成本
- [ ] 最优路线建议
#### 配送员管理子菜单
- [ ] 配送员列表
- [ ] 表格展示(姓名、评分、车辆、服务区)
- [ ] 编辑配送员信息
- [ ] 启用/禁用配送员
- [ ] 批量导入/导出
- [ ] 高级搜索和过滤
- [ ] 配送员审核
- [ ] 新申请列表
- [ ] 文件上传验证
- [ ] 批准/拒绝流程
- [ ] 审核历史
- [ ] 黑名单管理
- [ ] 冻结账户
- [ ] 记录冻结原因
- [ ] 申诉处理
- [ ] 黑名单解除流程
- [ ] 配送员分级
- [ ] 设置等级标准(销售额、评分、投诉率)
- [ ] 自动升降级
- [ ] 手动调整
- [ ] 等级权益配置
- [ ] 激励管理
- [ ] 创建激励活动
- [ ] 配置激励规则达成XX→获得YY
- [ ] 激励统计
- [ ] 申诉处理
- [ ] 投诉列表
- [ ] 处理工单
- [ ] 处理结果记录
- [ ] 投诉统计
#### 任务分配子菜单
- [ ] 自动分配规则
- [ ] 配置算法参数(距离权重、工作量均衡系数等)
- [ ] 地理围栏配置
- [ ] 测试规则
- [ ] 手动分配
- [ ] 选择订单和配送员
- [ ] 分配备注
- [ ] 批量分配
- [ ] 分配记录
- [ ] 历史查看
- [ ] 统计分析
#### 绩效考核子菜单
- [ ] 考核指标
- [ ] 定义指标(送达时间、评分、投诉等)
- [ ] 指标权重配置
- [ ] 目标设置
- [ ] 奖惩规则
- [ ] 月度考核
- [ ] 自动生成考核报告
- [ ] 考核分数计算
- [ ] 等级评定
- [ ] 结果公示
- [ ] 工资计算
- [ ] 基本工资+考核+分层计价
- [ ] 自动计算
- [ ] 工资表导出
#### 费用结算子菜单
- [ ] 费率配置
- [ ] 按距离/时间/订单量分层定价
- [ ] 特殊商品加价
- [ ] 规则管理
- [ ] 提现管理
- [ ] 提现申请审核
- [ ] 转账处理
- [ ] 到账确认
- [ ] 结算周期
- [ ] 配置周期(日/周/月结)
- [ ] 自动结算
- [ ] 结算报表
- [ ] 日/周/月结单导出
#### 车辆管理子菜单
- [ ] 车辆列表
- [ ] 车辆信息展示和编辑
- [ ] 关联配送员
- [ ] 车牌审核
- [ ] 行驶证审核
- [ ] 保险验证
- [ ] 年检查看
- [ ] 轨迹追踪
- [ ] 实时位置查看
- [ ] 路线可视化
- [ ] 行驶速度监控
- [ ] 异常告警
#### 数据库
- [ ] 创建 ml_delivery_drivers 表if not exists
- [ ] 创建 ml_delivery_tasks 表
- [ ] 创建 ml_delivery_performance 表
- [ ] 创建 ml_delivery_vehicles 表
- [ ] 创建 ml_delivery_payroll 表
**第6-8周交付成果**: 完整的配送管理菜单就位仅O2O模式
---
### 16. 商户管理菜单第9-10周仅平台模式
#### 商户统计子菜单
- [ ] 商户总数
- [ ] 按等级、状态、分类统计
- [ ] 增长曲线
- [ ] 商户排行
- [ ] 销售额/订单数/评分排行
- [ ] 导出排行榜
- [ ] 商户画像
- [ ] 分类分布
- [ ] 地区分布
- [ ] 等级分布
#### 商户审核子菜单
- [ ] 入驻申请
- [ ] 申请列表
- [ ] 资质审核
- [ ] 批准/驳回
- [ ] 申请历史
- [ ] 资质审核
- [ ] 身份验证
- [ ] 银行账户验证
- [ ] 税务信息验证
- [ ] 保证金缴纳
- [ ] 缴纳记录
- [ ] 缴纳确认
- [ ] 退款处理
- [ ] 激活管理
- [ ] 激活前检查
- [ ] 生成激活码
- [ ] 激活状态变更
#### 商户管理子菜单
- [ ] 商户列表
- [ ] 商户信息查看和编辑
- [ ] 启用/禁用
- [ ] 批量操作
- [ ] 商户分级
- [ ] 分级标准配置
- [ ] 自动升降级
- [ ] 手动调整
- [ ] 店铺信息
- [ ] 店铺名称、logo、简介编辑
- [ ] 营业时间配置
- [ ] 联系方式管理
- [ ] 账号、邮箱、电话、微信管理
- [ ] 冻结/解冻
- [ ] 冻结操作
- [ ] 解冻申请处理
#### 费用管理子菜单
- [ ] 保证金管理
- [ ] 保证金标准设置
- [ ] 缴纳记录
- [ ] 扣罚管理
- [ ] 退还流程
- [ ] 佣金配置
- [ ] 按分类/等级设定佣金率
- [ ] 新商户优惠期配置
- [ ] 佣金规则审批
- [ ] 佣金扣除
- [ ] 自动计算和扣除
- [ ] 明细查看
- [ ] 统计报表
- [ ] 提现管理
- [ ] 提现申请审核
- [ ] 转账处理
- [ ] 提现统计
- [ ] 罚款管理
- [ ] 罚款原因配置
- [ ] 罚款处理
- [ ] 申诉处理
- [ ] 结算报表
- [ ] 日/周/月结单导出
- [ ] 商户个人报表
- [ ] 分类汇总
#### 经营管理子菜单
- [ ] 商户数据
- [ ] 销售、转化、评分数据
- [ ] 数据对比
- [ ] 商户违规
- [ ] 投诉统计
- [ ] 退货率统计
- [ ] 风险评分
- [ ] 营销工具权限
- [ ] 配置可用功能
- [ ] 功能限制配置
- [ ] 商户沟通
- [ ] 发送通知
- [ ] 公告发布
- [ ] 商户退出
- [ ] 退出申请处理
- [ ] 清算流程
**第9-10周交付成果**: 完整的商户管理菜单就位(仅平台模式)
---
### 17. 行为分析菜单第11周
#### 用户行为追踪子菜单
- [ ] 浏览行为
- [ ] 页面访问统计
- [ ] 停留时长分析
- [ ] 路径分析
- [ ] 热力图
- [ ] 用户分群
- [ ] 收藏分析
- [ ] 收藏商品排行
- [ ] 收藏转购率
- [ ] 未购收藏提醒
- [ ] 购物车分析
- [ ] 放弃率分析
- [ ] 商品热度
- [ ] 平均金额
- [ ] 搜索热词
- [ ] 热词统计
- [ ] 搜索转化率
- [ ] 零结果搜索词
- [ ] 搜索趋势
- [ ] 用户路径
- [ ] 访问路径流
- [ ] 流失分析
- [ ] 转化路径
#### 订单风险识别子菜单
- [ ] 异常订单检测
- [ ] 虚假订单识别
- [ ] 高风险用户检测
- [ ] 大额采购预警
- [ ] 风险评分系统
- [ ] 黑名单管理
- [ ] 冻结恶意用户
- [ ] 黑名单原因记录
- [ ] 黑名单解除申诉
- [ ] 异常退货分析
- [ ] 退货率过高用户识别
- [ ] 退货模式分析
- [ ] 恶意评价识别
- [ ] 虚假好评检测
- [ ] 违规评价检测
- [ ] 处理建议
- [ ] 风险订单处理
- [ ] 待审核队列
- [ ] 手动审核
- [ ] 处理统计
#### 退款审核管理子菜单
- [ ] 待审核退款
- [ ] 待审核列表
- [ ] 订单信息查看
- [ ] 用户信息查看
- [ ] 审核决定(同意/拒绝)
- [ ] 自动退款规则
- [ ] 快速退款条件配置
- [ ] 规则管理
- [ ] 退款审批流
- [ ] 多级审批流配置
- [ ] 金额阈值设置
- [ ] 退款拒绝
- [ ] 拒绝原因管理
- [ ] 申诉处理
- [ ] 退款统计
- [ ] 退货率分析
- [ ] 退款成本统计
- [ ] 原因排行
- [ ] 物流退货追踪
- [ ] 退货物流信息
- [ ] 退货签收确认
#### 数据库
- [ ] 创建 user_behavior_tracking 表
- [ ] 创建 order_risk_assessment 表
- [ ] 创建 refund_audit_log 表
**第11周交付成果**: 完整的行为分析菜单就位
---
### 18. 审核管理菜单第12周
#### 财务审核子菜单
- [ ] 提现审核
- [ ] 待审核列表
- [ ] 账户验证
- [ ] 批准/驳回
- [ ] 转账处理
- [ ] 到账确认
- [ ] 发票审核
- [ ] 待审核列表
- [ ] 信息验证
- [ ] 批准/驳回
- [ ] 财务异常
- [ ] 异常交易提醒
- [ ] 人工审核
- [ ] 审核历史
- [ ] 已审核记录
- [ ] 审计日志
#### 商户审核子菜单
- [ ] 入驻申请审核
- [ ] 资料修改审核
- [ ] 营销活动审核
- [ ] 申诉审核
#### 用户审核子菜单
- [ ] 用户申诉审核
- [ ] 发票申请审核
- [ ] 账户异常处理
- [ ] 账户冻结申请
#### 内容审核子菜单
- [ ] 商品评价审核
- [ ] 待审核列表
- [ ] 内容检查
- [ ] 图片审核
- [ ] 虚假评价检测
- [ ] 用户反馈审核
- [ ] 待审核列表
- [ ] 反馈分配
- [ ] 回复管理
- [ ] 文章审核
- [ ] 待审核列表
- [ ] 内容检查
- [ ] 发布/驳回
- [ ] 评论审核
- [ ] 待审核列表
- [ ] 内容检查
- [ ] 删除/隐藏
**第12周交付成果**: 完整的审核管理菜单就位
---
## Phase 4: 验收和优化第13-14周
### UAT准备
- [ ] 编写UAT测试用例每个菜单20+用例)
- [ ] 准备测试数据(模拟真实业务场景)
- [ ] 准备UAT环境隔离于生产
### 业务人员培训
- [ ] 为各个角色制作培训手册
- [ ] 举办培训会议(按角色分组)
- [ ] 准备常见问题FAQ
- [ ] 建立问题反馈渠道
### 性能优化
- [ ] 数据库查询优化加索引、优化WHERE条件
- [ ] 大列表分页加载
- [ ] 报表缓存策略
- [ ] 前端懒加载和虚拟滚动
### 安全审计
- [ ] 权限漏洞检查(越权测试)
- [ ] SQL注入测试
- [ ] XSS漏洞检查
- [ ] CSRF保护验证
- [ ] 数据加密验证
### 灾备和回滚
- [ ] 准备回滚脚本
- [ ] 准备数据备份
- [ ] 制定应急预案
### 上线准备
- [ ] 制定上线计划和时间表
- [ ] 准备灰度方案先给10%用户)
- [ ] 准备监控告警配置
- [ ] 准备上线后的值班安排
**第13-14周交付成果**: 完成所有测试、优化和上线准备
---
## Phase 5: 上线和运维第15周+
### 灰度发布
- [ ] 第一批:超级管理员和技术团队
- [ ] 第二批:各部门经理(用户运营、商品、订单等)
- [ ] 第三批:执行专员和普通员工
### 线上监控
- [ ] 监控登录成功率
- [ ] 监控菜单加载时间
- [ ] 监控错误日志
- [ ] 监控权限验证失败率
- [ ] 监控审计日志生成
### Bug修复
- [ ] 建立Bug反馈机制
- [ ] 制定修复优先级
- [ ] 快速补丁发布
### 持续优化
- [ ] 收集用户反馈
- [ ] 分析用户使用习惯
- [ ] 优化UI/UX
- [ ] 增加新的小功能
---
## 🎯 总体甘特图
```
Phase 1 [========] 数据库和权限框架
Phase 2 [======] 菜单和首页重构
Phase 3 [=================] 新菜单实现
├─ 数据分析 [====]
├─ 配送管理 [======]
├─ 商户管理 [====]
├─ 行为分析 [==]
└─ 审核管理 [=]
Phase 4 [====] 测试和优化
Phase 5 [=====] 上线运维
周期15周
```
---
## ✅ 成功指标和验收标准
### 功能完整性
- ✅ 所有15个角色都能正常使用系统
- ✅ 每个菜单的所有页面都能正常访问
- ✅ 每个权限都能正确验证
### 性能指标
- ✅ 页面加载时间 < 2秒90分位
- ✅ 列表页翻页时间 < 1秒
- ✅ 报表生成时间 < 5秒
- ✅ 并发用户支持 > 100人
### 安全指标
- ✅ 权限漏洞 = 0个
- ✅ 权限验证覆盖率 = 100%
- ✅ 数据隔离测试通过率 = 100%
- ✅ 审计日志完整率 = 100%
### 业务指标
- ✅ 用户培训完成率 > 90%
- ✅ UAT测试通过率 > 95%
- ✅ 上线第一周Bug数 < 10个
- [ ] 用户满意度 > 4/5
---
## 📞 关键联系人
| 角色 | 姓名 | 联系方式 |
| ------------ | ---- | -------- |
| 项目经理 | - | - |
| 产品经理 | - | - |
| 技术主管 | - | - |
| 数据库管理员 | - | - |
| 前端负责人 | - | - |
| 后端负责人 | - | - |
| QA负责人 | - | - |
---
**记录**: 此检查清单需要定期更新每周review一次进度
**相关文档**:
- [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md) - 完整分析
- [ADMIN_INTEGRATION_QUICK_REFERENCE.md](ADMIN_INTEGRATION_QUICK_REFERENCE.md) - 快速参考
- [ADMIN_MENU_STRUCTURE_COMPARISON.md](ADMIN_MENU_STRUCTURE_COMPARISON.md) - 菜单结构对照

View File

@@ -1,981 +0,0 @@
# Admin管理系统 - 四端功能融合与权限体系设计
**文档时间**: 2026年2月4日
**目的**: 分析analytics、consumer、delivery、merchant四个端的管理相关功能设计融合进admin端后的权限和角色体系
---
## 🎯 文档摘要
本文档分析了mall项目四个业务端的功能识别出**60+**条可融合进admin管理端的功能设计了**15个**新的管理角色和相应的权限体系。通过权限细分,实现对不同管理人员的差异化权限控制,提升平台运营效率。
---
## 📊 第一部分:四端功能现状分析
### 1. Analytics数据分析端
**当前功能**:
- ✅ 销售报表分析销售趋势、GMV统计
- ✅ 用户分析(增长、活跃、留存、消费分布)
- ✅ 商品洞察(销售排行、分类分析、库存分析)
- ✅ 市场趋势(市场整体趋势、季节性分析)
- ✅ 优惠券效果分析发放、使用、转化、ROI
- ✅ 自定义报表(报表构建、多维度分析、导出)
- ✅ 数据钻取与对比功能
- ✅ 实时KPI看板
- ✅ 数据导出Excel、PDF
**可融合到admin的管理功能**:
| 功能 | 级别 | 描述 |
|------|------|------|
| **数据看板管理** | 核心 | 后台运营人员可配置首页看板展示哪些KPI指标 |
| **报表模板库** | 核心 | 管理员创建/管理/分享报表模板 |
| **定时报表** | 核心 | 创建每日/周/月定时生成的报表,自动发送给指定人员 |
| **数据权限管理** | 高 | 按角色配置数据可见范围(如只看自己部门的销售数据) |
| **导出配置** | 中 | 配置允许导出的数据类型、格式、频率限制 |
| **异常告警配置** | 高 | 当KPI下跌超过阈值时自动告警到相关人员 |
| **对标管理** | 中 | 设置目标KPI、行业对标、监控达成情况 |
| **报表审计日志** | 中 | 记录谁查看了哪些报表、何时查看、导出了什么数据 |
---
### 2. Consumer消费者端
**当前功能**:
- ✅ 订单管理(查看订单、支付、收货、评价)
- ✅ 购物车管理(加购、修改数量、清空)
- ✅ 收藏管理(收藏/取消收藏商品)
- ✅ 收货地址管理(新增、编辑、删除、设置默认)
- ✅ 支付流程(微信/支付宝/余额支付)
- ✅ 售后退款(申请退货、填写退款原因、上传凭证)
- ✅ 物流跟踪(实时查看物流信息)
- ✅ 优惠券领取(查看、领取、使用优惠券)
- ✅ 钱包管理(查看余额、充值、提现)
- ✅ 消息中心(订单提醒、营销消息、系统消息)
- ✅ 评价管理(商品评价、卖家回复)
- ✅ 订阅服务(订阅计划、续费管理)
**可融合到admin的管理功能**:
| 功能 | 级别 | 描述 |
|------|------|------|
| **用户行为分析** | 高 | 分析用户的收藏、购物车、浏览等行为,优化商品推荐 |
| **订单风险评估** | 高 | 识别异常订单(虚假订单、大额采购、恶意退货)进行人工审核 |
| **退款审核管理** | 核心 | 审核和处理用户的退款申请,配置自动退款规则 |
| **支付方式配置** | 中 | 启用/禁用支付方式、配置支付费率、限额 |
| **物流对接管理** | 核心 | 管理物流公司接口、面单模板、轨迹查询配置 |
| **地址库维护** | 低 | 维护国家、省市区三级地址数据库 |
| **钱包资金监管** | 核心 | 审计用户充值、提现、转账等资金流转 |
| **消息模板管理** | 中 | 配置订单提醒、营销消息、系统通知的模板和发送规则 |
| **优惠券发放策略** | 高 | 定向发放优惠券给特定用户群体(如新客、沉睡用户) |
| **用户黑名单管理** | 中 | 识别和冻结恶意用户账户,防止刷单和欺诈 |
| **订阅管理** | 中 | 审核和管理用户订阅计划、续费规则、自动续费 |
| **发票管理** | 中 | 用户申请发票,后台审核和邮寄管理 |
---
### 3. Delivery配送端
**当前功能**:
- ✅ 配送员工作台(工作状态切换、今日数据、当前任务)
- ✅ 配送任务管理(待接单、配送中、已完成)
- ✅ 订单详情查看(取货地址、送达地址、联系方式)
- ✅ 物流操作(开始取货、确认取货、开始配送、确认送达)
- ✅ 收益统计(今日收益、历史收益、提现)
- ✅ 配送员评价(用户评分、评价内容)
- ✅ 车辆管理(添加车辆、编辑车辆信息)
- ✅ 个人资料管理(头像、姓名、身份证、驾驶证)
- ✅ 离线模式支持
**可融合到admin的管理功能**:
| 功能 | 级别 | 描述 |
|------|------|------|
| **配送员管理** | 核心 | 配送员账号管理、等级设置、激励管理、状态监控 |
| **配送任务分配** | 核心 | 自动/手动分配订单给配送员,优化配送效率和成本 |
| **配送绩效考核** | 高 | 按送达时间、用户评分、投诉数等考核配送员,挂钩薪资 |
| **配送费用管理** | 核心 | 配置配送员分层计价(按距离、时间、订单量)、抽佣比例 |
| **配送员轨迹追踪** | 中 | 实时查看配送员位置、配送路线、行驶速度,防止刷单 |
| **配送员黑名单** | 中 | 冻结违规配送员、记录投诉详情、处理争议 |
| **车辆管理** | 中 | 管理配送员车辆信息、车牌、保险、年检状态 |
| **配送结算管理** | 高 | 按周/月结算配送员收入,扣除罚款、提现审核 |
| **配送团队管理** | 中 | 配置配送员所属团队、团队长、绩效汇总 |
| **异常订单处理** | 高 | 处理配送失败、拒收、用户投诉等异常情况 |
| **配送员激励** | 中 | 创建激励活动(日送单数奖励、月绩效奖等) |
| **申诉管理** | 中 | 处理用户对配送员的投诉、申诉、评分异议 |
---
### 4. Merchant商户端
**当前功能**:
- ✅ 商户个人资料管理(头像、昵称、简介)
- ✅ 店铺管理店铺名称、logo、分类、营业时间
- ✅ 商品展示(查看上架商品列表、商品详情)
- ✅ 销售数据查看(日销售额、订单数、粉丝数)
**可融合到admin的管理功能**:
| 功能 | 级别 | 描述 |
|------|------|------|
| **多商户管理** | 核心 | 管理平台上的多个商户入驻、审核、激活 |
| **商户入驻审核** | 核心 | 审核商户资质、营业执照、法人身份、缴纳保证金 |
| **商户分级体系** | 高 | 根据销售额、评分、投诉等指标对商户分级(普通/黄金/钻石) |
| **商户保证金管理** | 核心 | 收取保证金、冻结、解冻、扣罚(质量问题赔偿) |
| **商户佣金设置** | 核心 | 按商户等级、商品分类设定不同佣金率,自动扣除 |
| **商户提现管理** | 核心 | 审核商户提现申请、结算周期配置、到账管理 |
| **商户经营数据** | 高 | 查看商户销售数据、访客、转化、库存、评分等 |
| **商户违规处理** | 高 | 记录违规投诉、处以罚款、限流、下架、封禁 |
| **商户店铺装修** | 中 | 配置商户店铺展示哪些功能(如是否支持预订、会员卡) |
| **商户营销工具** | 中 | 管理商户可使用的营销工具(优惠券、活动模板等) |
| **商户沟通** | 低 | 发送公告、通知、政策更新到所有或特定商户 |
---
## 📈 第二部分:功能融合方案
### 融合核心原则
1. **管理视角优先** - 只融合需要后台管理的功能消费者自主操作保留在consumer
2. **权限细分** - 不同角色看不同的管理界面和功能
3. **数据隔离** - 多商户场景下确保数据权限隔离
4. **流程闭合** - 管理功能覆盖整个业务流程的关键决策点
### 融合后的Admin一级菜单调整方案
#### 当前13个菜单
```
1. 首页 (HOME)
2. 用户 (USER)
3. 商品 (PRODUCT)
4. 订单 (ORDER)
5. 营销 (MARKETING)
6. 分销 (DISTRIBUTION)
7. 客服 (KEFU)
8. 财务 (FINANCE)
9. 内容 (CMS)
10. 装修 (DECORATION)
11. 应用 (APP)
12. 设置 (SETTING)
13. 维护 (MAINTAIN)
```
#### 推荐融合后的18个菜单
```
1. 📊 首页 (HOME) - 保持不变(各角色看不同的仪表板)
2. 👥 用户 (USER) - 扩展+数据权限配置
3. 📦 商品 (PRODUCT) - 保持不变
4. 📋 订单 (ORDER) - 扩展+风险评估+物流管理
5. 🎯 营销 (MARKETING) - 保持不变
6. 👔 分销 (DISTRIBUTION) - 保持不变
7. 💬 客服 (KEFU) - 保持不变
8. 💰 财务 (FINANCE) - 保持不变
9. 📄 内容 (CMS) - 保持不变
10. 🎨 装修 (DECORATION) - 保持不变
11. 🔌 应用 (APP) - 保持不变
12. ⚙️ 设置 (SETTING) - 保持不变
13. 🛠️ 维护 (MAINTAIN) - 保持不变
14. 📈 数据分析 (ANALYTICS) - 【新增】融合analytics端
15. 🚚 配送管理 (DELIVERY) - 【新增】融合delivery端
16. 🏪 商户管理 (MERCHANT) - 【新增】融合merchant端
17. 📊 行为分析 (BEHAVIOR) - 【新增】用户行为追踪、订单风险识别
18. ⚖️ 审核管理 (REVIEW) - 【新增】统一的审核中心(退款、商户入驻、投诉等)
```
---
### 融合功能详细清单
#### 14⃣ 数据分析菜单 (ANALYTICS)
**4个功能分组12个管理页面**:
```
📊 数据分析
├── 📈 KPI看板管理
│ ├── 看板配置 - 选择要展示的KPI指标
│ ├── 看板样式 - 配置图表类型、颜色、布局
│ └── 看板权限 - 设置哪些角色可见
├── 📋 报表管理
│ ├── 报表模板库 - 预设/自定义报表模板
│ ├── 定时报表 - 配置自动生成和发送报表
│ ├── 报表审计 - 查看谁查看了哪些报表、何时导出
│ └── 数据权限 - 按角色配置数据可见范围
├── ⚠️ 异常告警
│ ├── 告警规则 - KPI下跌、库存预警、销售异常
│ ├── 告警频道 - 邮件、短信、系统通知
│ └── 告警历史 - 查看告警触发情况和处理记录
└── 🎯 对标管理
├── 目标设置 - 配置各部门月度/季度目标
├── 对标查询 - 与行业平均值对标
└── 达成分析 - 月度复盘和趋势分析
```
#### 15⃣ 配送管理菜单 (DELIVERY)
**6个功能分组25个管理页面**:
```
🚚 配送管理
├── 📊 配送统计
│ ├── 今日数据 - 完成订单、总收入、平均时间
│ ├── 配送员排行 - 今日/周/月表现排行
│ └── 路线分析 - 配送费用效率、完成率
├── 👥 配送员管理
│ ├── 配送员列表 - 查看/编辑配送员信息
│ ├── 配送员审核 - 新驾驶员资格审核
│ ├── 配送员黑名单 - 冻结违规配送员
│ ├── 配送员分级 - 按等级分配任务权重
│ ├── 配送员激励 - 创建激励活动和月度奖励
│ └── 申诉管理 - 处理用户对配送员投诉
├── 🎯 任务分配
│ ├── 自动分配规则 - 按配送员等级、距离、工作量
│ ├── 手动分配 - 管理员直接指派订单
│ └── 分配记录 - 查看分配历史和效率分析
├── 🏆 绩效考核
│ ├── 考核指标 - 送达时间、用户评分、投诉数等
│ ├── 考核设置 - 配置权重、目标、奖惩
│ ├── 月度考核 - 生成配送员考核报告
│ └── 工资计算 - 基本工资+考核奖惩+分层计价
├── 💳 费用结算
│ ├── 费率配置 - 按距离/时间/订单量分层定价
│ ├── 提现管理 - 审核和处理配送员提现
│ ├── 结算周期 - 配置按日/周/月结算
│ └── 结算报表 - 生成详细结算单
└── 🚗 车辆管理
├── 车辆列表 - 查看配送员车辆信息
├── 车牌审核 - 审核车牌、保险、年检
└── 轨迹追踪 - 实时查看配送员位置
```
#### 16⃣ 商户管理菜单 (MERCHANT)
**5个功能分组20个管理页面**:
```
🏪 商户管理
├── 📊 商户统计
│ ├── 商户总数 - 按等级、状态统计
│ ├── 商户排行 - 销售额、订单数、评分排行
│ └── 商户画像 - 分类/地区分布、增长趋势
├── 👤 商户审核
│ ├── 入驻申请 - 审核新商户资质
│ ├── 资质审核 - 营业执照、法人身份验证
│ ├── 保证金缴纳 - 确认保证金到账
│ └── 激活管理 - 将商户激活为可营运状态
├── 💼 商户管理
│ ├── 商户列表 - 查看/禁用/激活商户
│ ├── 商户分级 - 根据表现自动升降级或手动调整
│ ├── 店铺信息 - 修改商户店铺名称、logo、简介
│ ├── 商户联系方式 - 商户账号、邮箱、电话
│ └── 商户冻结/解冻 - 处理违规商户
├── 💰 费用管理
│ ├── 保证金管理 - 收取、扣罚、退还
│ ├── 佣金配置 - 按等级/分类设定佣金率
│ ├── 佣金扣除 - 查看自动扣除明细
│ ├── 提现管理 - 审核和处理商户提现
│ ├── 罚款管理 - 对违规商户进行罚款
│ └── 结算报表 - 月度商户结算单
└── 📈 经营管理
├── 商户数据 - 销售额、订单数、转化、评分
├── 商户违规 - 投诉数、退货率、售后问题
├── 营销工具权限 - 配置商户可使用的营销功能
├── 商户沟通 - 发送通知到指定商户
└── 商户退出 - 审核商户退出申请、清算
```
#### 17⃣ 行为分析菜单 (BEHAVIOR)
**3个功能分组12个管理页面**:
```
📊 行为分析
├── 👁️ 用户行为追踪
│ ├── 浏览行为 - 用户查看了哪些商品、停留时长
│ ├── 收藏分析 - 热门收藏商品、收藏转购率
│ ├── 购物车分析 - 购物车放弃分析、促进转化
│ ├── 搜索热词 - 用户搜索了哪些词、转化率
│ └── 用户路径 - 用户访问页面的路径分析
├── 🚨 订单风险识别
│ ├── 异常订单检测 - 虚假订单、高风险用户下单
│ ├── 黑名单管理 - 冻结恶意用户、记录黑名单
│ ├── 大额采购预警 - 识别可能的刷单行为
│ ├── 异常退货分析 - 识别退货率过高的用户
│ ├── 恶意评价识别 - 识别虚假好评、违规评价
│ └── 风险订单处理 - 手动审核、冻结、处罚
└── 📋 退款审核管理
├── 待审核退款 - 列表查看需要审核的退款申请
├── 自动退款规则 - 配置快速退款的条件
├── 退款审批流 - 设置多级审批流程
├── 退款拒绝 - 驳回不符合条件的退款
├── 退款统计 - 退货率、原因分析、成本统计
└── 物流退货追踪 - 跟踪退回的商品物流
```
#### 18⃣ 审核管理菜单 (REVIEW)
**4个功能分组15个管理页面**:
```
⚖️ 审核管理
├── 💸 财务审核
│ ├── 提现审核 - 用户/商户/配送员的提现申请
│ ├── 发票审核 - 用户发票申请审核
│ ├── 财务异常 - 大额转账、异常交易
│ └── 审核历史 - 已审核的申请记录
├── 📝 商户审核
│ ├── 入驻申请审核 - 新商户资质审核
│ ├── 资料修改审核 - 商户修改店铺信息审核
│ ├── 营销活动审核 - 商户发起的营销活动审核
│ └── 申诉审核 - 商户的申诉和投诉处理
├── 👤 用户审核
│ ├── 用户申诉 - 用户对订单、商品的申诉
│ ├── 发票申请 - 用户发票申请审核
│ ├── 账户异常 - 异常登录、账户被盗等
│ └── 账户冻结申请 - 用户申请冻结账户
└── ⭐ 内容审核
├── 商品评价审核 - 审核用户的商品评价
├── 用户反馈审核 - 审核用户反馈和投诉
├── 文章审核 - 审核CMS中的文章发布
└── 评论审核 - 审核商品/文章下的评论
```
---
## 🔐 第三部分:权限和角色体系设计
### 当前角色体系7个角色
```
1. 超级管理员 - 所有权限
2. 商品运营 - 商品相关权限
3. 订单管理员 - 订单相关权限
4. 营销专员 - 营销相关权限
5. 客服主管 - 客服和售后权限
6. 财务人员 - 财务相关权限
7. 数据分析师 - 所有数据查看权限(只读)
```
### 推荐新增角色体系15个角色
#### A. 核心管理层3个
```
1. 🔑 超级管理员 (Administrator)
├── 权限: 完全访问所有功能和数据
├── 页面: 所有菜单和页面完全展示
├── 数据: 无限制访问
├── 操作: 创建/编辑/删除/发布所有业务
├── 特殊: 系统配置、权限管理、管理员管理
└── 适用: 1-2人通常为技术主管或CEO
2. 📊 总经理/运营总监 (General Manager)
├── 权限: 访问所有一级菜单(除系统维护除外)
├── 页面: 首页、用户、商品、订单、营销、分销、财务、数据分析、配送、商户
├── 数据: 全量数据访问
├── 操作: 查看所有数据,关键决策类操作需二级确认
├── 只读: 系统配置、维护
└── 适用: 1-2人公司最高运营者
3. 👥 运营副总 (Assistant General Manager)
├── 权限: 访问关键菜单(用户、商品、订单、营销、财务、数据分析)
├── 页面: 首页(自定义看板)、用户、商品、订单、营销、财务、数据分析
├── 操作: 查看、分析、提建议;重大操作由总经理确认
└── 适用: 1-2人协助总经理
```
#### B. 运营管理层6个
```
4. 👥 用户运营经理 (User Operations Manager)
├── 菜单: 首页、用户、营销(仅用户相关部分)、数据分析
├── 用户功能:
│ ├── 用户查询、编辑、分组、标签、等级管理
│ ├── 用户行为分析(完全访问)
│ ├── 用户黑名单管理
│ └── 定向营销活动
├── 订单功能: 仅查看,风险订单可标记
├── 数据功能: 用户相关报表、用户画像
└── 适用: 1-2人
5. 📦 商品运营经理 (Product Operations Manager)
├── 菜单: 首页、商品、营销(仅促销相关)、数据分析
├── 商品功能:
│ ├── 商品管理(增删改查)
│ ├── 分类、规格、参数、标签管理
│ ├── 商品上下架
│ ├── 库存管理(可修改库存)
│ └── 评价管理和回复
├── 营销功能: 商品秒杀、拼团、砍价配置
├── 采购功能: 商品缺货预警、采购建议
└── 适用: 2-3人
6. 📋 订单管理经理 (Order Management Manager)
├── 菜单: 首页、订单、物流、售后、数据分析
├── 订单功能:
│ ├── 订单查询、发货、收货
│ ├── 订单备注、备用货物
│ ├── 异常订单处理
│ └── 批量打印和导出
├── 售后功能: 售后审核、退货处理、退款审批
├── 物流功能: 配送员分配、配送统计查看
├── 行为分析: 风险订单识别、黑名单管理
└── 适用: 2-3人
7. 🎯 营销运营经理 (Marketing Operations Manager)
├── 菜单: 首页、营销、用户分析、行为分析、数据分析
├── 营销功能:
│ ├── 所有营销活动创建/编辑/上线
│ ├── 优惠券管理和发放
│ ├── 积分管理和配置
│ ├── 活动效果分析
│ └── 用户定向运营
├── 分析功能: 营销ROI分析、活动对标
├── 数据导出: 营销数据可导出
└── 适用: 2人
8. 🚚 配送运营经理 (Delivery Operations Manager)
├── 菜单: 首页、配送管理、数据分析
├── 配送功能:
│ ├── 配送员管理(查看、冻结)
│ ├── 任务自动分配规则配置
│ ├── 绩效考核和工资计算
│ ├── 费用结算和提现审核
│ ├── 车辆管理
│ ├── 轨迹追踪
│ └── 申诉处理
├── 数据功能: 配送效率分析、成本分析
└── 适用: 1-2人仅O2O或自建配送
9. 🏪 商户运营经理 (Merchant Operations Manager)
├── 菜单: 首页、商户管理、审核管理、数据分析
├── 商户功能:
│ ├── 商户列表查看、信息编辑
│ ├── 商户分级调整
│ ├── 商户黑名单管理
│ ├── 佣金和费用配置
│ ├── 提现审核
│ ├── 销售数据查看
│ └── 违规处理
├── 审核功能: 提现审核、申诉处理
├── 数据功能: 商户排行、成本分析
└── 适用: 1-2人仅平台模式
```
#### C. 执行专员层4个
```
10. 💼 客服专员 (Customer Service Specialist)
├── 菜单: 首页、客服、订单(只读)、审核管理
├── 功能:
│ ├── 客服工作台
│ ├── 用户反馈处理
│ ├── 订单查询(只读)
│ ├── 退款申请处理(执行审批)
│ ├── 用户申诉处理
│ └── 申诉统计
└── 适用: 5-10人
11. 💰 财务专员 (Finance Specialist)
├── 菜单: 首页、财务、审核管理、商户管理
├── 功能:
│ ├── 财务数据查看
│ ├── 提现审核
│ ├── 发票管理
│ ├── 账单对账
│ ├── 佣金发放
│ ├── 余额管理
│ └── 财务报表导出
└── 适用: 2-3人
12. 📈 数据分析师 (Data Analyst)
├── 菜单: 首页、数据分析、行为分析、用户分析、所有数据报表
├── 权限:
│ ├── 所有数据和报表只读访问
│ ├── 可创建自定义报表
│ ├── 可导出所有数据
│ ├── 可访问原始数据(日志)
│ └── 无修改权
├── 特殊功能:
│ ├── 对标管理配置
│ ├── 异常告警规则配置
│ ├── 数据权限配置
│ └── 报表审计查看
└── 适用: 1-2人
13. 🔍 审核专员 (Review Specialist)
├── 菜单: 首页、审核管理、行为分析
├── 功能:
│ ├── 商品评价审核
│ ├── 用户反馈审核
│ ├── 发票申请审核
│ ├── 账户异常处理
│ ├── 内容审核
│ └── 审核统计
└── 适用: 2-3人内容较多情况
```
#### D. 专项权限角色2个
```
14. 🎬 内容编辑 (Content Editor)
├── 菜单: 首页(自定义看板)、内容、装修
├── 功能:
│ ├── 文章管理(创建、编辑、发布)
│ ├── 文章分类管理
│ ├── 首页装修配置
│ ├── 分类页装修
│ ├── 数据配置
│ ├── 主题和样式配置
│ └── 素材管理
├── 权限: 无财务、用户敏感数据权限
└── 适用: 1-2人运营/品宣)
15. 🔧 系统维护员 (System Administrator)
├── 菜单: 首页、维护、设置
├── 功能:
│ ├── 系统配置(不涉及业务逻辑)
│ ├── 定时任务管理
│ ├── 缓存刷新
│ ├── 日志查看
│ ├── 在线升级
│ ├── 数据库维护
│ ├── 文件管理
│ └── 对外接口管理
├── 特殊权限: 可查看系统日志、错误日志
└── 适用: 1-2人技术人员
```
---
### 权限矩阵总览
| 菜单 | 超管 | 总经理 | 副总 | 用户运营 | 商品运营 | 订单管理 | 营销运营 | 配送运营 | 商户运营 | 客服专员 | 财务专员 | 数据分析 | 审核专员 | 内容编辑 | 系统维护 |
| -------- | ---- | ------ | ---- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- | -------- |
| 首页 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 用户 | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| 商品 | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| 订单 | ✅ | ✅ | ✅ | ⚠️ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| 营销 | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| 分销 | ✅ | ✅ | ⚠️ | ❌ | ❌ | ❌ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| 客服 | ✅ | ✅ | ⚠️ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
| 财务 | ✅ | ✅ | ⚠️ | ❌ | ❌ | ⚠️ | ❌ | ⚠️ | ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| 内容 | ✅ | ✅ | ⚠️ | ❌ | ❌ | ❌ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ |
| 装修 | ✅ | ✅ | ⚠️ | ❌ | ❌ | ❌ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ |
| 应用 | ✅ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| 设置 | ✅ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| 维护 | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| 数据分析 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ✅ | ⚠️ | ❌ | ❌ |
| 配送管理 | ✅ | ✅ | ⚠️ | ❌ | ❌ | ⚠️ | ❌ | ✅ | ❌ | ⚠️ | ⚠️ | ✅ | ❌ | ❌ | ❌ |
| 商户管理 | ✅ | ✅ | ⚠️ | ❌ | ❌ | ❌ | ⚠️ | ❌ | ✅ | ❌ | ⚠️ | ✅ | ⚠️ | ❌ | ❌ |
| 行为分析 | ✅ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ✅ | ⚠️ | ❌ | ❌ |
| 审核管理 | ✅ | ✅ | ⚠️ | ❌ | ❌ | ⚠️ | ❌ | ❌ | ⚠️ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ❌ |
**说明**:
- ✅ = 完全访问
- ⚠️ = 部分访问(只读或仅特定功能)
- ❌ = 无访问权限
---
## 📱 第四部分:前端实现架构
### 页面结构调整方案
#### 1. 首页看板 - 动态适配
```vue
<template>
<view class="admin-dashboard">
<!-- 用户身份展示 -->
<view class="user-info">
{{ userRole.name }} - {{ userRole.realName }}
</view>
<!-- 根据角色动态渲染看板 -->
<view class="dashboard-content">
<!-- 超级管理员/总经理 - 全景看板 -->
<template v-if="userRole.id === 'admin' || userRole.id === 'gm'">
<AdminDashboard :config="dashboardConfig" />
</template>
<!-- 用户运营 - 用户聚焦看板 -->
<template v-else-if="userRole.id === 'user_ops'">
<UserOpssDashboard :config="dashboardConfig" />
</template>
<!-- 商品运营 - 商品聚焦看板 -->
<template v-else-if="userRole.id === 'product_ops'">
<ProductOpssDashboard :config="dashboardConfig" />
</template>
<!-- ... 其他角色特定看板 -->
</view>
</view>
</template>
```
#### 2. 侧边栏菜单 - 权限隐藏
```typescript
// 菜单树结构,根据权限过滤
const menuTree = [
{
id: "home",
label: "首页",
icon: "home",
path: "/pages/mall/admin/index_new",
roles: ["*"], // 所有角色可见
},
{
id: "user",
label: "用户",
icon: "user",
roles: [
"admin",
"gm",
"assistant_gm",
"user_ops",
"marketing_ops",
"data_analyst",
],
children: [
{
id: "user_list",
label: "用户列表",
path: "/pages/mall/admin/user-management",
},
{
id: "user_group",
label: "用户分组",
path: "/pages/mall/admin/user-statistics",
},
// ...
],
},
{
id: "delivery",
label: "配送管理",
icon: "truck",
roles: ["admin", "gm", "assistant_gm", "delivery_ops", "order_manager"],
children: [
{
id: "delivery_stats",
label: "配送统计",
path: "/pages/mall/admin/delivery/stats",
},
{
id: "delivery_driver",
label: "配送员管理",
path: "/pages/mall/admin/delivery/drivers",
},
// ...
],
},
{
id: "merchant",
label: "商户管理",
icon: "store",
roles: ["admin", "gm", "assistant_gm", "merchant_ops"],
children: [
{
id: "merchant_list",
label: "商户列表",
path: "/pages/mall/admin/merchant/list",
},
{
id: "merchant_review",
label: "商户审核",
path: "/pages/mall/admin/merchant/review",
},
// ...
],
},
// ... 其他菜单
];
// 根据用户权限过滤菜单
function getVisibleMenus(userRole: UserRole): MenuItem[] {
return menuTree
.filter(
(menu) => menu.roles.includes("*") || menu.roles.includes(userRole.id),
)
.map((menu) => ({
...menu,
children: menu.children?.filter(
(child) =>
child.roles?.includes("*") || child.roles?.includes(userRole.id),
),
}));
}
```
#### 3. 页面级权限验证
```typescript
// 页面组件中的权限守卫
export default {
data() {
return {
requiredPermissions: ["order:view", "order:approve", "order:shipment"],
};
},
beforeCreate() {
const hasPermission = this.requiredPermissions.every((perm) =>
this.$userStore.permissions.includes(perm),
);
if (!hasPermission) {
// 无权限,跳转回首页并显示提示
uni.navigateBack();
uni.showToast({ title: "无权限访问此功能" });
}
},
};
```
#### 4. 数据权限隔离
```typescript
// 查询时注入权限过滤
async function fetchOrderList() {
const userRole = this.$userStore.userRole;
let query = supabase.from("orders");
// 不同角色看不同的数据
switch (userRole.id) {
case "order_manager":
// 订单管理员看全部订单
break;
case "merchant_ops":
// 商户运营只看特定商户的订单
query = query.eq("merchant_id", userRole.merchantId);
break;
case "regional_manager":
// 地区管理员只看特定地区的订单
query = query.in("region_id", userRole.regionIds);
break;
}
return query.execute();
}
```
---
## 🔄 第五部分:实施路线图
### Phase 1: 权限体系建立 (第1-2周)
- [ ] 数据库设计role、permission、role_permission表
- [ ] 后端API开发角色权限查询、数据权限隔离
- [ ] 前端权限管理库menuFilter、permissionCheck等
- [ ] 测试用例编写
### Phase 2: 菜单重构 (第3-4周)
- [ ] 重构admin首页为动态看板
- [ ] 实现菜单树权限过滤
- [ ] 调整页面入口和路由
- [ ] 页面可访问性测试
### Phase 3: 新菜单实现 (第5-12周)
#### 第5-6周数据分析模块
- [ ] 看板配置页面
- [ ] 报表管理页面
- [ ] 异常告警配置
#### 第7-8周配送管理模块
- [ ] 配送员管理
- [ ] 任务分配
- [ ] 绩效考核
#### 第9-10周商户管理模块
- [ ] 商户审核
- [ ] 费用管理
- [ ] 经营分析
#### 第11-12周行为分析和审核模块
- [ ] 用户行为追踪
- [ ] 风险识别
- [ ] 审核管理
### Phase 4: 验收和上线 (第13-14周)
- [ ] UAT测试
- [ ] 性能优化
- [ ] 权限检查清单
- [ ] 线上灰度发布
---
## 📋 第六部分:数据权限设计细节
### 多商户数据隔离
```sql
-- 在所有关键表中添加 merchant_id 字段
ALTER TABLE orders ADD COLUMN merchant_id UUID;
ALTER TABLE products ADD COLUMN merchant_id UUID;
ALTER TABLE users ADD COLUMN created_by_merchant_id UUID;
-- 商户运营只能看自己的数据
CREATE POLICY merchant_ops_data_isolation ON orders
FOR SELECT
USING (
auth.uid() = current_user_id
AND (
-- 自己是商户运营,只看自己商户的数据
merchant_id = current_merchant_id
OR
-- 或者有查看其他商户的权限
EXISTS (
SELECT 1 FROM role_permissions
WHERE role_id = current_role_id
AND permission = 'order:view_other_merchant'
)
)
);
```
### 地区级权限隔离
```sql
-- 地区管理员只能查看指定地区的数据
CREATE POLICY regional_manager_isolation ON orders
FOR SELECT
USING (
auth.uid() = current_user_id
AND (
shipping_address ->> 'province' = ANY(allowed_regions)
OR role_id = 'admin'
)
);
```
### 时间范围权限
```sql
-- 财务人员只能查看过去30天的数据
CREATE POLICY finance_time_range ON orders
FOR SELECT
USING (
current_role_id = 'finance_specialist'
AND created_at >= now() - interval '30 days'
);
```
---
## 🎬 第七部分:页面布局和交互设计
### 左侧菜单 + 主内容区布局
```
┌─────────────────────────────────┐
│ 商城后台管理系统 │
├────────────┬────────────────────┤
│ │ │
│ 菜单树 │ 面包屑导航 │
│ ├─ 首页 │ 首页 > 订单 > 待审 │
│ ├─ 用户 │ │
│ ├─ 商品 │ 主内容区 │
│ ├─ 订单 │ ┌─────────────────┐│
│ │ ├─订单管理 │ │││
│ │ ├─售后 │ 订单列表 │││
│ │ └─统计 │ [表格] │││
│ │ │ [分页] │││
│ ├─ 数据分析 │ │││
│ ├─ 配送管理 │ │││
│ ├─ 商户管理 │ │││
│ ├─ 行为分析 │ │││
│ ├─ 审核管理 │ │││
│ ├─ 设置 │ │││
│ └─ 维护 │ │││
│ │└─────────────────┘│
└────────────┴────────────────────┘
```
### 响应式设计方案
```css
/* 桌面版 - 左菜单固定 */
@media (min-width: 1200px) {
.sidebar {
width: 250px;
}
.main-content {
margin-left: 250px;
}
}
/* 平板版 - 菜单可折叠 */
@media (min-width: 768px) and (max-width: 1199px) {
.sidebar {
width: 200px;
}
.sidebar.collapsed {
width: 60px;
}
.main-content {
margin-left: 200px;
}
}
/* 手机版 - 菜单底部 */
@media (max-width: 767px) {
.sidebar {
position: fixed;
bottom: 0;
height: 60px;
width: 100%;
}
.main-content {
margin-bottom: 60px;
}
}
```
---
## ✅ 附录:检查清单
### 权限设计检查
- [ ] 每个角色的权限都明确定义
- [ ] 没有权限提升漏洞(权限最小化原则)
- [ ] 敏感操作有二级确认机制
- [ ] 权限修改操作有审计日志
- [ ] 不同角色的数据完全隔离
### 功能完整性检查
- [ ] 每个菜单都有对应的权限
- [ ] 每个页面都有权限验证
- [ ] 页面内的操作按钮也要检查权限
- [ ] 删除、修改操作需要确认框
- [ ] 所有操作都要记录到审计日志
### 测试场景
- [ ] 用户无权限访问页面时的表现
- [ ] 用户有部分权限时的表现(按钮隐藏)
- [ ] 权限动态修改时的实时更新
- [ ] 多角色用户的权限优先级
- [ ] 跨角色数据隔离
---
**下一步**: 根据本分析文档建议先从Phase 1的权限体系建立开始逐步推进各个模块的实现。

View File

@@ -1,303 +0,0 @@
# Admin管理系统 - 融合方案快速参考
> 🚀 一页纸快速了解要融合什么、怎么融合、15个新角色是什么
---
## 📊 融合概览
| 源端 | 可融合的管理功能 | 新增菜单 | 涉及角色 |
| ------------------------ | ------------------------------ | --------------- | ------------------------ |
| **Analytics** 数据分析端 | 看板、报表、告警、对标 | 📈 数据分析菜单 | 数据分析师、各部门经理 |
| **Consumer** 消费者端 | 用户行为、订单风险、退款审核 | 📊 行为分析菜单 | 订单经理、审核专员、客服 |
| **Delivery** 配送端 | 配送员管理、任务分配、绩效考核 | 🚚 配送管理菜单 | 配送运营经理 |
| **Merchant** 商户端 | 商户审核、佣金管理、经营分析 | 🏪 商户管理菜单 | 商户运营经理 |
**融合后Admin菜单数**: 13 → 18个 (新增5个菜单)
---
## 🔑 15个角色体系一览
### 管理层 (3个)
```
1. 🔑 超级管理员 - 所有权限
2. 📊 总经理 - 所有一级菜单 + 全量数据
3. 👥 运营副总 - 关键菜单 + 查看权限为主
```
### 运营经理 (6个)
```
4. 👥 用户运营经理 - 用户、营销(用户)、数据分析
5. 📦 商品运营经理 - 商品、营销(促销)、库存
6. 📋 订单管理经理 - 订单、售后、物流、风险识别
7. 🎯 营销运营经理 - 营销、用户分析、行为分析
8. 🚚 配送运营经理 - 配送管理(仅自建配送)
9. 🏪 商户运营经理 - 商户管理(仅多商户模式)
```
### 执行专员 (4个)
```
10. 💼 客服专员 - 客服工作台、退款处理、申诉
11. 💰 财务专员 - 财务、提现审核、账单
12. 📈 数据分析师 - 所有数据查看 + 报表配置
13. 🔍 审核专员 - 内容审核、用户申诉、发票
```
### 专项角色 (2个)
```
14. 🎬 内容编辑 - 文章、装修、素材
15. 🔧 系统维护员 - 系统配置、缓存、日志
```
---
## 🗂️ 新增5个菜单详解
### 1⃣ 📈 数据分析菜单 (新增)
**4个分组12个页面**:
| 功能分组 | 页面 | 数量 |
| -------------- | ---------------------------- | ------ |
| 📊 KPI看板管理 | 看板配置、样式、权限 | 3 |
| 📋 报表管理 | 模板库、定时报表、审计、权限 | 4 |
| ⚠️ 异常告警 | 告警规则、频道、历史 | 3 |
| 🎯 对标管理 | 目标设置、对标查询、达成分析 | 2 |
| **合计** | | **12** |
**关键特性**:
- ✅ 各角色定制化看板
- ✅ 自动告警机制KPI异常
- ✅ 数据权限分级
- ✅ 定时报表发送
**适用于**: 总经理、各运营经理、数据分析师 (共10+人)
---
### 2⃣ 🚚 配送管理菜单 (新增)
**6个分组25个管理页面**:
| 功能分组 | 页面 | 数量 |
| ------------- | ------------------------------------ | ------ |
| 📊 配送统计 | 今日数据、排行、路线分析 | 3 |
| 👥 配送员管理 | 列表、审核、黑名单、分级、激励、申诉 | 6 |
| 🎯 任务分配 | 自动规则、手动分配、历史记录 | 3 |
| 🏆 绩效考核 | 指标、设置、月度考核、工资计算 | 4 |
| 💳 费用结算 | 费率、提现、周期、报表 | 4 |
| 🚗 车辆管理 | 车辆列表、审核、轨迹 | 3 |
| **合计** | | **25** |
**关键特性**:
- ✅ 智能任务分配算法
- ✅ 绩效挂钩薪资
- ✅ 实时轨迹追踪
- ✅ 风险识别(重复送达、虚假状态)
**适用于**: 仅O2O模式或自建配送体系
---
### 3⃣ 🏪 商户管理菜单 (新增)
**5个分组20个管理页面**:
| 功能分组 | 页面 | 数量 |
| ----------- | ------------------------------ | ------ |
| 📊 商户统计 | 商户总数、排行、画像 | 3 |
| 👤 商户审核 | 入驻、资质、保证金、激活 | 4 |
| 💼 商户管理 | 列表、分级、店铺、联系、冻结 | 5 |
| 💰 费用管理 | 保证金、佣金、提现、罚款、结算 | 5 |
| 📈 经营管理 | 数据、违规、权限、沟通、退出 | 5 |
| **合计** | | **22** |
**关键特性**:
- ✅ 完整的商户入驻 → 经营 → 退出流程
- ✅ 动态佣金分级
- ✅ 智能风控识别
- ✅ 多维度数据分析
**适用于**: 仅平台模式B2B2C
---
### 4⃣ 📊 行为分析菜单 (新增)
**3个分组12个管理页面**:
| 功能分组 | 页面 | 数量 |
| --------------- | ---------------------------------------------- | ------ |
| 👁️ 用户行为追踪 | 浏览、收藏、购物车、搜索、路径 | 5 |
| 🚨 订单风险识别 | 异常检测、黑名单、采购预警、退货分析、评价识别 | 6 |
| 📋 退款审核管理 | 待审核、自动规则、审批流、拒绝、统计、物流 | 6 |
| **合计** | | **17** |
**关键特性**:
- ✅ AI识别虚假订单 & 恶意用户
- ✅ 多维度风险评分
- ✅ 自动/手动审核流程
- ✅ 退款防控
**适用于**: 所有模式
---
### 5⃣ ⚖️ 审核管理菜单 (新增)
**4个分组15个管理页面**:
| 功能分组 | 页面 | 数量 |
| ----------- | ------------------------------ | ------ |
| 💸 财务审核 | 提现、发票、异常、历史 | 4 |
| 📝 商户审核 | 入驻、资料修改、活动、申诉 | 4 |
| 👤 用户审核 | 用户申诉、发票、账户异常、冻结 | 4 |
| ⭐ 内容审核 | 评价、反馈、文章、评论 | 4 |
| **合计** | | **16** |
**关键特性**:
- ✅ 统一审核中心
- ✅ 多级审批流程
- ✅ 审计日志完整
- ✅ 审核效率统计
**适用于**: 所有模式
---
## 🎬 15个角色权限速查表
### 按功能模块看谁有权限
| 功能模块 | 有权限的角色 | 主要操作 |
| ------------ | -------------------------------------- | ------------------------ |
| **用户管理** | 用户运营、总经理、副总、数据分析 | 查询、分组、标签、黑名单 |
| **商品管理** | 商品运营、总经理、副总、数据分析 | 上下架、库存、分类 |
| **订单处理** | 订单经理、总经理、副总、客服、数据分析 | 发货、售后、风险识别 |
| **营销活动** | 营销运营、总经理、副总、数据分析 | 创建、执行、ROI分析 |
| **配送管理** | 配送运营、总经理、副总 | 分配、考核、结算 |
| **商户管理** | 商户运营、总经理、副总、财务 | 审核、分级、结算 |
| **财务结算** | 财务专员、总经理、副总、数据分析 | 审核、提现、报表 |
| **数据分析** | 数据分析师、总经理、副总、各运营经理 | 配置、查看、导出 |
| **内容审核** | 审核专员、总经理、副总 | 审核、批准、拒绝 |
---
## 📐 实施优先级
### 🟢 **第一优先** (必做3-4周)
- [ ] 数据库role & permission 表
- [ ] 菜单权限系统
- [ ] 首页看板动态化
- [ ] 基础权限中间件
### 🟡 **第二优先** (应做4-6周)
- [ ] 数据分析菜单 (即时性高,收益快)
- [ ] 行为分析 & 审核菜单 (风控重要)
- [ ] 权限细分到按钮级
### 🟠 **第三优先** (可做6-8周)
- [ ] 配送管理菜单 (仅O2O)
- [ ] 商户管理菜单 (仅平台)
- [ ] 完整的审计日志系统
---
## 🔐 关键的权限规则
```
1⃣ 数据隔离
- 商户运营只能看自己商户的数据
- 地区经理只能看自己地区的数据
- 财务只能看过去30天的数据
2⃣ 操作限制
- 修改操作需要备注和审批
- 删除操作需要二次确认
- 金额>10000元的提现需要财务确认
3⃣ 审计记录
- 所有增删改操作都记录谁、何时、改了什么
- 权限修改要记录历史
- 敏感数据导出要记录
4⃣ 权限检查
- 页面级:检查角色是否有菜单访问权
- 操作级:检查是否有这个操作权限
- 数据级:查询时自动注入权限过滤条件
```
---
## 📱 页面展示差异示例
### 首页看板 - 根据角色显示不同内容
```
【总经理看到】:
- 全局KPI: 销售额、订单、用户、毛利
- 各部门数据总结
- 预警信息
- 快速操作快捷方式
【用户运营看到】:
- 用户相关KPI: 新增、活跃、留存、消费
- 用户分层数据
- 营销活动效果
- 用户行为洞察
【商品运营看到】:
- 商品相关KPI: 销售额、库存、评分
- 畅销/滞销商品
- 库存预警
- 采购建议
【财务专员看到】:
- 财务相关KPI: 收入、成本、利润
- 待审核项: 提现、发票、异常
- 应收应付
- 日报表
```
---
## 🎯 成功指标
- ✅ 页面加载速度 < 2s
- ✅ 权限验证失败率 < 0.01%
- ✅ 数据隔离无泄露
- ✅ 用户操作审计完整率 100%
- ✅ 新增角色学习曲线 < 1小时
---
## 📞 常见问题
**Q: 能不能给一个用户多个角色?**
A: 可以。系统支持用户绑定多个角色,权限取并集。
**Q: 怎么处理跨部门的权限?**
A: 使用权限模板。比如"跨部门审核员"模板包含多个部门的权限。
**Q: 新增角色需要修改多少代码?**
A: 只需在role表中插入新记录和权限关联无需代码改动数据驱动设计
**Q: 怎么确保数据权限不被绕过?**
A: 数据权限在数据库层用RLSRow Level Security策略强制代码再怎么改也绕不过。
---
**相关文件**: 完整分析在 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md)

View File

@@ -1,810 +0,0 @@
# Admin菜单结构 - 融合前后对照
---
## 📊 菜单进化图
### 融合前13个一级菜单
```
📊 Admin管理系统
├── 📈 首页 (HOME)
│ └── 仪表板
├── 👥 用户 (USER)
│ ├── 用户统计
│ ├── 用户管理
│ ├── 用户分组
│ ├── 用户标签
│ └── 用户等级
├── 📦 商品 (PRODUCT)
│ ├── 商品统计
│ ├── 商品管理
│ ├── 商品分类
│ ├── 商品规格
│ ├── 商品参数
│ ├── 商品标签
│ ├── 商品保障
│ └── 商品评论
├── 📋 订单 (ORDER)
│ ├── 订单统计
│ ├── 订单管理
│ ├── 售后订单
│ ├── 收银订单
│ ├── 核销记录
│ └── 订单配置
├── 🎯 营销 (MARKETING)
│ ├── 优惠券管理 (2页)
│ ├── 积分管理 (5页)
│ ├── 抽奖管理 (2页)
│ ├── 砍价管理 (2页)
│ ├── 拼团管理 (3页)
│ ├── 秒杀管理 (3页)
│ ├── 付费会员 (5页)
│ ├── 直播管理 (3页)
│ ├── 用户充值 (2页)
│ ├── 每日签到 (2页)
│ └── 其他营销 (2页)
├── 👔 分销 (DISTRIBUTION)
│ ├── 分销统计
│ ├── 分销员列表
│ └── 分销设置
├── 💬 客服 (KEFU)
│ ├── 客服列表
│ ├── 客服话术
│ ├── 用户留言
│ ├── 自动回复
│ └── 客服配置
├── 💰 财务 (FINANCE)
│ ├── 交易统计
│ ├── 财务操作
│ ├── 财务记录
│ ├── 佣金记录
│ └── 余额记录
├── 📄 内容 (CMS)
│ ├── 文章管理
│ └── 文章分类
├── 🎨 装修 (DECORATION)
│ ├── 首页装修
│ ├── 分类装修
│ ├── 商品装修
│ ├── 数据配置
│ ├── 主题风格
│ ├── 素材管理
│ ├── 链接管理
│ └── 装修预览
├── 🔌 应用 (APP)
│ ├── 应用统计
│ └── 应用列表
├── ⚙️ 设置 (SETTING)
│ ├── 系统配置
│ ├── 管理员管理
│ └── 角色管理
└── 🛠️ 维护 (MAINTAIN)
├── 开发配置 (6页)
├── 安全维护 (3页)
├── 数据维护 (3页)
├── 对外接口 (1页)
├── 语言设置 (4页)
├── 开发工具 (4页)
└── 系统信息 (1页)
🔢 统计: 13个一级菜单, 100+ 个管理页面
```
### 融合后18个一级菜单新增5个
```
📊 Admin管理系统升级版
├── 📈 首页 (HOME) ⭐ 动态看板
├── 👥 用户 (USER) ⭐ 扩展数据权限
├── 📦 商品 (PRODUCT) ✅ 保持
├── 📋 订单 (ORDER) ⭐ 扩展物流+风险识别
├── 🎯 营销 (MARKETING) ✅ 保持
├── 👔 分销 (DISTRIBUTION) ✅ 保持
├── 💬 客服 (KEFU) ✅ 保持
├── 💰 财务 (FINANCE) ✅ 保持
├── 📄 内容 (CMS) ✅ 保持
├── 🎨 装修 (DECORATION) ✅ 保持
├── 🔌 应用 (APP) ✅ 保持
├── ⚙️ 设置 (SETTING) ✅ 保持
├── 🛠️ 维护 (MAINTAIN) ✅ 保持
├── 📈 数据分析 (ANALYTICS) 🆕 新增 (12页)
├── 🚚 配送管理 (DELIVERY) 🆕 新增 (25页)
├── 🏪 商户管理 (MERCHANT) 🆕 新增 (22页)
├── 📊 行为分析 (BEHAVIOR) 🆕 新增 (17页)
└── ⚖️ 审核管理 (REVIEW) 🆕 新增 (16页)
🔢 统计: 18个一级菜单, 160+ 个管理页面
```
---
## 🆕 新增5个菜单详细结构
### 14. 📈 数据分析 (ANALYTICS)
```
📈 数据分析
├── 📊 KPI看板管理
│ ├── 看板配置
│ │ ├── 新建看板
│ │ ├── 编辑看板
│ │ ├── 删除看板
│ │ └── 看板列表
│ ├── 看板样式
│ │ ├── 图表类型选择 (柱状图、折线图、饼图等)
│ │ ├── 颜色方案
│ │ ├── 布局配置
│ │ └── 预览
│ └── 看板权限
│ ├── 指定角色可见
│ ├── 指定用户可见
│ └── 权限历史
├── 📋 报表管理
│ ├── 报表模板库
│ │ ├── 预设模板 (销售模板、用户模板等)
│ │ ├── 自定义模板
│ │ ├── 模板编辑
│ │ └── 模板分类
│ ├── 定时报表
│ │ ├── 创建定时报表
│ │ ├── 编辑定时规则 (日/周/月)
│ │ ├── 接收人配置
│ │ └── 发送历史
│ ├── 报表审计
│ │ ├── 查看记录
│ │ ├── 按用户筛选
│ │ ├── 按时间筛选
│ │ └── 导出审计日志
│ └── 数据权限
│ ├── 按角色配置可见数据范围
│ ├── 按地区/商户配置
│ └── 权限测试
├── ⚠️ 异常告警
│ ├── 告警规则
│ │ ├── KPI告警 (销售下跌、订单异常)
│ │ ├── 库存告警 (库存预警、缺货)
│ │ ├── 用户告警 (高风险用户、虚假账号)
│ │ ├── 成本告警 (毛利下降、费用超支)
│ │ └── 规则编辑
│ ├── 告警频道
│ │ ├── 邮件通知
│ │ ├── 短信通知
│ │ ├── 系统消息
│ │ └── 微信企业号
│ └── 告警历史
│ ├── 告警日志查看
│ ├── 处理状态标记
│ └── 统计分析
└── 🎯 对标管理
├── 目标设置
│ ├── 月度目标设置 (销售、利润、新客等)
│ ├── 部门目标分配
│ └── 目标历史查看
├── 对标查询
│ ├── 与去年同期对比
│ ├── 与行业平均对标
│ └── 对标数据导出
└── 达成分析
├── 完成度查看 (进度条、百分比)
├── 关键驱动因素分析
└── 月度复盘报告
```
### 15. 🚚 配送管理 (DELIVERY)
```
🚚 配送管理
├── 📊 配送统计
│ ├── 今日数据
│ │ ├── 待接单数
│ │ ├── 配送中数
│ │ ├── 已完成数
│ │ ├── 总收入
│ │ └── 平均送达时间
│ ├── 配送员排行
│ │ ├── 今日排行
│ │ ├── 周排行
│ │ ├── 月排行
│ │ └── 导出排行榜
│ └── 路线分析
│ ├── 配送费用效率
│ ├── 完成率分析
│ ├── 里程成本
│ └── 最优路线提示
├── 👥 配送员管理
│ ├── 配送员列表
│ │ ├── 配送员信息查看 (姓名、评分、车辆、服务区)
│ │ ├── 配送员编辑
│ │ ├── 启用/禁用
│ │ ├── 批量导入/导出
│ │ └── 高级搜索
│ ├── 配送员审核
│ │ ├── 新驾驶员申请审核
│ │ ├── 身份认证审核
│ │ ├── 驾驶证/行驶证验证
│ │ ├── 拒绝与再审
│ │ └── 审核历史
│ ├── 配送员黑名单
│ │ ├── 冻结账户
│ │ ├── 黑名单原因记录
│ │ ├── 申诉处理
│ │ └── 黑名单解除流程
│ ├── 配送员分级
│ │ ├── 等级设置 (初级/中级/高级/王牌)
│ │ ├── 升降级条件配置
│ │ ├── 按等级分配权重
│ │ └── 等级权益配置
│ ├── 配送员激励
│ │ ├── 激励活动创建
│ │ ├── 日送单数奖励 (达成100单奖励100元)
│ │ ├── 月度奖励 (销量冠军、满意度冠军)
│ │ ├── 特殊奖励
│ │ └── 激励统计
│ └── 申诉管理
│ ├── 用户投诉处理
│ ├── 投诉原因分类
│ ├── 处理结果记录
│ ├── 配送员申诉处理
│ └── 投诉统计分析
├── 🎯 任务分配
│ ├── 自动分配规则
│ │ ├── 距离优化算法
│ │ ├── 工作量均衡 (分配平衡系数)
│ │ ├── 等级优先级配置
│ │ ├── 地理围栏设置
│ │ └── 规则测试
│ ├── 手动分配
│ │ ├── 选择订单
│ │ ├── 选择配送员
│ │ ├── 分配备注
│ │ ├── 批量分配
│ │ └── 分配确认
│ └── 分配记录
│ ├── 分配历史查看
│ ├── 按配送员筛选
│ ├── 按时间筛选
│ ├── 分配效率统计
│ └── 分配日报表
├── 🏆 绩效考核
│ ├── 考核指标
│ │ ├── 送达时间达成率 (承诺时间内完成%)
│ │ ├── 用户评分 (平均分数)
│ │ ├── 投诉数 (0投诉、1-3投诉等级)
│ │ ├── 接单率 (接单/分配比例)
│ │ ├── 完成率 (完成/接单比例)
│ │ └── 自定义指标
│ ├── 考核设置
│ │ ├── 指标权重配置
│ │ ├── 目标设置 (比如送达率95%以上)
│ │ ├── 奖惩规则 (完成目标+奖励/未完成-扣款)
│ │ └── 生效时间设置
│ ├── 月度考核
│ │ ├── 生成考核报告
│ │ ├── 考核分数计算
│ │ ├── 等级评定 (优秀/良好/及格/不及格)
│ │ ├── 考核结果确认
│ │ └── 考核结果公示
│ └── 工资计算
│ ├── 基本工资配置 (底薪)
│ ├── 考核奖惩计算
│ ├── 分层计价计算 (单价*数量)
│ ├── 加班费计算
│ └── 最终工资表导出
├── 💳 费用结算
│ ├── 费率配置
│ │ ├── 按距离分层 (3km内100元, 3-5km120元)
│ │ ├── 按时间分层 (早8-11点配送50元/单)
│ │ ├── 按订单量分层 (日50单以上提价5%)
│ │ ├── 特殊商品加价 (重物加价、易损加价)
│ │ └── 规则生效管理
│ ├── 提现管理
│ │ ├── 提现申请查看
│ │ ├── 提现审核 (验证账户、金额)
│ │ ├── 提现驳回
│ │ ├── 提现到账确认
│ │ └── 提现统计
│ ├── 结算周期
│ │ ├── 周期配置 (日结/周结/月结)
│ │ ├── 结算日期设置
│ │ ├── 自动结算规则
│ │ └── 手动结算
│ └── 结算报表
│ ├── 日结单导出
│ ├── 周结单导出
│ ├── 月结单导出
│ ├── 配送员个人报表
│ └── 团队汇总报表
└── 🚗 车辆管理
├── 车辆列表
│ ├── 车辆信息查看 (车牌、类型、品牌、车龄)
│ ├── 车辆编辑
│ ├── 关联配送员
│ └── 批量上传
├── 车牌审核
│ ├── 行驶证审核
│ ├── 保险查看 (保期、理赔次数)
│ ├── 年检查看 (年检日期、状态)
│ ├── 异常处理 (逾期提醒、处理记录)
│ └── 审核统计
└── 轨迹追踪
├── 实时位置查看
├── 配送路线可视化
├── 行驶速度监控
├── 异常行为告警 (超速、停留过久)
└── 轨迹回放
```
### 16. 🏪 商户管理 (MERCHANT)
```
🏪 商户管理
├── 📊 商户统计
│ ├── 商户总数
│ │ ├── 按等级统计 (普通/黄金/钻石/VIP)
│ │ ├── 按状态统计 (申请中/审核中/已激活/已冻结)
│ │ ├── 按分类统计 (食品/服装/电子等)
│ │ └── 增长趋势 (新入驻商户)
│ ├── 商户排行
│ │ ├── 销售额排行
│ │ ├── 订单数排行
│ │ ├── 评分排行
│ │ ├── 新入驻排行
│ │ └── 导出排行榜
│ └── 商户画像
│ ├── 分类分布 (各分类商户数、销售额)
│ ├── 地区分布
│ ├── 等级分布
│ ├── 增长曲线 (月新增、留存)
│ └── 数据总结
├── 👤 商户审核
│ ├── 入驻申请
│ │ ├── 申请列表查看
│ │ ├── 申请人信息审核 (真名认证、身份证)
│ │ ├── 营业执照审核 (图片清晰、有效期)
│ │ ├── 经营范围确认
│ │ ├── 批准 / 驳回 / 再审请求
│ │ └── 申请历史
│ ├── 资质审核
│ │ ├── 法人身份验证
│ │ ├── 银行账户验证 (开户行、账户名)
│ │ ├── 税务信息验证
│ │ ├── 许可证验证 (若需要,如食品经营许可证)
│ │ └── 补充材料收集
│ ├── 保证金缴纳
│ │ ├── 保证金标准查看 (按分类设置)
│ │ ├── 商户缴纳记录
│ │ ├── 缴纳确认 (验证银行到账)
│ │ ├── 退款处理
│ │ └── 缴纳统计
│ └── 激活管理
│ ├── 待激活商户列表
│ ├── 激活前检查清单
│ ├── 生成激活令牌/邀请码
│ ├── 激活状态变更
│ └── 激活日志
├── 💼 商户管理
│ ├── 商户列表
│ │ ├── 商户信息查看 (店铺名、负责人、联系方式)
│ │ ├── 商户编辑 (店铺简介、营业时间)
│ │ ├── 启用/禁用
│ │ ├── 批量操作
│ │ └── 高级搜索/过滤
│ ├── 商户分级
│ │ ├── 分级标准设置
│ │ │ ├── 销售额阈值 (月销>10万→黄金)
│ │ │ ├── 评分阈值 (评分>4.5→黄金)
│ │ │ ├── 投诉率阈值 (投诉<0.1%→黄金)
│ │ │ └── 权重配置
│ │ ├── 自动升级/降级
│ │ ├── 手动调整
│ │ ├── 分级权益配置 (不同等级的佣金率、功能限制)
│ │ └── 分级统计
│ ├── 店铺信息
│ │ ├── 店铺名称编辑
│ │ ├── Logo上传/修改
│ │ ├── 店铺简介编辑
│ │ ├── 营业时间设置
│ │ ├── 联系方式管理
│ │ ├── 商户分类标签
│ │ └── 店铺风格/主题
│ ├── 商户联系方式
│ │ ├── 账号管理 (主账号、子账号)
│ │ ├── 邮箱管理
│ │ ├── 电话管理
│ │ ├── 微信绑定
│ │ └── 紧急联系人
│ └── 商户冻结/解冻
│ ├── 冻结账户 (违规、欠款等)
│ ├── 冻结原因记录
│ ├── 冻结期限设置
│ ├── 解冻申请处理
│ └── 冻结统计
├── 💰 费用管理
│ ├── 保证金管理
│ │ ├── 保证金标准 (按分类/等级)
│ │ ├── 缴纳记录查看
│ │ ├── 扣罚记录 (质量问题赔偿)
│ │ ├── 扣罚申请审批
│ │ ├── 退还流程 (商户申请→审批→转账)
│ │ └── 保证金账户余额
│ ├── 佣金配置
│ │ ├── 分类佣金率 (不同商品分类不同佣金)
│ │ ├── 等级佣金率 (不同商户等级不同佣金)
│ │ ├── 时间段差异 (不同时段不同佣金)
│ │ ├── 新商户优惠期 (前3个月佣金减半)
│ │ ├── 佣金规则审批
│ │ └── 佣金历史查看
│ ├── 佣金扣除
│ │ ├── 自动计算和扣除
│ │ ├── 佣金明细查看 (每笔订单的佣金)
│ │ ├── 佣金统计 (日/周/月)
│ │ ├── 异常佣金标记
│ │ └── 手动调整
│ ├── 提现管理
│ │ ├── 提现申请查看
│ │ ├── 提现审核 (验证商户身份、账户)
│ │ ├── 提现驳回
│ │ ├── 转账处理
│ │ ├── 提现到账确认
│ │ ├── 提现限额配置 (最低提现金额、频率)
│ │ └── 提现统计
│ ├── 罚款管理
│ │ ├── 罚款原因管理 (虚假商品、发货延期等)
│ │ ├── 罚款处理
│ │ ├── 罚款扣除 (从保证金/提现中扣除)
│ │ ├── 罚款申诉处理
│ │ └── 罚款统计
│ └── 结算报表
│ ├── 日结单 (日期、销售额、佣金、实收)
│ ├── 周结单
│ ├── 月结单
│ ├── 商户个人结算报表
│ ├── 分类汇总结算
│ └── 结算审计
└── 📈 经营管理
├── 商户数据
│ ├── 销售数据 (销售额、订单数、客单价)
│ ├── 转化数据 (浏览数、下单数、转化率)
│ ├── 评分数据 (商品评分、服务评分、物流评分)
│ ├── 数据对比 (与上周/上月对比)
│ └── 数据导出
├── 商户违规
│ ├── 投诉统计 (投诉数、类型)
│ ├── 退货率统计
│ ├── 售后问题 (破损、错发、不符)
│ ├── 违规风险评分
│ └── 风险等级 (绿/黄/红)
├── 营销工具权限
│ ├── 配置商户可使用的营销功能
│ │ ├── 优惠券功能
│ │ ├── 秒杀功能
│ │ ├── 拼团功能
│ │ └── 直播功能
│ ├── 功能限制配置 (如最多创建50个优惠券)
│ ├── 权限审批流程
│ └── 权限统计
├── 商户沟通
│ ├── 发送通知到指定商户
│ ├── 系统公告发布
│ ├── 政策更新通知
│ ├── 营销活动推荐
│ └── 通知送达记录
└── 商户退出
├── 退出申请处理
├── 风险提示 (有未发货订单等)
├── 清算流程 (冻结订单、财务清算)
├── 退出确认
└── 退出统计
```
### 17. 📊 行为分析 (BEHAVIOR)
```
📊 行为分析
├── 👁️ 用户行为追踪
│ ├── 浏览行为
│ │ ├── 页面访问统计 (首页、分类、商品详情)
│ │ ├── 停留时长分析 (用户在各页面停留多久)
│ │ ├── 用户路径分析 (用户的访问流程)
│ │ ├── 热力图 (页面哪些区域被点击最多)
│ │ ├── 浏览用户分群 (按行为分群)
│ │ └── 数据导出
│ ├── 收藏分析
│ │ ├── 收藏商品排行 (最多被收藏的商品)
│ │ ├── 收藏转购率 (收藏后购买率)
│ │ ├── 未购收藏商品提醒 (可做营销活动)
│ │ ├── 用户收藏分析 (活跃用户收藏特征)
│ │ └── 优化建议
│ ├── 购物车分析
│ │ ├── 购物车放弃分析
│ │ │ ├── 放弃购物车数
│ │ │ ├── 放弃率
│ │ │ ├── 放弃原因推测 (价格太高、缺货、物流费用)
│ │ │ └── 放弃用户重新激活策略
│ │ ├── 购物车商品热度
│ │ ├── 平均购物车金额
│ │ └── 购物车优化建议
│ ├── 搜索热词
│ │ ├── 搜索关键词统计
│ │ ├── 搜索转化率 (搜索后购买率)
│ │ ├── 高频搜索词 TOP100
│ │ ├── 零结果搜索词 (搜索不到的词)
│ │ ├── 搜索趋势 (词汇热度变化)
│ │ └── 搜索体验优化建议
│ └── 用户路径
│ ├── 访问路径流 (用户从进入到购买的路径)
│ ├── 路径热力 (最常走的路径)
│ ├── 流失分析 (用户在哪一步离开)
│ ├── 转化路径 (购买成功用户的路径特征)
│ ├── 异常路径 (反复刷新、加购又删除等)
│ └── 路径优化建议
├── 🚨 订单风险识别
│ ├── 异常订单检测
│ │ ├── 虚假订单识别
│ │ │ ├── 同一账号大额频繁下单
│ │ │ ├── 多账号同一IP/设备下单
│ │ │ ├── 异常地址提示 (收货地址频繁变化)
│ │ │ ├── 异常支付方式 (虚拟卡等)
│ │ │ └── 风险评分
│ │ ├── 高风险用户下单
│ │ │ ├── 新开账号大额采购
│ │ │ ├── 黑名单关联账号
│ │ │ └── 关联设备识别
│ │ ├── 异常订单列表
│ │ ├── 手动标记异常
│ │ └── 异常原因分类
│ ├── 黑名单管理
│ │ ├── 冻结恶意用户
│ │ ├── 黑名单原因记录 (虚假订单、退货欺诈等)
│ │ ├── 黑名单用户关联账号查询
│ │ ├── 黑名单解除申诉处理
│ │ ├── 黑名单进出记录
│ │ └── 黑名单统计
│ ├── 大额采购预警
│ │ ├── 大额订单自动识别 (>1000元)
│ │ ├── 短期集中采购识别 (1周内多次)
│ │ ├── 异常行为标记
│ │ ├── 人工审核队列
│ │ ├── 风险评分
│ │ └── 处理决策 (通过/冻结/需要确认)
│ ├── 异常退货分析
│ │ ├── 退货率过高用户识别 (退货率>30%)
│ │ ├── 模式分析 (是否存在规律退货)
│ │ ├── 原因分析 (质量问题 vs 改主意)
│ │ ├── 可信度评分
│ │ ├── 处理建议 (要求视频验收、限制退货等)
│ │ └── 高风险退货订单处理
│ ├── 恶意评价识别
│ │ ├── 虚假好评识别 (如通过赠品获赞)
│ │ ├── 违规评价识别 (含广告、联系方式等)
│ │ ├── 不实评价识别 (与商品描述不符)
│ │ ├── 评价质量评分
│ │ ├── 处理建议 (隐藏/删除/追踪)
│ │ └── 恶意评价统计
│ └── 风险订单处理
│ ├── 风险订单队列
│ ├── 手动审核
│ ├── 标记为可疑/确认安全/冻结
│ ├── 处理历史
│ └── 审核统计 (正确率、处理时间)
├── 📋 退款审核管理
│ ├── 待审核退款
│ │ ├── 待审核列表
│ │ ├── 订单信息查看 (商品、金额、原因)
│ │ ├── 用户信息查看 (信誉、历史退款数)
│ │ ├── 商家/平台责任判断
│ │ ├── 审核决定 (同意/拒绝/需补充信息)
│ │ └── 审核备注
│ ├── 自动退款规则
│ │ ├── 快速退款条件配置
│ │ │ ├── 商品未发货→自动退款
│ │ │ ├── 逾期未发货3天→自动退款
│ │ │ ├── 新用户不参与自动退款
│ │ │ └── 退款金额<100元→优先自动
│ │ ├── 规则编辑
│ │ ├── 规则生效
│ │ └── 自动退款统计
│ ├── 退款审批流
│ │ ├── 配置审批等级 (1级/2级/3级)
│ │ ├── 配置审批金额阈值
│ │ │ ├── 金额<100元→1级审核
│ │ │ ├── 100-500元→2级审核
│ │ │ ├── >500元→3级审核
│ │ ├── 配置审批人
│ │ ├── 催促审批 (超时自动上报)
│ │ └── 审批历史
│ ├── 退款拒绝
│ │ ├── 拒绝原因分类
│ │ ├── 拒绝模板
│ │ ├── 用户申诉处理
│ │ ├── 再次审核流程
│ │ └── 最终决定记录
│ ├── 退款统计
│ │ ├── 退货率分析
│ │ ├── 退款原因排行
│ │ ├── 平均退款金额
│ │ ├── 批准率/拒绝率
│ │ ├── 成本分析 (退款损失)
│ │ └── 同比数据
│ └── 物流退货追踪
│ ├── 退货订单物流信息
│ ├── 退货轨迹查看
│ ├── 退货签收确认
│ ├── 退货货物确认 (与订单匹配性)
│ ├── 退货完成
│ └── 异常物流处理 (物流丢失、损坏)
```
### 18. ⚖️ 审核管理 (REVIEW)
```
⚖️ 审核管理
├── 💸 财务审核
│ ├── 提现审核
│ │ ├── 待审核提现列表
│ │ ├── 提现信息查看 (申请金额、账户、时间)
│ │ ├── 提现账户验证 (账户名匹配)
│ │ ├── 余额验证 (确保有足够余额)
│ │ ├── 异常提现检测 (频繁大额提现)
│ │ ├── 批准/驳回
│ │ ├── 转账处理
│ │ ├── 到账确认
│ │ └── 审核统计
│ ├── 发票审核
│ │ ├── 待审核发票列表
│ │ ├── 发票信息审核 (抬头、金额、税号)
│ │ ├── 用户信息验证
│ │ ├── 订单金额匹配
│ │ ├── 批准/驳回
│ │ ├── 发票邮寄管理
│ │ ├── 发票寄送跟踪
│ │ └── 发票统计
│ ├── 财务异常
│ │ ├── 大额转账异常提醒
│ │ ├── 异常交易标记
│ │ ├── 人工审核
│ │ ├── 异常确认/驳回
│ │ └── 异常原因分析
│ └── 审核历史
│ ├── 已审核提现记录
│ ├── 已审核发票记录
│ ├── 审核员审计
│ ├── 按时间/申请人筛选
│ └── 审核统计报表
├── 📝 商户审核
│ ├── 入驻申请审核
│ │ ├── 待审核申请列表
│ │ ├── 商户资质查看
│ │ ├── 营业执照验真
│ │ ├── 经营范围确认
│ │ ├── 批准激活/驳回重审
│ │ ├── 审核备注
│ │ └── 入驻统计
│ ├── 资料修改审核
│ │ ├── 商户修改申请列表
│ │ ├── 修改内容对比 (原→新)
│ │ ├── 修改合法性审核
│ │ ├── 批准/拒绝
│ │ └── 修改历史记录
│ ├── 营销活动审核
│ │ ├── 商户创建的活动审核
│ │ ├── 活动规则合规性检查
│ │ ├── 虚假宣传检测
│ │ ├── 价格异常检测 (深度折扣)
│ │ ├── 批准发布/驳回修改
│ │ ├── 活动上线监控
│ │ └── 活动下线处理
│ └── 申诉审核
│ ├── 商户申诉列表
│ ├── 申诉内容查看
│ ├── 相关证据收集
│ ├── 原处理决定复查
│ ├── 申诉批准/驳回
│ ├── 申诉结果反馈
│ └── 申诉统计分析
├── 👤 用户审核
│ ├── 用户申诉
│ │ ├── 待审核申诉列表
│ │ ├── 申诉详情查看
│ │ ├── 相关订单/商品信息
│ │ ├── 申诉原因分类
│ │ ├── 申诉材料审核 (截图、视频等)
│ │ ├── 申诉批准/驳回
│ │ ├── 赔偿处理 (如有)
│ │ └── 申诉统计
│ ├── 发票申请
│ │ ├── 待审核发票申请列表
│ │ ├── 用户信息验证
│ │ ├── 订单金额匹配
│ │ ├── 发票抬头合法性检查
│ │ ├── 批准/拒绝
│ │ ├── 发票生成
│ │ ├── 发票配送
│ │ └── 发票统计
│ ├── 账户异常
│ │ ├── 异常登录提醒
│ │ ├── 异常账户操作 (短期大额提现)
│ │ ├── 账户被盗风险提示
│ │ ├── 用户申请冻结
│ │ ├── 强制账户安全检查
│ │ ├── 异常确认/解除
│ │ └── 账户恢复流程
│ └── 账户冻结申请
│ ├── 用户自申请冻结
│ ├── 冻结原因记录
│ ├── 冻结期限设置
│ ├── 冻结确认
│ ├── 冻结期间订单处理
│ ├── 解冻申请处理
│ └── 冻结统计
└── ⭐ 内容审核
├── 商品评价审核
│ ├── 待审核评价列表
│ ├── 评价内容检查 (违规词、广告)
│ ├── 图片审核 (色情、暴力等)
│ ├── 虚假评价检测
│ ├── 批准显示/隐藏/删除
│ ├── 审核备注
│ └── 评价审核统计
├── 用户反馈审核
│ ├── 待审核反馈列表
│ ├── 反馈内容审核
│ ├── 反馈优先级设置
│ ├── 内部处理分配
│ ├── 批准/驳回
│ ├── 反馈回复
│ └── 反馈统计
├── 文章审核
│ ├── 待审核文章列表
│ ├── 文章内容检查 (合法性、SEO)
│ ├── 文章图片审核
│ ├── 链接安全检查 (是否含恶意链接)
│ ├── 批准发布/驳回编辑
│ ├── 文章下线处理
│ └── 文章审核统计
└── 评论审核
├── 待审核评论列表
├── 评论内容检查 (违规词、人身攻击)
├── 评论来源验证
├── 批准显示/隐藏/删除
├── 评论者处罚 (如频繁违规)
├── 评论审核统计
└── 风险评论监控
```
---
## 📊 菜单统计对比
| 指标 | 融合前 | 融合后 | 增长 |
| ------------ | ------ | ---------------- | ------- |
| **一级菜单** | 13 | 18 | +5 |
| **管理页面** | 100+ | 160+ | +60 |
| **管理角色** | 7 | 15 | +8 |
| **功能分组** | ~50 | ~85 | +35 |
| **权限维度** | 按模块 | 按模块+数据+按钮 | 细化3倍 |
---
## 🔄 菜单的互联关系
```
首页仪表板
├─ 用户数据 ──→ 用户管理 ──→ 用户行为分析
├─ 商品数据 ──→ 商品管理 ──→ 产品洞察
├─ 订单数据 ──→ 订单管理 ──→ 行为分析(风险识别)
├─ 配送数据 ──→ 配送管理 ──→ 配送绩效
├─ 商户数据 ──→ 商户管理 ──→ 商户分析
├─ 营销数据 ──→ 营销管理 ──→ 营销ROI分析
├─ 财务数据 ──→ 财务管理 ──→ 数据分析(财务看板)
└─ 审核数据 ──→ 审核管理 ──→ 审核统计分析
数据分析菜单 ←── 所有菜单都可以跳转到数据分析查看对应数据
审核管理菜单 ←── 待审核项提醒 来自 财务/商户/用户/内容
```
---
**相关文档**: 更多详情见 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md)

View File

@@ -1,565 +0,0 @@
# Admin管理系统融合方案 - 执行总结
> 🎯 为你的mall项目设计的完整Admin管理端融合方案
**文档时间**: 2026年2月4日
**版本**: v1.0
**状态**: ✅ 已完成分析和文档化
---
## 📋 你提出的问题
> "结合我这个admin端的功能以及任务你查看一下analytics端、consumer端、delivery端、merchant端这些端的功能和任务有哪些是可以融合进我的这个admin端的综合这几个端我的这个admin端还应该设计哪些权限和角色现在我的这个admin端的想法就是融合其他几个端的关于管理方面的功能全部融合进我的这个admin端里面将很多的端的功能和任务融合进admin端里面然后划分不同的权限和角色然后根据权限和角色选择性展示不同的页面。"
---
## 💡 我做了什么
我对你的四个业务端进行了全面的功能分析识别出可融合进admin端的**60+条**管理功能,设计了**15个**新的管理角色和权限体系提出了从13个菜单扩展到18个菜单的完整方案并提供了详细的15周实施路线图。
---
## 📊 核心发现
### 1. 四端的管理功能融合潜力
#### Analytics数据分析端 ✅
**关键管理功能**: 8条
- 数据看板管理
- 报表模板库
- 定时报表配置
- 数据权限管理
- 导出权限控制
- 异常告警配置
- 对标管理
- 报表审计日志
💡 **融合价值**: 让各部门经理能在一个地方配置和查看自己的KPI看板
---
#### Consumer消费者端 ✅
**关键管理功能**: 12条
- 用户行为分析(浏览、收藏、购物车、搜索、路径)
- 订单风险识别(虚假订单、高风险用户、黑名单)
- 大额采购预警
- 异常退货分析
- 恶意评价识别
- 退款审核管理(待审核、自动规则、审批流、拒绝、统计、物流追踪)
- 支付方式配置
- 物流对接管理
- 地址库维护
- 钱包资金监管
- 消息模板管理
- 用户黑名单管理
💡 **融合价值**: 完整的风控和退款管理系统,保护平台资金和用户权益
---
#### Delivery配送端 ✅
**关键管理功能**: 12条
- 配送员管理(账号、等级、激励、状态)
- 自动和手动的智能任务分配
- 绩效考核(指标、考核、工资计算)
- 配送员轨迹追踪
- 配送员黑名单和申诉管理
- 车辆管理和年检管理
- 费用配置(按距离、时间、订单量分层)
- 配送结算和提现管理
- 团队管理
- 异常订单处理
- 配送员激励
- 申诉管理
💡 **融合价值**: 完整的配送运营管理系统仅O2O或自建配送模式
---
#### Merchant商户端 ✅
**关键管理功能**: 11条
- 多商户管理
- 商户入驻审核(资质、营业执照、身份、保证金)
- 商户分级体系(根据销售额、评分、投诉)
- 商户保证金管理(收取、冻结、扣罚)
- 商户佣金设置(按等级、分类、自动扣除)
- 商户提现管理
- 商户经营数据分析
- 商户违规处理(罚款、限流、下架、封禁)
- 商户店铺装修配置
- 商户营销工具权限
- 商户沟通和政策通知
💡 **融合价值**: 完整的平台商户管理系统仅平台模式B2B2C
---
### 2. 融合后的新系统架构
#### 菜单数量升级
```
融合前: 13个菜单 (100+页面)
融合后: 18个菜单 (160+页面) ← +5个新菜单+60页面
```
#### 5个新增菜单
| 菜单 | 来源 | 页数 | 适用场景 |
| ----------- | --------- | ---- | --------------- |
| 📈 数据分析 | Analytics | 12 | 所有场景 |
| 🚚 配送管理 | Delivery | 25 | O2O或自建配送 |
| 🏪 商户管理 | Merchant | 22 | 平台模式(B2B2C) |
| 📊 行为分析 | Consumer | 17 | 所有场景 |
| ⚖️ 审核管理 | 多个端 | 16 | 所有场景 |
**合计**: +5个菜单共92页新管理页面
---
### 3. 权限和角色体系设计
#### 当前状况
```
7个角色:
├── 超级管理员
├── 商品运营
├── 订单管理员
├── 营销专员
├── 客服主管
├── 财务人员
└── 数据分析师
```
#### 推荐升级到15个角色
**管理层** (3个)
- 🔑 超级管理员 - 所有权限
- 📊 总经理 - 全景管理
- 👥 运营副总 - 辅助决策
**运营经理** (6个) - 各功能模块负责人
- 👥 用户运营
- 📦 商品运营
- 📋 订单管理
- 🎯 营销运营
- 🚚 配送运营经理仅O2O
- 🏪 商户运营经理(仅平台)
**执行专员** (4个) - 一线操作人员
- 💼 客服专员
- 💰 财务专员
- 📈 数据分析师
- 🔍 审核专员
**专项角色** (2个) - 特殊功能
- 🎬 内容编辑
- 🔧 系统维护员
---
### 4. 权限矩阵
#### 完整的权限隔离
```
18个菜单 × 15个角色 = 270个权限点
特点:
✅ 每个角色只能访问相关的菜单
✅ 菜单内的页面也要权限验证
✅ 按钮级别的权限控制(修改/删除需要确认)
✅ 数据级别的权限隔离(商户运营只看自己商户的数据)
✅ 所有操作都有审计日志
```
**示例**: 用户运营经理看到的菜单
```
├── 首页用户相关KPI
├── 用户(完全访问)
├── 营销(仅用户相关部分)
├── 数据分析(用户和营销相关报表)
└── 行为分析(用户行为数据)
```
**示例**: 商户运营经理的数据隔离
```
能看到:
├── 所有商户的基本信息
├── 商户的销售数据
├── 商户的违规记录
└── 商户的提现申请
看不到:
├── 其他商户的内部合同
├── 财务详细结算单(除了自己处理的)
└── 系统配置和权限管理
```
---
## 📈 新增功能详解
### 菜单14: 📈 数据分析
**功能**: 统一的KPI看板和报表中心
**关键特性**:
- ✅ 各角色定制化看板总经理看全局KPI用户运营看用户增长
- ✅ 自动告警机制当销售额下跌10%自动通知相关人)
- ✅ 定时报表发送(每天/周/月自动生成报表发送给管理层)
- ✅ 数据权限分级财务只能看30天内数据普通员工看不到敏感指标
**适用于**: 所有公司
---
### 菜单15: 🚚 配送管理
**功能**: 完整的配送员运营和成本管理系统
**关键特性**:
- ✅ 智能任务分配(根据距离、工作量、驾驶员等级自动最优分配)
- ✅ 绩效挂钩薪资(送达时间、用户评分、投诉数直接影响工资)
- ✅ 实时轨迹追踪(防止虚假送达、重复刷单)
- ✅ 费用分层(按距离、时间段等配置不同的配送费)
**页数**: 25页6个功能分组
**适用于**: O2O平台或有自建配送体系的电商
---
### 菜单16: 🏪 商户管理
**功能**: 完整的多商户运营平台
**关键特性**:
- ✅ 商户入驻审核(从申请→审核资质→审核保证金→激活)
- ✅ 动态佣金分级(好商户享受低佣金比例)
- ✅ 风险识别(自动检测违规商户)
- ✅ 财务清算(自动计算佣金、冻结、罚款、提现)
**页数**: 22页5个功能分组
**适用于**: 平台模式B2B2C的电商平台
---
### 菜单17: 📊 行为分析
**功能**: 用户行为追踪和风险识别
**关键特性**:
- ✅ 用户行为追踪(用户看了什么、收藏了什么、为什么放弃购物车)
- ✅ 智能风控AI识别虚假订单、高风险用户、恶意退货
- ✅ 黑名单管理(自动冻结高风险用户)
- ✅ 退款审核(配置自动退款规则或多级审批)
**页数**: 17页3个功能分组
**适用于**: 所有公司
---
### 菜单18: ⚖️ 审核管理
**功能**: 统一的多维度审核中心
**关键特性**:
- ✅ 财务审核(提现、发票、异常交易)
- ✅ 商户审核(入驻、资料修改、营销活动)
- ✅ 用户审核(申诉、账户异常、冻结申请)
- ✅ 内容审核(评价、反馈、文章、评论)
**页数**: 16页4个功能分组
**适用于**: 所有公司
---
## 🔐 权限体系的核心原则
### 三层权限隔离
```
第1层 - 菜单级权限:
┌─────────────────────┐
│ Admin管理系统 │
├─────────────────────┤
│ ☑️ 首页 │
│ ☑️ 用户 │
│ ☑️ 商品 │
│ ☑️ 订单 │
│ ☑️ 营销 │
│ ☐ 分销 (无权限) │
│ ☑️ 客服 │
│ ☐ 财务 (无权限) │
│ ☑️ 数据分析 │
│ ☑️ 行为分析 │
└─────────────────────┘
用户运营只能看到能看的菜单
第2层 - 页面级权限:
某个菜单内,还要检查是否有权限访问该页面
比如"数据分析"菜单中:
✅ 用户可以访问"看板配置"页面
✅ 用户可以访问"报表模板"页面
❌ 用户无法访问"数据权限"页面(仅管理员)
第3层 - 数据级权限:
即使可以访问页面,查询的数据也要过滤
比如"商户运营"看商户列表:
- SQL会自动加上: WHERE merchant_id IN (允许的商户列表)
- 商户A运营只能看商户A的数据
- 即使黑客破解了URL也看不到其他商户数据
```
---
## 🎯 首页看板的动态化
现在和融合后的对比:
**融合前**: 所有人看到同一个首页
**融合后**: 每个角色看到定制化的首页
```
总经理看到的首页:
┌──────────────────────────┐
│ 📊 全局KPI │
├──────────────────────────┤
│ 今日销售额: ¥125,000 │
│ 今日订单数: 2,456 │
│ 新用户数: 128 │
│ 配送完成率: 98.5% │
├──────────────────────────┤
│ 📈 今日销售趋势图 │
│ 📈 7日销售对标 │
│ ⚠️ 异常告警(3条) │
│ 🔔 待处理工作(12条) │
└──────────────────────────┘
用户运营看到的首页:
┌──────────────────────────┐
│ 👥 用户相关KPI │
├──────────────────────────┤
│ 今日新用户: 128 │
│ 用户活跃度: 42.3% │
│ 用户留存率: 68.9% │
│ 平均客单价: ¥523 │
├──────────────────────────┤
│ 📈 新用户增长趋势 │
│ 📈 用户分级分布 │
│ 📈 用户来源分析 │
│ 🎯 营销活动效果 │
└──────────────────────────┘
```
---
## 📊 实施成本和时间
### 人力成本
- 数据库设计师: 1人 × 2周
- 后端工程师: 2人 × 15周
- 前端工程师: 2人 × 15周
- 测试工程师: 1人 × 6周
- 产品经理: 1人 × 15周
- **总人月**: ~11人月
### 时间表
```
第 1-2 周 | Phase 1 技术基础建设
第 2-3 周 | Phase 2 菜单和首页重构
第 4-5 周 | Phase 3 数据分析菜单
第 6-8 周 | Phase 3 配送管理菜单(仅O2O)
第 9-10周 | Phase 3 商户管理菜单(仅平台)
第11周 | Phase 3 行为分析菜单
第12周 | Phase 3 审核管理菜单
第13-14周 | Phase 4 验收和优化
第15周 | Phase 5 上线和运维
总计: 15周 (约3.5个月)
```
### 成本节省
| 项目 | 融合前 | 融合后 | 节省 |
| ------------------ | -------------- | ------- | -------- |
| 管理系统维护工作量 | 高 | 低 | 30-40% |
| 功能重复开发 | 多次 | 1次 | 大量节省 |
| 权限管理复杂度 | 分散 | 集中 | 降低50% |
| 员工学习成本 | 需要学多个系统 | 1个系统 | 降低60% |
---
## ✅ 你的需求满足情况
| 需求 | 满足度 | 说明 |
| -------------------- | ------- | ------------------------------ |
| 融合analytics功能 | ✅ 100% | 新增"数据分析"菜单 |
| 融合consumer功能 | ✅ 100% | 新增"行为分析"菜单 |
| 融合delivery功能 | ✅ 100% | 新增"配送管理"菜单仅O2O |
| 融合merchant功能 | ✅ 100% | 新增"商户管理"菜单(仅平台) |
| 设计新的权限体系 | ✅ 100% | 15个角色270个权限点 |
| 设计新的角色体系 | ✅ 100% | 从7个升级到15个角色 |
| 根据权限展示不同页面 | ✅ 100% | 三层权限隔离(菜单/页面/数据) |
| 管理方面功能融合 | ✅ 100% | 60+条管理功能融合 |
---
## 📁 生成的文档
你现在拥有4份完整的分析和实施文档
1. **[ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md)** (5000+字)
- 完整的四端功能分析
- 详细的融合方案
- 完整的15角色权限设计
- 前端实现架构
- 数据权限设计
2. **[ADMIN_INTEGRATION_QUICK_REFERENCE.md](ADMIN_INTEGRATION_QUICK_REFERENCE.md)** (2000+字)
- 快速参考指南
- 15个角色速查表
- 5个新菜单总结
- 权限矩阵速查
- 常见问题FAQ
3. **[ADMIN_MENU_STRUCTURE_COMPARISON.md](ADMIN_MENU_STRUCTURE_COMPARISON.md)** (3000+字)
- 菜单进化前后对照
- 5个新菜单的完整树形结构
- 100+页面的详细配置
- 菜单互联关系图
4. **[ADMIN_IMPLEMENTATION_CHECKLIST.md](ADMIN_IMPLEMENTATION_CHECKLIST.md)** (2500+字)
- 15周完整的实施检查清单
- Phase 0-5的详细任务
- 甘特图和时间表
- 成功指标和验收标准
5. **[ADMIN_DOCS_INDEX.md](ADMIN_DOCS_INDEX.md)** - 文档导航和索引
---
## 🎬 建议的下一步
### 立即行动(今天)
1. [ ] 你阅读 [ADMIN_INTEGRATION_QUICK_REFERENCE.md](ADMIN_INTEGRATION_QUICK_REFERENCE.md) (5分钟快速了解)
2. [ ] 与团队讨论方案的可行性
3. [ ] 决策是否采纳
### 本周内
1. [ ] 详细评审完整分析 [ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md](ADMIN_INTEGRATION_COMPREHENSIVE_ANALYSIS.md)
2. [ ] 确认15个角色是否与你的组织结构匹配
3. [ ] 确认是否需要调整(比如菜单名称、合并某些菜单等)
### 下周
1. [ ] 组建项目团队后端2人、前端2人、产品1人、QA1人
2. [ ] 启动Phase 1技术基础建设
3. [ ] 确定上线时间建议15周后
---
## 💬 常见问题
**Q: 为什么需要15个角色而不是更多/更少?**
A: 这是基于功能模块和企业阶层的平衡。15个角色涵盖了大多数电商企业的组织结构。你可以根据实际情况删除如没有配送功能就删除配送运营经理或新增如需要地区经理就增加
---
**Q: 我们是传统B2C不是平台模式需要所有菜单吗**
A: 不需要。如果你是:
- B2C自营: 删除"商户管理"菜单
- 不做O2O配送: 删除"配送管理"菜单
- 保留核心的"数据分析"、"行为分析"、"审核管理"菜单
---
**Q: 实施这个方案会影响现有系统吗?**
A: 不会。这是一个"加法"在现有13个菜单的基础上加5个新菜单。现有功能保持不变。
---
**Q: 权限系统会不会很复杂?**
A: 我们采用了"数据驱动"的权限设计。所有权限配置都在数据库中,不需要代码改动。添加新角色只需在数据库中插入记录即可。
---
**Q: 15周的时间表可以压缩吗**
A: 可以,但会影响质量。建议的优化方案:
- 如果只需要"数据分析"菜单: 压缩到8周
- 如果并行开发所有菜单: 可能需要更多人手
- 不建议低于12周否则会留下太多Bug
---
## 🏆 预期收益总结
| 收益维度 | 预期收益 |
| -------------- | ------------------------------------- |
| **运营效率** | 管理功能集中化节省员工40%的切换时间 |
| **数据安全** | 完整的权限隔离数据泄露风险降低90% |
| **系统维护** | 统一平台维护成本降低50% |
| **新功能速度** | 在权限框架上新增功能开发速度提升3倍 |
| **员工体验** | 学习1个系统而不是5个满意度提升 |
| **业务洞察** | 统一的数据看板决策速度提升50% |
---
## ✨ 最后的话
这个融合方案的核心思想是:**将分散在多个端的管理功能集中到一个统一的平台,通过完善的权限系统确保每个人只能看到和操作自己权限范围内的内容**。
不仅如此,这个方案也为你的未来扩展奠定了基础。当你需要添加新的管理功能时,只需:
1. 在权限表中定义新权限
2. 在菜单树中添加新菜单项
3. 开发新的页面/API
4. 分配权限给相应角色
再也不用担心权限管理的混乱了。
---
**方案完成日期**: 2026年2月4日
**总投入**: 4份分析文档 + 7000+行文本 + 50+张表格和图表
**质量等级**: ⭐⭐⭐⭐⭐ 企业级方案
**下一步**: 评审 → 采纳 → 实施 → 上线
祝你的Admin管理系统升级顺利🚀

View File

@@ -1,913 +0,0 @@
# Admin管理系统功能体系说明文档
## 📋 概览
这个Admin管理系统是一个完整的**电商运营管理平台**基于CRMEB v5标准版架构。系统采用**13个一级菜单 + 多级分组 + 100+ 业务页面**的架构,覆盖从商品、订单、用户、营销、财务到系统维护的全套运营功能。
**系统定位**: 服务于商城运营方的后台管理系统,控制商城的业务数据、运营策略和系统配置。
---
## 🎯 系统核心价值
| 维度 | 说明 |
| ------------ | ---------------------------------------------------------- |
| **控制范围** | 商品库存、订单状态、用户权益、营销活动、财务流水、系统配置 |
| **用户对象** | 商城运营人员、财务人员、市场人员、客服人员、超级管理员 |
| **核心功能** | 商品管理、订单处理、用户运营、营销活动、财务结算、系统维护 |
| **数据驱动** | 统计分析、趋势报表、KPI追踪、业务洞察 |
| **业务闭环** | 商品→订单→用户→营销→财务→反馈 |
---
## 📦 13个一级菜单体系
### 1⃣ **首页 (HOME)** - 运营驾驶舱
**作用**: 提供全局业务概览和快速操作入口
**关键页面**:
- 主页/仪表板 - KPI概览、近期业务指标、待办任务
**数据看板包含**:
- 今日销售额、订单数量、新用户数
- 7日销售趋势、订单转化率
- 实时库存预警、异常订单提醒
- 快速跳转到热点功能
---
### 2⃣ **用户 (USER)** - 会员生态管理
**职责**: 管理商城所有用户资料、分组、标签、等级体系
**功能分组** (4个):
#### 📊 用户统计
- 用户总数、活跃度、留存率、消费金额分布
- 用户获取渠道分析、付款用户占比
#### 👥 用户管理
- 查看/编辑用户基本信息(昵称、手机、头像等)
- 调整用户状态(正常/禁用/黑名单)
- 查看用户消费历史、积分、等级
- 批量操作:导出、修改分组标签等
#### 🏷️ 用户分组
- 创建自定义用户分组(如"VIP用户"、"沉睡用户"
- 按分组推送营销活动、定向优惠
- 用于精准营销的用户划分
#### 🎫 用户标签
- 创建/编辑用户标签(如"高消费"、"新客"、"促销敏感"
- 标签自动或手动关联用户
- 支持标签的营销自动化
#### 📈 用户等级
- 设定等级权益(折扣、积分倍数、返现等)
- 配置升级条件(消费金额、订单数等)
- 管理等级对应的特权
**核心控制**: 通过分组、标签、等级进行**精准用户分层运营**
---
### 3⃣ **商品 (PRODUCT)** - 商城商品库管理
**职责**: 管理商城所有商品、库存、分类、属性
**功能分组** (8个):
#### 📊 商品统计
- 商品总数、分类分布、库存统计
- 销售排行、搜索热词、待上架商品数
- 商品评分、好评率分析
#### 📦 商品管理
- 创建/编辑/下架商品
- 编辑商品详情(图文、参数、规格)
- 管理商品SKU库存、价格、图片
- 商品上下架、批量操作
- **保持活动**: 支持预留会员价、会员专享
#### 🏷️ 商品分类
- 创建/编辑商品分类体系2-3级
- 设定分类SEO、排序、展示位置
- 管理分类对应的属性模板
#### ⚙️ 商品规格
- 创建规格组(如"颜色"、"尺码"
- 定义规格值及对应的库存/价格
- 规格与SKU的对应关系
#### 📝 商品参数
- 设定商品参数项(如"材质"、"产地"
- 参数与商品分类的关联
- 用于商品详情页展示和搜索筛选
#### 🎯 商品标签
- 创建商品标签(如"热销"、"新品"、"打折"
- 标签与营销活动的关联
- 用于店铺前台的商品筛选
#### 🛡️ 商品保障
- 创建售后保障项(如"7天退货"、"质量保证"
- 关联商品到保障项
- 展示在商品详情页提升信心
#### ⭐ 商品评论
- 查看/回复用户评论
- 评论审核、删除管理
- 评论统计分析(好评率、内容热词)
**核心控制**: 通过SKU库存、价格、分类进行**商品生命周期管理**
---
### 4⃣ **订单 (ORDER)** - 交易流程管理
**职责**: 管理所有订单的全生命周期(下单→发货→收货→售后)
**功能分组** (5个):
#### 📊 订单统计
- 订单总数、GMV、转化率
- 订单状态分布(待支付、待发货、已发货等)
- 平均订单金额、人均消费
- 订单来源分析
#### 📋 订单管理
- 查看订单详情(商品、收货地址、金额、状态)
- 订单操作:发货、打印面单、修改收货地址
- 订单备注、备用货物
- 订单导出、批量打印
#### 🔄 售后订单(退货/售后)
- 查看退货/退款申请
- 退货审核、退款处理
- 售后物流跟踪
- 售后统计(退率、原因分析)
#### 🏪 收银订单
- 线下POS收银界面
- 快速创建订单、支付操作
- 支持二维码扫码支付
- 收银日结统计
#### ✅ 核销记录
- 核销码生成与管理
- 核销订单查询
- 支持门店/人员的核销统计
#### ⚙️ 订单配置
- 配置订单自动确认时间
- 配置退货/售后政策
- 设定订单超时时限
**核心控制**: 通过订单状态流转、发货、售后处理控制**收入确认和用户体验**
---
### 5⃣ **营销 (MARKETING)** - 多维度运营工具库
**职责**: 管理12类营销活动驱动用户消费、提升复购
**11个功能分组、50+ 管理页面**:
#### 🎫 优惠券 (2个页面)
- 优惠券创建:金额/折扣、使用条件、有效期
- 优惠券发放:定向用户、分享领取、后台发放
- 用户领取记录:追踪使用情况、效果分析
#### 💰 积分管理 (5个页面)
- **积分统计**: 用户总积分、增减趋势
- **积分商品**: 创建积分兑换商品池
- **积分订单**: 查看用户用积分下单的订单
- **积分记录**: 积分增减明细、来源分析
- **积分配置**: 消费返积分比例、积分过期策略
#### 🎲 抽奖管理 (2个页面)
- 抽奖列表:创建/编辑/删除抽奖活动
- 抽奖配置:奖品设置、中奖概率、用户参与限制
- 中奖记录查询、奖品发放管理
#### 🔪 砍价管理 (2个页面)
- 砍价商品:选择参与砍价的商品
- 砍价列表:查看砍价活动进展、用户参与情况
- 手动调价、砍价完成后的订单处理
#### 👥 拼团管理 (3个页面)
- 拼团商品:选择参与拼团的商品、设定拼团价
- 拼团列表:查看所有拼团活动
- 拼团统计:人数、成功率、营收分析
#### ⚡ 秒杀管理 (3个页面)
- 秒杀列表:创建/管理秒杀活动
- 秒杀商品:设定秒杀价、库存、时间段
- 秒杀配置:全局秒杀规则、展示设置
#### 👑 付费会员 (5个页面)
- **会员类型**: 创建会员等级(月卡、年卡等)、设定价格
- **会员权益**: 定义权益项(额外折扣、积分倍数、免运费等)
- **卡密会员**: 生成/导出卡密兑换码
- **会员记录**: 用户购买会员、续费、退款记录
- **会员配置**: 会员过期策略、自动续费设置
#### 🎬 直播管理 (3个页面)
- 直播间管理:创建/编辑/关闭直播间
- 直播商品管理:直播间绑定的商品列表
- 主播管理:主播账号、权限、直播统计
#### 💳 用户充值 (2个页面)
- 金额设置配置充值金额方案如充100送10
- 充值配置:充值赠送比例、自动转入余额设置
#### ✍️ 每日签到 (2个页面)
- 签到配置配置签到奖励梯度第1-7天
- 签到奖励:设定每天的奖励(积分/余额/优惠券)
#### 🔗 其他营销 (2个页面)
- **渠道码**: 生成推广二维码、追踪新用户来源
- **新人礼**: 创建新用户首购优惠、金额/时间限制
**核心控制**: 通过多维度营销活动驱动**交易额、复购率、用户黏性**
---
### 6⃣ **分销 (DISTRIBUTION)** - 分销体系管理
**职责**: 管理分销员、分销订单、佣金体系
**功能分组** (1个):
#### 📊 分销统计
- 分销总金额、分销员数量、佣金总额
- 分销排行、佣金排行
#### 👤 分销员列表
- 分销员信息查看:等级、下线数、销售额
- 分销员管理:升级、降级、冻结账户
- 分销员提现管理:提现申请、审核、到账确认
#### ⚙️ 分销设置
- 配置分销佣金体系1-3级佣金比例
- 设定分销员入驻要求、升级条件
- 配置佣金计算规则、提现限制
**核心控制**: 通过分销商体系**扩大销售覆盖**
---
### 7⃣ **客服 (KEFU)** - 客户服务管理
**职责**: 管理客服团队、用户沟通、反馈处理
**功能分组** (1个):
#### 👥 客服列表
- 客服账号管理:状态、在线/离线状态
- 客服工作量统计
#### 💬 客服话术
- 创建/编辑快捷话术库
- 按分类组织话术(订单、退货、投诉等)
#### 💌 用户留言
- 查看用户反馈/投诉
- 分配给客服、标记为已处理
- 反馈统计分析
#### 🤖 自动回复
- 配置触发词→自动回复规则
- 自动回复模板管理
#### ⚙️ 客服配置
- 配置客服工作时间、休息日
- 配置消息通知策略
**核心控制**: 通过客服系统提升**用户满意度和留存**
---
### 8⃣ **财务 (FINANCE)** - 资金流水和结算管理
**职责**: 监控所有资金流动、订单结算、佣金提现、财务报表
**功能分组** (5个):
#### 📊 交易统计
- 销售额、退款金额、净收入
- 支付方式分析(支付宝、微信、余额等)
- 销售趋势图表、日均GMV
#### 💰 财务操作
**提现申请**:
- 查看用户/分销员提现申请
- 审核批准/驳回、生成财务凭证
- 支持银行转账、第三方支付提现
**发票管理**:
- 用户发票申请、开票记录
- 发票模板、自动开票规则
#### 📋 财务记录 (3个子功能)
**充值记录**:
- 用户充值交易明细
- 支付状态、充值方式、到账情况
**资金流水**:
- 完整资金进出流水账
- 支持按日期、交易类型、用户筛选
**账单记录**:
- 订单结算单、账单汇总
- 按周期生成对账单
#### 🎁 佣金记录
- 分销员佣金统计
- 佣金明细、提现状态
- 佣金发放历史
#### 💳 余额记录
- 商户钱包余额统计:当前、累计、消耗
- 余额明细查询
- 余额来源分析(充值、返现、积分兑换)
**核心控制**: 通过财务管理**确保资金安全、提升财务透明度**
---
### 9⃣ **内容 (CMS)** - 内容管理系统
**职责**: 管理商城富文本内容(文章、帮助文档等)
**功能分组** (1个):
#### 📄 文章管理
- 创建/编辑文章:标题、图片、富文本内容
- 文章分类、标签、SEO优化
- 发布/草稿/下架管理
- 阅读统计、点赞评论
#### 📁 文章分类
- 创建文章分类体系
- 分类SEO、排序
**核心控制**: 管理**商城帮助文档、品牌文案、宣传内容**
---
### 🔟 **装修 (DECORATION)** - 前端页面装修
**职责**: 通过拖拽或配置化管理商城前端页面样式和内容
**功能分组** (1个):
#### 🎨 装修管理 (7个页面)
**首页装修**:
- 拖拽配置首页轮播图、广告、商品推荐位
- 配置首页各区域的展示商品、标题文案
**商品分类装修**:
- 配置分类页的展示商品、排序
**个人中心装修**:
- 配置用户中心页的菜单项、快捷功能
**数据配置**:
- 配置数据来源(如"热销商品"自动加载销售TOP10
**主题风格**:
- 选择全局主题色、字体、圆角等
- 支持多主题预设
**素材管理**:
- 上传/删除图片、视频素材库
- 素材搜索、分类
**链接管理**:
- 配置导航链接、页面跳转规则
- 支持链接到商品、分类、外部页面
**核心控制**: 通过装修系统**实现无代码化的页面管理**,快速调整前端展示
---
### 1⃣1⃣ **应用 (APP)** - 第三方应用/模块管理
**职责**: 管理安装的第三方应用和功能模块
**功能分组** (1个):
#### 📊 应用统计
- 已安装应用数量、使用情况
- 应用版本、更新日志
#### 📦 应用列表
- 浏览可用应用市场
- 安装/卸载/启用应用
- 应用设置
**核心控制**: 扩展系统功能,支持**插件化架构**
---
### 1⃣2⃣ **设置 (SETTING)** - 系统配置管理
**职责**: 配置系统全局参数和权限
**功能分组** (1个):
#### ⚙️ 系统配置
- 商城基本信息名称、logo、联系方式
- 支付配置:支付宝、微信支付、余额支付参数
- 物流配置:默认快递公司
- 邮件/短信配置:通知模板、发送账号
#### 👨‍💼 管理员管理
- 创建/编辑管理员账号
- 分配角色和权限
- 管理员登录日志
#### 🔐 角色管理
- 创建自定义角色(如"订单管理员"、"客服主管"
- 为角色分配权限(按功能模块)
- 角色与管理员的绑定
**核心控制**: 通过系统配置和权限管理**保障系统安全和运营规范**
---
### 1⃣3⃣ **维护 (MAINTAIN)** - 技术维护和系统工具
**职责**: 系统的技术维护、缓存、日志、数据备份等
**功能分组** (7个):
#### 🛠️ 开发配置 (6个页面)
**配置分类**:
- 创建/编辑配置分类项(如"商城名称"、"运费模板"
- 用于系统全局参数的管理
**组合数据**:
- 预定义数据组(如"配送方式"、"发票类型"
**定时任务**:
- 创建/编辑定时任务
- 任务调度、执行日志
**权限维护**:
- 管理系统权限项
- 权限与功能的对应关系
**模块配置**:
- 启用/禁用功能模块
- 模块参数配置
**自定事件**:
- 创建业务事件(如"订单完成"、"用户注册"
- 事件触发的自动化规则
#### 🔒 安全维护 (3个页面)
**刷新缓存**:
- 清除系统缓存、数据库缓存
- 重新预热热点数据
**系统日志**:
- 查看系统运行日志
- 管理员操作日志、错误日志
**在线升级**:
- 检查系统版本更新
- 执行系统升级
#### 📊 数据维护 (3个页面)
**物流公司**:
- 配置快递公司、API接口
- 面单模板、物流查询配置
**城市数据**:
- 维护国内城市数据库
- 用于订单收货地址、物流选择
**清除数据**:
- 清除过期订单数据、日志
- 数据库优化
#### 🌐 对外接口 (1个页面)
**账号管理**:
- 第三方应用的API Key管理
- 权限分配、调用统计
#### 🌍 语言设置 (4个页面)
**语言列表**:
- 配置系统支持的语言(中文、英文、日文等)
**语言详情**:
- 翻译文案编辑
**地区列表**:
- 配置地理区域
**翻译配置**:
- 自动翻译规则、翻译API配置
#### 🧰 开发工具 (4个页面)
**数据库管理**:
- 执行SQL查询、备份
- 数据库优化、表结构查看
**文件管理**:
- 文件上传/下载目录管理
- 磁盘空间统计
**接口管理**:
- API列表、文档生成
- API调用日志、性能分析
**数据字典**:
- 系统数据字典维护
- 用于代码中的常量值定义
#### 📱 系统信息 (1个页面)
**系统信息**:
- 查看系统运行信息PHP版本、数据库版本、服务器配置
- 系统诊断、性能指标
**核心控制**: 通过维护系统**确保平台稳定运行、数据安全**
---
## 📊 管理功能矩阵
| 功能域 | 核心实体 | 主要操作 | 关键指标 | 权限控制 |
| -------- | --------------------------- | ---------------------------- | ---------------------- | -------------- |
| **用户** | 会员、分组、标签、等级 | 增删改查、分组管理、标签分类 | 用户数、活跃度、消费额 | 用户管理权限 |
| **商品** | 商品、SKU、分类、规格、参数 | 上架、定价、库存、分类 | 商品数、销售额、库存 | 商品管理权限 |
| **订单** | 订单、物流、售后、支付 | 发货、收货、售后、退款 | 订单数、GMV、转化率 | 订单管理权限 |
| **营销** | 优惠券、积分、活动、会员 | 创建活动、发放优惠、统计效果 | 参与度、转化率、ROI | 营销管理权限 |
| **财务** | 订单款、提现、佣金、余额 | 结算、提现审核、报表 | 营收、成本、利润 | 财务查看权限 |
| **内容** | 文章、分类、SEO | 发布、编辑、下架 | 阅读量、SEO排名 | 内容发布权限 |
| **装修** | 页面、组件、素材 | 拖拽配置、换肤 | 页面PV、点击率 | 页面装修权限 |
| **客服** | 客服账号、消息、反馈 | 分配工单、处理反馈 | 响应时间、满意度 | 客服管理权限 |
| **系统** | 管理员、角色、权限、配置 | 配置参数、分配权限 | 系统可用性、安全 | 超级管理员权限 |
---
## 🔄 业务流程及数据流转
### 销售流程
```
商品管理 → 库存配置 → 营销活动 → 用户下单 → 订单处理 → 财务结算
```
1. **商品侧**: 运营人员在"商品"模块创建商品、设定SKU库存和价格
2. **营销侧**: 运营人员在"营销"模块创建优惠券、秒杀、拼团等活动吸引用户
3. **用户侧**: 通过"用户"模块进行分组、标签、等级管理,精准营销
4. **订单侧**: 用户下单后,订单自动流入"订单"模块,客服人员完成发货、售后
5. **财务侧**: 订单完成后自动在"财务"模块生成结算单、佣金记录
### 完整业务链条示例
**场景**: 运营秒杀活动
1. **商品准备** (商品模块)
- 编辑商品详情、上传图片、设定SKU库存
2. **营销配置** (营销模块)
- 创建秒杀活动,选定秒杀商品
- 设定秒杀价格、库存、时间段
3. **用户准备** (用户模块)
- 识别高价值用户、秒杀敏感用户
- 标签筛选用户做精准推送
4. **用户下单** (用户端)
- 用户看到秒杀商品,下单支付
5. **订单处理** (订单模块)
- 客服确认订单、生成面单
- 安排发货、更新物流状态
- 用户收货后处理售后
6. **财务清算** (财务模块)
- 订单自动结算到商户账户
- 积分返还、佣金计算、提现处理
---
## 📈 关键看板和决策数据
### 运营看板(首页仪表板)
**实时KPI**:
- 今日销售额、订单数、新用户数
- 7日销售趋势图表
- 实时库存预警(库存<10
- 待处理订单数、待处理售后单数
### 商品看板(商品模块)
**关键数据**:
- 商品总数、分类分布
- 库存统计(库存充足/不足/缺货)
- 销售排行TOP10商品
- 评价统计(平均分、好评率)
### 订单看板(订单模块)
**关键数据**:
- 今日订单数、GMV
- 订单状态分布(待支付、待发货、已发货等)
- 人均订单金额
- 退款率、售后率
### 用户看板(用户模块)
**关键数据**:
- 用户总数、日新增、月活
- 用户分级分布(普通/VIP/超VIP
- 消费金额分布、人均消费
- 用户留存率、复购率
### 财务看板(财务模块)
**关键数据**:
- 累计收入、本月收入
- 支出(退款、提现、成本)
- 净利润、利润率
- 应收账款、应付账款
### 营销看板(营销模块)
**关键数据**:
- 活动参与度(优惠券使用率、活动参与人数)
- 活动ROI活动成本 vs 带来的销售额)
- 优惠券转化率领取vs使用
- 积分商品热度(兑换次数)
---
## 🔐 权限和角色设计
### 典型角色定义
#### 角色1: 超级管理员
- **权限**: 所有功能完全访问
- **主要操作**: 系统配置、管理员管理、权限分配
#### 角色2: 商品运营
- **权限**: 商品、分类、规格、参数全部操作
- **限制**: 不能访问财务、订单发货、系统配置
- **主要操作**: 商品上下架、库存管理、分类管理
#### 角色3: 订单管理员
- **权限**: 订单、物流、售后操作
- **限制**: 不能修改订单价格、不能查看财务
- **主要操作**: 订单发货、售后审核、物流跟踪
#### 角色4: 营销专员
- **权限**: 所有营销功能、用户分组标签、内容发布
- **限制**: 不能查看财务详情、不能修改系统配置
- **主要操作**: 营销活动创建、用户运营、内容发布
#### 角色5: 客服主管
- **权限**: 客服账号、用户反馈、订单查询
- **限制**: 只读权限、不能修改订单
- **主要操作**: 工单分配、反馈处理、满意度统计
#### 角色6: 财务人员
- **权限**: 财务模块全部操作(交易统计、提现审核、账单)
- **限制**: 不能修改订单、不能修改商品价格
- **主要操作**: 财务结算、提现审核、报表生成
#### 角色7: 数据分析师
- **权限**: 所有统计模块只读、报表查看
- **限制**: 不能修改数据、不能操作业务
- **主要操作**: 数据报表、趋势分析、KPI追踪
---
## 🎯 系统应该重点监控的指标
### 业务KPI (每日监控)
#### 销售维度
-**日销售额 (GMV)**: 反映商城生意量
-**订单数**: 反映交易频率
-**人均订单金额**: 反映客单价
-**转化率**: 访问→下单的转化
-**退单率**: 订单的取消比例
#### 用户维度
-**新用户数**: 获客能力
-**活跃用户数**: 用户粘性
-**复购用户数 / 复购率**: 用户留存能力
-**用户消费分布**: 高价值用户占比
#### 商品维度
-**库存预警数**: 及时补货
-**销售排行**: 识别爆品
-**滞销商品**: 及时处理
-**好评率**: 商品质量监控
#### 营销维度
-**优惠券使用率**: 营销有效性
-**活动ROI**: 成本与收益比
-**参与度**: 活动吸引力
#### 财务维度
-**应收账款**: 资金风险
-**提现审核周期**: 用户满意度
-**佣金成本率**: 成本控制
-**退款金额**: 问题识别
#### 客服维度
-**平均响应时间**: 服务质量
-**问题解决率**: 有效性
-**用户满意度**: 服务评价
### 系统指标 (每周监控)
- 📊 **系统可用性**: 系统运行时间占比
- ⚠️ **错误率**: API错误、页面异常
- 🚀 **页面加载时间**: 用户体验
- 💾 **数据库大小**: 存储空间使用
- 🔒 **安全事件**: 异常登录、数据泄露
---
## 📱 设备和渠道覆盖
### Admin系统支持的运行环境
- **Web浏览器**: 桌面/笔记本电脑访问
- **移动浏览器**: 手机/平板访问(响应式设计)
- **原生应用**: uni-app-x构建的iOS/Android应用
### 访问方式
1. **Web端**: 直接访问域名 `admin.yourshop.com``yourshop.com/admin`
2. **移动端**: 使用uni-app-x构建的App离线支持
3. **PC客户端**: 通过Electron或Native壳打包的桌面应用
---
## 🔌 与其他系统的集成点
### 关键集成
1. **Supabase数据库**: Admin的所有数据存储和查询
2. **支付网关**: 订单支付、充值、提现处理(支付宝、微信)
3. **物流系统**: 面单打印、物流查询、自动揽收
4. **短信/邮件系统**: 订单通知、营销推送
5. **文件存储**: 商品图片、用户头像、素材库上传到云存储
6. **报表引擎**: ECharts/其他可视化库生成报表图表
7. **第三方API**: 翻译API、地理编码、数据分析等
---
## 📋 总结对照表
| 问题 | 答案 |
| --------------------- | -------------------------------------------------------------------- |
| **Admin端控制什么** | 商城的商品、订单、用户、营销、财务、客服等全部业务领域 |
| **要看什么?** | 销售KPI、库存预警、订单状态、用户活跃度、营销效果、财务报表 |
| **核心价值是?** | 提供统一的后台运营平台,实现数据驱动的商城管理 |
| **主要使用者是?** | 商城运营、市场、财务、客服、超级管理员等角色 |
| **与C端的关系是** | Admin是C端商城的后台一对一的关系。Admin的配置直接影响C端展示和功能 |
| **重点功能是?** | 商品管理、订单处理、用户运营、营销活动、财务结算 |
---
## 🚀 快速导航
- 📊 [查看所有功能列表](#13个一级菜单体系)
- 💡 [了解核心业务流程](#业务流程及数据流转)
- 📈 [监控关键指标](#系统应该重点监控的指标)
- 🔐 [配置权限和角色](#权限和角色设计)
- 🔧 [系统维护和配置](#系统配置管理)
---
**文档版本**: 1.0
**最后更新**: 2024年
**相关文档**:
- [ADMIN_STATUS_AND_TODO.md](ADMIN_STATUS_AND_TODO.md) - Admin开发进度
- [ADMIN_FEATURES_AND_ROADMAP.md](ADMIN_FEATURES_AND_ROADMAP.md) - Admin功能规划
- [DOCS_OVERVIEW.md](DOCS_OVERVIEW.md) - 全项目文档导航

View File

@@ -1,12 +0,0 @@
order-management
product-classification
product-labels
product-management
product-parameters
product-protection
product-reviews
product-specifications
product-statistics
system-settings
user-management
user-statistics

View File

@@ -1,25 +0,0 @@
design/index.backup
homePage/components/KpiMiniCard
homePage/index1
marketing/index
order/aftersales-order/index
order/cashier-order/index
order/order-configuration/index
order/order-management/index
order/order-statistics/index
order/write-off-records/index
product/product-classification/index
product/product-label/index
product/product-management/index
product/product-param/index
product/product-protection/index
product/product-reviews/index
product/product-specifications/index
product/product-statistics/index
system/index
user/user-configuration/index
user/user-grouping/index
user/user-label/index
user/user-level/index
user/user-management/index
user/user-statistics/index

View File

@@ -1,116 +0,0 @@
article//index
article/category
article/create
article/edit
content//index
design//index
design/category
design/components
design/custom
design/homepage
design/index.backup
design/product
design/templates
homePage//index
homePage/components/KpiMiniCard
homePage/index1
maintain/data/city-data
maintain/data/clear-data
maintain/data/logistics-company
maintain/dev-config/category
maintain/dev-config/combination-data
maintain/dev-config/cron-job
maintain/dev-config/custom-event
maintain/dev-config/module-config
maintain/dev-config/permission
maintain/dev-tools/api
maintain/dev-tools/codegen
maintain/dev-tools/database
maintain/dev-tools/data-dict
maintain/dev-tools/file
maintain/external/account
maintain/i18n/language-detail
maintain/i18n/language-list
maintain/i18n/region-list
maintain/i18n/translate-config
maintain/security/online-upgrade
maintain/security/refresh-cache
maintain/security/system-log
maintain/system-info
marketing//index
marketing/coupon/list
marketing/coupon/receive
marketing/groupbuy/goods
marketing/groupbuy/list
marketing/live/anchor
marketing/live/goods
marketing/live/room
marketing/lottery/config
marketing/lottery/list
marketing/member/card
marketing/member/config
marketing/member/record
marketing/member/rights
marketing/member/type
marketing/newcomer
marketing/points//index
marketing/points/config
marketing/points/goods
marketing/points/order
marketing/points/record
marketing/points/stats
marketing/recharge/amount
marketing/recharge/config
marketing/recharge/record
marketing/seckill/config
marketing/seckill/goods
marketing/seckill/list
marketing/signin/record
marketing/signin/rule
marketing-management
order/aftersales-order//index
order/cashier-order//index
order/order-configuration//index
order/order-management//index
order/order-statistics//index
order/write-off-records//index
product/product-classification//index
product/product-label//index
product/product-management//index
product/product-param//index
product/product-protection//index
product/product-reviews//index
product/product-specifications//index
product/product-statistics//index
service//index
service/autoReply
service/config
service/message
service/script
subscription/plan-management
subscription/user-subscriptions
system//index
system/agreement-settings
system/api/collect
system/api/logistics
system/api/pay
system/api/sms
system/api/storage
system/api/waybill
system/api/yht/config
system/api/yht/page
system/message-management
system/permission/admin-list
system/permission/permission-setting
system/permission/role
system/receipt-settings
system/shipping/courier
system/shipping/freight-template
system/shipping/pickup/points
system/shipping/pickup/verifiers
user/user-configuration//index
user/user-grouping//index
user/user-label//index
user/user-level//index
user/user-management//index
user/user-statistics//index

View File

@@ -6,15 +6,18 @@
//自己的配置自己解开即可
// export const SUPA_URL: string = 'http://192.168.1.61:18000'
// export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
export const SUPA_URL: string = 'http://192.168.1.62:18000'
export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzczNjIxMTQzLCJleHAiOjE5MzEzMDExNDN9.gkYe875_vsdcdsKbhOTwwe2klkuMNj_UY45aq4zwuy0'
// export const SUPA_URL: string = 'http://192.168.1.63:18000'
//export const SUPA_URL: string = 'http://192.168.1.62:18000'
//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
// export const SUPA_URL: string = 'http://192.168.1.61:18000'
// export const SUPA_KEY: string = 'eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJyb2xlIjogImFub24iLCAiaXNzIjogInN1cGFiYXNlIiwgImlhdCI6IDE3Njk4NDczMzQsICJleHAiOiAyMDg1MjA3MzM0fQ.js-2CS5_cUmf4iVv8aCmmx9iyFsQvLNDbt8YYOngeLU'
export const SUPA_URL: string = 'http://119.146.131.237:9126'
export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
// WebSocket 实时连接(内网使用 ws:// 而非 wss://
// export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'
export const WS_URL: string = 'ws://192.168.1.62:18000/realtime/v1/websocket'
// export const WS_URL: string = 'ws://192.168.1.63:18000/realtime/v1/websocket'
//export const WS_URL: string = 'ws://192.168.1.62:18000/realtime/v1/websocket'
export const WS_URL: string = 'ws://192.168.1.61:18000/realtime/v1/websocket'
// 推送服务地址(用于本地调试,可改为 http://<your-ip>:7301
export const PUSH_SERVER_URL: string = 'http://192.168.1.62:7301'

View File

@@ -1,25 +0,0 @@
import os
def check_tags(file_path):
print(f"Checking {file_path}")
try:
content = open(file_path, 'r', encoding='utf-8').read()
tags = ['template', 'script', 'style', 'view', 'text', 'image', 'scroll-view']
for tag in tags:
o = content.count(f'<{tag}')
c = content.count(f'</{tag}>')
if o != c:
print(f" [ERROR] {tag}: open={o}, close={c}")
else:
print(f" [OK] {tag}: {o}")
except Exception as e:
print(f" [ERROR] Failed to read: {e}")
files = [
r'd:\骅锋\mall\pages\mall\admin\order\order-statistics\index.uvue',
r'd:\骅锋\mall\pages\mall\admin\order\list.uvue',
r'd:\骅锋\mall\pages\mall\admin\order\order-configuration\index.uvue'
]
for f in files:
check_tags(f)

View File

@@ -0,0 +1,225 @@
<template>
<uni-drawer ref="drawerRef" mode="right" :width="600">
<view class="drawer-content">
<view class="drawer-header">
<text class="title">{{ isEdit ? "编辑文章" : "添加文章" }}</text>
<uni-icons type="closeempty" size="24" @click="close"></uni-icons>
</view>
<scroll-view scroll-y class="drawer-body">
<uni-forms
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
>
<uni-forms-item label="文章标题" name="title" required>
<uni-easyinput
v-model="formData.title"
placeholder="请输入文章标题"
/>
</uni-forms-item>
<uni-forms-item label="文章分类" name="categoryId" required>
<picker
mode="selector"
:range="categoryList"
range-key="name"
@change="onCategoryChange"
:value="categoryIndex"
>
<view
class="picker-view"
style="
height: 36px;
line-height: 36px;
border: 1px solid #e5e5e5;
border-radius: 4px;
padding: 0 10px;
color: #333;
"
>
{{
formData.categoryId
? getCategoryName(formData.categoryId)
: "请选择文章分类"
}}
</view>
</picker>
</uni-forms-item>
<uni-forms-item label="文章摘要" name="summary">
<uni-easyinput
type="textarea"
v-model="formData.summary"
placeholder="请输入文章摘要"
/>
</uni-forms-item>
<uni-forms-item label="文章封面" name="cover">
<uni-easyinput
v-model="formData.cover"
placeholder="请输入封面图片URL"
/>
</uni-forms-item>
<uni-forms-item label="文章内容" name="content" required>
<view
class="editor-container"
style="
border: 1px solid #e5e5e5;
border-radius: 4px;
min-height: 300px;
"
>
<richtext-editor
v-model="formData.content"
placeholder="请输入文章内容..."
></richtext-editor>
</view>
</uni-forms-item>
</uni-forms>
</scroll-view>
<view class="drawer-footer">
<button class="btn-cancel" @click="close">取消</button>
<button
class="btn-confirm"
type="primary"
@click="submit"
:loading="loading"
>
确定
</button>
</view>
</view>
</uni-drawer>
</template>
<script setup>
import { ref, reactive, computed } from "vue";
import { api } from "@/services/api.js";
import mockStore from "@/stores/useMockData.js";
const emit = defineEmits(["success"]);
const drawerRef = ref(null);
const formRef = ref(null);
const isEdit = ref(false);
const loading = ref(false);
const editId = ref(null);
const categoryList = computed(() => mockStore.mockCategories);
const formData = reactive({
title: "",
categoryId: "",
summary: "",
cover: "",
content: "",
});
const categoryIndex = computed(() => {
if (!formData.categoryId) return -1;
return categoryList.value.findIndex((c) => c.id === formData.categoryId);
});
const getCategoryName = (id) => {
const category = categoryList.value.find((c) => c.id === id);
return category ? category.name : "请选择文章分类";
};
const onCategoryChange = (e) => {
const index = e.detail.value;
formData.categoryId = categoryList.value[index].id;
};
const rules = {
title: {
rules: [{ required: true, errorMessage: "请输入文章标题" }],
},
categoryId: {
rules: [{ required: true, errorMessage: "请选择文章分类" }],
},
content: {
rules: [{ required: true, errorMessage: "请输入文章内容" }],
},
};
const open = (row) => {
if (row) {
isEdit.value = true;
editId.value = row.id;
Object.assign(formData, {
title: row.title,
categoryId: row.categoryId,
summary: row.summary,
cover: row.cover,
content: row.content,
});
} else {
isEdit.value = false;
editId.value = null;
Object.assign(formData, {
title: "",
categoryId: "",
summary: "",
cover: "",
content: "",
});
}
drawerRef.value.open();
};
const close = () => {
drawerRef.value.close();
};
const submit = async () => {
try {
await formRef.value.validate();
loading.value = true;
if (isEdit.value) {
await api.updateArticle(editId.value, formData);
} else {
await api.addArticle(formData);
}
uni.showToast({ title: "保存成功", icon: "success" });
emit("success");
close();
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
defineExpose({ open, close });
</script>
<style scoped>
.drawer-content {
display: flex;
flex-direction: column;
height: 100%;
background-color: #fff;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #eee;
}
.drawer-header .title {
font-size: 16px;
font-weight: bold;
}
.drawer-body {
flex: 1;
padding: 15px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
padding: 15px;
border-top: 1px solid #eee;
}
.drawer-footer button {
margin-left: 10px;
min-width: 80px;
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<uni-drawer ref="drawerRef" mode="right" :width="400">
<view class="drawer-content">
<view class="drawer-header">
<text class="title">{{ isEdit ? "编辑分类" : "添加分类" }}</text>
<uni-icons type="closeempty" size="24" @click="close"></uni-icons>
</view>
<scroll-view scroll-y class="drawer-body">
<uni-forms
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
>
<uni-forms-item label="分类名称" name="name" required>
<uni-easyinput
v-model="formData.name"
placeholder="请输入分类名称"
/>
</uni-forms-item>
<uni-forms-item label="排序" name="sort">
<uni-easyinput
type="number"
v-model="formData.sort"
placeholder="请输入排序"
/>
</uni-forms-item>
<uni-forms-item label="状态" name="status">
<switch
:checked="formData.status === 1"
@change="(e) => (formData.status = e.detail.value ? 1 : 0)"
/>
</uni-forms-item>
</uni-forms>
</scroll-view>
<view class="drawer-footer">
<button class="btn-cancel" @click="close">取消</button>
<button
class="btn-confirm"
type="primary"
@click="submit"
:loading="loading"
>
确定
</button>
</view>
</view>
</uni-drawer>
</template>
<script setup>
import { ref, reactive } from "vue";
import { api } from "@/services/api.js";
const emit = defineEmits(["success"]);
const drawerRef = ref(null);
const formRef = ref(null);
const isEdit = ref(false);
const loading = ref(false);
const editId = ref(null);
const formData = reactive({
name: "",
sort: 0,
status: 1,
});
const rules = {
name: {
rules: [{ required: true, errorMessage: "请输入分类名称" }],
},
};
const open = (row) => {
if (row) {
isEdit.value = true;
editId.value = row.id;
Object.assign(formData, {
name: row.name,
sort: row.sort,
status: row.status,
});
} else {
isEdit.value = false;
editId.value = null;
Object.assign(formData, {
name: "",
sort: 0,
status: 1,
});
}
drawerRef.value.open();
};
const close = () => {
drawerRef.value.close();
};
const submit = async () => {
try {
await formRef.value.validate();
loading.value = true;
if (isEdit.value) {
await api.updateCategory(editId.value, formData);
} else {
await api.addCategory(formData);
}
uni.showToast({ title: "保存成功", icon: "success" });
emit("success");
close();
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
defineExpose({ open, close });
</script>
<style scoped>
.drawer-content {
display: flex;
flex-direction: column;
height: 100%;
background-color: #fff;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #eee;
}
.drawer-header .title {
font-size: 16px;
font-weight: bold;
}
.drawer-body {
flex: 1;
padding: 15px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
padding: 15px;
border-top: 1px solid #eee;
}
.drawer-footer button {
margin-left: 10px;
min-width: 80px;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<picker class="page-select" :range="pageSizeOptionLabels" :value="pageSizeIndex" @change="onPageSizeChange">
<view class="page-select-inner">
<text class="page-val">{{ pageSize }} 条/页</text>
<text class="arrow-down">▼</text>
</view>
</picker>
<view class="page-btns">
<view class="p-btn" :class="{ disabled: currentPage <= 1 }" @click="onPageBtnClick(currentPage - 1)">
<text></text>
</view>
<view
v-for="(p, index) in visiblePages"
:key="index"
class="p-btn"
:class="{ active: p === currentPage, 'ellipsis-btn': p === -1 }"
@click="onPageBtnClick(p)">
<text>{{ p === -1 ? '...' : p }}</text>
</view>
<view class="p-btn" :class="{ disabled: currentPage >= totalPage }" @click="onPageBtnClick(currentPage + 1)">
<text></text>
</view>
</view>
<view class="page-jump">
<text class="jump-txt">前往</text>
<input
class="jump-input"
type="number"
:value="jumpPageInput"
@input="onJumpInputChange"
@confirm="onJumpPage"
@blur="onJumpPage"
placeholder="页码"
/>
<text class="jump-txt">页</text>
</view>
</view>
</template>
<script setup lang="uts">
const props = defineProps({
total: { type: Number, default: 0 },
loading: { type: Boolean, default: false },
currentPage: { type: Number, default: 1 },
pageSize: { type: Number, default: 10 },
pageSizeOptionLabels: { type: Array, default: (): string[] => [] },
pageSizeIndex: { type: Number, default: 0 },
visiblePages: { type: Array, default: (): number[] => [] },
totalPage: { type: Number, default: 1 },
jumpPageInput: { type: String, default: '' }
})
const emit = defineEmits(['page-size-change', 'page-change', 'update:jumpPageInput', 'jump-page'])
const onPageSizeChange = (e : any) => {
emit('page-size-change', e)
}
const onPageBtnClick = (p : number) => {
if (p !== -1) {
emit('page-change', p)
}
}
const onJumpInputChange = (e : any) => {
emit('update:jumpPageInput', e.detail.value as string)
}
const onJumpPage = () => {
emit('jump-page')
}
</script>
<style scoped lang="scss">
/* 分页 */
.pagination-footer {
padding: 16px 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 16px;
border-top: 1px solid #e8eaec;
background-color: #fff;
}
.total-txt { font-size: 14px; color: #515a6e; }
.page-select {
border: 1px solid #dcdee2;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
transition: border 0.2s;
}
.page-select:hover { border-color: #2d8cf0; }
.page-select-inner {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
height: 32px;
gap: 8px;
}
.page-val { font-size: 14px; color: #515a6e; }
.arrow-down { font-size: 10px; color: #c0c4cc; }
.page-btns { display: flex; flex-direction: row; gap: 4px; }
.p-btn {
min-width: 32px; height: 32px; padding: 0 4px; border: 1px solid #dcdee2; border-radius: 4px;
display: flex; align-items: center; justify-content: center; font-size: 14px; color: #515a6e;
background-color: #fff; cursor: pointer; transition: all 0.2s;
}
.p-btn:hover:not(.disabled):not(.active):not(.ellipsis-btn) {
border-color: #2d8cf0; color: #2d8cf0;
}
.p-btn.active { background-color: #2d8cf0; border-color: #2d8cf0; color: #fff; }
.p-btn.disabled { color: #c5c8ce; background-color: #f7f7f7; cursor: not-allowed; border-color: #dcdee2; }
.p-btn.ellipsis-btn { border: none; cursor: default; }
.page-jump {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.jump-txt { font-size: 14px; color: #515a6e; }
.jump-input {
width: 50px;
height: 32px;
border: 1px solid #dcdee2;
border-radius: 4px;
text-align: center;
font-size: 14px;
color: #515a6e;
transition: border 0.2s;
}
.jump-input:focus { border-color: #2d8cf0; outline: none; }
</style>

View File

@@ -0,0 +1,102 @@
<template>
<view class="switch-mock" :class="modelValue ? 'switch-on' : ''" @click="toggle" :style="customStyle">
<view class="switch-dot"></view>
<text class="switch-text">{{ modelValue ? activeText : inactiveText }}</text>
</view>
</template>
<script setup lang="uts">
/**
* StatusSwitch 状态切换组件
* 用于替代原生 switch提供更符合后台管理系统的样式和交互
*/
const props = defineProps({
/**
* 绑定值
*/
modelValue: {
type: Boolean,
default: false
},
/**
* 开启时的文字
*/
activeText: {
type: String,
default: '开启'
},
/**
* 关闭时的文字
*/
inactiveText: {
type: String,
default: '关闭'
},
/**
* 自定义样式
*/
customStyle: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const toggle = () => {
const newValue = !props.modelValue
emit('update:modelValue', newValue)
emit('change', newValue)
}
</script>
<style scoped>
.switch-mock {
width: 54px;
height: 24px;
background-color: #ccc;
border-radius: 12px;
position: relative;
transition: all 0.3s;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 6px;
cursor: pointer;
}
.switch-on {
background-color: #1890ff;
}
.switch-dot {
width: 18px;
height: 18px;
background-color: #fff;
border-radius: 50%;
position: absolute;
left: 3px;
top: 3px;
transition: all 0.3s;
}
.switch-on .switch-dot {
left: 33px;
}
.switch-text {
font-size: 12px;
color: #fff;
margin-left: auto;
line-height: 24px;
display: flex;
flex-direction: row;
align-items: center;
}
.switch-on .switch-text {
margin-left: 0;
margin-right: auto;
}
</style>

View File

@@ -0,0 +1 @@

View File

@@ -21,134 +21,75 @@
</view>
</view>
</view>
<view v-if="loading || !chartOption || !chartOption.series || chartOption.series.length === 0" class="chart-loading">
<view v-if="loading || !chartOption || chartOption == null" class="chart-loading">
<text>{{ loading ? '加载中...' : '暂无数据' }}</text>
</view>
<EChartsView v-else class="chart-box" :option="chartOption" :key="'map-' + mapType" />
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import { ref, watch, onMounted } from 'vue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
/**
* 销售地域分布组件 (UTS / uni-app-x)
* 1:1 复刻数据分析中心地图分布
*/
type RegionDataItem = { name: string; value: number }
export default {
components: {
EChartsView
},
props: {
const props = defineProps({
startDate: { type: Date, required: true },
endDate: { type: Date, required: true },
topMerchants: { type: Array, default: () => [] },
topMerchants: { type: Array, default: () => [] as Array<any> },
loading: { type: Boolean, default: false }
},
data() {
return {
mapType: 'china', // 'china' | 'world'
regionData: [] as Array<RegionDataItem>,
chartOption: {} as any
})
const mapType = ref('china')
const regionData = ref<Array<RegionDataItem>>([])
const chartOption = ref<any>(null)
// --- 数据处理逻辑 ---
function toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return (obj as Array<any>).map((item : any) : any => toPlainObject(item))
}
},
watch: {
startDate: { handler() { this.loadData() }, deep: true },
endDate: { handler() { this.loadData() }, deep: true },
topMerchants: { handler() { this.loadData() }, deep: true }
},
mounted() {
this.loadData()
},
methods: {
switchMapType(type: string) {
this.mapType = type
this.buildChartOption()
},
async loadData() {
try {
// 暂时使用模拟数据,后续可以创建 RPC 函数获取真实省份数据
// 基于商家数据生成省份分布(模拟)
const mockProvinces: Array<RegionDataItem> = [
{ name: '广东', value: 0 },
{ name: '北京', value: 0 },
{ name: '上海', value: 0 },
{ name: '浙江', value: 0 },
{ name: '江苏', value: 0 },
{ name: '山东', value: 0 },
{ name: '河南', value: 0 },
{ name: '四川', value: 0 },
{ name: '湖北', value: 0 },
{ name: '湖南', value: 0 },
{ name: '福建', value: 0 },
{ name: '安徽', value: 0 },
{ name: '河北', value: 0 },
{ name: '陕西', value: 0 },
{ name: '江西', value: 0 },
{ name: '重庆', value: 0 },
{ name: '辽宁', value: 0 },
{ name: '云南', value: 0 },
{ name: '广西', value: 0 },
{ name: '山西', value: 0 },
{ name: '内蒙古', value: 0 },
{ name: '贵州', value: 0 },
{ name: '新疆', value: 0 },
{ name: '天津', value: 0 },
{ name: '吉林', value: 0 },
{ name: '黑龙江', value: 0 },
{ name: '海南', value: 0 },
{ name: '甘肃', value: 0 },
{ name: '宁夏', value: 0 },
{ name: '青海', value: 0 },
{ name: '西藏', value: 0 }
]
// 如果有商家数据,可以基于商家数量或 GMV 分配省份
const merchants = this.topMerchants as Array<any>
const totalSales = merchants.reduce((sum: number, m: any) => {
return sum + (Number(m.sales) || 0)
}, 0)
if (totalSales > 0 && merchants.length > 0) {
// 基于商家数量分配省份
const merchantCount = merchants.length
for (let i = 0; i < Math.min(merchantCount, mockProvinces.length); i++) {
const sales = Number(merchants[i].sales) || 0
mockProvinces[i].value = Math.round(sales * (Math.random() * 0.5 + 0.5))
const plain: Record<string, any> = {}
const keys = Object.keys(obj as object)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const value = (obj as Record<string, any>)[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
plain[key] = toPlainObject(value)
} else {
// 使用随机数据
for (let i = 0; i < mockProvinces.length; i++) {
mockProvinces[i].value = Math.round(Math.random() * 100000)
plain[key] = value
}
}
return plain
}
this.regionData = mockProvinces
this.buildChartOption()
} catch (e) {
console.error('❌ AnalyticsRegionMap loadData failed', e)
this.regionData = []
this.buildChartOption()
}
},
buildChartOption() {
if (!this.regionData || this.regionData.length === 0) {
this.chartOption = {}
function buildChartOption() {
if (regionData.value.length === 0) {
chartOption.value = null
return
}
const maxValue = Math.max(...this.regionData.map((d) => d.value), 1)
const currentData = regionData.value
const maxValue = Math.max(...currentData.map((d: RegionDataItem): number => d.value), 1)
if (this.mapType === 'china') {
// 中国地图配置(使用 geo 组件,兼容性更好)
this.chartOption = {
let option: any = {}
if (mapType.value === 'china') {
option = {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const name = params.name || '未知'
const value = params.value || 0
return `${name}<br/>销售额: ¥${this.formatMoney(value)}`
}
trigger: 'item'
},
visualMap: {
min: 0,
@@ -191,7 +132,7 @@ export default {
type: 'map',
map: 'china',
geoIndex: 0,
data: this.regionData,
data: currentData,
label: {
show: true,
fontSize: 11,
@@ -218,8 +159,7 @@ export default {
]
}
} else {
// 全国地图配置(简化版,使用柱状图展示 TOP 省份)
this.chartOption = {
option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
@@ -227,7 +167,7 @@ export default {
grid: { left: 60, right: 20, top: 36, bottom: 60 },
xAxis: {
type: 'category',
data: this.regionData
data: currentData
.sort((a, b) => b.value - a.value)
.slice(0, 15)
.map((d) => d.name),
@@ -240,11 +180,7 @@ export default {
yAxis: {
type: 'value',
axisLabel: {
color: 'rgba(0,0,0,0.55)',
formatter: (value: number) => {
if (value >= 10000) return (value / 10000).toFixed(1) + '万'
return String(Math.round(value))
}
color: 'rgba(0,0,0,0.55)'
},
splitLine: {
lineStyle: { color: 'rgba(0,0,0,0.06)' }
@@ -254,7 +190,7 @@ export default {
{
name: '销售额',
type: 'bar',
data: this.regionData
data: currentData
.sort((a, b) => b.value - a.value)
.slice(0, 15)
.map((d) => d.value),
@@ -268,112 +204,59 @@ export default {
}
}
// 转换为纯 JS 对象
this.chartOption = this.toPlainObject(this.chartOption)
},
chartOption.value = toPlainObject(option)
}
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
},
function loadData() {
const mockProvinces: Array<RegionDataItem> = [
{ name: '广东', value: 0 }, { name: '北京', value: 0 }, { name: '上海', value: 0 },
{ name: '浙江', value: 0 }, { name: '江苏', value: 0 }, { name: '山东', value: 0 },
{ name: '河南', value: 0 }, { name: '四川', value: 0 }, { name: '湖北', value: 0 },
{ name: '湖南', value: 0 }, { name: '福建', value: 0 }, { name: '安徽', value: 0 },
{ name: '河北', value: 0 }, { name: '陕西', value: 0 }, { name: '江西', value: 0 },
{ name: '重庆', value: 0 }, { name: '辽宁', value: 0 }, { name: '云南', value: 0 },
{ name: '广西', value: 0 }
]
// 工具函数:将 UTS 对象转换为纯 JavaScript 对象
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => this.toPlainObject(item))
const merchants = props.topMerchants as Array<any>
if (merchants.length > 0) {
for (let i = 0; i < Math.min(merchants.length, mockProvinces.length); i++) {
const salesStr = String(merchants[i].sales || '0')
const sales = parseFloat(salesStr)
mockProvinces[i].value = Math.round(sales * (Math.random() * 0.3 + 0.7))
}
const plain: any = {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]
if (typeof value === 'function' || key.startsWith('_') || key === 'toJSON') {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
isSimple = false
break
}
}
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
for (let i = 0; i < mockProvinces.length; i++) {
mockProvinces[i].value = Math.round(Math.random() * 100000)
}
}
regionData.value = mockProvinces
buildChartOption()
}
return plain
}
}
function switchMapType(type: string) {
mapType.value = type
buildChartOption()
}
watch([() => props.startDate, () => props.endDate, () => props.topMerchants], () => {
loadData()
})
onMounted(() => {
loadData()
})
</script>
<style>
.region-map {
width: 100%;
}
.map-head {
margin-bottom: 8px;
}
.map-head-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
flex: 1;
}
.map-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.map-switch {
display: flex;
flex-direction: row;
gap: 4px;
background: #f3f4f6;
border-radius: 8px;
padding: 2px;
}
.map-switch-btn {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
color: rgba(0,0,0,0.65);
background: transparent;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.map-switch-btn.active {
background: #fff;
color: #111;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.chart-box {
width: 100%;
height: 360px;
}
.chart-loading {
width: 100%;
height: 360px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
<style scoped>
.region-map { width: 100%; }
.map-head { margin-bottom: 8px; }
.map-head-left { display: flex; flex-direction: row; align-items: center; gap: 12px; flex: 1; }
.map-title { font-size: 14px; font-weight: 600; color: #111; }
.map-switch { display: flex; flex-direction: row; gap: 4px; background: #f3f4f6; border-radius: 8px; padding: 2px; }
.map-switch-btn { padding: 6px 12px; border-radius: 6px; font-size: 12px; color: rgba(0,0,0,0.65); background: transparent; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
.map-switch-btn.active { background: #fff; color: #111; font-weight: 500; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.chart-box { width: 100%; height: 360px; }
.chart-loading { width: 100%; height: 360px; display: flex; align-items: center; justify-content: center; color: rgba(0,0,0,0.45); font-size: 14px; }
</style>

View File

@@ -1,12 +1,24 @@
<template>
<view class="gender-card">
<view class="gender-card" ref="cardRef">
<view class="card-header">
<text class="title">用户性别比例</text>
</view>
<view class="card-content">
<view class="chart-container">
<!-- 上部/左部图例区 - 复刻新图样式 (stacked) -->
<view class="legend-col">
<view class="legend-item" v-for="(item, index) in (genderData as UTSJSONObject[])" :key="index">
<view class="legend-dot" :style="{ backgroundColor: (item['itemStyle'] as UTSJSONObject)['color'] as string }"></view>
<text class="legend-label">{{ item['name'] }}</text>
</view>
</view>
<!-- 下部图表区 - 核心容器 -->
<view class="chart-col" ref="chartWrapRef">
<!-- 图表组件 -->
<EChartsView :option="chartOption" class="donut-chart" />
<!-- 中心文字:绝对居中 -->
<view class="center-text">
<text class="total-label">总用户数</text>
<text class="total-value">{{ totalUsers }}</text>
@@ -16,29 +28,149 @@
</view>
</template>
<script setup lang="uts">
<script lang="uts">
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import { ref, watchEffect } from 'vue'
import { showSubSider, isMainAsideCollapsed, layoutMode } from '@/layouts/admin/store/adminNavStore.uts'
type GenderRow = {
name: string
value: number
/**
* 用户性别比例 - CRMEB 1:1 复刻版 (响应式增强)
* 解决了:<1200px 布局切换、Canvas 溢出、图表裁切、中心偏移问题
*/
export default {
components: {
EChartsView
},
data() {
return {
totalUsers: 789,
genderData: [
{ value: 400, name: '男', itemStyle: { color: '#1890ff' } },
{ value: 300, name: '女', itemStyle: { color: '#febc2c' } },
{ value: 89, name: '未知', itemStyle: { color: '#919eab' } }
],
chartOption: {} as any,
resizeObserver: null as any | null
}
},
computed: {
navState(): string {
return `${showSubSider.value}-${isMainAsideCollapsed.value}-${layoutMode.value}`
}
},
watch: {
navState() {
// 侧边栏/断点状态变化时,强制触发 resize
this.triggerRobustResize()
}
},
mounted() {
this.setupResizeSystem()
// 📌 参考 AnalyticsPieChart 的延迟初始化策略,解决 H5 渲染 0 宽高问题
setTimeout(() => {
this.initChart()
}, 300)
},
unmounted() {
if (this.resizeObserver != null) {
(this.resizeObserver as any).disconnect()
}
window.removeEventListener('resize', this.triggerRobustResize)
const sidebar = document.querySelector('.admin-sidebar-container') || document.querySelector('.admin-main-aside')
if (sidebar != null) {
sidebar.removeEventListener('transitionend', this.triggerRobustResize)
}
},
methods: {
initChart() {
// 📌 参考 AnalyticsPieChart 的数据映射和 PlainObject 处理
const plainData = (this.genderData as Array<any>).map((it) => {
const itemStyle = it['itemStyle'] ? this.toPlainObject(it['itemStyle']) : {} as any
return {
name: String(it['name']),
value: Number(it['value']),
itemStyle: itemStyle
}
})
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
backgroundColor: '#fff',
padding: [10, 15],
textStyle: { color: '#333' },
extraCssText: 'box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: 4px;'
},
legend: { show: false },
series: [
{
name: '性别比例',
type: 'pie',
radius: ['58%', '75%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
label: { show: false },
emphasis: {
scale: true,
scaleSize: 10,
label: { show: false }
},
data: plainData
}
]
}
this.chartOption = this.toPlainObject(option)
},
/**
* 建立可靠 resize 闭环
*/
setupResizeSystem() {
// 1. ResizeObserver 监听容器真实尺寸变化 (节流处理)
if (typeof ResizeObserver !== 'undefined') {
let timer: any = null
this.resizeObserver = new ResizeObserver(() => {
if (timer) return
timer = setTimeout(() => {
this.triggerRobustResize()
timer = null
}, 100)
})
const wrap = (this.$refs['chartWrapRef'] as any).$el
if (wrap != null) {
(this.resizeObserver as any).observe(wrap)
}
}
const props = defineProps<{
startDate: string
endDate: string
}>()
// 2. Window Resize 兜底
window.addEventListener('resize', this.triggerRobustResize)
const totalUsers = ref<number>(0)
const chartOption = ref<any>({})
// 3. 监听侧边栏动画结束 (transitionend)
const sidebar = document.querySelector('.admin-sidebar-container') || document.querySelector('.admin-main-aside')
if (sidebar != null) {
sidebar.addEventListener('transitionend', this.triggerRobustResize)
}
},
function toPlainObject(obj: any): any {
triggerRobustResize() {
// 触发一次 layout 后的渲染
requestAnimationFrame(() => {
window.dispatchEvent(new Event('resize'))
// 补充第二次 frame 针对重构动画的延迟校准
setTimeout(() => {
window.dispatchEvent(new Event('resize'))
}, 30)
})
},
toPlainObject(obj: any): any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => toPlainObject(item))
return obj.map((item: any): any => this.toPlainObject(item))
}
const plain: any = {}
for (const key in obj) {
@@ -48,6 +180,7 @@ function toPlainObject(obj: any): any {
continue
}
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
// 📌 参考 AnalyticsPieChart 的优化逻辑,处理普通对象拷贝
let isSimple = true
for (const k in value) {
if (typeof value[k] === 'object' && value[k] !== null) {
@@ -55,7 +188,7 @@ function toPlainObject(obj: any): any {
break
}
}
plain[key] = isSimple ? { ...value } : toPlainObject(value)
plain[key] = isSimple ? { ...value } : this.toPlainObject(value)
} else {
plain[key] = value
}
@@ -63,108 +196,8 @@ function toPlainObject(obj: any): any {
}
return plain
}
function initChartFromRows(rows: Array<GenderRow>) {
const normalized: any = { '未知': 0, '男': 0, '女': 0 }
for (let i = 0; i < rows.length; i++) {
const name = `${rows[i].name}`
const value = Number(rows[i].value) || 0
if (name === '男' || name === '女') {
normalized[name] = value
} else {
normalized['未知'] += value
}
}
totalUsers.value = normalized['未知'] + normalized['男'] + normalized['女']
const data = [
{ value: normalized['未知'], name: '未知', itemStyle: { color: '#999999' } },
{ value: normalized['男'], name: '男', itemStyle: { color: '#3b82f6' } },
{ value: normalized['女'], name: '女', itemStyle: { color: '#f97316' } }
]
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
top: '0%',
left: 'center',
icon: 'rect',
itemWidth: 15,
itemHeight: 15,
textStyle: {
fontSize: 12,
color: '#666'
}
},
series: [
{
name: '性别比例',
type: 'pie',
radius: ['50%', '75%'],
center: ['50%', '60%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: false
}
},
labelLine: {
show: false
},
data
}
]
}
chartOption.value = toPlainObject(option)
}
async function loadData() {
try {
if (!props.startDate || !props.endDate) {
totalUsers.value = 0
initChartFromRows([])
return
}
await ensureSupabaseReady()
const p = new UTSJSONObject()
p.set('p_start_date', props.startDate)
p.set('p_end_date', props.endDate)
const res: any = await supa.rpc('rpc_analytics_user_gender_distribution', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
console.log('[AnalyticsUserGenderSection] props', props.startDate, props.endDate)
console.log('[AnalyticsUserGenderSection] rpc rows', rows)
initChartFromRows(rows as Array<GenderRow>)
} catch (e) {
console.error('AnalyticsUserGenderSection loadData failed', e)
totalUsers.value = 0
initChartFromRows([])
}
}
watchEffect(() => {
const s = props.startDate
const e = props.endDate
if (s && e) {
loadData()
} else {
totalUsers.value = 0
initChartFromRows([])
}
})
</script>
<style scoped lang="scss">
@@ -172,58 +205,132 @@ watchEffect(() => {
background: #fff;
border-radius: 4px;
padding: 20px;
margin-bottom: 20px;
height: 521px;
display: flex;
flex-direction: column;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
width: 100%;
height: 100%; /* 确保沾满父级格子 */
}
.card-header {
margin-bottom: 20px;
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: bold;
font-weight: bold; /* 同步地图 bold */
color: #333;
}
.card-content {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
min-height: 0;
}
/* 图例区 (横向排列) 对齐图片 */
.legend-col {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.legend-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-label {
font-size: 14px;
color: #333;
}
/* 核心图表列 (centered) */
.chart-col {
flex: 1;
width: 100%;
position: relative;
min-height: 0; /* 允许 flex 压缩 */
overflow: visible !important;
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.chart-container {
position: relative;
width: 100%;
height: 100%;
/* 响应式断点 - 维持垂直布局并增加空间 */
@media (max-width: 1199.98px) {
.chart-col {
height: 400px; /* 增加高度,给图表更多空间 */
min-height: 400px;
}
.donut-chart {
width: 100%;
height: 100%;
.gender-card {
height: auto; /* 在单列模式下允许高度自适应 */
min-height: 480px; /* 确保整体卡片有足够高度 */
}
}
/* 强制覆盖 EChartsView 样式 (确保完整铺满并通过 ECharts 配置居中) */
:deep(.donut-chart) {
width: 100% !important;
height: 100% !important;
.ec-wrap {
position: relative !important;
width: 100% !important;
height: 100% !important;
overflow: visible !important;
}
.ec-canvas {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
}
}
/* 图表中心文字 (必须绝对居中于容器,微调位置垂直视觉居中) */
.center-text {
position: absolute;
top: 60%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
user-select: none;
z-index: 10;
}
.total-label {
font-size: 14px;
color: #999;
margin-bottom: 4px;
font-size: 15px;
color: #666;
margin-bottom: 0;
font-weight: 700;
}
.total-value {
font-size: 28px;
font-weight: bold;
font-size: 48px;
font-weight: 700;
color: #333;
line-height: 1.1;
}
</style>

View File

@@ -161,7 +161,10 @@ export default {
background: #fff;
border-radius: 4px;
padding: 20px;
margin-bottom: 16px;
/* margin-bottom 移除,由父级 Grid gap 控制或统一添加 */
display: flex;
flex-direction: column;
height: 100%; /* 让卡片自身也撑满容器 */
}
.card-header {
@@ -177,7 +180,8 @@ export default {
.card-content {
display: flex;
flex-direction: row;
height: 450px;
flex: 1; /* 让内容区在容器高时自适应 */
min-height: 450px; /* 最小基础高度 */
}
.map-section {

View File

@@ -507,7 +507,7 @@ export class AkSupaQueryBuilder {
convertedData = result.data as T | Array<T>;
}
result.data = convertedData
const aaa = result as AkReqResponse<T | Array<T>
const aaa = result as AkReqResponse<T | Array<T>>
// const aaa = {
// status: result.status,
// data: convertedData,

View File

@@ -1,6 +1,8 @@
// /components/supadb/aksupa.uts
import { AkReqResponse, AkReqUploadOptions, AkReq } from '@/uni_modules/ak-req/index.uts'
import type { AkReqOptions } from '@/uni_modules/ak-req/index.uts'
import { toUniError } from '@/utils/utils.uts'
import { IS_TEST_MODE } from '@/ak/config.uts'
export type AkSupaSignInResult = {
access_token : string;
@@ -527,7 +529,7 @@ export class AkSupaQueryBuilder {
convertedData = result.data as T | Array<T>;
}
result.data = convertedData
const aaa = result as AkReqResponse<T | Array<T>
const aaa = result as AkReqResponse<T | Array<T>>
// const aaa = {
// status: result.status,
// data: convertedData,
@@ -656,6 +658,73 @@ export class AkSupa {
}
}
/**
* 模拟 supabase-js 的 auth 属性,提供认证相关方法
*/
get auth() : AkSupa {
return this;
}
/**
* 校验密码或登录(别名,兼容 supabase-js 命名)
*/
async signInWithPassword(credentials : UTSJSONObject) : Promise<AkSupaSignInResult> {
const email = credentials.getString('email');
const password = credentials.getString('password');
if (email == null || password == null) {
throw new Error('Email and password are required');
}
return await this.signIn(email, password);
}
/**
* 更新用户信息(如修改密码、修改元数据等)
* 对应 Supabase Auth API: PUT /auth/v1/user (部分版本或Kong配置可能只支持PUT)
*/
async updateUser(attributes : UTSJSONObject) : Promise<AkReqResponse<any>> {
const url = this.baseUrl + '/auth/v1/user';
const token = AkReq.getToken();
if (token == null || token == '') {
throw new Error('未登录,无法更新用户信息');
}
const headers = {
apikey: this.apikey,
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
} as UTSJSONObject;
// 尝试先用 PUT 方法,因为部分环境 PATCH 报 405 Method Not Allowed
const reqOptions : AkReqOptions = {
url,
method: 'PUT',
headers,
data: attributes,
contentType: 'application/json'
};
// updateUser 后Supabase 会返回更新后的用户对象
let res = await AkReq.request(reqOptions, false);
// 如果 PUT 也是 405则尝试 PATCH
if (res.status == 405) {
reqOptions.method = 'PATCH';
res = await AkReq.request(reqOptions, false);
}
if (res.status >= 200 && res.status < 300 && res.data != null) {
// 如果返回了新的 user 对象,更新本地缓存
try {
const newUser = new UTSJSONObject(res.data);
this.user = newUser;
if (this.session != null) {
this.session!!.user = newUser;
}
} catch (e) {}
}
return res;
}
// [CHANGE][2026-01-30] hydrate user from /auth/v1/user when token exists in storage
async hydrateSessionFromStorage() : Promise<boolean> {
try {
@@ -784,7 +853,12 @@ export class AkSupa {
};
}
async signUp(email : string, password : string) : Promise<UTSJSONObject> {
async signUp(email : string, password : string, options ?: UTSJSONObject) : Promise<UTSJSONObject> {
const body = { email, password } as UTSJSONObject;
if (options != null && options.get('data') != null) {
body['data'] = options.get('data');
}
const res = await AkReq.request({
url: this.baseUrl + '/auth/v1/signup',
method: 'POST',
@@ -792,7 +866,7 @@ export class AkSupa {
apikey: this.apikey,
'Content-Type': 'application/json'
} as UTSJSONObject,
data: { email, password } as UTSJSONObject,
data: body,
contentType: 'application/json'
}, false);
return res.data as UTSJSONObject;
@@ -807,11 +881,19 @@ export class AkSupa {
*/
async select(table : string, filter ?: string | null, options ?: AkSupaSelectOptions) : Promise<AkReqResponse<any>> {
let url = this.baseUrl + '/rest/v1/' + table;
const token = AkReq.getToken()
let headers = {
apikey: this.apikey,
'Content-Type': 'application/json',
Authorization: `Bearer ${AkReq.getToken() ?? ''}`
'Content-Type': 'application/json'
} as UTSJSONObject;
// 只有在明确有用户 token 的情况下才发送 Authorization
// 否则只带 apikey这样 Kong 会自动映射到 anon 角色,避免 JWT 校验失败
if (token != null && token != '') {
headers['Authorization'] = `Bearer ${token}`;
}
let params : string[] = [];
if (options != null) {
if (options.columns != null && !(options.columns == "")) params.push('select=' + encodeURIComponent(options.columns ?? ""));
@@ -897,10 +979,17 @@ async select_uts(table : string, filter ?: UTSJSONObject | null, options ?: AkSu
*/
async insert(table : string, row : UTSJSONObject | Array<UTSJSONObject>) : Promise<AkReqResponse<any>> {
const url = this.baseUrl + '/rest/v1/' + table;
const headers = {
const token = AkReq.getToken()
let authHeader = `Bearer ${this.apikey}`;
if (token != null && token != '') {
authHeader = `Bearer ${token}`;
}
let headers = {
apikey: this.apikey,
'Content-Type': 'application/json',
Authorization: `Bearer ${AkReq.getToken() ?? ''}`,
Authorization: authHeader,
Prefer: 'return=representation'
} as UTSJSONObject;
@@ -910,7 +999,7 @@ async select_uts(table : string, filter ?: UTSJSONObject | null, options ?: AkSu
url,
method: 'POST',
headers,
data: row, // 可以是单个对象或数组
data: row,
contentType: 'application/json'
};
return await this.requestWithAutoRefresh(reqOptions);
@@ -928,12 +1017,18 @@ async update(table : string, filter : string | null, values : UTSJSONObject) : P
if (filter!=null && filter !== "") {
url += '?' + filter;
}
const headers = {
const token = AkReq.getToken()
let headers = {
apikey: this.apikey,
'Content-Type': 'application/json',
Authorization: `Bearer ${AkReq.getToken() ?? ''}`,
Prefer: 'return=representation'
} as UTSJSONObject;
if (token != null && token != '') {
headers['Authorization'] = `Bearer ${token}`;
}
let reqOptions : AkReqOptions = {
url,
method: 'PATCH',
@@ -955,12 +1050,18 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
if (filter!=null && filter !== "") {
url += '?' + filter;
}
const headers = {
const token = AkReq.getToken()
let headers = {
apikey: this.apikey,
'Content-Type': 'application/json',
Authorization: `Bearer ${AkReq.getToken() ?? ''}`,
Prefer: 'return=representation'
} as UTSJSONObject;
if (token != null && token != '') {
headers['Authorization'] = `Bearer ${token}`;
}
let reqOptions : AkReqOptions = {
url,
method: 'DELETE',
@@ -978,11 +1079,17 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
*/
async rpc(functionName : string, params ?: UTSJSONObject) : Promise<AkReqResponse<any>> {
const url = `${this.baseUrl}/rest/v1/rpc/${functionName}`;
const headers = {
const token = AkReq.getToken()
let headers = {
apikey: this.apikey,
'Content-Type': 'application/json',
Authorization: `Bearer ${AkReq.getToken() ?? ''}`
'Content-Type': 'application/json'
} as UTSJSONObject;
if (token != null && token != '') {
headers['Authorization'] = `Bearer ${token}`;
}
let reqOptions : AkReqOptions = {
url,
method: 'POST',
@@ -1012,7 +1119,7 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
} as UTSJSONObject,
data: { refresh_token: this.session?.refresh_token } as UTSJSONObject,
contentType: 'application/json'
}, false);
}, true);
if (res.status == 200 && (res.data != null)) {
const data = res.data as UTSJSONObject;
const access_token = data.getString('access_token') ?? '';
@@ -1042,31 +1149,46 @@ async delete(table : string, filter : string | null) : Promise<AkReqResponse<any
// AkSupa类内新增自动刷新封装
async requestWithAutoRefresh(reqOptions : AkReqOptions, isRetry = false) : Promise<AkReqResponse<any>> {
let res = await AkReq.request(reqOptions, false);
// JWT过期Supabase风格
const isJwtExpired = (res.status == 401); //res != null && res.data != null && typeof res.data == 'object' && (res.data as UTSJSONObject)?.getString('code') == 'PGRST301';
// 401未授权
const isUnauthorized = (res.status == 401);
if ((isJwtExpired || isUnauthorized) && !isRetry) {
// JWT过期/401未授权
const needsHandle = (res.status == 401);
if (needsHandle && !isRetry) {
const ok = await this.refreshSession();
if (ok) {
const newToken = AkReq.getToken() ?? ''
let headers = reqOptions.headers
if (headers == null) {
headers = new UTSJSONObject()
}
if (typeof headers.set == 'function') {
headers.set('Authorization', `Bearer ${AkReq.getToken() ?? ''}`)
headers.set('Authorization', `Bearer ${newToken}`)
reqOptions.headers = headers
}
res = await AkReq.request(reqOptions, false);
} else {
uni.removeStorageSync('user_id');
uni.removeStorageSync('token');
// 如果是测试模式且失败401且无法刷新不再抛出异常阻止执行但确保 res.error 有值
if (IS_TEST_MODE === true) {
console.warn('[TestMode] Token expired or not found, but continuing anyway. Status:', res.status)
console.log('[TestMode] Response body:', JSON.stringify(res.data))
if (res.error == null) {
res.error = toUniError('认证失败 (401)', 'UNAUTHORIZED');
}
return res;
}
// completely removed global storage clearing
uni.reLaunch({ url: '/pages/user/login' });
throw toUniError('登录已过期,请重新登录', '用户认证失败');
}
}
// 额外检查:如果 status >= 400 且 res.error 为空,注入一个 error
if (res.status >= 400 && res.error == null) {
res.error = toUniError(`请求失败: ${res.status}`, 'HTTP_ERROR');
}
return res;
}
}
@@ -1132,4 +1254,15 @@ export function createClient(url : string, key : string) : AkSupa {
return new AkSupa(url, key);
}
/**
* 创建一个临时 Supabase 客户端实例,用于校验密码等操作,不会污染全局 Token
*/
export function createTempClient(url : string, key : string) : AkSupa {
const supa = new AkSupa(url, key);
// 临时客户端不执行持久化逻辑,直接清空可能已加载的 session
supa.session = null;
supa.user = null;
return supa;
}
export default AkSupa;

View File

@@ -26,7 +26,7 @@ export function checkConnection() {
}
// 兼容 supaReady Promise
export const supaReady = Promise.resolve(true)
export const supaReady = supaInstance.hydrateSessionFromStorage()
// 如果有其他需要导出的函数,可以这样导出:
export function initializeSupabase(url: string, key: string) {

View File

@@ -1,3 +1,12 @@
---
🚧 注意:
⚠ 注意:当前使用 mock 数据,后续真实接口完成后替换
真实接口地址和返回字段未确定,请后续接口联调完成后再替换。
文档标记维持在此文件中,以便后续开发和对接。
---
# uni-app-x 页面修复指南
## 📋 文档概述
@@ -23,7 +32,18 @@
- `768px - 1199px`: 固定 2 列 (`grid-template-columns: repeat(2, minmax(0, 1fr))`)。
- `< 768px`: 固定 1 列 (`grid-template-columns: repeat(1, minmax(0, 1fr))`)。
4. 使用 `minmax(0, 1fr)` 配分子项 `min-width: 0` 确保在任何容器宽度下网格不被撑爆。
- **强制规则**: 任何页面都不允许出现一行 3 个卡片的情况。
- **强制规则**: 任何页面都不允许出现一行 3 个卡片的情况。(注:除非是类似“商品分类”样式的预览展示,需遵循下文响应式规则)
#### **原因二十七:响应式预览网格布局 (装饰/设计模块规范)**
- **现象**: 在小屏下预览手机模型重叠或在大屏下留白过多。
- **解决方案**:
1. 使用 `display: grid` 代替 `flex-wrap`
2. **三段式响应式**:
- `> 1250px`: `grid-template-columns: repeat(3, 1fr);` (一行 3 个)
- `700px ~ 1250px`: `grid-template-columns: repeat(2, 1fr);` (一行 2 个)
- `< 700px`: `grid-template-columns: 1fr;` (一行 1 个)
3. **Case Study**: `pages/mall/admin/decoration/category.uvue` (商品分类) 采用了此标准实现 1:1 视觉复刻。
#### **原因十三:侧边栏响应式断点与 Overlay 冲突 (严重体验红线)**
@@ -119,6 +139,73 @@
- `chart-col` 在桌面端 (>=1200) 使用中等高度 (约 320-360px)。
- 在移动端/窄屏 (<1200) 自动扩展为大高度 (约 500-600px),以匹配全宽展示的视觉张力。
#### **原因十九:布局组件依赖缺失导致的级联加载失败**
- **现象**: 控制台报 `GET http://.../AdminLayout.uvue?import net::ERR_CACHE_READ_FAILURE``TypeError: Failed to fetch dynamically imported module`
- **原因**: 核心布局组件(如 `AdminLayout.uvue`)在 `<script setup>` 中使用了 `watch``computed` 或其他 Vue API 但**未在顶部 import**。这会导致 JavaScript 语法解析错误,使整个模块加载失败。由于它是所有页面的父容器,会导致全站白屏且报错信息具有误导性(看似网络错误,实为语法错误)。
- **解决方案**:
1. 检查所有在 `setup` 块中使用的 Vue API 是否已显式导入:`import { ref, computed, watch, onMounted } from 'vue'`
2. 使用浏览器的 Network 面板查看失败的 `.uvue?import` 请求详情,查看看具体的语法错误堆栈。
#### **原因二十UVUE 组件导入路径不规范与生命周期误用**
- **现象**: 页面显示正常但控制台抛出 `Unhandled error during execution of async component loader``onLoad is not defined`
- **原因**:
1. **路径缺失后缀**: 在 UVUE 全局构建环境下,导入自定义 `.uvue` 组件必须显式包含 `.uvue` 后缀。
2. **生命周期冲突**: 在 `<script setup>` 语法糖中直接编写 `onLoad(() => {})` 而未从 `@dcloudio/uni-app` 导入。
- **解决方案**:
1. **强制后缀**: `import AdminLayout from '@/layouts/admin/AdminLayout.uvue'`
2. **规范生命周期**:
- 简单初始化建议统一使用 Vue 标准的 `onMounted(() => {})`
- 如需获取页面参数,通过 `getCurrentPages()` 获取当前页面实例的 `options`
- 禁止在 `setup` 顶层直接定义未声明的 `onLoad/onShow`
#### **原因二十一Admin 内部路由 DYNAMIC IMPORT 兼容性问题**
- **现象**: 在 Admin 后台切换菜单或打开特定维护页面时,控制台抛出 `TypeError: Failed to fetch dynamically imported module``ERR_CACHE_READ_FAILURE`
- **原因**:
1. **环境限制**: 在某些 `uni-app-x` 的 H5 Vite 编译环境下,针对 `.uvue` 文件的动态 `import()` 支持可能存在不稳定性。
2. **语法敏感**: 如果被异步加载的组件本身存在轻微的 UTS/Composition API 语法错误Vite 的 H5 运行时可能无法正确捕获并提示,而是直接抛出网络加载失败相关的错误。
- **解决方案**:
1. **转为静态导入**: 在 `adminComponentMap.uts` 顶部使用静态 `import` 导入所有管理端子页面组件。
2. **组件映射**: 维护 `componentMap` 为静态 Map避免在运行时使用 `defineAsyncComponent`,从而提高页面的加载成功率和抗语法错误风险。
#### **原因二十二Tab 切换高度抖动 (Tab Switching Height Jitter)**
- **现象**: 在包含多标签切换的配置页面中,切换不同标签时,由于内容块高度差异较大,导致整个容器或页面产生明显的跳动/收缩。
- **原因**: 每个 `v-if``v-show` 对应的内容高度不一致,且父容器没有设置 `min-height` 进行视觉对齐。
- **解决方案**: 为切换内容的公共父容器(如 `.config-body``.tab-content`)设置一个统一的 `min-height`(推荐 400px - 600px 之间,视表单复杂度而定),确保最矮的标签页下仍能撑开容器。
#### **原因二十三:循环依赖导致的 500 错误 (Circular Dependency & AdminLayout)**
- **现象**: 浏览器报 `500 Internal Server Error`,控制台显示 `Failed to fetch dynamically imported module``net::ERR_ABORTED`
- **原因**: 核心布局组件 `AdminLayout.uvue` 导入了 `adminComponentMap.uts`(用于动态渲染子页面),而子页面内部又通过 `import AdminLayout` 引用了布局组件。这种循环依赖在 Vite/UTS 环境下会导致整个加载链路崩溃。
- **解决方案**:
1. 所有在 `adminComponentMap.uts` 中注册的**子页面**Sub-pages**严禁**在 `<template>` 中包裹 `<AdminLayout>`,也**严禁**在 `<script>``import AdminLayout`
2. 布局由主入口统一提供,子页面只需编写内容区的 `<view>`
3. **例外**: 仅当某个页面是独立存在的(例如登录页、引导页),且需要独立布局时,才允许自行包装。
#### **原因二十四ECharts 图表不显示与容器高度塌陷 (Grid/Flex 嵌套陷阱)**
- **现象**: 页面加载后饼图/折线图区域空白,且高度为 0或仅显示为一条细线Grid 布局中左右卡片高度不一致(左高右低或反之)。
- **原因**:
1. **Grid 子项默认行为**: CSS Grid 的直接子项 (`.gender-col`) 默认是 `display: block`。若其内部卡片 (`.gender-card`) 设置了 `height: 100%`,在某些浏览器或特定嵌套层级下,如果父级没有显式高度(仅由兄弟元素撑开),`100%` 无法正确计算,导致高度塌陷。
2. **Canvas 依赖**: ECharts 的 Canvas 依赖父容器的实际像素高度。如果初始化时父容器高度为 0因 Flex 压缩或加载时序Canvas 就会渲染成 0x0。
3. **Margin 干扰**: 左侧卡片 (`AnalyticsUserMapTable`) 原本带有 `margin-bottom: 16px`。在 Grid 布局(`align-items: stretch` 默认)中Grid Cell 的高度包含了这个 Margin。这导致左侧卡片的 _可见背景区域_ 比右侧卡片 __ 了 16px因为 Margin 是透明的),视觉上顶部对齐但底部不对齐。
- **解决方案**:
1. **Grid 子项 Flex 化**:
```css
.map-col,
.gender-col {
min-width: 0;
display: flex; /* 关键:启用 Flex 上下文 */
flex-direction: column; /* 确保子元素垂直填充 */
}
```
这样 Grid Cell 变为 Flex Container其子元素 (`.gender-card`) 的 `height: 100%` 或 `flex: 1` 就能正确基于 Grid Cell 的最终高度进行计算。
2. **移除组件级 Margin**: Grid 布局应使用 `gap` 属性控制间距,组件自身 (`.user-map-card`) 不应自带外部 Margin否则会破坏等高计算。
3. **强制 Canvas 填充**: 图表组件容器需设置 `width: 100% !important; height: 100% !important;` 且 `position: absolute` (配合父级 relative) 或 `flex: 1`,确保填满 Flex 空间。
## 🛠️ 完整修复流程
```
@@ -1860,6 +1947,263 @@ const iconMap: Record<string, string> = {
- **CRMEB 路由映射**: 1:1 复刻 CRMEB 的路由和菜单结构
- **双侧边栏布局**: 主侧边栏(一级) + 二级侧边栏(分组)
## 🎯 阶段十八: Vue/Vite 编译失败导致的连锁依赖雪崩 (500 错误与动态导入阻断)
### **原因三十二SCSS 括号闭合错误引发的 ?import 连锁报错**
- **现象**:
1. 浏览器控制台出现核心组件的 SCSS 编译失败GET /pages/mall/admin/product/product-management/index.uvue?...&lang.scss 500
2. 随后出现警告:[Vue warn]: Unhandled error during execution of async component loader
3. 最终报错阻断页面级加载TypeError: Failed to fetch dynamically imported module: /pages/mall/admin/homePage/index.uvue?import
- **原因**:
在修改或合并页面(如整合目录结构)时,不慎破坏了 <style lang="scss"> 其中的结构(例如留下了一个多余的闭合大括号 } 或丢失了 </style>)。由于 Vite 处理 uni-app-x 时是按块编译的CSS 预处理报错会导致服务端直接向该组件抛出 **500 错误**。
在 "内部路由/状态驱动" 模式下,我们的系统依赖 dminComponentMap.uts 全量静态扫描所有的管理页面。一旦链路树中的某个子节点(例如 product-management/index.uvue发生了 500 编译失败,会导致整个模块依赖树发生雪崩。父级页面(如引了全局 Layout 的 homePage/index.uvue会因为底层的依赖断裂无法组装出正确的 JS 模块,最终导致 **动态导入失败** 的假象。
- **解决方案**:
1. **禁止盲目改路由****绝对不要**因为看到 homePage 报错就去重写 homePage 或者怀疑路由表配错了。
2. **顺藤摸瓜找源头**:沿着浏览器 Network 或者 Console 错误的最顶部往上翻,找到第一个且唯一一个抛出 500 的资源(在本例中是 lang.scss
3. **修复语法树**:回到那个触发 500 的文件,检查并修复 emplate、script、style 标签的闭锁以及其内部(特别是 SCSS 嵌套)的语法错误(如括号配对)。语法自洽后,整个异步组件树便会瞬间全量恢复正常。
- **防止复发规范**: 当执行文件全局批量替换或目录大迁移后,切勿遗留未闭合的代码块。修复问题必须采用“由底向外”的收敛原则。
---
这个指南现在涵盖了 uni-app-x 项目开发中最常见的 15 类问题(新增 CRMEB 路由体系复刻),为后续开发提供了完整的故障排除和最佳实践指导。 🚀
这个指南现在涵盖了 uni-app-x 项目开发中最常见的 17 类问题(新增动态导入与语法遮蔽解析),为后续开发提供了完整的故障排除和最佳实践指导。 🚀
### 原因二十一:动态导入 (Dynamic Import) 导致 H5 加载异常 (net::ERR_CACHE_READ_FAILURE)
**问题描述:**
在 H5 环境下,使用 `defineAsyncComponent` 或 Vite 默认的动态导入语法加载 `.uvue` 组件时,经常出现 `net::ERR_CACHE_READ_FAILURE` 错误。这通常是因为 UTS 编译器在处理动态分包时,无法正确生成或缓存对应的脚本模块。
**解决方案:**
1. **强制改为静态导入**:在路由配置文件(如 `adminComponentMap.uts`)中,不要使用 `() => import(...)`,而是直接使用顶层的 `import` 语句导入所有组件。
2. **中心化映射表**:建立一个中心化的组件映射表,利用 UTS 的强类型特性确保组件在编译期就被分析和包含。
### 原因二十二:语法错误导致模块加载失败 (Masked Syntax Errors)
**问题描述:**
某些 UTS 语法错误(如非标准的泛型写法 `reactive<T>` 或不兼容的脚本标签 `<script uts>`)在 H5 模式下不会直接报出详细的语法错误,而是会导致生成的 JS 模块无效,从而触发浏览器的 `net::ERR_CACHE_READ_FAILURE`。
**解决方案:**
1. **标准化 Script 标签**:统一使用 `<script setup lang="uts">` 或 `<script lang="uts">`(配合 `defineComponent`)。
2. **规范响应式声明**:避免在 `reactive` 上直接使用泛型(如 `reactive<T>(...)`),应使用类型断言 `reactive(...) as T`。
3. **避免遗留的 Options API 写法**:尽量将旧的 `export default { data(), methods() }` 结构转换为 Composition API 模式,以获得最佳的 UTS 编译支持。
### 原因二十三AdminLayout 循环依赖导致 500 错误 (Circular Dependency)
**问题描述:**
在 H5 环境下,为某个子页面添加 `AdminLayout` 包裹后Vite 或编译器报错 `500 Internal Server Error (ERR_ABORTED)`,且页面无法加载。
**原因:**
1. **依赖闭环**`AdminLayout` 导入了侧边栏和路由逻辑,路由逻辑引用了 `adminComponentMap.uts`,而映射表又导入了所有子页面组件。
2. **递归调用**:如果子页面组件再次导入 `AdminLayout` 试图自我包裹,就会形成死循环依赖,导致 Vite 开发服务器或编译后的模块加载失败。
**解决方案:**
1. **分层包裹架构**:所有注册在 `adminComponentMap.uts` 中的“子页面组件”**必须禁止** 导入和使用 `AdminLayout`。
2. **统一入口提供布局**:由父级容器(如 `admin/index.uvue` 或顶层路由组件)负责一次性提供 `AdminLayout` 框架。
### 原因二十四:标签切换/动态显显引起的高度抖动 (Layout Jitter)
**问题描述:**
使用 `v-if` 在不同配置标签间切换,或者动态显示/隐藏表单项时外部卡片Card的尺寸会突然跳动影响视觉稳定性。
**解决方案:**
1. **预设最小高度**:在 `config-card` 或 `config-body` 上显式设置 `min-height`(如 `550px` 或 `600px`),确保即使内容较少,容器高度也保持不变。
2. **容器钳制**:确保 `min-height` 足够覆盖该页面中所有可能出现的最高配置项组合。
### 原因二十五:边距一致性与像素级对齐 (Margin Consistency)
**问题描述:**
页面顶部标题、内容卡片、底部边距在不同页面不一致。例如:有些页面顶部紧贴 breadcrumb有些页面底部多出大量空白左右间距不统一。
**解决方案:**
1. **容器级 padding 置空**:子页面根组件(如 `.admin-page`)应设置 `padding: 0`,完全信任 `AdminLayout.uvue` 中 `.content-inner` 提供的 `20px` 标准边距 (`--admin-page-padding-desktop`)。
2. **三段式间距 (20px 规则)**
- **Header-to-Content Gap (20px)**: 顶部白色标题栏与下方内容卡片之间,统一定义 `margin-top: 20px`。
- **External Padding (20px)**: 由框架层 `.content-inner` 提供全局 20px 间距,确保页面四周留白均匀。
3. **内容区域一致性**:所有装修类的预览组件(如 `.preview-column`)和管理卡片(如 `.manage-card`)应通过这种 20px 的分层间距保持视觉节奏一致。
### 原因二十六:装修模块的“白色背景卡片”统一化 (Unified White Card Pattern)
**问题描述:**
装修Decoration和设计Design模块中预览区PhonePreview和设置区Settings如果背景色参差不齐灰白交替或使用独立阴影的小卡片会显得界面琐碎且不专业。
**解决方案:**
1. **大卡片容器化**:将左侧预览、中间预览、右侧配置等所有相关内容全部封装在一个 `.main-card` 或 `.card-container` 白色背景容器内,并使用统一阴影 (`0 2px 12px 0 rgba(0, 0, 0, 0.05)`)。
2. **示例应用 - 主题风格 (theme-style.uvue)**
- 移除原有的多个独立预览 Card统一至一个大背景。
- 颜色选择项ThemeItem重构为带有 1px 细边框和特定圆角的微型容器。
- 预览手机MockPhone间距固定为 30px并确保在容器内水平平铺。
3. **避免深色背景块**:在配置表单内部,尽量避免使用深灰色(如 `#f6f8fb`)背景块,改用白色背景加浅灰色细边框 (`1px solid #f0f0f0`),使整体视觉更加通透一致。
4. **内部列间距对齐**左边栏MenuSide、中预览PhonePreview、右配置Settings的内部 Padding 应维持在 30px-40px 左右,确保内容呼吸感一致。
### 原因二十七:首屏加载资源请求暴增 (Dev Mode Requests Spike)
**问题描述:**
浏览器 Network 面板显示瞬间 500+ 个 JS/Vue 文件请求,页面加载由于 Pending 状态卡顿。
**原因分析:**
- **Vite 开发机制**:在 HBuilderX 的开发模式下Vite 采用原生 ESM 加载,不进行文件合并。每一个 `.uvue`、`.uts`、脚本、样式都是独立的 HTTP 请求。
- **依赖引用过深**:首屏主页面引用了全量的公共样式或未优化的重型组件。
**解决方案:**
1. **构建验证**:明确 500+ 请求是“开发环境”特有现象。上线前执行 `npm run build`Vite 会自动将这些文件合并为极少数的 Chunk。
2. **样式按需导入**:避免在 `App.uvue` 中全局导入仅管理端使用的重型 CSS改为在 `AdminLayout.uvue` 或具体子包中局部导入。
### 原因二十八:路由冗余导致 H5 启动缓慢 (Pages.json 优化与分包)
**问题描述:**
`pages` 数组过大,导致 H5 下打包的入口主体体积过载,白屏时间长。
**解决方案:**
1. **分包化重构 (subPackages)**:将所有 Admin 管理页面按业务域(如 `admin/order`、`admin/product`)由主包 `pages` 移入 `subPackages`。
2. **首页最小化**:调整 `pages.json` 顺序,确保应用启动首屏(如 `login.uvue`)位于主包且依赖最少。
### 原因二十九Mock 模式下的认证后门与状态同步 (Auth Bypass)
**问题描述:**
本地调试时,由于 Supabase Session 过期或网络不通导致无法进入管理后台,且登录逻辑复杂时难以快速验证 UI。
**解决方案:**
1. **Admin Backdoor**:在 `login.uvue` 实现中加入 `admin/admin` 静态验证逻辑,直接操作 `store.uts` 的状态,绕过网络请求。
2. **状态注入规范**:手动登录成功后,必须显式调用 `setIsLoggedIn(true)` 和 `setUserProfile(adminProfile)`,否则会触发响应式布局逻辑失效。
3. **依赖引用补全**:在 `script setup` 中必须显式 `import { setIsLoggedIn, setUserProfile } from '@/utils/store.uts'`,避免 `ReferenceError`。
### 原因三十Vite 依赖预构建与手动拆包 (Manual Chunks)
**问题描述:**
生产环境下第三方大库Vue, Uni-App, AntD 等)与业务业务逻辑混在一起,导致每次局部代码更新都会使巨大的主包缓存失效。
**解决方案:**
在 `vite.config.js` 中配置 `build.rollupOptions.output.manualChunks`,将 `node_modules` 下的大型库强制拆分为独立文件(如 `vendor-vue`、`vendor-uni`)。这样首屏加载只需下载一次体积恒定的核心库,后续仅下载变化的业务代码。
### 原因三十一:跨域预检失败导致 REST 请求被浏览器拦截 (CORS Preflight)
**现象:**
- 控制台报错:
- `Access to XMLHttpRequest at 'http://192.168.1.61:9122/rest/v1/...' from origin 'http://localhost:5173' has been blocked by CORS policy`
- `Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header`
- `GET ... net::ERR_FAILED`
**原因分析:**
1. H5 调试环境的页面源是 `http://localhost:5173`,请求目标是 `http://192.168.1.61:9122`,属于跨域请求(协议/域名/IP/端口任一不同即跨域)。
2. 业务请求携带 `apikey`、`authorization`、`content-type` 等非简单请求头时,浏览器会先发 `OPTIONS` 预检请求。
3. 目标服务(通常是网关/Kong/Nginx对 `OPTIONS` 响应中缺少 `Access-Control-Allow-Origin`(以及允许方法、允许头),浏览器会在前端直接拦截后续 GET/POST。
4. 配置地址与实际请求地址不一致也会放大问题,例如项目配置可能是 `192.168.1.61:9122`,而实际报错请求是其他主机,两者很可能不是同一服务或同一网关配置。
**解决方案:**
1. **后端网关开启 CORS根本修复**
- 允许来源:`http://localhost:5173`
- 允许方法:`GET, POST, PUT, PATCH, DELETE, OPTIONS`
- 允许请求头:`apikey, authorization, content-type, prefer, x-client-info`
- 确保 `OPTIONS` 返回 `200/204` 且带完整 CORS 响应头
2. **本地开发使用 Vite 代理(前端临时规避)**
- 在 `vite.config.js` 配置 `server.proxy`,将 `/rest/v1`、`/auth/v1` 等路径代理到真实网关,避免浏览器直接跨域。
3. **统一 URL 配置**
- 检查 `ak/config.uts` 中 `SUPA_URL` 与实际发起请求的主机是否一致,避免请求落到未配置 CORS 的地址。
**快速排查命令(后端自检):**
```bash
curl -i -X OPTIONS "http://192.168.1.61:9122/rest/v1/ml_coupon_templates?select=*" \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: apikey,authorization,content-type"
```
若响应头中没有 `Access-Control-Allow-Origin`,即可确认是服务端 CORS 配置问题,而非前端语法或请求代码问题。
---
## 🎯 阶段十六: UI/UX与数据库集成深度打磨 (商品分类实战)
### **背景与目标**
本阶段以“商品分类 (`classification/index.uvue`)”页面为实战案例,彻底剔除前端 Mock 数据,深度结合真实 PostgreSQL 数据库Supabase结构进行前后端联调。同时打磨前端表单与自定义 UI 组件,以确保交互体验达到最佳标准。
### **核心技术栈与实战难点**
#### **1. 前后端数据类型的精确映射**
不同于简单的 JSON前端必须严格适配数据库设计的各类约束
- **UUID 约束**: 分类的 `id` 及 `parent_id` 必须由普通整数转为字符串UUID格式。
- **Array 数组类型**: 数据库中存储的 `path` 字段为 `text[]` (例如 `{"数码电器","智能手机"}`)。前端接收时需要正确将其反序列化为 UTS/JS 的 `string[]`。
- **UTSJSONObject 转换红线**: Supabase 在请求/返回时强制声明类型检查,嵌套类型或者未知格式会导致 UTS 强转 `as UTSJSONObject` 失败。我们需要利用 `.replace('{', '[').replace('}', ']')` 与 `JSON.parse` 显式完成 postgres `text[]` 到 JS 数组格式的清洗。
#### **2. 分类层级 (Path & Level)的自动计算**
对于具有树形结构的数据,新建或更新分类时:
- 新节点的 `level` 动态等于其选定父节点的 `level + 1`,默认为 1。
- 新节点的 `path` 必须完整继承父节点的 `path` 加当前节点自己的 `name`。
- **抽屉组件联动 (Drawer Form)**:实现在 `picker` 表单选择上级分类时,前端自动提取对应父分类对象,用于准备组装上述关联属性,大幅降低人工误操作。
#### **3. 自定义组件样式交互调优 (UX 打磨)**
对于模拟原生的 Switch 切换开关等手工 UI 元素 (`.switch-mock`),极易出现细微排版与手势体验不佳的问题。
- **失效的纵向居中**: `<text>` 作为 Flex 的子元素使用 `display: flex; align-items: center` 时经常无法触发完美的纵轴对齐。**解决方案**: 为包裹单行文字的 `text` class 指定与父容器等高的 `line-height` (`line-height: 24px;` 对齐父元素 `height: 24px`)。
- **热区与手势反馈**: 原生浏览器端缺失 pointer 样式会让用户认为该元素不可点击。需要在 `.switch-mock` 顶层增加 `cursor: pointer;`。
### **最佳实践总结 (最佳体验法则)**
1. **统一数据源 (Single Source of Truth)**:一旦存在后台建表规范 (如 `CONSUMER_DB_DOC`),前端 `interface` 层应100%镜像字段名,规避繁琐的字段隐射。
2. **渐进式数据增强 (Progressive Data Enhancement)**: 表格展示优先处理 `loadData`,并引入 `buildParentOptions` 把平铺的数据Flat Data映射到 `tree` 组件或下拉框Select/Picker
3. **细节决定体验**: 在处理 `div`/`view` 改写的组件时,必须补充缺失的交互态(鼠标悬浮、点击动效、内部绝对居中对齐)。
4. **组件化复用 (Componentization)**: 对于管理后台高频使用的 UI 元素(如状态开关),必须抽取为全局或业务组件(如 `StatusSwitch`),确保交互一致性,支持自定义文字(开启/显示、关闭/隐藏)并简化外部代码量。
---
## 🎯 阶段十七: 全局 UI 组件化与标准化 (StatusSwitch 实战)
### **背景与目标**
为了消除管理后台各子页面中“状态开关”实现不统一(样式各异、交互逻辑分散)的问题,本阶段实施了全局 `StatusSwitch` 组件的封装与存量页面自动化替换。
### **核心技术实现**
#### **1. 业务组件封装 (`StatusSwitch.uvue`)**
- **双向绑定**: 完美支持 `v-model` (uts 模式下的 `modelValue`)。
- **自定义语义化**: 通过 `activeText` 和 `inactiveText` 区分不同场景(如:开启/关闭、显示/隐藏、启用/禁用)。
- **像素级布局对齐**:
- 强制 `line-height` 等于容器高度 (24px),解决 uts 环境下文本垂直居中的顽疾。
- 响应式 `switch-dot` 偏移逻辑,确保开关滑块在开启态精确到位。
- 顶部容器追加 `cursor: pointer;` 增强 Web/H5 端的交互反馈。
#### **2. 存量代码“手术级”替换**
针对以下高频场景完成了组件替换:
- **商品分类 (`classification/index.uvue`)**: 剔除冗余的 20+ 行 CSS简化模板结构。
- **商品标签 (`labels/index.uvue`)**: 同时支持“状态”和“移动端展示(显示/隐藏)”两种不同语义。
- **商品保障 (`protection/index.uvue`)**: 统一各页面视觉断点。
### **标准化规范 (开发红线)**
- **禁止内联开关样式**: 凡涉及状态切换的开关,严禁在页面级定义 `.switch-mock` 或 `.status-switch-mini`。必须统一导入并使用 `@/components/StatusSwitch.uvue`。
- **接口同步**: 必须通过 `v-model` 绑定数据项属性,并在 `@change` 事件中通过 Supabase 完成数据库状态回写,确保 UI 与持久层同步。
- **文字对齐**: 任何时候文字都应在开关中央水平垂直居中,禁止出现文字偏上或偏下的视觉瑕疵。
---
这个指南现在涵盖了 uni-app-x 项目开发中最常见的 33 类问题(包含全局组件化最佳实践),为后续开发提供了完整的故障排除和标准化指导。 🚀

View File

@@ -1,27 +0,0 @@
import re
def find_mismatched_text_tags(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
stack = []
print(f"Scanning {file_path}...")
for i, line in enumerate(lines):
# Use a better regex to find opening and closing tags separately
open_tags = re.findall(r'<text\b', line)
close_tags = re.findall(r'</text>', line)
for _ in open_tags:
stack.append(i + 1)
for _ in close_tags:
if stack:
stack.pop()
else:
print(f" Extra closing tag </text> at line {i+1}")
for line_num in stack:
print(f" Unclosed <text> tag at line {line_num}")
find_mismatched_text_tags(r'd:\骅锋\mall\pages\mall\admin\order\order-configuration\index.uvue')
find_mismatched_text_tags(r'd:\骅锋\mall\pages\mall\admin\order\order-statistics\index.uvue')
find_mismatched_text_tags(r'd:\骅锋\mall\pages\mall\admin\order\list.uvue')

72
fixHeader.js Normal file
View File

@@ -0,0 +1,72 @@
const fs = require("fs");
const path = "D:/骅锋/mall/layouts/admin/components/AdminHeader.uvue";
let text = fs.readFileSync(path, "utf-8");
const targetScript = `<script setup lang="uts">
import { ref, computed } from 'vue'
import {
toggleSubSider,
showSubSider,
layoutMode,
isOverlayVisible,
isMobileMenuOpen,
openRoute
} from '@/layouts/admin/store/adminNavStore.uts'
import { state, logout } from '@/utils/store.uts'
const showUserMenu = ref(false)
const userName = computed((): string => state.userProfile.username || state.userProfile.email || 'admin')
let menuTimer: number | null = null
function handleMouseEnter() {
if (menuTimer !== null) {
clearTimeout(menuTimer)
menuTimer = null
}
showUserMenu.value = true
}
function handleMouseLeave() {
menuTimer = setTimeout(() => {
showUserMenu.value = false
}, 300) as unknown as number
}
function toggleUserMenu() {
showUserMenu.value = !showUserMenu.value
}
function goToUserCenter() {
showUserMenu.value = false
openRoute('home_user_center')
}
function handleLogout() {
showUserMenu.value = false
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
logout()
uni.removeStorageSync('adminRole')
uni.removeStorageSync('token')
uni.reLaunch({
url: '/pages/user/login'
})
}
}
})
}
const props = defineProps<{`;
text = text.replace(
/<script setup lang=["']uts["']>[\s\S]*?const props = defineProps</,
targetScript + "",
);
fs.writeFileSync(path, text, "utf-8");
console.log("Done");

146
fixUser.js Normal file
View File

@@ -0,0 +1,146 @@
const fs = require("fs");
const path = "D:/骅锋/mall/pages/mall/admin/userCenter/index.uvue";
let text = fs.readFileSync(path, "utf-8");
const targetScript = `<script setup lang="uts">
import { reactive, computed, onMounted } from 'vue'
import { state, logout } from '@/utils/store.uts'
import { supa } from '@/utils/supabase.uts'
const userAccount = computed((): string => state.userProfile.email || 'demo@example.com')
const avatarUrl = computed((): string => state.userProfile.avatar_url || '/static/logo.png')
const formData = reactive({
name: '',
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
onMounted(() => {
formData.name = state.userProfile.username || ''
})
const onSubmit = async () => {
if (formData.name.trim() == '') {
uni.showToast({ title: '姓名不能为空', icon: 'none' })
return
}
// 修改密码逻辑
if (formData.oldPassword != '' || formData.newPassword != '' || formData.confirmPassword != '') {
if (formData.oldPassword == '') {
uni.showToast({ title: '需要输入原始密码', icon: 'none' })
return
}
if (formData.newPassword == '') {
uni.showToast({ title: '新密码不能为空', icon: 'none' })
return
}
if (formData.newPassword !== formData.confirmPassword) {
uni.showToast({ title: '两次输入的新密码不一致', icon: 'none' })
return
}
if (formData.newPassword === formData.oldPassword) {
uni.showToast({ title: '新密码不能与原始密码相同', icon: 'none' })
return
}
uni.showLoading({ title: '验证并提交中...' })
try {
const email = state.userProfile.email
if (email == '') {
uni.hideLoading()
uni.showToast({ title: '账号缺失,无法验证', icon: 'none' })
return
}
// 1. 验证原密码
const resSignIn = await supa.auth.signInWithPassword({
email: email,
password: formData.oldPassword
})
if (resSignIn.error != null) {
uni.hideLoading()
uni.showToast({ title: '原密码错误', icon: 'none' })
return
}
// 2. 更新新密码
const resUpdate = await supa.auth.updateUser({
password: formData.newPassword
})
if (resUpdate.error != null) {
uni.hideLoading()
uni.showToast({ title: '密码更新失败', icon: 'none' })
return
}
// 3. 同时更新姓名
if (formData.name !== state.userProfile.username) {
await supa.from('ak_users').update({
username: formData.name
}).eq('id', state.userProfile.id)
}
uni.hideLoading()
uni.showToast({ title: '修改成功, 请重新登录', icon: 'success' })
// 退出登录
setTimeout(() => {
logout()
uni.removeStorageSync('adminRole')
uni.removeStorageSync('token')
uni.reLaunch({
url: '/pages/user/login'
})
}, 1500)
return
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络异常', icon: 'none' })
return
}
}
// 仅修改基本信息
if (formData.name !== state.userProfile.username) {
uni.showLoading({ title: '保存中...' })
try {
const res = await supa.from('ak_users').update({
username: formData.name
}).eq('id', state.userProfile.id)
if (res.error != null) {
uni.hideLoading()
uni.showToast({ title: '保存失败', icon: 'none' })
return
}
state.userProfile.username = formData.name
uni.hideLoading()
uni.showToast({ title: '保存成功', icon: 'success' })
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络异常', icon: 'none' })
}
} else {
uni.showToast({ title: '无修改内容', icon: 'none' })
}
}
</script>`;
text = text.replace(
/<script setup lang=["']uts["']>[\s\S]*?<\/script>/,
targetScript,
);
text = text.replace(/value="demo"/, ':value="userAccount"');
text = text.replace(/src="\/static\/logo\.png"/, ':src="avatarUrl"');
fs.writeFileSync(path, text, "utf-8");
console.log("Done");

View File

@@ -1,5 +1,9 @@
<template>
<template>
<view class="layout-root">
<view v-if="!isAuthReady" class="auth-loading-overlay" style="flex: 1; display: flex; align-items: center; justify-content: center; height: 100vh; background-color: #f5f5f5;">
<text style="color: #666; font-size: 16px;">身份鉴权中...</text>
</view>
<template v-else>
<!-- 统一遮罩层 (复刻 CRMEB: 用于所有 Overlay 状态) -->
<view
class="mobile-mask"
@@ -17,7 +21,7 @@
@toggle="toggleMainAsideCollapse"
@menu-click="onTopMenuClick"
:asideWidth="ASIDE_W"
/>
></AdminAside>
<!-- 二级侧边栏 (1:1 复刻 CRMEB 抽屉/Dock 平滑切换) -->
<AdminSubSider
@@ -31,7 +35,7 @@
:asideWidth="layoutMode === 'mobile' ? 0 : ASIDE_W"
:width="SUB_W"
@sub-click="onSubClick"
/>
></AdminSubSider>
<!-- 右侧内容区 -->
<view
@@ -46,7 +50,7 @@
@search="onSearch"
@refresh="onRefresh"
@notify="onNotify"
/>
></AdminHeader>
<!-- 标签页 (CRMEB风格) - 移动端可以隐藏或滚动 -->
<AdminTagsView
@@ -58,25 +62,24 @@
@close-other="onCloseOther"
@close-all="onCloseAll"
@refresh="onRefresh"
/>
></AdminTagsView>
<!-- 内容展示区 (内部路由渲染) -->
<view class="content-scroll">
<view class="content-inner" :class="{ 'is-mobile': isMobile }">
<slot></slot>
<component :is="currentComponent" v-if="!isPageLoading && currentComponent != null" />
<AdminPageLoading v-if="isPageLoading" />
<slot v-if="hasAccess"></slot>
<component :is="currentComponent" v-if="hasAccess && !isPageLoading && currentComponent != null"></component>
<AdminPageLoading v-if="isPageLoading"></AdminPageLoading>
</view>
<AdminFooter />
<AdminFooter></AdminFooter>
</view>
</view>
</template>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
import { getCurrentUser } from '@/utils/store.uts'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import AdminAside from '@/layouts/admin/components/AdminAside.uvue'
import AdminSubSider from '@/layouts/admin/components/AdminSubSider.uvue'
import AdminHeader from '@/layouts/admin/components/AdminHeader.uvue'
@@ -113,11 +116,14 @@ import {
closeAllTabs,
toggleMainAsideCollapse as storeToggleCollapse,
toggleSubSider as storeToggleSubSider,
initNavState
initNavState,
restoreNavState
} from '@/layouts/admin/store/adminNavStore.uts'
import type { TabItem } from '@/layouts/admin/store/adminNavStore.uts'
import { getComponent } from '@/layouts/admin/router/adminComponentMap.uts'
import { hasAdminModuleAccess } from '@/layouts/admin/utils/role.uts'
import { ensureAdminSession, handleSessionExpired } from '@/layouts/admin/utils/adminAuth.uts'
const props = defineProps({
currentPage: {
@@ -132,6 +138,13 @@ const SUB_W = 200
// 页面加载状态
const isPageLoading = ref(false)
const isAuthReady = ref(false)
const hasAccess = computed<boolean>(() => {
return hasAdminModuleAccess(activeTopMenuId.value)
})
const hasNotification = ref<boolean>(false)
@@ -260,6 +273,8 @@ const breadcrumb = computed<Array<{id: string, title: string}>>(() => {
// 当前渲染的组件
const currentComponent = computed<any>(() => {
const route = findRouteById(activeRouteId.value)
if (!route) return null
return getComponent(route.componentKey)
@@ -383,22 +398,39 @@ function onNotify(): void {
let resizeTid: any = null
onMounted(async () => {
if (!ensureAnalyticsLogin({ toastTitle: '请先登录以访问管理后台' })) return
const profile = await getCurrentUser()
const role = profile?.role
if (!role || !['admin', 'analytics'].includes(role)) {
uni.showToast({ title: '权限不足', icon: 'none' })
setTimeout(() => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}, 800)
// 挂载鉴权相关的事件监听器,并在首次进入时阻断认证
try {
const isOk = await ensureAdminSession()
if (!isOk) {
return // 鉴权失败内部已处理跳转,终止后续挂载
}
} catch (e) {
handleSessionExpired()
return
}
uni.$on('AUTH_SESSION_EXPIRED', () => { handleSessionExpired() })
// 增加 visibilitychange 监听:在用户电脑休眠或者长时间放置重新激活时,核验会话
// #ifdef H5
if (typeof window !== 'undefined') {
window.addEventListener('visibilitychange', handleVisibilityChange)
}
// #endif
isAuthReady.value = true
// 第二段:尝试从 sessionStorage 恢复上次打开的页签状态
// restoreNavState 会校验路由有效性和权限,过滤无效项后重建 tabs
const restored = restoreNavState()
if (!restored) {
// 首次打开 / 缓存为空 / 恢复失败 → 走默认初始化只有首页tab
initNavState()
if (props.currentPage != '') {
openRoute(props.currentPage as string)
}
}
// restored=true 时activeRouteId 已由 restoreNavState 正确设置,无需额外跳转
// 初始化窗口宽度
windowWidth.value = uni.getWindowInfo().windowWidth
@@ -428,20 +460,48 @@ onMounted(async () => {
})
})
})
// ===== Auth State Handlers =====
let _lastVisibilityCheck = 0
const handleVisibilityChange = async () => {
// #ifdef H5
if (document.visibilityState === 'visible') {
const now = Date.now()
// 节流机制,防止频繁切换标签页产生过多校验
if (now - _lastVisibilityCheck < 10000) return
_lastVisibilityCheck = now
// 简易验证即可,如果底层 token 已经失效而 fetch 拿不到,就会报出 401 触发全局 AUTH_SESSION_EXPIRED
try {
await ensureAdminSession()
} catch(e) {}
}
// #endif
}
onUnmounted(() => {
uni.$off('AUTH_SESSION_EXPIRED')
// #ifdef H5
if (typeof window !== 'undefined') {
window.removeEventListener('visibilitychange', handleVisibilityChange)
}
// #endif
})
</script>
<style scoped lang="scss">
.layout-root {
--admin-page-padding-desktop: 12px;
--admin-page-padding-mobile: 8px;
--admin-section-gap: 12px;
--admin-card-padding: 16px;
--admin-page-padding-desktop: 20px;
--admin-page-padding-mobile: 10px;
--admin-section-gap: 20px;
--admin-card-padding: 24px;
display: flex;
flex-direction: row;
width: 100%;
min-height: 100vh;
background: #f0f2f5;
background: #f5f7f9;
position: relative;
}
@@ -485,7 +545,7 @@ onMounted(async () => {
flex-direction: column;
min-height: 100vh;
transition: margin-left 300ms ease;
background: #f0f2f5;
background: #f5f7f9;
width: 100%;
}
@@ -515,7 +575,7 @@ onMounted(async () => {
flex: 1;
overflow-y: scroll;
overflow-x: auto; /* 允许横向滚动,兼容极端窄屏 */
background: #f0f2f5;
background: #f5f7f9;
padding: 0;
}

View File

@@ -1,208 +0,0 @@
# 🎉 CRMEB 路由系统清理完成
## 清理日期
2026年2月2日
## 清理内容
### 1. pages.json 配置清理
**删除了整个 pages/mall/admin 子包配置**
- 移除60+ 个旧管理页面配置
- 减少:从 80+ KB → 12.4 KB
- 保留:主入口 `pages/mall/admin/homePage/index`
**清理前的 subPackages:**
```json
{
"root": "pages/mall/admin",
"pages": [
{ "path": "content/index", ... },
{ "path": "design/index", ... },
{ "path": "user-management", ... },
// ... 57 more pages ...
]
}
```
**清理后的 subPackages:**
- pages/mall/consumer (消费端)
- pages/mall/delivery (配送端)
- pages/mall/analytics (数据分析)
- pages/mall/merchant (商家中心)
- pages/mall/service (客服工作台)
### 2. 废弃文件删除
**删除:`layouts/admin/utils/menu.uts`**
- 原因:使用旧路径格式(如 `/pages/mall/admin/user-management`
- 替代adminRoutes.uts 使用规范路径(如 `/pages/mall/admin/user/list`
- 确认:无任何文件引用此文件
### 3. 代码重复清理(之前完成)
**AdminLayout.uvue: 394行 → 227行**
- 删除45+ 行重复的导航代码
- 保留:纯 CRMEB 内部路由逻辑
## 警告说明
### Vue Router 警告(可安全忽略)
```
[Vue Router warn]: No match found for location with path "/pages/mall/admin/user-management?action=config"
```
**为什么出现:**
- uni-app-x 框架在初始化时检测到旧路由引用
- 或某些历史代码尝试注册路由
**为什么可以忽略:**
- ✅ 管理后台使用**内部路由系统**state-driven不依赖 Vue Router
- ✅ 路由切换通过 `openRoute()``<component :is="currentComponent" />` 实现
- ✅ adminRoutes.uts 配置完整正确
- ✅ 不影响功能运行
## 当前架构
### 路由系统文件结构
```
layouts/admin/
├── router/
│ ├── adminRoutes.uts ← 核心路由配置9个顶级菜单30+路由)
│ └── adminComponentMap.uts ← 组件映射30+组件静态导入)
├── store/
│ └── adminNavStore.uts ← 导航状态管理(标签页、菜单选中)
└── AdminLayout.uvue ← 布局容器227行纯净
```
### 路由配置示例
```typescript
// adminRoutes.uts 中的正确格式
{
id: 'user_list',
title: '用户管理',
path: '/pages/mall/admin/user/list', // ✅ 规范路径
componentKey: 'UserList',
parentId: 'user',
groupId: 'user-manage'
}
// ❌ 旧 menu.uts 的错误格式(已删除)
{
id: 'user-list',
title: '用户管理',
path: '/pages/mall/admin/user-management' // ❌ 不规范
}
```
## 验证结果
### 文件系统
```powershell
pages.json: 526 lines, 12.4 KB
AdminLayout.uvue: 227 lines
adminRoutes.uts: 564 lines
废弃文件已删除: menu.uts
```
### 编译状态
```
✅ JSON 语法: 正确
✅ ESLint: 仅警告vue/comment-directive无致命错误
✅ 500 错误: 已消除Vite 不再预加载 60+ 旧页面)
```
### 保留的 subPackages
```json
{
"subPackages": [
{ "root": "pages/mall/consumer" }, // 消费端 (8页)
{ "root": "pages/mall/delivery" }, // 配送端 (6页)
{ "root": "pages/mall/analytics" }, // 数据分析 (5页)
{ "root": "pages/mall/merchant" }, // 商家中心 (3页)
{ "root": "pages/mall/service" } // 客服 (3页)
]
}
```
## 系统运行说明
### 管理后台路由流程
1. **入口加载**: `pages/mall/admin/homePage/index` → AdminLayout.uvue
2. **内部路由**: adminNavStore.openRoute() → 更新 activeRouteId
3. **组件切换**: computed currentComponent → adminComponentMap.get(componentKey)
4. **渲染**: `<component :is="currentComponent" />`
### 无需 pages.json 配置
管理后台的所有 30+ 页面路由都通过内部路由系统管理,**不需要在 pages.json 中配置**。这就是为什么可以安全删除 pages/mall/admin 子包配置。
### 标签页系统
- 默认固定: 首页home_index
- 动态添加: 点击菜单时自动添加到 tabs 数组
- 状态持久: ref/computed 响应式管理
## 下一步测试
### 建议测试流程
1. **启动开发服务器**
```bash
npm run dev:h5
```
2. **检查浏览器控制台**
- 应该没有 404/500 错误
- Vue Router 警告可忽略(一次性,不影响功能)
3. **功能测试**
- ✅ 顶部菜单切换9个菜单
- ✅ 侧边栏导航
- ✅ 标签页操作(打开/关闭)
- ✅ 组件渲染30+ PlaceholderPage
4. **性能验证**
- 页面加载速度(不再预加载 60+ 无用页面)
- 内存占用(静态组件映射)
## 总结
**已完成:**
- pages.json 清理(删除 60+ 页配置,减少 70KB
- AdminLayout.uvue 代码去重(删除 45+ 行)
- 废弃文件删除menu.uts
- 架构统一(全部使用 adminRoutes.uts
🎯 **核心优势:**
- **内部路由系统**:不依赖 uni.navigateTo() 或 Vue Router
- **状态驱动**ref/computed 实现响应式路由
- **静态映射**所有组件预导入uni-app-x 限制)
- **CRMEB 1:1**:完整复刻 CRMEB v5 路由体系
🔍 **可安全忽略的警告:**
- Vue Router 警告(框架初始化时的历史遗留检测)
- vue/comment-directive ESLint 警告(代码注释格式)
---
**🎊 路由系统清理完成!系统已就绪可供测试。**

View File

@@ -53,11 +53,15 @@ function getIconText(icon: string): string {
'product': '📦',
'order': '📜',
'marketing': '📉',
'content': '📝',
'share': '✈️',
'customer-service': '💬',
'finance': '💰',
'statistic': '📊',
'content': '📝',
'decoration': '🎨',
'app': '📱',
'setting': '⚙️',
'maintenance': '🛠️'
'tool': '🛠️',
'statistic': '📊'
}
return iconMap[icon] || icon.charAt(0).toUpperCase()
}

View File

@@ -6,7 +6,7 @@
<text class="menu-icon">☰</text>
</view>
<!-- Desktop/Tablet Hamburger (1:1 复刻 CRMEB 切换二级侧边栏) -->
<!-- Desktop/Tablet Hamburger -->
<view class="menu-toggle desktop-only" @click="onToggleSubSider">
<text class="menu-icon">☰</text>
</view>
@@ -18,30 +18,125 @@
</text>
</view>
<!-- 移动端简单标题 (CSS 控制显隐) -->
<text class="mobile-title mobile-only">{{ currentTitle }}</text>
</view>
<view class="header-right">
<view class="hbtn" @click="$emit('search')"><text>🔍</text></view>
<view v-if="!isMobile" class="hbtn" @click="$emit('refresh')"><text></text></view>
<view v-if="!isMobile" class="hbtn" @click="$emit('refresh')"><text>🔄</text></view>
<view class="hbtn" @click="$emit('notify')">
<text>🔔</text>
<view class="dot" v-if="hasNotification"></view>
</view>
<!-- 用户个人中心 / 下拉菜单 -->
<view class="admin-user-menu"
@mouseenter="handleUserMenuOver"
@mouseleave="handleUserMenuLeave"
>
<view class="admin-user-trigger" @click="toggleUserMenu">
<text class="user-name">{{ userName }}</text>
<text class="user-arrow">▼</text>
</view>
<view v-if="showUserMenu" class="admin-user-dropdown">
<view class="admin-user-dropdown-item" @click.stop="goToUserCenter">
<text class="admin-user-dropdown-text">个人中心</text>
</view>
<view class="admin-user-dropdown-item" @click.stop="handleLogout">
<text class="admin-user-dropdown-text">退出登录</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { computed } from 'vue'
import { ref, computed } from 'vue'
import {
toggleSubSider,
showSubSider,
layoutMode,
isOverlayVisible,
isMobileMenuOpen
isMobileMenuOpen,
openRoute
} from '@/layouts/admin/store/adminNavStore.uts'
import { state, logout } from '@/utils/store.uts'
import { clearAdminRoleCache, getCurrentAdminRole } from '@/layouts/admin/utils/role.uts'
const showUserMenu = ref(false)
const userName = computed((): string => {
if (state.userProfile?.username != null && state.userProfile!.username != '') return state.userProfile!.username as string
if (state.authUser != null) {
if (state.authUser!.getString('email') != null && state.authUser!.getString('email') != '') return state.authUser!.getString('email') as string
if (state.authUser!.getString('phone') != null && state.authUser!.getString('phone') != '') return state.authUser!.getString('phone') as string
if (state.authUser!.getString('id') != null && state.authUser!.getString('id') != '') return (state.authUser!.getString('id') as string).substring(0, 8)
}
return '未知用户'
})
let hideMenuTimer: number | null = null
function handleUserMenuOver(e: any) {
// #ifdef H5
if (hideMenuTimer !== null) {
clearTimeout(hideMenuTimer as number)
hideMenuTimer = null
}
showUserMenu.value = true
// #endif
}
function handleUserMenuLeave(e: any) {
// #ifdef H5
if (hideMenuTimer !== null) {
clearTimeout(hideMenuTimer as number)
}
hideMenuTimer = setTimeout(() => {
showUserMenu.value = false
hideMenuTimer = null
}, 150) as number
// #endif
}
function toggleUserMenu(e: any) {
if (e && typeof e.stopPropagation === 'function') {
e.stopPropagation()
}
showUserMenu.value = !showUserMenu.value
}
function goToUserCenter(e: any) {
if (e && typeof e.stopPropagation === 'function') {
e.stopPropagation()
}
showUserMenu.value = false
openRoute('home_user_center')
}
function handleLogout(e: any) {
if (e && typeof e.stopPropagation === 'function') {
e.stopPropagation()
}
showUserMenu.value = false
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
logout()
clearAdminRoleCache()
uni.removeStorageSync('token')
// Force the layout cleanup to wait for the dialog to disappear
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
}, 300)
}
}
})
}
const props = defineProps<{
breadcrumb: Array<{id: string, title: string}>
@@ -56,12 +151,6 @@ defineEmits<{
(e:'toggle-mobile-menu'): void
}>()
/**
* 核心切换逻辑:
* 1. Desktop: 切换 showSubSider (Dock状态)
* 2. Tablet: 切换 isOverlayVisible (Overlay状态)
* 3. Mobile: 切换 isMobileMenuOpen (Mobile Aside)
*/
function onToggleSubSider(): void {
if (layoutMode.value === 'desktop') {
toggleSubSider()
@@ -82,6 +171,9 @@ const currentTitle = computed((): string => {
<style>
.header {
overflow: visible;
position: relative;
z-index: 1001;
height: 56px;
background: #fff;
border-bottom: 1px solid #eef2f7;
@@ -105,6 +197,7 @@ const currentTitle = computed((): string => {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.menu-icon {
@@ -152,11 +245,17 @@ const currentTitle = computed((): string => {
}
.header-right {
overflow: visible;
position: relative;
z-index: 100;
pointer-events: auto;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
height: 100%;
}
.hbtn {
width: 34px;
height: 34px;
@@ -166,7 +265,9 @@ const currentTitle = computed((): string => {
justify-content: center;
background: #f6f7fb;
position: relative;
cursor: pointer;
}
.dot {
width: 8px;
height: 8px;
@@ -176,4 +277,75 @@ const currentTitle = computed((): string => {
top: 6px;
right: 6px;
}
/* 后台用户菜单部分 */
.admin-user-menu {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
height: 100%; /* 和 header 高度一致,确保悬停不中断 */
overflow: visible;
}
.admin-user-trigger {
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
padding: 0 10px;
height: 100%;
}
.user-name {
font-size: 14px;
color: #333;
}
.user-arrow {
font-size: 12px;
color: #666;
margin-left: 4px;
}
.admin-user-dropdown {
position: absolute;
top: 56px;
right: 0;
min-width: 120px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
z-index: 10000;
overflow: visible;
display: flex;
flex-direction: column;
padding: 5px 0;
}
.admin-user-dropdown-item {
height: 40px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0 20px;
}
.admin-user-dropdown-text {
font-size: 14px;
color: #333;
line-height: 40px;
}
/* #ifdef H5 */
.admin-user-dropdown-item:hover {
background-color: #f5f7fa;
}
.admin-user-dropdown-item:hover .admin-user-dropdown-text {
color: #1890ff;
}
/* #endif */
</style>

View File

@@ -6,25 +6,180 @@
* value: 组件引用
*
* 注意:
* 1. 组件已切换为 defineAsyncComponent 异步导入,优化 H5 环境下的加载性能与包体积
* 1. 组件已切换为 静态导入 (Static Import),以解决 H5 环境下的加载异常 (net::ERR_CACHE_READ_FAILURE)
* 2. 组件路径使用 @ 别名
* 3. 占位组件统一使用 PlaceholderPage
*/
import { defineAsyncComponent } from 'vue'
// 导入占位组件
import PlaceholderPage from '@/layouts/admin/components/PlaceholderPage.uvue'
// 导入首页(内部组件,不包含 AdminLayout
import HomeIndex from '@/layouts/admin/pages/HomeIndex.uvue'
import UserCenter from '@/pages/mall/admin/userCenter/index.uvue'
// 用户、商品、订单模块已改为 defineAsyncComponent 异步加载,移除静态导入以优化 H5 加载性能
// --- 店铺模块 ---
import ShopManage from '@/pages/mall/admin/shop/manage.uvue'
import ShopCreate from '@/pages/mall/admin/shop/create.uvue'
// 营销设置模块暂时使用 PlaceholderPage
// 避免循环依赖问题
// --- 用户模块 ---
import UserStatistic from '@/pages/mall/admin/user/statistics/index.uvue'
import UserList from '@/pages/mall/admin/user/management/index.uvue'
import UserLevel from '@/pages/mall/admin/user/level/index.uvue'
import UserGroup from '@/pages/mall/admin/user/grouping/index.uvue'
import UserLabel from '@/pages/mall/admin/user/label/index.uvue'
import UserMemberConfig from '@/pages/mall/admin/user/config/index.uvue'
// 营销、内容、财务、客服、装修等模块已改为 defineAsyncComponent 异步加载,移除静态导入以优化 H5 加载性能
// --- 商品模块 ---
import ProductStatistic from '@/pages/mall/admin/product/product-statistics/index.uvue'
import ProductList from '@/pages/mall/admin/product/product-management/index.uvue'
import ProductEdit from '@/pages/mall/admin/product/product-management/components/edit.uvue'
import ProductMemberPrice from '@/pages/mall/admin/product/product-management/components/member-price.uvue'
import ProductClassify from '@/pages/mall/admin/product/classification/index.uvue'
import ProductReply from '@/pages/mall/admin/product/reviews/index.uvue'
import ProductAttr from '@/pages/mall/admin/product/specifications/index.uvue'
import ProductParam from '@/pages/mall/admin/product/parameters/index.uvue'
import ProductLabel from '@/pages/mall/admin/product/labels/index.uvue'
import ProductProtection from '@/pages/mall/admin/product/protection/index.uvue'
// --- 订单模块 ---
import OrderList from '@/pages/mall/admin/order/order-management/index.uvue'
import OrderStatistic from '@/pages/mall/admin/order/order-statistics/index.uvue'
import OrderRefund from '@/pages/mall/admin/order/aftersales-order/index.uvue'
import OrderCashier from '@/pages/mall/admin/order/cashier-order/index.uvue'
import OrderVerify from '@/pages/mall/admin/order/write-off-records/index.uvue'
import OrderConfig from '@/pages/mall/admin/order/order-configuration/index.uvue'
// --- 营销模块 ---
import MarketingCouponList from '@/pages/mall/admin/marketing/coupon/coupon-list/index.uvue'
import MarketingCouponUser from '@/pages/mall/admin/marketing/coupon/claim-record/index.uvue'
import MarketingIntegralStatistic from '@/pages/mall/admin/marketing/points/statistics/index.uvue'
import MarketingIntegralProduct from '@/pages/mall/admin/marketing/points/products/index.uvue'
import MarketingIntegralOrder from '@/pages/mall/admin/marketing/points/orders/index.uvue'
import MarketingIntegralRecord from '@/pages/mall/admin/marketing/points/record/index.uvue'
import MarketingIntegralConfig from '@/pages/mall/admin/marketing/points/config/index.uvue'
import MarketingLotteryList from '@/pages/mall/admin/marketing/lottery/list/index.uvue'
import MarketingLotteryConfig from '@/pages/mall/admin/marketing/lottery/configuration/index.uvue'
import MarketingBargainProduct from '@/pages/mall/admin/marketing/bargain/products/index.uvue'
import MarketingBargainList from '@/pages/mall/admin/marketing/bargain/list/index.uvue'
import MarketingCombinationProduct from '@/pages/mall/admin/marketing/combination/products/index.uvue'
import MarketingCombinationList from '@/pages/mall/admin/marketing/combination/list/index.uvue'
import MarketingCombinationCreate from '@/pages/mall/admin/marketing/combination/index.uvue'
import MarketingSeckillList from '@/pages/mall/admin/marketing/seckill/list/index.uvue'
import MarketingSeckillProduct from '@/pages/mall/admin/marketing/seckill/products/index.uvue'
import MarketingSeckillConfig from '@/pages/mall/admin/marketing/seckill/config/index.uvue'
import MarketingMemberType from '@/pages/mall/admin/marketing/member/type/index.uvue'
import MarketingMemberRight from '@/pages/mall/admin/marketing/member/right/index.uvue'
import MarketingMemberCard from '@/pages/mall/admin/marketing/member/kami-membership/index.uvue'
import MarketingMemberRecord from '@/pages/mall/admin/marketing/member/record/index.uvue'
import MarketingMemberConfig from '@/pages/mall/admin/marketing/member/config/index.uvue'
import MarketingLiveRoom from '@/pages/mall/admin/marketing/live/live-management/index.uvue'
import MarketingLiveProduct from '@/pages/mall/admin/marketing/live/products-management/index.uvue'
import MarketingLiveAnchor from '@/pages/mall/admin/marketing/live/streamer-management/index.uvue'
import MarketingRechargeQuota from '@/pages/mall/admin/marketing/recharge/amount-setting/index.uvue'
import MarketingRechargeConfig from '@/pages/mall/admin/marketing/recharge/config/index.uvue'
import MarketingCheckinConfig from '@/pages/mall/admin/marketing/checkin/config/index.uvue'
import MarketingCheckinReward from '@/pages/mall/admin/marketing/checkin/reward/index.uvue'
import MarketingNewcomerGift from '@/pages/mall/admin/marketing/newcomer/index.uvue'
import MarketingStatisticIndex from '@/pages/mall/admin/marketing/marketing-statistics/index.uvue'
// --- 内容模块 ---
import CmsArticle from '@/pages/mall/admin/cms/article/index.uvue'
import CmsCategory from '@/pages/mall/admin/cms/category/index.uvue'
// --- 财务模块 ---
import FinanceTransactionStats from '@/pages/mall/admin/finance/transaction-statistics/index.uvue'
import FinanceWithdrawal from '@/pages/mall/admin/finance/finance-operations/request/index.uvue'
import FinanceInvoice from '@/pages/mall/admin/finance/finance-operations/management/index.uvue'
import FinanceRecharge from '@/pages/mall/admin/finance/finance-record/recharge-record/index.uvue'
import FinanceCapitalFlow from '@/pages/mall/admin/finance/finance-record/flow/index.uvue'
import FinanceBill from '@/pages/mall/admin/finance/finance-record/billing-record/index.uvue'
import FinanceCommission from '@/pages/mall/admin/finance/commission-record/index.uvue'
import FinanceBalanceStats from '@/pages/mall/admin/finance/balance-record/statistics/index.uvue'
import FinanceBalanceRecord from '@/pages/mall/admin/finance/balance-record/record/index.uvue'
// --- 设置模块 ---
import SettingSystemConfig from '@/pages/mall/admin/setting/system/index.uvue'
import SettingMessageIndex from '@/pages/mall/admin/setting/message/index.uvue'
import SettingProtocolIndex from '@/pages/mall/admin/setting/agreement/index.uvue'
import SettingTicketIndex from '@/pages/mall/admin/setting/receipt/index.uvue'
import SettingAuthRole from '@/pages/mall/admin/setting/auth/role-management/index.uvue'
import SettingAuthAdmin from '@/pages/mall/admin/setting/auth/admin-management/index.uvue'
import SettingAuthPermission from '@/pages/mall/admin/setting/auth/menu-management/index.uvue'
import SettingDeliveryStaff from '@/pages/mall/admin/setting/delivery/management/index.uvue'
import SettingDeliveryStation from '@/pages/mall/admin/setting/delivery/setting/station/index.uvue'
import SettingDeliveryVerifier from '@/pages/mall/admin/setting/delivery/setting/verifier/index.uvue'
import SettingDeliveryTemplate from '@/pages/mall/admin/setting/delivery/setting/template/index.uvue'
import SettingInterfaceOnepassConfig from '@/pages/mall/admin/setting/interface/onepass/config/index.uvue'
import SettingInterfaceOnepassIndex from '@/pages/mall/admin/setting/interface/onepass/index.uvue'
import SettingInterfaceStorage from '@/pages/mall/admin/setting/interface/storage/index.uvue'
import SettingInterfaceCollect from '@/pages/mall/admin/setting/interface/collect/index.uvue'
import SettingInterfaceLogistics from '@/pages/mall/admin/setting/interface/logistics/index.uvue'
import SettingInterfaceESheet from '@/pages/mall/admin/setting/interface/e-sheet/index.uvue'
import SettingInterfaceSms from '@/pages/mall/admin/setting/interface/sms/index.uvue'
import SettingInterfacePayment from '@/pages/mall/admin/setting/interface/payment/index.uvue'
// --- 分销模块 ---
import DistributionPromoter from '@/pages/mall/admin/distribution/distributor-management/index.uvue'
import DistributionLevel from '@/pages/mall/admin/distribution/level/index.uvue'
import DistributionSetting from '@/pages/mall/admin/distribution/setting/index.uvue'
import DivisionList from '@/pages/mall/admin/distribution/business-division/business-division-list/index.uvue'
import DivisionAgent from '@/pages/mall/admin/distribution/business-division/agent-list/index.uvue'
import DivisionApply from '@/pages/mall/admin/distribution/business-division/agent-application/index.uvue'
// --- 客服模块 ---
import KefuList from '@/pages/mall/admin/kefu/list/index.uvue'
import KefuWords from '@/pages/mall/admin/kefu/rhetoric/index.uvue'
import KefuFeedback from '@/pages/mall/admin/kefu/user-message/index.uvue'
import KefuAutoReply from '@/pages/mall/admin/kefu/auto-reply/index.uvue'
import KefuConfig from '@/pages/mall/admin/kefu/config/index.uvue'
// --- 装修模块 ---
import DecorationHome from '@/pages/mall/admin/decoration/homepage-decoration/index.uvue'
import DecorationCategory from '@/pages/mall/admin/decoration/product-category/index.uvue'
import DecorationUser from '@/pages/mall/admin/decoration/personal-center/index.uvue'
import DecorationData from '@/pages/mall/admin/decoration/data-config/index.uvue'
import DecorationStyle from '@/pages/mall/admin/decoration/theme-style/index.uvue'
import DecorationMaterial from '@/pages/mall/admin/decoration/material-management/index.uvue'
import DecorationLink from '@/pages/mall/admin/decoration/link-management/index.uvue'
// --- 应用模块 ---
import AppWechatMenu from '@/pages/mall/admin/app/wechat/menu/index.uvue'
import AppWechatNews from '@/pages/mall/admin/app/wechat/management/index.uvue'
import AppWechatReplyFollow from '@/pages/mall/admin/app/wechat/reply/follow/index.uvue'
import AppWechatReplyKeyword from '@/pages/mall/admin/app/wechat/reply/keyword/index.uvue'
import AppWechatReplyInvalid from '@/pages/mall/admin/app/wechat/reply/invalid/index.uvue'
import AppWechatConfig from '@/pages/mall/admin/app/wechat/config/index.uvue'
import AppRoutineDownload from '@/pages/mall/admin/app/routine/download/index.uvue'
import AppRoutineConfig from '@/pages/mall/admin/app/routine/config/index.uvue'
import AppMobileConfig from '@/pages/mall/admin/app/mobile/config/index.uvue'
import AppMobileVersion from '@/pages/mall/admin/app/mobile/version/index.uvue'
import AppPcDesign from '@/pages/mall/admin/app/pc/design/index.uvue'
import AppPcConfig from '@/pages/mall/admin/app/pc/config/index.uvue'
// --- 维护模块 ---
import MaintainDevConfig from '@/pages/mall/admin/maintain/dev-config/category/index.uvue'
import MaintainDevData from '@/pages/mall/admin/maintain/dev-config/combination-data/index.uvue'
import MaintainDevTask from '@/pages/mall/admin/maintain/dev-config/cron-job/index.uvue'
import MaintainDevAuth from '@/pages/mall/admin/maintain/dev-config/permission/index.uvue'
import MaintainDevModule from '@/pages/mall/admin/maintain/dev-config/module-config/index.uvue'
import MaintainDevEvent from '@/pages/mall/admin/maintain/dev-config/custom-event/index.uvue'
import MaintainSecurityCache from '@/pages/mall/admin/maintain/security/refresh-cache/index.uvue'
import MaintainSecurityLog from '@/pages/mall/admin/maintain/security/system-log/index.uvue'
import MaintainSecurityUpgrade from '@/pages/mall/admin/maintain/security/online-upgrade/index.uvue'
import MaintainDataLogistics from '@/pages/mall/admin/maintain/data/logistics/index.uvue'
import MaintainDataCity from '@/pages/mall/admin/maintain/data/city-data/index.uvue'
import MaintainDataClear from '@/pages/mall/admin/maintain/data/clear/index.uvue'
import MaintainApiAccount from '@/pages/mall/admin/maintain/api/account/index.uvue'
import MaintainLangList from '@/pages/mall/admin/maintain/lang/list/index.uvue'
import MaintainLangDetail from '@/pages/mall/admin/maintain/lang/detail/index.uvue'
import MaintainLangRegion from '@/pages/mall/admin/maintain/lang/region/index.uvue'
import MaintainLangConfig from '@/pages/mall/admin/maintain/lang/config/index.uvue'
import MaintainToolDb from '@/pages/mall/admin/maintain/dev-tools/database/index.uvue'
import MaintainToolFile from '@/pages/mall/admin/maintain/dev-tools/file/index.uvue'
import MaintainToolApi from '@/pages/mall/admin/maintain/dev-tools/interface/index.uvue'
import MaintainToolDic from '@/pages/mall/admin/maintain/dev-tools/data-dictionary/index.uvue'
import MaintainSysInfo from '@/pages/mall/admin/maintain/sys/info/index.uvue'
/**
* 组件映射表
@@ -32,180 +187,175 @@ import HomeIndex from '@/layouts/admin/pages/HomeIndex.uvue'
export const componentMap: Map<string, any> = new Map([
// 首页
['HomeIndex', HomeIndex],
['UserCenter', UserCenter],
// 店铺模块
['ShopManage', ShopManage],
['ShopCreate', ShopCreate],
// 用户模块
['UserStatistic', defineAsyncComponent(() => import('@/pages/mall/admin/user/statistics/index.uvue'))],
['UserList', defineAsyncComponent(() => import('@/pages/mall/admin/user/management/index.uvue'))],
['UserLevel', defineAsyncComponent(() => import('@/pages/mall/admin/user/level/index.uvue'))],
['UserGroup', defineAsyncComponent(() => import('@/pages/mall/admin/user/grouping/index.uvue'))],
['UserLabel', defineAsyncComponent(() => import('@/pages/mall/admin/user/label/index.uvue'))],
['UserMemberConfig', defineAsyncComponent(() => import('@/pages/mall/admin/user/configuration/index.uvue'))],
['UserStatistic', UserStatistic],
['UserList', UserList],
['UserLevel', UserLevel],
['UserGroup', UserGroup],
['UserLabel', UserLabel],
['UserMemberConfig', UserMemberConfig],
// 商品模块
['ProductStatistic', defineAsyncComponent(() => import('@/pages/mall/admin/product/product-statistics/index.uvue'))],
['ProductList', defineAsyncComponent(() => import('@/pages/mall/admin/product/product-management/index.uvue'))],
['ProductEdit', defineAsyncComponent(() => import('@/pages/mall/admin/product/product-management/edit.uvue'))],
['ProductMemberPrice', defineAsyncComponent(() => import('@/pages/mall/admin/product/product-management/member-price.uvue'))],
['ProductClassify', defineAsyncComponent(() => import('@/pages/mall/admin/product/classification/index.uvue'))],
['ProductReply', defineAsyncComponent(() => import('@/pages/mall/admin/product/reviews/index.uvue'))],
['ProductAttr', defineAsyncComponent(() => import('@/pages/mall/admin/product/specifications/index.uvue'))],
['ProductParam', defineAsyncComponent(() => import('@/pages/mall/admin/product/parameters/index.uvue'))],
['ProductLabel', defineAsyncComponent(() => import('@/pages/mall/admin/product/labels/index.uvue'))],
['ProductProtection', defineAsyncComponent(() => import('@/pages/mall/admin/product/protection/index.uvue'))],
['ProductStatistic', ProductStatistic],
['ProductList', ProductList],
['ProductEdit', ProductEdit],
['ProductMemberPrice', ProductMemberPrice],
['ProductClassify', ProductClassify],
['ProductReply', ProductReply],
['ProductAttr', ProductAttr],
['ProductParam', ProductParam],
['ProductLabel', ProductLabel],
['ProductProtection', ProductProtection],
// 订单模块
['OrderList', defineAsyncComponent(() => import('@/pages/mall/admin/order/order-management/index.uvue'))],
['OrderStatistic', defineAsyncComponent(() => import('@/pages/mall/admin/order/order-statistics/index.uvue'))],
['OrderRefund', defineAsyncComponent(() => import('@/pages/mall/admin/order/aftersales-order/index.uvue'))],
['OrderCashier', defineAsyncComponent(() => import('@/pages/mall/admin/order/cashier-order/index.uvue'))],
['OrderVerify', defineAsyncComponent(() => import('@/pages/mall/admin/order/write-off-records/index.uvue'))],
['OrderConfig', defineAsyncComponent(() => import('@/pages/mall/admin/order/order-configuration/index.uvue'))],
['OrderList', OrderList],
['OrderStatistic', OrderStatistic],
['OrderRefund', OrderRefund],
['OrderCashier', OrderCashier],
['OrderVerify', OrderVerify],
['OrderConfig', OrderConfig],
// 营销模块已改为异步加载
// 1. 优惠券
['MarketingCouponList', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/coupon/list.uvue'))],
['MarketingCouponUser', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/coupon/user.uvue'))],
// 2. 积分管理
['MarketingIntegralStatistic', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/integral/statistic.uvue'))],
['MarketingIntegralProduct', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/integral/list.uvue'))],
['MarketingIntegralOrder', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/integral/order.uvue'))],
['MarketingIntegralRecord', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/integral/record.uvue'))],
['MarketingIntegralConfig', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/integral/config.uvue'))],
// 3. 抽奖管理
['MarketingLotteryList', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/lottery/list.uvue'))],
['MarketingLotteryConfig', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/lottery/config.uvue'))],
// 4. 砍价管理
['MarketingBargainProduct', PlaceholderPage],
['MarketingBargainList', PlaceholderPage],
// 5. 拼团管理
['MarketingCombinationProduct', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/combination/product.uvue'))],
['MarketingCombinationList', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/combination/list.uvue'))],
['MarketingCombinationCreate', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/combination/create.uvue'))],
// 6. 秒杀管理
['MarketingSeckillList', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/seckill/list.uvue'))],
['MarketingSeckillProduct', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/seckill/product.uvue'))],
['MarketingSeckillConfig', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/seckill/config.uvue'))],
// 7. 付费会员
['MarketingMemberType', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/member/type.uvue'))],
['MarketingMemberRight', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/member/right.uvue'))],
['MarketingMemberCard', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/member/card.uvue'))],
['MarketingMemberRecord', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/member/record.uvue'))],
['MarketingMemberConfig', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/member/config.uvue'))],
// 8. 直播管理
['MarketingLiveRoom', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/live/room.uvue'))],
['MarketingLiveProduct', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/live/product.uvue'))],
['MarketingLiveAnchor', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/live/anchor.uvue'))],
// 9. 用户充值
['MarketingRechargeQuota', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/recharge/quota.uvue'))],
['MarketingRechargeConfig', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/recharge/config.uvue'))],
// 10. 每日签到
['MarketingCheckinConfig', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/checkin/config.uvue'))],
['MarketingCheckinReward', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/checkin/reward.uvue'))],
// 11. 渠道码 & 新人礼
// 营销模块
['MarketingCouponList', MarketingCouponList],
['MarketingCouponUser', MarketingCouponUser],
['MarketingIntegralStatistic', MarketingIntegralStatistic],
['MarketingIntegralProduct', MarketingIntegralProduct],
['MarketingIntegralOrder', MarketingIntegralOrder],
['MarketingIntegralRecord', MarketingIntegralRecord],
['MarketingIntegralConfig', MarketingIntegralConfig],
['MarketingLotteryList', MarketingLotteryList],
['MarketingLotteryConfig', MarketingLotteryConfig],
['MarketingBargainProduct', MarketingBargainProduct],
['MarketingBargainList', MarketingBargainList],
['MarketingCombinationProduct', MarketingCombinationProduct],
['MarketingCombinationList', MarketingCombinationList],
['MarketingCombinationCreate', MarketingCombinationCreate],
['MarketingSeckillList', MarketingSeckillList],
['MarketingSeckillProduct', MarketingSeckillProduct],
['MarketingSeckillConfig', MarketingSeckillConfig],
['MarketingMemberType', MarketingMemberType],
['MarketingMemberRight', MarketingMemberRight],
['MarketingMemberCard', MarketingMemberCard],
['MarketingMemberRecord', MarketingMemberRecord],
['MarketingMemberConfig', MarketingMemberConfig],
['MarketingLiveRoom', MarketingLiveRoom],
['MarketingLiveProduct', MarketingLiveProduct],
['MarketingLiveAnchor', MarketingLiveAnchor],
['MarketingRechargeQuota', MarketingRechargeQuota],
['MarketingRechargeConfig', MarketingRechargeConfig],
['MarketingCheckinConfig', MarketingCheckinConfig],
['MarketingCheckinReward', MarketingCheckinReward],
['MarketingChannelList', PlaceholderPage],
['MarketingNewcomerGift', defineAsyncComponent(() => import('@/pages/mall/admin/marketing/newcomer/index.uvue'))],
['MarketingNewcomerGift', MarketingNewcomerGift],
['MarketingStatisticIndex', MarketingStatisticIndex],
// 内容模块
['CmsArticle', defineAsyncComponent(() => import('@/pages/mall/admin/cms/article/list.uvue'))],
['CmsCategory', defineAsyncComponent(() => import('@/pages/mall/admin/cms/category/list.uvue'))],
['CmsArticle', CmsArticle],
['CmsCategory', CmsCategory],
// 财务模块
['FinanceTransactionStats', defineAsyncComponent(() => import('@/pages/mall/admin/finance/transaction_stats.uvue'))],
['FinanceWithdrawal', defineAsyncComponent(() => import('@/pages/mall/admin/finance/withdrawal.uvue'))],
['FinanceInvoice', defineAsyncComponent(() => import('@/pages/mall/admin/finance/invoice.uvue'))],
['FinanceRecharge', defineAsyncComponent(() => import('@/pages/mall/admin/finance/recharge.uvue'))],
['FinanceCapitalFlow', defineAsyncComponent(() => import('@/pages/mall/admin/finance/capital_flow.uvue'))],
['FinanceBill', defineAsyncComponent(() => import('@/pages/mall/admin/finance/bill.uvue'))],
['FinanceCommission', defineAsyncComponent(() => import('@/pages/mall/admin/finance/commission.uvue'))],
['FinanceBalanceStats', defineAsyncComponent(() => import('@/pages/mall/admin/finance/balance_stats.uvue'))],
['FinanceBalanceRecord', defineAsyncComponent(() => import('@/pages/mall/admin/finance/balance_record.uvue'))],
['FinanceTransactionStats', FinanceTransactionStats],
['FinanceWithdrawal', FinanceWithdrawal],
['FinanceInvoice', FinanceInvoice],
['FinanceRecharge', FinanceRecharge],
['FinanceCapitalFlow', FinanceCapitalFlow],
['FinanceBill', FinanceBill],
['FinanceCommission', FinanceCommission],
['FinanceBalanceStats', FinanceBalanceStats],
['FinanceBalanceRecord', FinanceBalanceRecord],
// 数据模块 - 暂时使用占位组件
// 数据模块
['StatisticIndex', PlaceholderPage],
// 设置模块
['SettingSystemConfig', defineAsyncComponent(() => import('@/pages/mall/admin/setting/system/config.uvue'))],
['SettingMessageIndex', defineAsyncComponent(() => import('@/pages/mall/admin/setting/message.uvue'))],
['SettingProtocolIndex', defineAsyncComponent(() => import('@/pages/mall/admin/setting/agreement.uvue'))],
['SettingTicketIndex', defineAsyncComponent(() => import('@/pages/mall/admin/setting/ticket.uvue'))],
['SettingAuthRole', defineAsyncComponent(() => import('@/pages/mall/admin/setting/auth/role.uvue'))],
['SettingAuthAdmin', defineAsyncComponent(() => import('@/pages/mall/admin/setting/auth/admin.uvue'))],
['SettingAuthPermission', defineAsyncComponent(() => import('@/pages/mall/admin/setting/auth/permission.uvue'))],
['SettingDeliveryStaff', defineAsyncComponent(() => import('@/pages/mall/admin/setting/delivery/staff.uvue'))],
['SettingDeliveryStation', defineAsyncComponent(() => import('@/pages/mall/admin/setting/delivery/station.uvue'))],
['SettingDeliveryVerifier', defineAsyncComponent(() => import('@/pages/mall/admin/setting/delivery/verifier.uvue'))],
['SettingDeliveryTemplate', defineAsyncComponent(() => import('@/pages/mall/admin/setting/delivery/template.uvue'))],
['SettingInterfaceOnepassConfig', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/onepass/config.uvue'))],
['SettingInterfaceOnepassIndex', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/onepass/index.uvue'))],
['SettingInterfaceStorage', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/storage.uvue'))],
['SettingInterfaceCollect', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/collect.uvue'))],
['SettingInterfaceLogistics', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/logistics.uvue'))],
['SettingInterfaceESheet', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/e-sheet.uvue'))],
['SettingInterfaceSms', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/sms.uvue'))],
['SettingInterfacePayment', defineAsyncComponent(() => import('@/pages/mall/admin/setting/interface/payment.uvue'))],
['SettingSystemConfig', SettingSystemConfig],
['SettingMessageIndex', SettingMessageIndex],
['SettingProtocolIndex', SettingProtocolIndex],
['SettingTicketIndex', SettingTicketIndex],
['SettingAuthRole', SettingAuthRole],
['SettingAuthAdmin', SettingAuthAdmin],
['SettingAuthPermission', SettingAuthPermission],
['SettingDeliveryStaff', SettingDeliveryStaff],
['SettingDeliveryStation', SettingDeliveryStation],
['SettingDeliveryVerifier', SettingDeliveryVerifier],
['SettingDeliveryTemplate', SettingDeliveryTemplate],
['SettingInterfaceOnepassConfig', SettingInterfaceOnepassConfig],
['SettingInterfaceOnepassIndex', SettingInterfaceOnepassIndex],
['SettingInterfaceStorage', SettingInterfaceStorage],
['SettingInterfaceCollect', SettingInterfaceCollect],
['SettingInterfaceLogistics', SettingInterfaceLogistics],
['SettingInterfaceESheet', SettingInterfaceESheet],
['SettingInterfaceSms', SettingInterfaceSms],
['SettingInterfacePayment', SettingInterfacePayment],
// 分销模块
['DistributionStatistic', PlaceholderPage],
['DistributionPromoter', defineAsyncComponent(() => import('@/pages/mall/admin/distribution/promoter/index.uvue'))],
['DistributionLevel', defineAsyncComponent(() => import('@/pages/mall/admin/distribution/level/index.uvue'))],
['DistributionSetting', defineAsyncComponent(() => import('@/pages/mall/admin/distribution/setting/index.uvue'))],
['DivisionList', defineAsyncComponent(() => import('@/pages/mall/admin/distribution/division/list.uvue'))],
['DivisionAgent', defineAsyncComponent(() => import('@/pages/mall/admin/distribution/division/agent.uvue'))],
['DivisionApply', defineAsyncComponent(() => import('@/pages/mall/admin/distribution/division/apply.uvue'))],
['DistributionPromoter', DistributionPromoter],
['DistributionLevel', DistributionLevel],
['DistributionSetting', DistributionSetting],
['DivisionList', DivisionList],
['DivisionAgent', DivisionAgent],
['DivisionApply', DivisionApply],
// 客服模块
['KefuList', defineAsyncComponent(() => import('@/pages/mall/admin/kefu/list.uvue'))],
['KefuWords', defineAsyncComponent(() => import('@/pages/mall/admin/kefu/words.uvue'))],
['KefuFeedback', defineAsyncComponent(() => import('@/pages/mall/admin/kefu/feedback.uvue'))],
['KefuAutoReply', defineAsyncComponent(() => import('@/pages/mall/admin/kefu/auto_reply.uvue'))],
['KefuConfig', defineAsyncComponent(() => import('@/pages/mall/admin/kefu/config.uvue'))],
['KefuList', KefuList],
['KefuWords', KefuWords],
['KefuFeedback', KefuFeedback],
['KefuAutoReply', KefuAutoReply],
['KefuConfig', KefuConfig],
// 装修模块
['DecorationHome', defineAsyncComponent(() => import('@/pages/mall/admin/decoration/home.uvue'))],
['DecorationCategory', defineAsyncComponent(() => import('@/pages/mall/admin/decoration/category.uvue'))],
['DecorationUser', defineAsyncComponent(() => import('@/pages/mall/admin/decoration/user.uvue'))],
['DecorationData', defineAsyncComponent(() => import('@/pages/mall/admin/decoration/data-config.uvue'))],
['DecorationStyle', defineAsyncComponent(() => import('@/pages/mall/admin/design/theme-style.uvue'))],
['DecorationMaterial', defineAsyncComponent(() => import('@/pages/mall/admin/design/material.uvue'))],
['DecorationLink', defineAsyncComponent(() => import('@/pages/mall/admin/design/link-management.uvue'))],
['DecorationHome', DecorationHome],
['DecorationCategory', DecorationCategory],
['DecorationUser', DecorationUser],
['DecorationData', DecorationData],
['DecorationStyle', DecorationStyle],
['DecorationMaterial', DecorationMaterial],
['DecorationLink', DecorationLink],
// 应用模块
['AppWechatMenu', defineAsyncComponent(() => import('@/pages/mall/admin/app/wechat/menu.uvue'))],
['AppWechatNews', defineAsyncComponent(() => import('@/pages/mall/admin/app/wechat/news.uvue'))],
['AppWechatReplyFollow', defineAsyncComponent(() => import('@/pages/mall/admin/app/wechat/reply/follow.uvue'))],
['AppWechatReplyKeyword', defineAsyncComponent(() => import('@/pages/mall/admin/app/wechat/reply/keyword.uvue'))],
['AppWechatReplyInvalid', defineAsyncComponent(() => import('@/pages/mall/admin/app/wechat/reply/invalid.uvue'))],
['AppWechatConfig', defineAsyncComponent(() => import('@/pages/mall/admin/app/wechat/config.uvue'))],
['AppRoutineDownload', defineAsyncComponent(() => import('@/pages/mall/admin/app/routine/download.uvue'))],
['AppRoutineConfig', defineAsyncComponent(() => import('@/pages/mall/admin/app/routine/config.uvue'))],
['AppMobileConfig', defineAsyncComponent(() => import('@/pages/mall/admin/app/mobile/config.uvue'))],
['AppMobileVersion', defineAsyncComponent(() => import('@/pages/mall/admin/app/mobile/version.uvue'))],
['AppPcDesign', defineAsyncComponent(() => import('@/pages/mall/admin/app/pc/design.uvue'))],
['AppPcConfig', defineAsyncComponent(() => import('@/pages/mall/admin/app/pc/config.uvue'))],
['AppWechatMenu', AppWechatMenu],
['AppWechatNews', AppWechatNews],
['AppWechatReplyFollow', AppWechatReplyFollow],
['AppWechatReplyKeyword', AppWechatReplyKeyword],
['AppWechatReplyInvalid', AppWechatReplyInvalid],
['AppWechatConfig', AppWechatConfig],
['AppRoutineDownload', AppRoutineDownload],
['AppRoutineConfig', AppRoutineConfig],
['AppMobileConfig', AppMobileConfig],
['AppMobileVersion', AppMobileVersion],
['AppPcDesign', AppPcDesign],
['AppPcConfig', AppPcConfig],
// 维护模块
['MaintainDevConfig', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-config/category.uvue'))],
['MaintainDevData', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-config/combination-data.uvue'))],
['MaintainDevTask', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-config/cron-job.uvue'))],
['MaintainDevAuth', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-config/permission.uvue'))],
['MaintainDevModule', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-config/module-config.uvue'))],
['MaintainDevEvent', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-config/custom-event.uvue'))],
['MaintainSecurityCache', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/security/refresh-cache.uvue'))],
['MaintainSecurityLog', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/security/system-log.uvue'))],
['MaintainSecurityUpgrade', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/security/online-upgrade.uvue'))],
['MaintainDataLogistics', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/data/logistics.uvue'))],
['MaintainDataCity', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/data/city.uvue'))],
['MaintainDataClear', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/data/clear.uvue'))],
['MaintainApiAccount', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/api/account.uvue'))],
['MaintainLangList', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/lang/list.uvue'))],
['MaintainLangDetail', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/lang/detail.uvue'))],
['MaintainLangRegion', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/lang/region.uvue'))],
['MaintainLangConfig', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/lang/config.uvue'))],
['MaintainToolDb', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-tools/database.uvue'))],
['MaintainToolFile', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-tools/file.uvue'))],
['MaintainToolApi', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-tools/api.uvue'))],
['MaintainToolDic', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/dev-tools/data-dict.uvue'))],
['MaintainSysInfo', defineAsyncComponent(() => import('@/pages/mall/admin/maintain/sys/info.uvue'))]
['MaintainDevConfig', MaintainDevConfig],
['MaintainDevData', MaintainDevData],
['MaintainDevTask', MaintainDevTask],
['MaintainDevAuth', MaintainDevAuth],
['MaintainDevModule', MaintainDevModule],
['MaintainDevEvent', MaintainDevEvent],
['MaintainSecurityCache', MaintainSecurityCache],
['MaintainSecurityLog', MaintainSecurityLog],
['MaintainSecurityUpgrade', MaintainSecurityUpgrade],
['MaintainDataLogistics', MaintainDataLogistics],
['MaintainDataCity', MaintainDataCity],
['MaintainDataClear', MaintainDataClear],
['MaintainApiAccount', MaintainApiAccount],
['MaintainLangList', MaintainLangList],
['MaintainLangDetail', MaintainLangDetail],
['MaintainLangRegion', MaintainLangRegion],
['MaintainLangConfig', MaintainLangConfig],
['MaintainToolDb', MaintainToolDb],
['MaintainToolFile', MaintainToolFile],
['MaintainToolApi', MaintainToolApi],
['MaintainToolDic', MaintainToolDic],
['MaintainSysInfo', MaintainSysInfo]
])
/**

View File

@@ -29,6 +29,8 @@ export type RouteRecord = {
/**
* 菜单分组类型
*/
import { hasAdminModuleAccess } from '@/layouts/admin/utils/role.uts'
export type MenuGroup = {
id: string
title: string
@@ -65,12 +67,22 @@ export const topMenus: TopMenu[] = [
order: 1,
groups: []
},
{
id: 'shop',
title: '店铺',
icon: 'shop',
path: '/pages/mall/admin/shop/manage',
order: 2,
groups: [
{ id: 'shop-manage', title: '', order: 1 }
]
},
{
id: 'user',
title: '用户',
icon: 'user',
path: '/pages/mall/admin/user/management/index',
order: 2,
order: 3,
groups: [
{ id: 'user-manage', title: '', order: 1 }
]
@@ -79,7 +91,7 @@ export const topMenus: TopMenu[] = [
id: 'order',
title: '订单',
icon: 'order',
path: '/pages/mall/admin/order/list',
path: '/pages/mall/admin/order/order-management/index',
order: 3,
groups: [
{ id: 'order-manage', title: '', order: 1 }
@@ -119,10 +131,11 @@ export const topMenus: TopMenu[] = [
id: 'distribution',
title: '分销',
icon: 'share',
path: '/pages/mall/admin/distribution/statistic',
path: '/pages/mall/admin/distribution/promoter/index',
order: 6,
groups: [
{ id: 'distribution-manage', title: '', order: 1 }
{ id: 'distribution-manage', title: '', order: 1 },
{ id: 'distribution-division', title: '事业部', order: 2 }
]
},
{
@@ -230,7 +243,37 @@ export const routes: RouteRecord[] = [
order: 1
},
// ========== 店铺模块 ==========
{
id: 'shop_manage',
title: '店铺管理',
path: '/pages/mall/admin/shop/manage',
componentKey: 'ShopManage',
parentId: 'shop',
groupId: 'shop-manage',
order: 1
},
{
id: 'shop_create',
title: '申请入驻',
path: '/pages/mall/admin/shop/create',
componentKey: 'ShopCreate',
parentId: 'shop',
groupId: 'shop-manage',
hidden: true,
order: 2
},
// ========== 用户模块 ==========
// ========== 个人中心 ==========
{
id: 'home_user_center',
title: '个人中心',
path: '/pages/mall/admin/userCenter/index',
componentKey: 'UserCenter',
order: 100
},
{
id: 'user_statistic',
title: '用户统计',
@@ -281,6 +324,15 @@ export const routes: RouteRecord[] = [
auth: ['user-user-level'],
order: 5
},
{
id: 'user_config',
title: '用户配置',
path: '/pages/mall/admin/user/config/index',
componentKey: 'UserMemberConfig',
parentId: 'user',
groupId: 'user-manage',
order: 6
},
// ========== 商品模块 ==========
{
@@ -399,7 +451,7 @@ export const routes: RouteRecord[] = [
{
id: 'order_list',
title: '订单管理',
path: '/pages/mall/admin/order/list',
path: '/pages/mall/admin/order/order-management/index',
componentKey: 'OrderList',
parentId: 'order',
groupId: 'order-manage',
@@ -474,7 +526,7 @@ export const routes: RouteRecord[] = [
{
id: 'marketing_integral_statistic',
title: '积分统计',
path: '/pages/mall/admin/marketing/integral/statistic',
path: '/pages/mall/admin/marketing/points/statistic',
componentKey: 'MarketingIntegralStatistic',
parentId: 'marketing',
groupId: 'marketing-integral',
@@ -484,7 +536,7 @@ export const routes: RouteRecord[] = [
{
id: 'marketing_integral_product',
title: '积分商品',
path: '/pages/mall/admin/marketing/integral/product',
path: '/pages/mall/admin/marketing/points/product',
componentKey: 'MarketingIntegralProduct',
parentId: 'marketing',
groupId: 'marketing-integral',
@@ -494,7 +546,7 @@ export const routes: RouteRecord[] = [
{
id: 'marketing_integral_order',
title: '积分订单',
path: '/pages/mall/admin/marketing/integral/order',
path: '/pages/mall/admin/marketing/points/order',
componentKey: 'MarketingIntegralOrder',
parentId: 'marketing',
groupId: 'marketing-integral',
@@ -504,7 +556,7 @@ export const routes: RouteRecord[] = [
{
id: 'marketing_integral_record',
title: '积分记录',
path: '/pages/mall/admin/marketing/integral/record',
path: '/pages/mall/admin/marketing/points/record',
componentKey: 'MarketingIntegralRecord',
parentId: 'marketing',
groupId: 'marketing-integral',
@@ -514,7 +566,7 @@ export const routes: RouteRecord[] = [
{
id: 'marketing_integral_config',
title: '积分配置',
path: '/pages/mall/admin/marketing/integral/config',
path: '/pages/mall/admin/marketing/points/config',
componentKey: 'MarketingIntegralConfig',
parentId: 'marketing',
groupId: 'marketing-integral',
@@ -905,6 +957,7 @@ export const routes: RouteRecord[] = [
componentKey: 'DistributionStatistic',
parentId: 'distribution',
groupId: 'distribution-manage',
hidden: true,
order: 1
},
{
@@ -936,30 +989,30 @@ export const routes: RouteRecord[] = [
},
{
id: 'division_list',
title: '事业部管理',
title: '事业部列表',
path: '/pages/mall/admin/distribution/division/list',
componentKey: 'DivisionList',
parentId: 'distribution',
groupId: 'distribution-manage',
order: 5
groupId: 'distribution-division',
order: 1
},
{
id: 'division_agent',
title: '代理商管理',
title: '代理商列表',
path: '/pages/mall/admin/distribution/division/agent',
componentKey: 'DivisionAgent',
parentId: 'distribution',
groupId: 'distribution-manage',
order: 6
groupId: 'distribution-division',
order: 2
},
{
id: 'division_apply',
title: '事业部申请',
title: '代理商申请',
path: '/pages/mall/admin/distribution/division/apply',
componentKey: 'DivisionApply',
parentId: 'distribution',
groupId: 'distribution-manage',
order: 7
groupId: 'distribution-division',
order: 3
},
// ========== 客服模块 ==========
@@ -1173,7 +1226,10 @@ export const routes: RouteRecord[] = [
* 获取所有一级菜单
*/
export function getTopMenus(): TopMenu[] {
return topMenus.sort((a, b) => a.order - b.order)
// 基于 role 的模块过滤
return topMenus
.filter(m => hasAdminModuleAccess(m.id))
.sort((a, b) => a.order - b.order)
}
/**

View File

@@ -7,6 +7,10 @@ export type MenuNode = {
}
export const settingSubSiderMenu: MenuNode[] = [
{ id: 'system_setting_group', title: '系统设置', type: 'group', children: [
{ id: 'setting_system_config', title: '系统配置', type: 'page', path: '/pages/mall/admin/setting/system/config' }
]
},
{ id: 'setting_message_index', title: '消息管理', type: 'page', path: '/pages/mall/admin/setting/message' },
{ id: 'setting_protocol_index', title: '协议设置', type: 'page', path: '/pages/mall/admin/setting/agreement' },
{ id: 'setting_ticket_index', title: '小票配置', type: 'page', path: '/pages/mall/admin/setting/ticket' },

View File

@@ -12,6 +12,26 @@ import {
getTopMenus
} from '@/layouts/admin/router/adminRoutes.uts'
import { addView, activeFullPath, visitedViews } from './tagsViewStore.uts'
import { hasAdminModuleAccess } from '@/layouts/admin/utils/role.uts'
// ============================================
// Tabs 持久化 keys使用 sessionStorage与 token 策略一致)
// sessionStorage 在同一标签页 F5 刷新后保留,关闭标签页后自动清除
// ============================================
const TAB_STORAGE_KEY = 'admin_opened_tabs'
const ACTIVE_ROUTE_KEY = 'admin_active_route'
const LAST_SUB_STORAGE_KEY = 'admin_last_sub_by_menu'
/** 将当前 tabs / activeRouteId / lastSubIdByMenu 写入 sessionStorage */
function persistNavState(): void {
// #ifdef H5
try {
sessionStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(tabs.value))
sessionStorage.setItem(ACTIVE_ROUTE_KEY, activeRouteId.value)
sessionStorage.setItem(LAST_SUB_STORAGE_KEY, JSON.stringify(lastSubIdByMenu.value))
} catch (_) {}
// #endif
}
/**
* 标签页类型
@@ -77,15 +97,35 @@ export const isOverlayVisible = ref<boolean>(false)
* @param addTab 是否添加到标签页
*/
export function openRoute(routeId: string, addTab: boolean = true): void {
const route = findRouteById(routeId)
if (!route) {
console.warn(`[AdminNav] Route not found: ${routeId}`)
return
}
// 基于 role 的页面访问拦截
// route.parentId 对应上方 topMenus 的 id。这里校验是否有权限
const moduleId = route.parentId ? route.parentId : route.id.split('_')[0]
if (!hasAdminModuleAccess(moduleId)) {
uni.showToast({
title: '您没有权限访问该模块',
icon: 'none'
})
console.warn(`[AdminNav] Access denied for role to module: ${moduleId}`)
// 回退到首页
if (routeId !== 'home_index') {
openRoute('home_index', addTab)
}
return
}
// 更新当前路由
activeRouteId.value = routeId
// 更新一级菜单选中态
if (route.parentId) {
activeTopMenuId.value = route.parentId
@@ -99,6 +139,9 @@ export function openRoute(routeId: string, addTab: boolean = true): void {
// 添加到标签页
if (addTab) {
addTabItem(route)
} else {
// 即使不新增 tabactiveRouteId 变化了也要持久化
persistNavState()
}
}
@@ -129,6 +172,9 @@ function addTabItem(route: RouteRecord): void {
// 更新新版 TagsViewStore
addView(route, route.path)
activeFullPath.value = route.path
// 持久化到 sessionStorage
persistNavState()
}
/**
@@ -157,6 +203,8 @@ export function closeTab(tabId: string): void {
}
tabs.value.splice(index, 1)
// 持久化
persistNavState()
}
/**
@@ -171,6 +219,8 @@ export function closeOtherTabs(keepTabId: string): void {
if (!stillExists) {
openRoute(keepTabId, false)
}
// 持久化
persistNavState()
}
/**
@@ -184,6 +234,8 @@ export function closeAllTabs(): void {
if (homeTab) {
openRoute(homeTab.id, false)
}
// 持久化
persistNavState()
}
/**
@@ -201,7 +253,7 @@ export function toggleSubSider(): void {
}
/**
* 初始化导航状态
* 初始化导航状态(首次进入 / 恢复失败时的兜底)
* 在 AdminLayout 组件 onMounted 时调用
*/
export function initNavState(): void {
@@ -223,6 +275,86 @@ export function initNavState(): void {
openRoute('home_index', false)
}
/**
* 从 sessionStorage 恢复导航状态F5刷新后调用
* - 校验每个缓存 tab 的路由是否仍然存在
* - 校验是否仍有权限
* - 过滤无效项后重建 tabs
* - 恢复 activeRouteId 和菜单高亮
* @returns true=恢复成功false=无有效缓存或恢复失败(调用方应走 initNavState 兜底)
*/
export function restoreNavState(): boolean {
// #ifdef H5
try {
const rawTabs = sessionStorage.getItem(TAB_STORAGE_KEY)
if (!rawTabs) return false
const savedTabs = JSON.parse(rawTabs) as TabItem[]
if (!Array.isArray(savedTabs) || savedTabs.length === 0) return false
// 校验并过滤:路由必须存在且有权限
const validTabs = savedTabs.filter(tab => {
const route = findRouteById(tab.id)
if (!route) return false // 路由已不存在(文件删除或路由表变更)
const moduleId = route.parentId ?? tab.id.split('_')[0]
return hasAdminModuleAccess(moduleId) // 检查权限
})
// 首页 tab 始终保留(防止全部被过滤后无处可去)
const homeRoute = findRouteById('home_index')
if (homeRoute && !validTabs.some(t => t.id === 'home_index')) {
validTabs.unshift({
id: homeRoute.id,
title: homeRoute.title,
path: homeRoute.path,
isAffix: homeRoute.isAffix || false
})
}
if (validTabs.length === 0) return false
// 重建 tabs
tabs.value = validTabs
validTabs.forEach(tab => {
const route = findRouteById(tab.id)
if (route) addView(route, route.path)
})
// 恢复 lastSubIdByMenu
try {
const rawLast = sessionStorage.getItem(LAST_SUB_STORAGE_KEY)
if (rawLast) {
lastSubIdByMenu.value = JSON.parse(rawLast) as Record<string, string>
}
} catch (_) {}
// 恢复 activeRouteId
const savedActive = sessionStorage.getItem(ACTIVE_ROUTE_KEY)
const activeIsValid = savedActive != null &&
findRouteById(savedActive) != null &&
validTabs.some(t => t.id === savedActive)
const targetId = activeIsValid ? savedActive! : validTabs[0].id
activeRouteId.value = targetId
activeFullPath.value = findRouteById(targetId)?.path ?? ''
const activeRoute = findRouteById(targetId)
if (activeRoute?.parentId) {
activeTopMenuId.value = activeRoute.parentId
} else {
activeTopMenuId.value = targetId.split('_')[0]
}
console.log('[AdminNav] 已从缓存恢复导航状态, tabs:', validTabs.length, 'active:', targetId)
return true
} catch (e) {
console.warn('[AdminNav] 恢复导航状态失败,将走默认初始化:', e)
return false
}
// #endif
return false
}
/**
* 根据 currentPage 同步状态
* 用于页面组件传入 currentPage prop 时的状态同步

View File

@@ -0,0 +1,81 @@
import { state, getCurrentUser } from '@/utils/store.uts'
import { clearAdminRoleCache, refreshAdminRole } from './role.uts'
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
let __isHandlingExpired = false
/**
* 统一“登录过期”闭环处理
* - 防止重复弹窗
* - 清理所有认证、用户相关缓存
* - 重置状态树
* - 统一跳转回登录页
*/
export function handleSessionExpired(reason?: string) {
if (__isHandlingExpired) return
__isHandlingExpired = true
console.warn('[AdminAuth] 执行会话过期统一闭环:', reason ?? '未知原因')
// 1. 弹出提示 (确保只弹一次)
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
duration: 2000
})
// 2. 清除本地相关存储
try {
supa.signOut()
} catch(e){}
clearAdminRoleCache()
// 3. 重置全局业务状态树,防止其他组件看到旧内存残影
state.isLoggedIn = false
state.authUser = null
state.userProfile = { username: '', email: '' }
// 4. 跳转登录页
setTimeout(() => {
uni.reLaunch({
url: '/pages/user/login'
})
setTimeout(() => { __isHandlingExpired = false }, 1000)
}, 1000)
}
/**
* 统一的后台启动恢复授权流程
* 返回是否恢复/保持有效的登录态
*/
export async function ensureAdminSession(): Promise<boolean> {
try {
// ① 等待 hydrateSessionFromStorage() 完成从storage恢复token→异步网络请求
// 必须在 getSession() 之前 await否则刷新后 this.session 仍为 null
// 会被误判为未登录并触发 handleSessionExpired。
try { await supaReady } catch (_) {}
const sessionInfo = supa.getSession()
if (sessionInfo.session == null) {
console.warn('[AdminAuth] 没有发现凭证,要求重新登录')
handleSessionExpired('No credentials found')
return false
}
// 主动检查并补齐
const role = await refreshAdminRole()
if (role === 'unknown' || (state.userProfile != null && state.userProfile!.id == null)) {
// 等等,如果是断网状态呢?其实 store 里已经用 status <= 0 判断过断网了。
// 上一步在 store/getCurrentUser 时,如果返回 401 才会真正导致清空。
console.warn('[AdminAuth] Session被认为无效或用户确权失败')
handleSessionExpired('Role verification failed')
return false
}
return true
} catch (e) {
console.error('[AdminAuth] 鉴权启动异常:', e)
handleSessionExpired('Auth exception')
return false
}
}

View File

@@ -0,0 +1,131 @@
import { state, getCurrentUser } from '@/utils/store.uts'
import supa from '@/components/supadb/aksupainstance.uts'
/**
* 将任意角色类型的原始值格式化为标准化的应用角色
*/
export function normalizeRole(rawRole: any | null): string {
if (rawRole == null || rawRole === undefined) return 'unknown'
const roleStr = String(rawRole).trim().toLowerCase()
if (roleStr === 'admin') return 'admin'
if (roleStr === 'merchant') return 'merchant'
return 'unknown'
}
/**
* 判断是否为纯后台管理员
*/
export function isAdminRole(role: string): boolean {
return normalizeRole(role) === 'admin'
}
/**
* 判断是否为商户角色
*/
export function isMerchantRole(role: string): boolean {
return normalizeRole(role) === 'merchant'
}
/**
* 获取当前的标准化角色 (同步方法)
*/
export function getCurrentAdminRole(): string {
// 1. 最高优先级:当前响应式内存 userProfile已查数据库
if (state.userProfile != null && state.userProfile!.role != null) {
const memRole = normalizeRole(state.userProfile!.role)
if (memRole === 'admin' || memRole === 'merchant') {
return memRole
}
}
// 2. Auth Session兜底获取Tab 隔离):
const sessionUser = supa.getSession().user
if (sessionUser != null) {
const meta = sessionUser.get("user_metadata") as UTSJSONObject | null
if (meta != null && meta.getString("role") != null) {
const metaRole = normalizeRole(meta.getString("role"))
if (metaRole === "admin" || metaRole === "merchant") return metaRole
}
}
console.warn("[AdminRole] 未能获取到有效的管理端角色,准备安全降级...")
return "unknown"
}
/**
* 清理本地相关角色和管理端缓存 (登出时调用)
*/
export function clearAdminRoleCache(): void {
// 清理 admin 专属
uni.removeStorageSync('adminRole')
uni.removeStorageSync('admin_role')
uni.removeStorageSync('merchant_id')
}
/**
* 校验并写入最新的 adminRole (用于 Login 后或者 Layout 挂载时强制刷新)
*/
export async function refreshAdminRole(): Promise<string> {
const userStrProfile = await getCurrentUser()
let finalRole = 'unknown'
if (userStrProfile != null && userStrProfile.role != null) {
finalRole = normalizeRole(userStrProfile.role)
console.log('[AdminRole] 从 ak_users 读取真实身份成功:', finalRole)
} else {
// metadata fallback
const sessionInfo = supa.getSession()
if (sessionInfo.user != null) {
const meta = sessionInfo.user?.get("user_metadata") as UTSJSONObject | null
if (meta != null && meta.getString('role') != null) {
finalRole = normalizeRole(meta.getString('role'))
console.log('[AdminRole] 从 Auth Metadata 读取兜底身份:', finalRole)
}
}
}
if (finalRole !== 'unknown') {
// uni.setStorageSync('adminRole', finalRole) // 移除缓存耦合,强制按单例会话状态刷新
if (state.userProfile != null) {
state.userProfile!.role = finalRole
}
console.log('[AdminRole] 最新角色已写入状态和缓存:', finalRole)
}
return finalRole
}
export function getVisibleTopMenuIds(role: string): string[] {
const normRole = normalizeRole(role)
if (normRole === 'admin') {
return ['home', 'shop', 'user', 'order', 'product', 'marketing', 'distribution', 'kefu', 'finance', 'cms', 'decoration', 'app', 'setting', 'maintain']
}
if (normRole === 'merchant') {
return ['home', 'shop', 'order', 'product', 'marketing', 'finance']
}
return ['home']
}
export function hasAdminModuleAccess(moduleId: string | undefined): boolean {
if (!moduleId) return true
const role = getCurrentAdminRole()
const normRole = normalizeRole(role)
if (normRole === 'unknown') {
return moduleId === 'home'
}
if (normRole === 'admin') {
return true
}
if (normRole === 'merchant') {
const allowed = ['home', 'shop', 'order', 'product', 'marketing', 'finance']
return allowed.includes(moduleId)
}
return false
}

View File

@@ -1,2 +1,2 @@
// Bridge entry to ensure Vite serves JS MIME while loading UTS entry.
import './main.uts'
import "./main.uts";

View File

@@ -1,6 +1,6 @@
{
"name": "mall",
"appid": "__UNI__9462CA7",
"appid": "__UNI__81482FF",
"description": "A multi-role e-commerce application.",
"versionName": "1.0.0",
"versionCode": "100",
@@ -48,10 +48,7 @@
"setting": {
"urlCheck": false
},
"usingComponents": true,
"unipush": {
"enable": false
}
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
@@ -73,20 +70,6 @@
"mode": "hash",
"base": "./"
}
},
"web": {
"router": {
"mode": ""
},
"unipush": {
"enable": false
}
},
"app-android": {
"distribute": {
"modules": {
"uni-push": {}
}
}
}
}

View File

@@ -1,10 +0,0 @@
{
"pages": [
{
"path": "pages/minimal",
"style": {
"navigationBarTitleText": "最小测试"
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,115 +0,0 @@
# SQL 文件整理完成
## ✅ 已完成的整理
### 1. 移除重复的简化表定义
- ✅ 从 `ANALYTICS_DB_SCHEMA.sql` 中移除了简化的 `user_sessions``page_views` 定义
- ✅ 添加了注释说明依赖关系
### 2. 添加依赖说明
- ✅ 在 `01_create_tables.sql` 中添加了注释,说明可能与 `USER_AUTH_SCHEMA.sql` 重复
- ✅ 在 `USER_AUTH_SCHEMA.sql` 中添加了注释,说明可能与 `01_create_tables.sql` 重复
---
## 📋 当前文件结构
### `pages/user/test/` - 用户认证相关
1. **`USER_AUTH_SCHEMA.sql`** ⭐
- `ak_users` 表(业务用户资料)
- `users` 表(统计用,可能与 analytics 重复)
- `user_sessions` 表(会话统计,可能与 analytics 重复)
- `upsert_user_profile` RPC 函数
- `handle_new_user` 触发器函数(注释中)
2. **`USER_AUTH_TRIGGER.sql`** ⭐
- `on_auth_user_created` 触发器(在 auth.users 插入时自动创建 ak_users
3. **`USER_AUTH_TEST_DATA.sql`**(可选)
- 测试数据
### `pages/mall/analytics/test/` - 数据分析相关
1. **`01_create_tables.sql`** ⭐
- 业务核心表:`orders`, `order_items`, `products`, `merchants`
- 统计表:`users`, `user_sessions`, `page_views`(可能与 USER_AUTH_SCHEMA.sql 重复)
- RLS 策略
- `update_updated_at_column` 函数和触发器
2. **`ANALYTICS_DB_SCHEMA.sql`** ⭐
- 分析表:`analytics_*` 系列表
- RPC 函数(用于数据分析)
- **已移除**:简化的 `user_sessions``page_views` 定义
3. **`02_insert_test_data.sql`**(可选)
- 业务表测试数据
4. **`ANALYTICS_TEST_SEED.sql`**(可选)
- 分析表测试数据
5. **`03_test_queries.sql`**(可选)
- 测试查询
6. **`04_cleanup.sql`**(可选)
- 清理脚本
---
## 🚀 推荐执行顺序
### 首次部署
```sql
-- 1. 用户认证表(包含 users, user_sessions
pages/user/test/USER_AUTH_SCHEMA.sql
pages/user/test/USER_AUTH_TRIGGER.sql
-- 2. 业务表(会跳过已存在的 users, user_sessions
pages/mall/analytics/test/01_create_tables.sql
-- 3. 分析表(依赖业务表)
pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql
-- 4. 测试数据(可选)
pages/mall/analytics/test/02_insert_test_data.sql
pages/mall/analytics/test/ANALYTICS_TEST_SEED.sql
```
### 后续更新
- 如果只更新分析表,只需执行 `ANALYTICS_DB_SCHEMA.sql`
- 如果只更新业务表,只需执行 `01_create_tables.sql`
- 如果只更新用户认证,只需执行 `USER_AUTH_SCHEMA.sql`
---
## 🔍 重复内容说明
### 已处理的重复
1.**`user_sessions` 表** - 保留在 `USER_AUTH_SCHEMA.sql``01_create_tables.sql` 中的完整定义,移除 `ANALYTICS_DB_SCHEMA.sql` 中的简化定义
2.**`page_views` 表** - 保留在 `01_create_tables.sql` 中的完整定义,移除 `ANALYTICS_DB_SCHEMA.sql` 中的简化定义
### 保留的重复(安全)
1. **`users` 表** - 在 `USER_AUTH_SCHEMA.sql``01_create_tables.sql` 中都有定义,使用 `IF NOT EXISTS` 不会冲突
2. **`update_updated_at_column` 函数** - 在多个文件中定义,使用 `CREATE OR REPLACE FUNCTION` 不会冲突
3. **触发器** - 使用 `IF NOT EXISTS``DROP TRIGGER IF EXISTS` 确保不会冲突
---
## ✅ 验证
执行以下查询验证表结构:
```sql
-- 检查 user_sessions 表字段(应该是完整定义)
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'user_sessions' AND table_schema = 'public'
ORDER BY ordinal_position;
-- 检查 page_views 表字段(应该是完整定义)
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'page_views' AND table_schema = 'public'
ORDER BY ordinal_position;
```
**预期结果**
- `user_sessions` 应包含id, user_id, session_token, last_active_at, is_active, ip_address, user_agent, created_at, updated_at
- `page_views` 应包含id, user_id, path, source, referrer, ip_address, user_agent, created_at

View File

@@ -1,119 +0,0 @@
# SQL 文件整理说明
## 📋 重复内容分析
经过检查,发现以下重复定义:
### 1. **`users` 表**(重复)
-`pages/user/test/USER_AUTH_SCHEMA.sql` (第 63-71 行)
-`pages/mall/analytics/test/01_create_tables.sql` (第 43-51 行)
- **状态**:两个定义相同,使用 `CREATE TABLE IF NOT EXISTS` 不会冲突,但建议统一
### 2. **`user_sessions` 表**(重复,定义略有不同)
-`pages/user/test/USER_AUTH_SCHEMA.sql` (第 76-86 行) - **完整定义**(推荐)
-`pages/mall/analytics/test/01_create_tables.sql` (第 24-34 行) - **完整定义**(相同)
- ⚠️ `pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql` (第 19-25 行) - **简化定义**(字段较少)
### 3. **`page_views` 表**(重复,定义不同)
-`pages/mall/analytics/test/01_create_tables.sql` (第 90-99 行) - **完整定义**(推荐)
- ⚠️ `pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql` (第 30-36 行) - **简化定义**(字段较少)
### 4. **`update_updated_at_column` 函数**(重复)
-`pages/user/test/USER_AUTH_SCHEMA.sql` (第 93-99 行)
-`pages/mall/analytics/test/01_create_tables.sql` (第 107-113 行)
- **状态**:两个定义相同,使用 `CREATE OR REPLACE FUNCTION` 不会冲突
### 5. **触发器**(部分重复)
- `USER_AUTH_SCHEMA.sql`: `update_users_updated_at`, `update_user_sessions_updated_at`
- `01_create_tables.sql`: `update_orders_updated_at`, `update_user_sessions_updated_at`, `update_users_updated_at`
---
## 🎯 整理方案
### 方案一:保持现状(推荐)
**优点**:每个文件独立,使用 `IF NOT EXISTS``CREATE OR REPLACE` 不会冲突
**缺点**:有重复代码
**执行顺序**
1. `pages/user/test/USER_AUTH_SCHEMA.sql` - 创建用户认证相关表
2. `pages/mall/analytics/test/01_create_tables.sql` - 创建业务表(会跳过已存在的表)
3. `pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql` - 创建分析表(会跳过已存在的表)
### 方案二:统一到基础表文件(更清晰)
**优点**:减少重复,职责清晰
**缺点**:需要重构文件结构
**建议结构**
- `00_base_tables.sql` - 基础表users, user_sessions, page_views
- `01_user_auth.sql` - 用户认证表ak_users和函数
- `02_business_tables.sql` - 业务表orders, products, merchants等
- `03_analytics_tables.sql` - 分析表analytics_*
---
## 📝 当前文件职责
### `pages/user/test/` 目录
- **`USER_AUTH_SCHEMA.sql`** - 用户认证核心表ak_users, users, user_sessions和 RPC 函数
- **`USER_AUTH_TRIGGER.sql`** - 数据库触发器(自动创建 ak_users
- **`USER_AUTH_TEST_DATA.sql`** - 测试数据
### `pages/mall/analytics/test/` 目录
- **`01_create_tables.sql`** - 业务表orders, users, user_sessions, products, merchants, order_items, page_views+ RLS
- **`ANALYTICS_DB_SCHEMA.sql`** - 分析表analytics_*+ RPC 函数
- **`02_insert_test_data.sql`** - 业务表测试数据
- **`ANALYTICS_TEST_SEED.sql`** - 分析表测试数据
- **`03_test_queries.sql`** - 测试查询
- **`04_cleanup.sql`** - 清理脚本
---
## ✅ 推荐操作
### 立即执行(保持现状)
当前文件结构可以使用,因为:
1. 所有表使用 `CREATE TABLE IF NOT EXISTS`
2. 所有函数使用 `CREATE OR REPLACE FUNCTION`
3. 触发器使用 `CREATE TRIGGER IF NOT EXISTS``DROP TRIGGER IF EXISTS`
**执行顺序**
```sql
-- 1. 用户认证表
pages/user/test/USER_AUTH_SCHEMA.sql
pages/user/test/USER_AUTH_TRIGGER.sql
-- 2. 业务表(会跳过已存在的 users, user_sessions
pages/mall/analytics/test/01_create_tables.sql
-- 3. 分析表(会跳过已存在的 user_sessions, page_views
pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql
```
### 未来优化(可选)
如果需要减少重复,可以:
1.`ANALYTICS_DB_SCHEMA.sql` 中移除 `user_sessions``page_views` 的简化定义
2. 确保 `01_create_tables.sql` 先执行,提供完整定义
3.`ANALYTICS_DB_SCHEMA.sql` 中添加注释说明依赖关系
---
## 🔍 验证重复
执行以下查询检查表是否存在:
```sql
-- 检查 users 表
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'users' AND table_schema = 'public';
-- 检查 user_sessions 表
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'user_sessions' AND table_schema = 'public';
-- 检查 page_views 表
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'page_views' AND table_schema = 'public';
```

119
pages/contents/articles.vue Normal file
View File

@@ -0,0 +1,119 @@
<template>
<view class="page-container">
<view class="header-actions">
<button type="primary" size="mini" @click="openArticleDrawer()">
添加文章
</button>
<button
type="default"
size="mini"
@click="openCategoryDrawer()"
style="margin-left: 10px"
>
分类管理
</button>
</view>
<uni-table border stripe emptyText="暂无数据">
<uni-tr>
<uni-th width="50" align="center">ID</uni-th>
<uni-th align="center">文章标题</uni-th>
<uni-th width="150" align="center">文章分类</uni-th>
<uni-th width="200" align="center">摘要</uni-th>
<uni-th width="150" align="center">操作</uni-th>
</uni-tr>
<uni-tr v-for="item in articleList" :key="item.id">
<uni-td align="center">{{ item.id }}</uni-td>
<uni-td align="center">{{ item.title }}</uni-td>
<uni-td align="center">{{ getCategoryName(item.categoryId) }}</uni-td>
<uni-td align="center">{{ item.summary }}</uni-td>
<uni-td align="center">
<button size="mini" type="primary" @click="openArticleDrawer(item)">
编辑
</button>
</uni-td>
</uni-tr>
</uni-table>
<view class="pagination-box">
<uni-pagination
:total="total"
:current="page"
:pageSize="pageSize"
@change="onPageChange"
/>
</view>
<!-- 抽屉组件 -->
<ArticleFormDrawer ref="articleDrawerRef" @success="fetchArticles" />
<CategoryFormDrawer ref="categoryDrawerRef" @success="fetchCategories" />
</view>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { api } from "@/services/api.js";
import mockStore from "@/stores/useMockData.js";
import ArticleFormDrawer from "@/components/ArticleFormDrawer.vue";
import CategoryFormDrawer from "@/components/CategoryFormDrawer.vue";
const articleList = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const articleDrawerRef = ref(null);
const categoryDrawerRef = ref(null);
const fetchArticles = async () => {
const res = await api.getArticles(page.value, pageSize.value);
if (res.code === 200) {
articleList.value = res.data.list;
total.value = res.data.total;
}
};
const fetchCategories = async () => {
// 触发分类数据更新,由于使用了 reactive这里其实可以不调接口但为了模拟真实流程还是调用一下
await api.getCategories();
};
const getCategoryName = (categoryId) => {
const category = mockStore.mockCategories.find((c) => c.id === categoryId);
return category ? category.name : "未知分类";
};
const onPageChange = (e) => {
page.value = e.current;
fetchArticles();
};
const openArticleDrawer = (row = null) => {
articleDrawerRef.value.open(row);
};
const openCategoryDrawer = (row = null) => {
categoryDrawerRef.value.open(row);
};
onMounted(() => {
fetchCategories();
fetchArticles();
});
</script>
<style scoped>
.page-container {
padding: 20px;
background-color: #fff;
}
.header-actions {
margin-bottom: 20px;
display: flex;
}
.pagination-box {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -80,7 +80,8 @@ const handleDelete = (item: VersionItem) => {
<style scoped lang="scss">
.admin-page {
padding: 20px;
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
}
.action-bar {

View File

@@ -48,7 +48,7 @@
<view class="form-item row">
<text class="label">网站描述:</text>
<view class="form-content">
<textarea class="form-textarea" v-model="config.description" placeholder="网站描述" />
<textarea class="form-textarea" v-model="config.description" placeholder="网站描述"></textarea>
<text class="tip">网站描述</text>
</view>
</view>
@@ -148,7 +148,8 @@ const handleSubmit = async () => {
<style scoped lang="scss">
.admin-page {
padding: 20px;
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
}
.content-card {

View File

@@ -196,7 +196,8 @@ function handleSubmit() {
<style scoped lang="scss">
.admin-page {
padding: 20px;
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
}
.content-card {

View File

@@ -1,185 +0,0 @@
/**
* 文章管理服务层
* 提供文章列表、详情、保存、删除等接口
*/
// 文章列表项数据结构
export interface ArticleItem {
id: number
title: string
category_id: number
category_name: string
image: string
description: string
status: number // 0: 未发布, 1: 已发布
views: number
created_at: string
updated_at: string
}
// 文章详情数据结构
export interface ArticleDetail {
id: number
title: string
category_id: number
image: string
description: string
content: string
status: number
created_at: string
updated_at: string
}
// 文章创建/编辑参数
export interface ArticlePayload {
title: string
category_id: number
image: string
description: string
content: string
status: number
}
/**
* 获取文章列表
* @param params 查询参数 { page, limit, keyword, status, category_id }
* @returns Promise<{ items: ArticleItem[], total: number }>
*/
export function getArticleList(params: any = {}): Promise<any> {
// TODO: 替换为实际 API 调用
// return uni.$http.get('/article/list', { params })
return new Promise((resolve) => {
setTimeout(() => {
resolve({
items: [
{
id: 1,
title: '如何选择合适的商品分类',
category_id: 1,
category_name: '运营指南',
image: '/static/article-1.png',
description: '商品分类是电商平台的重要组成部分...',
status: 1,
views: 128,
created_at: '2026-01-28 10:30:00',
updated_at: '2026-01-28 10:30:00'
},
{
id: 2,
title: '商城营销活动最佳实践',
category_id: 2,
category_name: '营销技巧',
image: '/static/article-2.png',
description: '分享最新的营销活动策略和案例...',
status: 1,
views: 256,
created_at: '2026-01-27 15:20:00',
updated_at: '2026-01-27 15:20:00'
},
{
id: 3,
title: '用户评价管理指南',
category_id: 1,
category_name: '运营指南',
image: '/static/article-3.png',
description: '如何有效管理用户的评价和反馈...',
status: 0,
views: 64,
created_at: '2026-01-26 09:15:00',
updated_at: '2026-01-26 09:15:00'
}
],
total: 3
})
}, 300)
})
}
/**
* 获取文章详情
* @param id 文章ID
* @returns Promise<ArticleDetail>
*/
export function getArticleDetail(id: number): Promise<ArticleDetail> {
// TODO: 替换为实际 API 调用
// return uni.$http.get(`/article/${id}`)
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id,
title: '如何选择合适的商品分类',
category_id: 1,
image: '/static/article-1.png',
description: '商品分类是电商平台的重要组成部分...',
content: '<h2>标题</h2><p>详细内容...</p>',
status: 1,
created_at: '2026-01-28 10:30:00',
updated_at: '2026-01-28 10:30:00'
})
}, 300)
})
}
/**
* 保存文章(新建或编辑)
* @param data 文章数据
* @param id 文章ID编辑时传入
* @returns Promise<{ success: boolean, message: string, id?: number }>
*/
export function saveArticle(data: ArticlePayload, id?: number): Promise<any> {
// TODO: 替换为实际 API 调用
// const method = id ? 'PUT' : 'POST'
// const url = id ? `/article/${id}` : '/article'
// return uni.$http[method === 'PUT' ? 'put' : 'post'](url, data)
return new Promise((resolve) => {
setTimeout(() => {
resolve({
success: true,
message: id ? '编辑成功' : '新建成功',
id: id || Math.floor(Math.random() * 10000)
})
}, 500)
})
}
/**
* 删除文章
* @param id 文章ID
* @returns Promise<{ success: boolean, message: string }>
*/
export function deleteArticle(id: number): Promise<any> {
// TODO: 替换为实际 API 调用
// return uni.$http.delete(`/article/${id}`)
return new Promise((resolve) => {
setTimeout(() => {
resolve({
success: true,
message: '删除成功'
})
}, 300)
})
}
/**
* 发布/取消发布文章
* @param id 文章ID
* @param status 状态 (0: 取消发布, 1: 发布)
* @returns Promise<{ success: boolean, message: string }>
*/
export function publishArticle(id: number, status: number): Promise<any> {
// TODO: 替换为实际 API 调用
// return uni.$http.put(`/article/${id}/publish`, { status })
return new Promise((resolve) => {
setTimeout(() => {
resolve({
success: true,
message: status === 1 ? '发布成功' : '取消发布成功'
})
}, 300)
})
}

View File

@@ -1,178 +0,0 @@
/**
* 文章分类管理服务层
* 提供分类列表、保存、删除等接口
*/
// 分类数据结构
export interface CategoryItem {
id: number
name: string
description: string
image: string
article_count: number
sort: number
status: number // 0: 禁用, 1: 启用
created_at: string
updated_at: string
}
// 分类创建/编辑参数
export interface CategoryPayload {
name: string
description: string
image: string
sort: number
status: number
}
/**
* 获取分类列表
* @param params 查询参数 { page, limit, keyword, status }
* @returns Promise<{ items: CategoryItem[], total: number }>
*/
export function getCategoryList(params: any = {}): Promise<any> {
// TODO: 替换为实际 API 调用
// return uni.$http.get('/article/category/list', { params })
return new Promise((resolve) => {
setTimeout(() => {
resolve({
items: [
{
id: 1,
name: '运营指南',
description: '关于商城运营的各类指南和教程',
image: '/static/category-1.png',
article_count: 12,
sort: 1,
status: 1,
created_at: '2026-01-15 10:00:00',
updated_at: '2026-01-20 15:30:00'
},
{
id: 2,
name: '营销技巧',
description: '营销活动策略和最佳实践',
image: '/static/category-2.png',
article_count: 8,
sort: 2,
status: 1,
created_at: '2026-01-15 11:00:00',
updated_at: '2026-01-19 14:20:00'
},
{
id: 3,
name: '常见问题',
description: '常见问题解答和故障排除',
image: '/static/category-3.png',
article_count: 5,
sort: 3,
status: 1,
created_at: '2026-01-15 12:00:00',
updated_at: '2026-01-18 09:45:00'
},
{
id: 4,
name: '产品更新',
description: '产品更新日志和新功能介绍',
image: '/static/category-4.png',
article_count: 3,
sort: 4,
status: 0,
created_at: '2026-01-16 10:00:00',
updated_at: '2026-01-17 16:00:00'
}
],
total: 4
})
}, 300)
})
}
/**
* 获取分类详情
* @param id 分类ID
* @returns Promise<CategoryItem>
*/
export function getCategoryDetail(id: number): Promise<CategoryItem> {
// TODO: 替换为实际 API 调用
// return uni.$http.get(`/article/category/${id}`)
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id,
name: '运营指南',
description: '关于商城运营的各类指南和教程',
image: '/static/category-1.png',
article_count: 12,
sort: 1,
status: 1,
created_at: '2026-01-15 10:00:00',
updated_at: '2026-01-20 15:30:00'
})
}, 300)
})
}
/**
* 保存分类(新建或编辑)
* @param data 分类数据
* @param id 分类ID编辑时传入
* @returns Promise<{ success: boolean, message: string, id?: number }>
*/
export function saveCategory(data: CategoryPayload, id?: number): Promise<any> {
// TODO: 替换为实际 API 调用
// const method = id ? 'PUT' : 'POST'
// const url = id ? `/article/category/${id}` : '/article/category'
// return uni.$http[method === 'PUT' ? 'put' : 'post'](url, data)
return new Promise((resolve) => {
setTimeout(() => {
resolve({
success: true,
message: id ? '编辑成功' : '新建成功',
id: id || Math.floor(Math.random() * 10000)
})
}, 500)
})
}
/**
* 删除分类
* @param id 分类ID
* @returns Promise<{ success: boolean, message: string }>
*/
export function deleteCategory(id: number): Promise<any> {
// TODO: 替换为实际 API 调用
// return uni.$http.delete(`/article/category/${id}`)
return new Promise((resolve) => {
setTimeout(() => {
resolve({
success: true,
message: '删除成功'
})
}, 300)
})
}
/**
* 启用/禁用分类
* @param id 分类ID
* @param status 状态 (0: 禁用, 1: 启用)
* @returns Promise<{ success: boolean, message: string }>
*/
export function toggleCategoryStatus(id: number, status: number): Promise<any> {
// TODO: 替换为实际 API 调用
// return uni.$http.put(`/article/category/${id}/status`, { status })
return new Promise((resolve) => {
setTimeout(() => {
resolve({
success: true,
message: status === 1 ? '启用成功' : '禁用成功'
})
}, 300)
})
}

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('category')
const title = ref<string>('category')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('create')
const title = ref<string>('create')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('edit')
const title = ref<string>('edit')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('article-index')
const title = ref<string>('文章管理')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -0,0 +1,554 @@
<template>
<view class="admin-cms-article">
<view class="content-body">
<!-- 顶部过滤栏 -->
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">文章分类:</text>
<view class="select-mock">
<text class="select-val">{{ filterCategory }}</text>
<text class="arrow-down">▼</text>
</view>
</view>
<view class="filter-item">
<text class="label-txt">文章搜索:</text>
<input class="search-input" placeholder="请输入" v-model="filterKeyword" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="query-txt">查询</text>
</view>
</view>
<!-- 主要内容区域 -->
<view class="table-card border-shadow">
<view class="card-header">
<view class="btn-primary" @click="handleAdd">
<text class="btn-txt">添加文章</text>
</view>
</view>
<!-- 数据表格 -->
<view class="table-header">
<view class="th col-id"><text class="th-txt">ID</text></view>
<view class="th col-img"><text class="th-txt">文章图片</text></view>
<view class="th col-name"><text class="th-txt">文章名称</text></view>
<view class="th col-cat"><text class="th-txt">文章分类</text></view>
<view class="th col-link"><text class="th-txt">关联商品</text></view>
<view class="th col-v"><text class="th-txt">浏览量</text></view>
<view class="th col-time"><text class="th-txt">时间</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view class="table-row" v-for="item in pagedList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ item.id }}</text></view>
<view class="td col-img">
<view class="img-box"><text class="img-placeholder">🖼️</text></view>
</view>
<view class="td col-name"><text class="td-txt">{{ item.name }}</text></view>
<view class="td col-cat"><text class="td-txt">{{ item.categoryName }}</text></view>
<view class="td col-link"><text class="td-txt">{{ item.linkedProduct }}</text></view>
<view class="td col-v"><text class="td-txt">{{ item.views }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.time }}</text></view>
<view class="td col-op">
<view class="op-links">
<text class="link-txt" @click="handleEdit(item)">编辑</text>
<view class="divider"></view>
<text class="link-txt">关联</text>
<view class="divider"></view>
<text class="link-txt danger">删除</text>
<view class="divider"></view>
<text class="link-txt">复制链接</text>
<text class="arrow-small">▼</text>
</view>
</view>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
<!-- 侧边弹窗 (Drawer) -->
<view v-if="showDrawer" :class="['drawer-mask', isClosing ? 'mask-fade-out' : '']" @click="closeDrawer">
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<view class="tab-item active">
<text class="tab-txt">文章信息</text>
<view class="tab-line"></view>
</view>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<scroll-view class="drawer-body" :scroll-y="true">
<view class="form-grid">
<view class="form-row">
<view class="form-col">
<view class="label-box"><text class="required">*</text><text class="label-txt">标题:</text></view>
<view class="input-box">
<input class="input-base" v-model="formTitle" placeholder="请输入" />
<text class="input-count">{{ formTitle.length }}/80</text>
</view>
</view>
<view class="form-col">
<view class="label-box"><text class="label-txt">作者:</text></view>
<view class="input-box">
<input class="input-base" v-model="formAuthor" placeholder="请输入" />
<text class="input-count">{{ formAuthor.length }}/10</text>
</view>
</view>
</view>
<view class="form-row mt-20" style="position: relative; z-index: 10;">
<view class="form-col" style="position: relative; z-index: 11;">
<view class="label-box"><text class="required">*</text><text class="label-txt">文章分类:</text></view>
<view class="input-box z-20" style="position: relative;">
<view class="select-mock" @click.stop="toggleDropdown">
<text class="select-val">{{ formCategory || '请选择' }}</text>
<text class="arrow-down">▼</text>
</view>
<view v-if="dropdownVisible" class="dropdown-list">
<view
v-for="(cat, index) in categoryList"
:key="index"
class="dropdown-item"
@click="selectCategory(cat)">
<text class="dropdown-txt">{{ cat }}</text>
</view>
</view>
</view>
</view>
<view class="form-col">
<view class="label-box"><text class="label-txt">文章简介:</text></view>
<view class="input-box">
<textarea class="textarea-base" v-model="formIntro" placeholder="请输入"></textarea>
<text class="input-count">{{ formIntro.length }}/300</text>
</view>
</view>
</view>
<view class="form-row mt-20">
<view class="form-col full">
<view class="label-box"><text class="required">*</text><text class="label-txt">图文封面:</text></view>
<view class="upload-container" @click="handleUploadCover">
<view class="upload-btn">
<text class="plus-icon">+</text>
</view>
<text class="tip-txt mt-10">建议尺寸500 x 312 px</text>
</view>
</view>
</view>
</view>
<!-- 文章内容区块 -->
<view class="section-title mt-40">
<view class="title-inner active">
<text class="title-txt">文章内容</text>
<view class="title-line"></view>
</view>
</view>
<view class="editor-section">
<view class="label-box mb-10"><text class="required">*</text><text class="label-txt">文章内容:</text></view>
<view class="rich-editor-mock">
<view class="editor-toolbar">
<text class="tool-ic" @click="formatText('bold')">B</text>
<text class="tool-ic" @click="formatText('italic')">I</text>
<text class="tool-ic" @click="formatText('underline')">U</text>
<text class="tool-ic" @click="insertImage">🖼️</text>
</view>
<view class="editor-content-container">
<editor id="editor" class="editor-instance" placeholder="在此输入文章内容..." @ready="onEditorReady"></editor>
</view>
</view>
</view>
<!-- 其他设置 -->
<view class="section-title mt-40">
<view class="title-inner active">
<text class="title-txt">其他设置</text>
<view class="title-line"></view>
</view>
</view>
<view class="settings-section">
<view class="form-item">
<view class="label-box-wide"><text class="label-txt">banner显示:</text></view>
<view class="radio-group">
<view class="radio-item" @click="formBanner = true">
<view :class="['radio-circle', formBanner ? 'checked' : '']"><view v-if="formBanner" class="radio-in"></view></view>
<text class="radio-la">显示</text>
</view>
<view class="radio-item" @click="formBanner = false">
<view :class="['radio-circle', !formBanner ? 'checked' : '']"><view v-if="!formBanner" class="radio-in"></view></view>
<text class="radio-la">不显示</text>
</view>
</view>
</view>
<view class="form-item mt-20">
<view class="label-box-wide"><text class="label-txt">热门文章:</text></view>
<view class="radio-group">
<view class="radio-item" @click="formHot = true">
<view :class="['radio-circle', formHot ? 'checked' : '']"><view v-if="formHot" class="radio-in"></view></view>
<text class="radio-la">显示</text>
</view>
<view class="radio-item" @click="formHot = false">
<view :class="['radio-circle', !formHot ? 'checked' : '']"><view v-if="!formHot" class="radio-in"></view></view>
<text class="radio-la">不显示</text>
</view>
</view>
</view>
</view>
<view class="submit-container mt-40">
<view class="btn-submit" @click="handleConfirm">
<text class="submit-txt">提交</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const filterCategory = ref('全部')
const filterKeyword = ref('')
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 articleList 为 fetchArticleList() 调用
const articleList = ref([
{ id: '260', name: '赋能消费 | 卷狗优选迈向文化消费新时代', categoryName: '消费文化', linkedProduct: '', views: '3349', time: '2025-04-01 16:34' },
{ id: '259', name: '把重要的日子放在桌面', categoryName: '品牌资讯', linkedProduct: '2024新款吹风机...', views: '260', time: '2025-04-01 16:32' },
{ id: '258', name: '春季家居好物推荐', categoryName: '购物心得', linkedProduct: '', views: '1820', time: '2025-03-28 10:00' },
{ id: '257', name: '如何挑选适合自己的护肤品', categoryName: '消费文化', linkedProduct: '', views: '2100', time: '2025-03-25 14:20' },
{ id: '256', name: '品牌创始人专访:坚守初心', categoryName: '品牌资讯', linkedProduct: '', views: '980', time: '2025-03-20 09:15' },
{ id: '255', name: '高效厨房整理术', categoryName: '购物心得', linkedProduct: '多功能收纳盒...', views: '3456', time: '2025-03-18 11:30' },
{ id: '254', name: '2025年度流行色解析', categoryName: '消费文化', linkedProduct: '', views: '4120', time: '2025-03-15 16:00' },
{ id: '253', name: '新品发布:智能水杯上市', categoryName: '品牌资讯', linkedProduct: '智能水杯Pro', views: '5230', time: '2025-03-10 10:00' },
{ id: '252', name: '宠物用品选购指南', categoryName: '购物心得', linkedProduct: '宠物牵引绳', views: '770', time: '2025-03-08 12:00' },
{ id: '251', name: '极简生活方式的艺术', categoryName: '消费文化', linkedProduct: '', views: '6100', time: '2025-03-05 08:30' },
{ id: '250', name: '环保购物袋全测评', categoryName: '购物心得', linkedProduct: '帆布购物袋', views: '1430', time: '2025-03-01 14:00' },
{ id: '249', name: '春节营销实战复盘', categoryName: '品牌资讯', linkedProduct: '', views: '2210', time: '2025-02-20 09:00' },
{ id: '248', name: '提升生活品质的10个妙招', categoryName: '消费文化', linkedProduct: '', views: '8900', time: '2025-02-15 10:00' },
{ id: '247', name: '运动装备选购攻略', categoryName: '购物心得', linkedProduct: '跑步鞋推荐款', views: '3310', time: '2025-02-10 11:00' },
{ id: '246', name: '年货节好物大盘点', categoryName: '品牌资讯', linkedProduct: '', views: '7640', time: '2025-02-05 14:00' },
{ id: '245', name: '居家健身的正确打开方式', categoryName: '消费文化', linkedProduct: '哑铃套装', views: '4450', time: '2025-01-28 09:30' },
{ id: '244', name: '厨房小家电测评合集', categoryName: '购物心得', linkedProduct: '空气炸锅推荐', views: '5560', time: '2025-01-20 10:00' },
{ id: '243', name: '品牌十周年回顾', categoryName: '品牌资讯', linkedProduct: '', views: '3120', time: '2025-01-15 16:00' },
{ id: '242', name: '冬季护肤必备清单', categoryName: '消费文化', linkedProduct: '', views: '6780', time: '2025-01-10 14:00' },
{ id: '241', name: '新年礼物推荐:让 Ta 惊喜', categoryName: '购物心得', linkedProduct: '礼品套装', views: '9900', time: '2025-01-01 00:00' }
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => articleList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return articleList.value.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
const categoryList = ref(['购物心得', '消费文化', '品牌资讯', '全部']) // Mock data mirroring categories
const dropdownVisible = ref(false)
const showDrawer = ref(false)
const isClosing = ref(false)
const formTitle = ref('')
const formAuthor = ref('')
const formCategory = ref('')
const formIntro = ref('')
const formContent = ref('')
const formBanner = ref(false)
const formHot = ref(false)
const toggleDropdown = () => {
dropdownVisible.value = !dropdownVisible.value
}
const selectCategory = (cat: string) => {
formCategory.value = cat
dropdownVisible.value = false
}
let editorCtx: any = null
const onEditorReady = () => {
// @ts-ignore
uni.createSelectorQuery().select('#editor').context((res) => {
editorCtx = res.context
}).exec()
}
const formatText = (name: string) => {
if (editorCtx) {
editorCtx.format(name)
}
}
const insertImage = () => {
uni.chooseImage({
count: 1,
success: (res) => {
if (editorCtx) {
editorCtx.insertImage({
src: res.tempFilePaths[0],
alt: '图像'
})
}
}
})
}
const handleAdd = () => {
formTitle.value = ''
formAuthor.value = ''
formCategory.value = ''
formIntro.value = ''
formContent.value = ''
formBanner.value = false
formHot.value = false
isClosing.value = false
dropdownVisible.value = false
showDrawer.value = true
}
const handleEdit = (item: any) => {
formTitle.value = item.name
formAuthor.value = '管理员'
formCategory.value = item.categoryName
formIntro.value = '这是一段文章简介...'
formContent.value = '<p>这是一段富文本内容...</p>'
formBanner.value = false
formHot.value = true
isClosing.value = false
dropdownVisible.value = false
showDrawer.value = true
}
const closeDrawer = () => {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
const handleUploadCover = () => {
uni.chooseImage({
count: 1,
success: (res) => {
// 在实际应用中这里应该上传到服务器
// 这里简单模拟展示
uni.showToast({ title: '上传成功', icon: 'success' })
// formCover.value = res.tempFilePaths[0]
}
})
}
const handleConfirm = () => {
if (formTitle.value.trim() == '') {
uni.showToast({ title: '请输入标题', icon: 'none' })
return
}
// 模拟保存逻辑
const newId = String(241 + articleList.value.length)
articleList.value.unshift({
id: newId,
name: formTitle.value,
categoryName: formCategory.value || '未分类',
linkedProduct: '',
views: '0',
time: '2026-02-26 12:00'
})
uni.showToast({ title: '发布成功', icon: 'success' })
closeDrawer()
}
const handleQuery = () => { console.log('Querying...') }
</script>
<style scoped lang="scss">
.admin-cms-article { padding: 0; background-color: transparent; min-height: auto; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 30px; margin-bottom: 20px; }
.filter-item { display: flex; flex-direction: row; align-items: center; }
.label-txt { font-size: 14px; color: #606266; margin-right: 12px; }
.select-mock { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 15px; }
.select-val { font-size: 13px; color: #333; }
.arrow-down { font-size: 10px; color: #999; }
.search-input { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 15px; font-size: 13px; }
.btn-query { width: 76px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.query-txt { color: #fff; font-size: 14px; }
.table-card { display: flex; flex-direction: column; }
.card-header { padding: 20px; }
.btn-primary { width: 100px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.btn-txt { color: #fff; font-size: 13px; }
.table-header { height: 50px; background-color: #eaf2ff; display: flex; flex-direction: row; align-items: center; }
.th { padding: 0 15px; }
.th-txt { font-size: 13px; color: #606266; font-weight: bold; }
.table-row { height: 80px; display: flex; flex-direction: row; align-items: center; border-bottom: 1px solid #f0f0f0; }
.td { padding: 0 15px; }
.td-txt { font-size: 13px; color: #606266; }
.img-box { width: 60px; height: 40px; background-color: #f5f7fa; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.img-placeholder { font-size: 20px; }
.col-id { width: 80px; justify-content: center; }
.col-img { width: 100px; }
.col-name { flex: 2; }
.col-cat { width: 120px; }
.col-link { flex: 2; }
.col-v { width: 100px; justify-content: center; }
.col-time { width: 180px; justify-content: center; }
.col-op { width: 220px; }
.op-links { display: flex; flex-direction: row; align-items: center; }
.link-txt { font-size: 13px; color: #2d8cf0; cursor: pointer; }
.danger { color: #ed4014; }
.divider { width: 1px; height: 12px; background-color: #e8eaec; margin: 0 8px; }
.arrow-small { font-size: 8px; color: #2d8cf0; margin-left: 2px; }
.pagination-bar { padding: 20px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.page-info { display: flex; flex-direction: row; align-items: center; }
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; transition: opacity 0.3s; }
.mask-fade-out { opacity: 0; }
.drawer-content { position: absolute; top: 0; right: 0; width: 50%; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
.slide-out { animation: slideOut 0.3s ease forwards; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes slideOut { from { transform: translateX(0); } to { transform: translateX(100%); } }
.drawer-header { height: 54px; padding: 0 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.tab-item { height: 100%; display: flex; align-items: center; position: relative; }
.tab-txt { font-size: 15px; color: #2d8cf0; font-weight: 500; }
.tab-line { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background-color: #2d8cf0; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.drawer-body { flex: 1; padding: 24px; background-color: #fff; }
.section-title { height: 44px; border-bottom: 1px solid #f0f0f0; margin-bottom: 24px; display: flex; flex-direction: row; }
.title-inner { height: 100%; display: flex; align-items: center; position: relative; padding: 0 10px; }
.title-txt { font-size: 15px; color: #333; font-weight: 500; }
.title-inner.active .title-txt { color: #2d8cf0; }
.title-line { position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background-color: #2d8cf0; display: none; }
.title-inner.active .title-line { display: block; }
.form-grid { display: flex; flex-direction: column; }
.form-row { display: flex; flex-direction: row; gap: 30px; }
.form-col { flex: 1; display: flex; flex-direction: row; align-items: flex-start; }
.form-col.full { flex: none; width: 100%; }
.label-box { width: 100px; display: flex; flex-direction: row; justify-content: flex-end; margin-right: 15px; padding-top: 8px; flex-shrink: 0; }
.label-box-wide { width: 120px; display: flex; flex-direction: row; justify-content: flex-end; margin-right: 15px; flex-shrink: 0; }
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; }
.input-box { flex: 1; position: relative; }
.z-20 { z-index: 20; }
.input-base { width: 100%; height: 38px; border: 1px solid #dcdee2; border-radius: 4px; padding: 0 45px 0 12px; font-size: 14px; }
.textarea-base { width: 100%; height: 100px; border: 1px solid #dcdee2; border-radius: 4px; padding: 10px 12px; font-size: 14px; }
.input-count { position: absolute; bottom: 8px; right: 12px; font-size: 12px; color: #999; }
.upload-container { display: flex; flex-direction: column; }
.upload-btn { width: 80px; height: 80px; border: 1px dashed #dcdee2; border-radius: 4px; display: flex; align-items: center; justify-content: center; background-color: #f8f8f9; }
.plus-icon { font-size: 30px; color: #999; }
.tip-txt { font-size: 12px; color: #999; }
.editor-section { display: flex; flex-direction: column; }
.rich-editor-mock { border: 1px solid #dcdee2; border-radius: 4px; min-height: 400px; display: flex; flex-direction: column; }
.editor-toolbar { height: 44px; background-color: #fafafa; border-bottom: 1px solid #dcdee2; display: flex; flex-direction: row; align-items: center; padding: 0 15px; gap: 20px; flex-wrap: wrap; }
.tool-ic { font-size: 16px; color: #515a6e; cursor: pointer; padding: 5px 10px; border: 1px solid #eee; border-radius: 4px; }
.tool-ic:active { background-color: #f0f0f0; }
.editor-content-container { flex: 1; background-color: #fff; padding: 15px; }
.editor-instance { width: 100%; height: 350px; font-size: 14px; }
.settings-section { display: flex; flex-direction: column; }
.form-item { display: flex; flex-direction: row; align-items: center; }
.radio-group { display: flex; flex-direction: row; gap: 30px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-circle { width: 16px; height: 16px; border: 1px solid #dcdee2; border-radius: 50%; margin-right: 8px; display: flex; align-items: center; justify-content: center; }
.radio-circle.checked { border-color: #2d8cf0; }
.radio-in { width: 8px; height: 8px; background-color: #2d8cf0; border-radius: 50%; }
.radio-la { font-size: 14px; color: #606266; }
.dropdown-list {
position: absolute;
top: 40px;
left: 0;
width: 100%;
background-color: #fff;
border: 1px solid #dcdee2;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 100;
max-height: 200px;
overflow-y: auto;
}
.dropdown-item {
padding: 10px 15px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background-color: #f5f7fa;
}
.dropdown-txt {
font-size: 14px;
color: #606266;
}
.submit-container { display: flex; justify-content: flex-start; padding-left: 135px; padding-bottom: 50px; }
.btn-submit { width: 120px; height: 40px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.submit-txt { color: #fff; font-size: 14px; }
.drawer-footer { height: 60px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; align-items: center; padding: 0 24px; background-color: #fff; }
.btn-cancel { padding: 8px 20px; border: 1px solid #dcdee2; border-radius: 4px; margin-right: 15px; }
.btn-confirm { padding: 8px 20px; background-color: #2d8cf0; border-radius: 4px; }
.cancel-txt { font-size: 14px; color: #606266; }
.confirm-txt { font-size: 14px; color: #fff; }
.mt-20 { margin-top: 20px; }
.mt-40 { margin-top: 40px; }
.mt-10 { margin-top: 10px; }
.mb-10 { margin-bottom: 10px; }
</style>

View File

@@ -1,467 +0,0 @@
<template>
<view class="admin-cms-article">
<view class="content-body">
<!-- 顶部过滤栏 -->
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">文章分类:</text>
<picker :value="categoryIndex" :range="categoryOptions" range-key="label" @change="onFilterCategoryChange">
<view class="select-mock">
<text class="select-val">{{ categoryOptions[categoryIndex]?.label || '全部' }}</text>
<text class="arrow-down">▼</text>
</view>
</picker>
</view>
<view class="filter-item">
<text class="label-txt">文章搜索:</text>
<input class="search-input" placeholder="请输入标题搜索" v-model="filterKeyword" @confirm="handleQuery" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="query-txt">查询</text>
</view>
<view class="btn-reset" @click="handleReset">
<text class="reset-txt">重置</text>
</view>
</view>
<!-- 主要内容区域 -->
<view class="table-card border-shadow">
<view class="card-header">
<view class="btn-primary" @click="handleAdd">
<text class="btn-txt">添加文章</text>
</view>
</view>
<!-- 数据表格 -->
<view class="table-header">
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-img"><text class="th-txt">封面图</text></view>
<view class="th col-name"><text class="th-txt">文章标题</text></view>
<view class="th col-v"><text class="th-txt">浏览量</text></view>
<view class="th col-time"><text class="th-txt">最后更新</text></view>
<view class="th col-status"><text class="th-txt">状态</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="articleList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无文章数据</text>
</view>
<view v-else class="table-row" v-for="(item, index) in articleList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-img">
<image v-if="item.image" :src="item.image" mode="aspectFill" class="article-cover"></image>
<view v-else class="img-placeholder"><text class="p-txt">无图</text></view>
</view>
<view class="td col-name">
<view class="title-box">
<text class="td-txt ellipsis-2">{{ item.title }}</text>
<text class="sub-txt">分类: {{ item.category_name }}</text>
</view>
</view>
<view class="td col-v"><text class="td-txt">{{ item.views }}</text></view>
<view class="td col-time"><text class="td-txt">{{ item.updated_at.substring(0, 16).replace('T', ' ') }}</text></view>
<view class="td col-status">
<view :class="['switch-mock', item.status === 1 ? 'active' : '']" @click="toggleStatus(item)">
<view class="switch-handle"></view>
</view>
</view>
<view class="td col-op">
<view class="op-links">
<text class="link-txt" @click="handleEdit(item)">编辑</text>
<view class="divider"></view>
<text class="link-txt danger" @click="handleDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<view class="pagination-bar">
<text class="page-total">共 {{ total }} 条</text>
<view class="page-nav">
<view class="nav-btn" :class="{ disabled: page <= 1 }" @click="prevPage"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">{{ page }}</text></view>
<view class="nav-btn" :class="{ disabled: page >= totalPages }" @click="nextPage"><text class="nav-icon"> > </text></view>
</view>
</view>
</view>
</view>
<!-- 侧边弹窗 (Drawer) -->
<view v-if="showDrawer" :class="['drawer-mask', isClosing ? 'mask-fade-out' : '']" @click="closeDrawer">
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<view class="tab-item active">
<text class="tab-txt">{{ isEdit ? '编辑文章' : '添加文章' }}</text>
<view class="tab-line"></view>
</view>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<scroll-view class="drawer-body" :scroll-y="true">
<view class="form-grid">
<view class="form-item">
<view class="label-box"><text class="required">*</text><text class="label-txt">标题:</text></view>
<view class="input-box">
<input class="input-base" v-model="form.title" placeholder="请输入文章标题" />
</view>
</view>
<view class="form-item">
<view class="label-box"><text class="required">*</text><text class="label-txt">文章分类:</text></view>
<view class="input-box">
<picker :value="formCategoryIndex" :range="categoryOptions" range-key="label" @change="onFormCategoryChange">
<view class="select-mock">
<text class="select-val">{{ categoryOptions[formCategoryIndex]?.label || '请选择分类' }}</text>
<text class="arrow-down">▼</text>
</view>
</picker>
</view>
</view>
<view class="form-item">
<view class="label-box"><text class="label-txt">作者:</text></view>
<view class="input-box">
<input class="input-base" v-model="form.author" placeholder="请输入作者" />
</view>
</view>
<view class="form-item align-start">
<view class="label-box pt-10"><text class="label-txt">文章简介:</text></view>
<view class="input-box">
<textarea class="textarea-base" v-model="form.description" placeholder="请输入简要说明" />
</view>
</view>
<view class="form-item align-start">
<view class="label-box pt-10"><text class="required">*</text><text class="label-txt">文章内容:</text></view>
<view class="input-box">
<textarea class="textarea-base content-area" v-model="form.content" placeholder="请输入文章正文" />
</view>
</view>
<view class="form-item">
<view class="label-box"><text class="label-txt">发布状态:</text></view>
<view class="radio-group">
<view class="radio-item" @click="form.status = 1">
<view :class="['radio-circle', form.status === 1 ? 'checked' : '']"><view v-if="form.status === 1" class="radio-in"></view></view>
<text class="radio-la">立即发布</text>
</view>
<view class="radio-item" @click="form.status = 0">
<view :class="['radio-circle', form.status === 0 ? 'checked' : '']"><view v-if="form.status === 0" class="radio-in"></view></view>
<text class="radio-la">暂不发布</text>
</view>
</view>
</view>
</view>
<view class="submit-container mt-40">
<view class="btn-submit" @click="handleConfirm">
<text class="submit-txt">提交</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, computed } from 'vue'
import {
fetchArticlePage,
fetchArticleCategoryPage,
saveArticle,
deleteArticle,
setArticleStatus,
fetchArticleDetail,
type ArticleItem,
type ArticleCategory
} from '@/services/admin/cmsService.uts'
const filterKeyword = ref('')
const articleList = ref<ArticleItem[]>([])
const categories = ref<ArticleCategory[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const categoryIndex = ref(0)
const categoryOptions = computed(() : any[] => {
const opts = [{ label: '全部分类', value: null }]
categories.value.forEach(c => {
opts.push({ label: c.name, value: c.id })
})
return opts
})
const showDrawer = ref(false)
const isClosing = ref(false)
const isEdit = ref(false)
const formCategoryIndex = ref(0)
const form = reactive({
id: '' as string | null,
category_id: '',
title: '',
author: '',
description: '',
content: '',
status: 1,
image: null as string | null
})
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
onMounted(() => {
loadCategories()
loadData()
})
async function loadCategories() {
const res = await fetchArticleCategoryPage(1, 100) // 获取所有分类用于筛选
categories.value = res.items
}
async function loadData() {
loading.value = true
try {
const catId = categoryOptions.value[categoryIndex.value]?.value as string | null
const res = await fetchArticlePage(
page.value,
pageSize.value,
catId,
null,
filterKeyword.value || null
)
articleList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function handleQuery() {
page.value = 1
loadData()
}
function handleReset() {
filterKeyword.value = ''
categoryIndex.value = 0
page.value = 1
loadData()
}
function onFilterCategoryChange(e : any) {
categoryIndex.value = e.detail.value as number
handleQuery()
}
function onFormCategoryChange(e : any) {
formCategoryIndex.value = e.detail.value as number
form.category_id = categoryOptions.value[formCategoryIndex.value].value as string
}
function handleAdd() {
isEdit.value = false
form.id = null
form.title = ''
form.author = ''
form.category_id = ''
form.description = ''
form.content = ''
form.status = 1
formCategoryIndex.value = 0
isClosing.value = false
showDrawer.value = true
}
async function handleEdit(item : ArticleItem) {
loading.value = true
try {
const detail = await fetchArticleDetail(item.id)
if (detail != null) {
isEdit.value = true
form.id = detail.id
form.title = detail.title
form.author = detail.author || ''
form.category_id = detail.category_id
form.description = detail.description || ''
form.content = detail.content
form.status = detail.status
const idx = categoryOptions.value.findIndex(opt => opt.value == detail.category_id)
formCategoryIndex.value = idx > -1 ? idx : 0
isClosing.value = false
showDrawer.value = true
}
} catch (e) {
uni.showToast({ title: '获取详情失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function toggleStatus(item : ArticleItem) {
const target = item.status === 1 ? 0 : 1
const ok = await setArticleStatus(item.id, target)
if (ok) {
item.status = target
uni.showToast({ title: '状态已更新' })
}
}
async function handleDelete(item : ArticleItem) {
uni.showModal({
title: '提示',
content: `确定删除文章 "${item.title}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteArticle(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function handleConfirm() {
if (!form.title || !form.category_id || !form.content) {
uni.showToast({ title: '请完善文章必填信息', icon: 'none' })
return
}
const resId = await saveArticle(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeDrawer()
loadData()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
function closeDrawer() {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (page.value < totalPages.value) { page.value++; loadData(); } }
</script>
<style scoped lang="scss">
.admin-cms-article { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 15px; margin-bottom: 20px; }
.filter-item { display: flex; flex-direction: row; align-items: center; }
.label-txt { font-size: 14px; color: #606266; margin-right: 12px; }
.select-mock { min-width: 160px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 15px; }
.select-val { font-size: 13px; color: #333; }
.arrow-down { font-size: 10px; color: #999; }
.search-input { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 15px; font-size: 13px; }
.btn-query { padding: 0 20px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-reset { padding: 0 20px; height: 34px; background-color: #fff; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.query-txt { color: #fff; font-size: 14px; }
.reset-txt { color: #606266; font-size: 14px; }
.table-card { display: flex; flex-direction: column; }
.card-header { padding: 20px; }
.btn-primary { width: 100px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-txt { color: #fff; font-size: 13px; }
.table-header { height: 50px; background-color: #eaf2ff; display: flex; flex-direction: row; align-items: center; }
.th { padding: 0 15px; }
.th-txt { font-size: 13px; color: #606266; font-weight: bold; }
.table-row { height: 80px; display: flex; flex-direction: row; align-items: center; border-bottom: 1px solid #f0f0f0; }
.td { padding: 0 15px; }
.td-txt { font-size: 13px; color: #606266; }
.article-cover { width: 60px; height: 40px; border-radius: 4px; background-color: #f5f7fa; }
.img-placeholder { width: 60px; height: 40px; background-color: #f5f7fa; border-radius: 4px; display: flex; align-items: center; justify-content: center; .p-txt { font-size: 10px; color: #ccc; } }
.title-box { display: flex; flex-direction: column; gap: 4px; .sub-txt { font-size: 11px; color: #999; } }
.ellipsis-2 { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.col-id { width: 60px; justify-content: center; }
.col-img { width: 90px; }
.col-name { flex: 1; }
.col-v { width: 80px; justify-content: center; }
.col-time { width: 160px; justify-content: center; }
.col-status { width: 100px; justify-content: center; }
.col-op { width: 180px; }
.switch-mock { width: 40px; height: 20px; background-color: #dcdfe6; border-radius: 10px; position: relative; transition: all 0.3s; cursor: pointer; }
.switch-mock.active { background-color: #2d8cf0; }
.switch-handle { width: 16px; height: 16px; background-color: #fff; border-radius: 8px; position: absolute; top: 2px; left: 2px; transition: all 0.3s; }
.active .switch-handle { left: 22px; }
.op-links { display: flex; flex-direction: row; align-items: center; }
.link-txt { font-size: 13px; color: #2d8cf0; cursor: pointer; }
.danger { color: #ed4014; }
.divider { width: 1px; height: 12px; background-color: #e8eaec; margin: 0 8px; }
.pagination-bar { padding: 20px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.page-nav { display: flex; flex-direction: row; align-items: center; }
.nav-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; cursor: pointer; }
.nav-item { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; }
.nav-item.active { background-color: #2d8cf0; border-color: #2d8cf0; .nav-num { color: #fff; } }
.nav-num, .nav-icon { font-size: 13px; color: #606266; }
.disabled { opacity: 0.5; cursor: not-allowed; }
/* Drawer Drawer */
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; }
.mask-fade-out { opacity: 0; }
.drawer-content { position: absolute; top: 0; right: 0; width: 60%; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
.slide-out { animation: slideOut 0.3s ease forwards; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes slideOut { from { transform: translateX(0); } to { transform: translateX(100%); } }
.drawer-header { height: 54px; padding: 0 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.tab-item { height: 100%; display: flex; align-items: center; position: relative; }
.tab-txt { font-size: 15px; color: #2d8cf0; font-weight: 500; }
.tab-line { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background-color: #2d8cf0; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; padding: 5px; }
.drawer-body { flex: 1; padding: 24px 30px; background-color: #fff; }
.form-grid { display: flex; flex-direction: column; gap: 24px; }
.form-item { display: flex; flex-direction: row; align-items: center; }
.align-start { align-items: flex-start; }
.label-box { width: 100px; display: flex; flex-direction: row; justify-content: flex-end; margin-right: 15px; flex-shrink: 0; }
.pt-10 { padding-top: 10px; }
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; }
.input-box { flex: 1; }
.input-base { width: 100%; height: 38px; border: 1px solid #dcdee2; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.textarea-base { width: 100%; height: 80px; border: 1px solid #dcdee2; border-radius: 4px; padding: 10px 12px; font-size: 14px; }
.content-area { height: 300px; }
.radio-group { display: flex; flex-direction: row; gap: 30px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-circle { width: 16px; height: 16px; border: 1px solid #dcdee2; border-radius: 50%; margin-right: 8px; display: flex; align-items: center; justify-content: center; }
.radio-circle.checked { border-color: #2d8cf0; }
.radio-in { width: 8px; height: 8px; background-color: #2d8cf0; border-radius: 50%; }
.radio-la { font-size: 14px; color: #606266; }
.submit-container { display: flex; justify-content: flex-end; padding-bottom: 40px; }
.btn-submit { width: 100px; height: 38px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.submit-txt { color: #fff; font-size: 14px; }
</style>

View File

@@ -0,0 +1,391 @@
<template>
<view class="admin-cms-category">
<view class="content-body">
<!-- 顶部过滤栏 -->
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">是否显示:</text>
<view class="select-mock">
<text class="select-val">请选择</text>
<text class="arrow-down">▼</text>
</view>
</view>
<view class="filter-item">
<text class="label-txt">分类名称:</text>
<input class="search-input" placeholder="请输入分类名称" v-model="filterKeyword" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="query-txt">查询</text>
</view>
</view>
<!-- 主要内容区域 -->
<view class="table-card border-shadow">
<view class="card-header">
<view class="btn-primary" @click="handleAdd">
<text class="btn-txt">添加文章分类</text>
</view>
</view>
<!-- 数据表格 -->
<view class="table-header">
<view class="th col-id"><text class="th-txt">ID</text></view>
<view class="th col-name"><text class="th-txt">分类名称</text></view>
<view class="th col-img"><text class="th-txt">分类图片</text></view>
<view class="th col-status"><text class="th-txt">状态</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view class="table-row" v-for="item in pagedList" :key="item.id">
<view class="td col-id"><text class="td-txt">{{ item.id }}</text></view>
<view class="td col-name"><text class="td-txt">{{ item.name }}</text></view>
<view class="td col-img">
<view class="img-box-placeholder"></view>
</view>
<view class="td col-status">
<StatusSwitch v-model="item.status" />
</view>
<view class="td col-op">
<view class="op-links">
<text class="link-txt" @click="handleEdit(item)">编辑</text>
<view class="divider"></view>
<text class="link-txt danger">删除</text>
<view class="divider"></view>
<text class="link-txt">查看文章</text>
</view>
</view>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
<!-- 侧边弹窗 (Drawer) -->
<view v-if="showDrawer" :class="['drawer-mask', isClosing ? 'mask-fade-out' : '']" @click="closeDrawer">
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<text class="drawer-title">添加分类</text>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<scroll-view class="drawer-body" :scroll-y="true">
<view class="form-item row">
<view class="label-box"><text class="label-txt">上级分类:</text></view>
<view class="input-box z-10" style="position: relative;">
<view class="select-mock" @click="toggleParentDropdown">
<text class="select-val">{{ formParentCategory }}</text>
<text class="arrow-down">▼</text>
</view>
<view v-if="parentDropdownVisible" class="dropdown-list">
<view
v-for="(cat, index) in parentCategoryList"
:key="index"
class="dropdown-item"
@click="selectParentCategory(cat)">
<text class="dropdown-txt">{{ cat }}</text>
</view>
</view>
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="required">*</text><text class="label-txt">分类名称:</text></view>
<view class="input-box">
<input class="input-base" v-model="formName" placeholder="请输入分类名称" />
</view>
</view>
<view class="form-item row align-start">
<view class="label-box pt-10"><text class="required">*</text><text class="label-txt">分类简介:</text></view>
<view class="input-box">
<textarea class="textarea-mini" v-model="formDesc" placeholder="请输入分类简介"></textarea>
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="label-txt">分类图片:</text></view>
<view class="input-box">
<view class="upload-btn" @click="handleUpload">
<view v-if="!formImage" class="img-icon">🖼️</view>
<text v-else style="font-size:12px;">已上传</text>
</view>
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="label-txt">排序:</text></view>
<view class="input-box">
<input class="input-base" type="number" v-model="formSort" />
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="label-txt">状态:</text></view>
<StatusSwitch v-model="formStatus" activeText="显示" inactiveText="隐藏" />
</view>
</scroll-view>
<view class="drawer-footer">
<view class="btn-cancel" @click="closeDrawer"><text class="cancel-txt">取消</text></view>
<view class="btn-confirm" @click="handleConfirm"><text class="confirm-txt">确定</text></view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import StatusSwitch from '@/components/StatusSwitch.uvue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const filterKeyword = ref('')
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 categoryList 为 fetchCategoryList() 调用
const categoryList = ref([
{ id: '200', name: '购物心得', status: true },
{ id: '199', name: '消费文化', status: true },
{ id: '198', name: '品牌资讯', status: true },
{ id: '197', name: '新品上市', status: true },
{ id: '196', name: '生活方式', status: false },
{ id: '195', name: '健康养生', status: true },
{ id: '194', name: '科技数码', status: true },
{ id: '193', name: '家居家装', status: false },
{ id: '192', name: '美食美味', status: true },
{ id: '191', name: '旅行户外', status: true },
{ id: '190', name: '时尚穿搭', status: true },
{ id: '189', name: '亲子活动', status: false },
{ id: '188', name: '身心健康', status: true },
{ id: '187', name: '财经商业', status: true },
{ id: '186', name: '文化艺术', status: true },
{ id: '185', name: '社交情感', status: false },
{ id: '184', name: '工具技能', status: true },
{ id: '183', name: '玩具游戏', status: true },
{ id: '182', name: '守护联盟', status: true },
{ id: '181', name: '全部分类', status: true }
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => categoryList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return categoryList.value.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
const showDrawer = ref(false)
const isClosing = ref(false)
const formName = ref('')
const formDesc = ref('')
const formSort = ref(0)
const formStatus = ref(true)
const formParentCategory = ref('顶级分类')
const formImage = ref('')
const parentCategoryList = ref(['顶级分类', '购物心得', '消费文化', '品牌资讯'])
const parentDropdownVisible = ref(false)
const toggleParentDropdown = () => {
parentDropdownVisible.value = !parentDropdownVisible.value
}
const selectParentCategory = (val: string) => {
formParentCategory.value = val
parentDropdownVisible.value = false
}
const handleUpload = () => {
formImage.value = 'uploaded'
uni.showToast({ title: '模拟上传图片', icon: 'none' })
}
const handleAdd = () => {
formName.value = ''
formDesc.value = ''
formSort.value = 0
formStatus.value = true
formParentCategory.value = '顶级分类'
formImage.value = ''
isClosing.value = false
parentDropdownVisible.value = false
showDrawer.value = true
}
const handleEdit = (item: any) => {
formName.value = item.name
// 模拟填充其他字段
formDesc.value = '分类简介...'
formSort.value = 0
formStatus.value = item.status
formParentCategory.value = '顶级分类'
formImage.value = 'exists'
isClosing.value = false
parentDropdownVisible.value = false
showDrawer.value = true
}
const closeDrawer = () => {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
const handleConfirm = () => {
if (formName.value.trim() == '') {
uni.showToast({ title: '请输入分类名称', icon: 'none' })
return
}
// 模拟保存逻辑
const newId = String(182 + categoryList.value.length)
categoryList.value.unshift({
id: newId,
name: formName.value,
status: formStatus.value
})
uni.showToast({ title: '添加成功', icon: 'success' })
closeDrawer()
}
const handleQuery = () => { console.log('Querying...') }
</script>
<style scoped lang="scss">
.admin-cms-category { padding: 0; background-color: transparent; min-height: auto; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 30px; margin-bottom: 20px; }
.filter-item { display: flex; flex-direction: row; align-items: center; }
.label-txt { font-size: 14px; color: #606266; margin-right: 12px; }
.select-mock { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 0 15px; }
.select-val { font-size: 13px; color: #c0c4cc; }
.arrow-down { font-size: 10px; color: #999; }
.search-input { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 15px; font-size: 13px; }
.btn-query { width: 76px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.query-txt { color: #fff; font-size: 14px; }
.table-card { display: flex; flex-direction: column; }
.card-header { padding: 20px; }
.btn-primary { width: 140px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.btn-txt { color: #fff; font-size: 13px; }
.table-header { height: 50px; background-color: #eaf2ff; display: flex; flex-direction: row; align-items: center; }
.th { padding: 0 15px; }
.th-txt { font-size: 13px; color: #606266; font-weight: bold; }
.table-row { height: 60px; display: flex; flex-direction: row; align-items: center; border-bottom: 1px solid #f0f0f0; }
.td { padding: 0 15px; }
.td-txt { font-size: 13px; color: #606266; }
.col-id { width: 80px; justify-content: center; }
.col-name { flex: 1; justify-content: flex-start; }
.col-img { flex: 1; justify-content: center; }
.col-status { width: 120px; justify-content: center; }
.col-op { width: 220px; justify-content: center; }
.img-box-placeholder { width: 40px; height: 40px; background-color: #f5f7fa; border-radius: 4px; }
.op-links { display: flex; flex-direction: row; align-items: center; }
.link-txt { font-size: 13px; color: #2d8cf0; cursor: pointer; }
.danger { color: #ed4014; }
.divider { width: 1px; height: 12px; background-color: #e8eaec; margin: 0 10px; }
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; transition: opacity 0.3s; }
.mask-fade-out { opacity: 0; }
.drawer-content { position: absolute; top: 0; right: 0; width: 50%; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
.slide-out { animation: slideOut 0.3s ease forwards; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes slideOut { from { transform: translateX(0); } to { transform: translateX(100%); } }
.drawer-header { height: 60px; padding: 0 24px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.drawer-title { font-size: 16px; font-weight: bold; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; padding: 5px; }
.drawer-body { flex: 1; padding: 24px 30px; }
.form-item { margin-bottom: 24px; display: flex; flex-direction: row; align-items: center; }
.label-box { width: 120px; display: flex; justify-content: flex-end; align-items: center; margin-right: 20px; flex-shrink: 0; }
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; text-align: right; }
.input-box { flex: 1; }
.input-base { width: 100%; height: 38px; border: 1px solid #dcdee2; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.dropdown-list {
position: absolute;
top: 40px;
left: 0;
width: 100%;
background-color: #fff;
border: 1px solid #dcdee2;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 100;
max-height: 200px;
overflow-y: auto;
}
.dropdown-item {
padding: 10px 15px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background-color: #f5f7fa;
}
.dropdown-txt {
font-size: 14px;
color: #606266;
}
.textarea-mini { width: 100%; height: 80px; border: 1px solid #dcdee2; border-radius: 4px; padding: 10px 12px; font-size: 14px; }
.upload-btn {
width: 70px;
height: 70px;
border: 1px solid #dcdee2;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.img-icon { font-size: 30px; color: #ccc; }
.drawer-footer { height: 60px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; align-items: center; padding: 0 24px; }
.btn-cancel { padding: 8px 20px; border: 1px solid #dcdee2; border-radius: 4px; margin-right: 15px; }
.btn-confirm { padding: 8px 20px; background-color: #2d8cf0; border-radius: 4px; }
.cancel-txt { font-size: 14px; color: #606266; }
.confirm-txt { font-size: 14px; color: #fff; }
</style>

View File

@@ -1,390 +0,0 @@
<template>
<view class="admin-cms-category">
<view class="content-body">
<!-- 顶部过滤栏 -->
<view class="filter-card border-shadow">
<view class="filter-item">
<text class="label-txt">分类名称:</text>
<input class="search-input" placeholder="请输入分类名称" v-model="filterKeyword" @confirm="handleQuery" />
</view>
<view class="btn-query" @click="handleQuery">
<text class="query-txt">查询</text>
</view>
<view class="btn-reset" @click="handleReset">
<text class="reset-txt">重置</text>
</view>
</view>
<!-- 主要内容区域 -->
<view class="table-card border-shadow">
<view class="card-header">
<view class="btn-primary" @click="handleAdd">
<text class="btn-txt">添加文章分类</text>
</view>
</view>
<!-- 数据表格 -->
<view class="table-header">
<view class="th col-id"><text class="th-txt">序号</text></view>
<view class="th col-name"><text class="th-txt">分类名称</text></view>
<view class="th col-img"><text class="th-txt">分类图标</text></view>
<view class="th col-sort"><text class="th-txt">排序</text></view>
<view class="th col-status"><text class="th-txt">状态</text></view>
<view class="th col-op"><text class="th-txt">操作</text></view>
</view>
<view class="table-body">
<view v-if="loading" class="table-loading" style="padding: 40px; text-align: center;">
<text>加载中...</text>
</view>
<view v-else-if="categoryList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
<text>暂无分类数据</text>
</view>
<view v-else v-for="(item, index) in categoryList" :key="item.id" class="table-row">
<view class="td col-id"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td col-name"><text class="td-txt">{{ item.name }}</text></view>
<view class="td col-img">
<image v-if="item.icon" :src="item.icon" mode="aspectFit" class="cate-icon"></image>
<view v-else class="img-placeholder"></view>
</view>
<view class="td col-sort"><text class="td-txt">{{ item.sort }}</text></view>
<view class="td col-status">
<view :class="['switch-mock', item.status === 1 ? 'active' : '']" @click="toggleStatus(item)">
<view class="switch-handle"></view>
</view>
</view>
<view class="td col-op">
<view class="op-links">
<text class="link-txt" @click="handleEdit(item)">编辑</text>
<view class="divider"></view>
<text class="link-txt danger" @click="handleDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页 -->
<view class="pagination-bar">
<text class="page-total">共 {{ total }} 条</text>
<view class="page-nav">
<view class="nav-btn" :class="{ disabled: page <= 1 }" @click="prevPage"><text class="nav-icon"> < </text></view>
<view class="nav-item active"><text class="nav-num">{{ page }}</text></view>
<view class="nav-btn" :class="{ disabled: page >= totalPages }" @click="nextPage"><text class="nav-icon"> > </text></view>
</view>
</view>
</view>
</view>
<!-- 侧边弹窗 (Drawer) -->
<view v-if="showDrawer" :class="['drawer-mask', isClosing ? 'mask-fade-out' : '']" @click="closeDrawer">
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<text class="drawer-title">{{ isEdit ? '编辑分类' : '添加分类' }}</text>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<scroll-view class="drawer-body" :scroll-y="true">
<view class="form-item row">
<view class="label-box"><text class="required">*</text><text class="label-txt">分类名称:</text></view>
<view class="input-box">
<input class="input-base" v-model="formName" placeholder="请输入分类名称" />
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="label-txt">排序:</text></view>
<view class="input-box">
<input class="input-base" type="number" v-model="formSort" />
</view>
</view>
<view class="form-item row">
<view class="label-box"><text class="label-txt">状态:</text></view>
<view class="radio-group">
<view class="radio-item" @click="formStatus = true">
<view :class="['radio-circle', formStatus ? 'checked' : '']"><view v-if="formStatus" class="radio-in"></view></view>
<text class="radio-la">显示</text>
</view>
<view class="radio-item" @click="formStatus = false">
<view :class="['radio-circle', !formStatus ? 'checked' : '']"><view v-if="!formStatus" class="radio-in"></view></view>
<text class="radio-la">隐藏</text>
</view>
</view>
</view>
</scroll-view>
<view class="drawer-footer">
<view class="btn-cancel" @click="closeDrawer"><text class="cancel-txt">取消</text></view>
<view class="btn-confirm" @click="handleConfirm"><text class="confirm-txt">确定</text></view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import {
fetchArticleCategoryPage,
saveArticleCategory,
deleteArticleCategory,
setArticleCategoryStatus,
type ArticleCategory
} from '@/services/admin/cmsService.uts'
const filterKeyword = ref('')
const categoryList = ref<ArticleCategory[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const loading = ref(false)
const jumpPage = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
return Math.ceil(total.value / pageSize.value)
})
const showDrawer = ref(false)
const isClosing = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
const formName = ref('')
const formSort = ref(0)
const formStatus = ref(true)
const formIcon = ref<string | null>(null)
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchArticleCategoryPage(page.value, pageSize.value, filterKeyword.value || null)
categoryList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载分类失败', icon: 'none' })
} finally {
loading.value = false
}
}
function handleQuery() {
page.value = 1
loadData()
}
function handleReset() {
filterKeyword.value = ''
page.value = 1
loadData()
}
function handleAdd() {
isEdit.value = false
editingId.value = null
formName.value = ''
formSort.value = 0
formStatus.value = true
formIcon.value = null
isClosing.value = false
showDrawer.value = true
}
function handleEdit(item: ArticleCategory) {
isEdit.value = true
editingId.value = item.id
formName.value = item.name
formSort.value = item.sort
formStatus.value = item.status === 1
formIcon.value = item.icon
isClosing.value = false
showDrawer.value = true
}
async function toggleStatus(item: ArticleCategory) {
const targetStatus = item.status === 1 ? 0 : 1
try {
const resId = await saveArticleCategory(
item.id,
item.name,
item.icon,
item.sort,
targetStatus
)
if (resId != null) {
item.status = targetStatus
uni.showToast({ title: '状态已更新' })
}
} catch (e: any) {
const errMsg = e?.message || '操作失败'
uni.showToast({ title: errMsg, icon: 'none' })
}
}
async function handleDelete(item: ArticleCategory) {
uni.showModal({
title: '删除确认',
content: `确定要删除分类 "${item.name}" 吗?\n\n⚠ 警告:该操作将同时删除该分类下的所有文章!`,
confirmText: '确认删除',
confirmColor: '#ed4014',
success: async (res) => {
if (res.confirm) {
try {
const ok = await deleteArticleCategory(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
} catch (e: any) {
// 显示后端抛出的具体错误信息(如权限不足)
const errMsg = e?.message || '删除失败'
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
}
}
}
})
}
function closeDrawer() {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
async function handleConfirm() {
if (!formName.value) {
uni.showToast({ title: '请输入分类名称', icon: 'none' })
return
}
try {
const resId = await saveArticleCategory(
editingId.value,
formName.value,
formIcon.value,
formSort.value,
formStatus.value ? 1 : 0
)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeDrawer()
loadData()
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
function prevPage() {
if (page.value > 1) {
page.value--
loadData()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value++
loadData()
}
}
function goToJumpPage() {
const target = parseInt(jumpPage.value)
if (!isNaN(target) && target >= 1 && target <= totalPages.value) {
page.value = target
loadData()
jumpPage.value = ''
} else {
uni.showToast({ title: '页码无效', icon: 'none' })
}
}
</script>
<style scoped lang="scss">
.admin-cms-category { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.filter-card { padding: 20px; display: flex; flex-direction: row; align-items: center; gap: 15px; margin-bottom: 20px; }
.filter-item { display: flex; flex-direction: row; align-items: center; }
.label-txt { font-size: 14px; color: #606266; margin-right: 12px; }
.search-input { width: 220px; height: 38px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 15px; font-size: 13px; }
.btn-query { padding: 0 20px; height: 34px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-reset { padding: 0 20px; height: 34px; background-color: #fff; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.query-txt { color: #fff; font-size: 14px; }
.reset-txt { color: #606266; font-size: 14px; }
.table-card { display: flex; flex-direction: column; }
.card-header { padding: 20px; }
.btn-primary { width: 140px; height: 36px; background-color: #2d8cf0; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-txt { color: #fff; font-size: 13px; }
.table-header { height: 50px; background-color: #eaf2ff; display: flex; flex-direction: row; align-items: center; }
.th { padding: 0 15px; }
.th-txt { font-size: 13px; color: #606266; font-weight: bold; }
.table-row { height: 60px; display: flex; flex-direction: row; align-items: center; border-bottom: 1px solid #f0f0f0; }
.td { padding: 0 15px; }
.td-txt { font-size: 13px; color: #606266; }
.col-id { width: 80px; justify-content: center; }
.col-name { flex: 1; justify-content: flex-start; }
.col-img { width: 120px; justify-content: center; }
.col-sort { width: 100px; justify-content: center; }
.col-status { width: 120px; justify-content: center; }
.col-op { width: 180px; justify-content: center; }
.cate-icon { width: 40px; height: 40px; border-radius: 4px; background-color: #f5f7fa; }
.img-placeholder { width: 40px; height: 40px; background-color: #f5f7fa; border-radius: 4px; }
.switch-mock { width: 40px; height: 20px; background-color: #dcdfe6; border-radius: 10px; position: relative; transition: all 0.3s; cursor: pointer; }
.switch-mock.active { background-color: #2d8cf0; }
.switch-handle { width: 16px; height: 16px; background-color: #fff; border-radius: 8px; position: absolute; top: 2px; left: 2px; transition: all 0.3s; }
.active .switch-handle { left: 22px; }
.op-links { display: flex; flex-direction: row; align-items: center; }
.link-txt { font-size: 13px; color: #2d8cf0; cursor: pointer; }
.danger { color: #ed4014; }
.divider { width: 1px; height: 12px; background-color: #e8eaec; margin: 0 10px; }
.pagination-bar { padding: 20px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; }
.page-total { font-size: 13px; color: #606266; margin-right: 15px; }
.page-nav { display: flex; flex-direction: row; align-items: center; }
.nav-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; cursor: pointer; }
.nav-item { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin: 0 4px; }
.nav-item.active { background-color: #2d8cf0; border-color: #2d8cf0; }
.nav-item.active .nav-num { color: #fff; }
.nav-num, .nav-icon { font-size: 13px; color: #606266; }
.disabled { opacity: 0.5; cursor: not-allowed; }
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; }
.drawer-content { position: absolute; top: 0; right: 0; width: 450px; height: 100%; background-color: #fff; display: flex; flex-direction: column; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.2); animation: slideIn 0.3s ease; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.slide-out { animation: slideOut 0.3s ease forwards; }
@keyframes slideOut { from { transform: translateX(0); } to { transform: translateX(100%); } }
.drawer-header { height: 60px; padding: 0 24px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.drawer-title { font-size: 16px; font-weight: bold; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.drawer-body { flex: 1; padding: 24px 30px; }
.form-item { margin-bottom: 24px; display: flex; flex-direction: row; align-items: center; }
.label-box { width: 100px; display: flex; justify-content: flex-end; align-items: center; margin-right: 20px; flex-shrink: 0; }
.required { color: #ed4014; margin-right: 4px; }
.label-txt { font-size: 14px; color: #606266; }
.input-box { flex: 1; }
.input-base { width: 100%; height: 38px; border: 1px solid #dcdee2; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.radio-group { display: flex; flex-direction: row; gap: 30px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-circle { width: 16px; height: 16px; border: 1px solid #dcdee2; border-radius: 50%; margin-right: 8px; display: flex; align-items: center; justify-content: center; }
.radio-circle.checked { border-color: #2d8cf0; }
.radio-in { width: 8px; height: 8px; background-color: #2d8cf0; border-radius: 50%; }
.radio-la { font-size: 14px; color: #606266; }
.drawer-footer { height: 60px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; align-items: center; padding: 0 24px; }
.btn-cancel { padding: 8px 20px; border: 1px solid #dcdee2; border-radius: 4px; margin-right: 15px; cursor: pointer; }
.btn-confirm { padding: 8px 20px; background-color: #2d8cf0; border-radius: 4px; cursor: pointer; }
.cancel-txt { font-size: 14px; color: #606266; }
.confirm-txt { font-size: 14px; color: #fff; }
</style>

View File

@@ -1,65 +0,0 @@
<template>
<AdminLayout currentPage="content-list">
<view class="Page">
<view class="Header">
<text class="Title">文章管理</text>
<text class="SubTitle">content/index</text>
</view>
<view class="Card">
<text class="Label">页面参数query</text>
<text class="Mono">{{ params }}</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const params = ref('')
onLoad((options) => {
// options: Record<string, any>
params.value = JSON.stringify(options ?? {})
})
</script>
<style>
.Page {
padding: 24rpx;
}
.Header {
padding: 24rpx;
border-radius: 16rpx;
background: #ffffff;
}
.Title {
font-size: 36rpx;
font-weight: 700;
}
.SubTitle {
margin-top: 8rpx;
font-size: 24rpx;
opacity: 0.7;
}
.Card {
margin-top: 24rpx;
padding: 24rpx;
border-radius: 16rpx;
background: #ffffff;
}
.Label {
font-size: 26rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.Mono {
font-size: 24rpx;
font-family: monospace;
line-height: 36rpx;
word-break: break-all;
}
</style>

View File

@@ -259,7 +259,7 @@ const getGroupMockOrigPrice = (i: number): string => {
<style scoped lang="scss">
.preview-column {
width: 420px;
background-color: #f7f8fa;
background-color: #ffffff;
display: flex;
justify-content: center;
padding: 40px;

View File

@@ -13,7 +13,7 @@
</view>
<!-- 主内容区:三栏布局 -->
<view class="main-content">
<view class="main-content anim-fade-in">
<view class="card-container border-shadow">
<!-- A. 左栏:配置分类菜单 -->
<MenuSide
@@ -179,8 +179,9 @@ const handleSelectLink = (index: number) => {
<style scoped lang="scss">
.admin-data-config {
background-color: #f0f2f5;
min-height: 100vh;
padding: 0;
background-color: transparent;
min-height: auto;
display: flex;
flex-direction: column;
}
@@ -212,7 +213,8 @@ const handleSelectLink = (index: number) => {
.main-content {
flex: 1;
padding: 24px;
padding: 0;
margin-top: 20px;
}
.card-container {
@@ -244,7 +246,7 @@ const handleSelectLink = (index: number) => {
.settings-desc { font-size: 13px; color: #999; }
/* 开屏广告特有样式 */
.ad-special-fields { padding: 20px; background-color: #f6f8fb; border-radius: 8px; margin-bottom: 20px; }
.ad-special-fields { padding: 20px; background-color: #fff; border: 1px solid #f0f0f0; border-radius: 8px; margin-bottom: 20px; }
.form-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 15px; }
.field-label { width: 80px; font-size: 14px; color: #333; }
.input-with-unit { display: flex; flex-direction: row; align-items: center; }

View File

@@ -1,333 +0,0 @@
<template>
<view class="admin-decoration-home">
<view class="content-container">
<!-- 左侧:手机预览区 (保持原样作为预览参考) -->
<view class="preview-section border-shadow">
<view class="phone-mock">
<view class="phone-inner">
<view class="phone-header-img">
<view class="status-bar-mock"></view>
<view class="search-bar-mock">
<text class="search-ic">🔍</text>
<text class="search-ph">请输入搜索词</text>
</view>
<view class="tabs-mock">
<text class="tab-item active">首页</text>
<text class="tab-item">生活家居</text>
<text class="tab-item">运动专区</text>
<text class="tab-more">≡</text>
</view>
</view>
<scroll-view class="phone-scroll" scroll-y="true">
<view class="banner-mock">
<view class="banner-box">
<text class="banner-txt">DIY 页面预览</text>
</view>
</view>
<view class="notice-mock">
<view class="notice-ic">📢</view>
<text class="notice-txt">此处展示选中的装修模板预览</text>
</view>
<view style="height: 100px;"></view>
</scroll-view>
<view class="tabbar-mock">
<view class="tb-item active"><text class="tb-ic">🏠</text><text class="tb-txt">首页</text></view>
<view class="tb-item"><text class="tb-ic">👤</text><text class="tb-txt">我的</text></view>
</view>
</view>
</view>
</view>
<!-- 右侧:列表管理区 -->
<view class="list-section">
<view class="manage-card border-shadow">
<view class="action-bar">
<view class="btn-primary-blue mr-10" @click="handleAdd">
<text class="btn-txt">添加页面</text>
</view>
<view class="filter-item ml-20">
<input class="search-input-box" placeholder="搜索模板名称" v-model="searchQuery" @confirm="onSearch" />
</view>
</view>
<!-- 表格 -->
<view class="table-container">
<view v-if="isLoading" class="loading-state">
<text class="loading-txt">数据加载中...</text>
</view>
<view v-else-if="tableData.length === 0" class="empty-state">
<text class="empty-txt">暂无装修模板,请点击左上角添加</text>
</view>
<template v-else>
<view class="table-header-row">
<view class="th" style="width: 80px;">序号</view>
<view class="th" style="flex: 2;">模板名称</view>
<view class="th" style="flex: 1;">模板类型</view>
<view class="th" style="width: 100px; text-align: center;">状态</view>
<view class="th" style="flex: 2;">更新时间</view>
<view class="th" style="width: 220px;">操作</view>
</view>
<view v-for="(item, index) in tableData" :key="item.id" class="table-body-row">
<view class="td" style="width: 80px;">{{ (page - 1) * pageSize + index + 1 }}</view>
<view class="td" style="flex: 2;">
<text class="td-name">{{ item.name }}</text>
<view v-if="item.is_home" class="home-tag"><text class="ht-txt">当前首页</text></view>
</view>
<view class="td" style="flex: 1;">
<text class="type-txt">{{ getTypeText(item.type) }}</text>
</view>
<view class="td" style="width: 100px; justify-content: center;">
<text :class="['status-dot', item.is_active ? 'active' : '']"></text>
<text class="status-txt">{{ item.is_active ? '已启用' : '未启用' }}</text>
</view>
<view class="td" style="flex: 2;">{{ item.updated_at.substring(0, 16).replace('T', ' ') }}</view>
<view class="td" style="width: 220px;">
<view class="op-links">
<text class="op-link" @click="handleEdit(item)">设计</text>
<text class="op-split">|</text>
<text class="op-link" v-if="!item.is_home" @click="handleSetHome(item)">设为首页</text>
<text class="op-split" v-if="!item.is_home">|</text>
<text class="op-link text-danger" v-if="!item.is_home" @click="handleDelete(item)">删除</text>
</view>
</view>
</view>
</template>
</view>
<!-- 分页器 -->
<view class="pagination-footer" v-if="total > 0">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="onPrevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', page >= totalPages ? 'disabled' : '']" @click="onNextPage"> > </text>
</view>
</view>
</view>
</view>
</view>
<!-- 添加页面侧边栏 -->
<view v-if="showDrawer" :class="['drawer-mask', isClosing ? 'mask-fade-out' : '']" @click="closeDrawer">
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<text class="title-txt">添加装修页面</text>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<view class="drawer-body">
<view class="form-item-v">
<text class="v-label">页面名称</text>
<input class="v-input" v-model="formName" placeholder="例如2026年货节首页" />
</view>
<view class="form-item-v">
<text class="v-label">页面类型</text>
<view class="radio-group">
<view class="radio-item" @click="formType = 'home'">
<view :class="['radio-dot', formType === 'home' ? 'active' : '']"></view>
<text class="radio-txt">首页</text>
</view>
<view class="radio-item" @click="formType = 'topic'">
<view :class="['radio-dot', formType === 'topic' ? 'active' : '']"></view>
<text class="radio-txt">专题页</text>
</view>
</view>
</view>
</view>
<view class="drawer-footer">
<button class="btn-cancel" @click="closeDrawer">取消</button>
<button class="btn-save" @click="handleSavePage">确定添加</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import { fetchDiyPageList, saveDiyPage, deleteDiyPage, setAsHomePage, type DiyPage } from '@/services/admin/decorationService.uts'
const tableData = ref<DiyPage[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const isLoading = ref(false)
const searchQuery = ref('')
const showDrawer = ref(false)
const isClosing = ref(false)
const formName = ref('')
const formType = ref('home')
const totalPages = computed((): number => {
return Math.ceil(total.value / pageSize) || 1
})
onMounted(() => {
loadData()
})
async function loadData() {
isLoading.value = true
try {
const res = await fetchDiyPageList(searchQuery.value || null, null, page.value, pageSize)
tableData.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '数据加载失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function onSearch() {
page.value = 1
loadData()
}
async function handleSetHome(item: DiyPage) {
uni.showLoading({ title: '正在设置...' })
const success = await setAsHomePage(item.id)
uni.hideLoading()
if (success) {
uni.showToast({ title: '首页设置成功' })
loadData()
}
}
async function handleDelete(item: DiyPage) {
uni.showModal({
title: '确认删除',
content: `确定要删除模板 "${item.name}" 吗?`,
success: async (res) => {
if (res.confirm) {
const success = await deleteDiyPage(item.id)
if (success) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
const handleAdd = () => {
formName.value = ''
formType.value = 'home'
showDrawer.value = true
isClosing.value = false
}
const closeDrawer = () => {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
const handleSavePage = async () => {
if (!formName.value) {
uni.showToast({ title: '请输入页面名称', icon: 'none' })
return
}
const id = await saveDiyPage(null, formName.value, formType.value, {} as UTSJSONObject)
if (id != null) {
uni.showToast({ title: '添加成功' })
closeDrawer()
loadData()
}
}
const handleEdit = (item: DiyPage) => {
uni.showToast({ title: '装修编辑器加载中...', icon: 'none' })
}
function onPrevPage() { if (page.value > 1) { page.value--; loadData(); } }
function onNextPage() { if (page.value < totalPages.value) { page.value++; loadData(); } }
function getTypeText(type: string): string {
if (type === 'home') return '首页'
if (type === 'topic') return '专题页'
if (type === 'user') return '个人中心'
return type
}
</script>
<style scoped lang="scss">
.admin-decoration-home { background-color: #f0f2f5; min-height: 100vh; padding: 24px; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.content-container { display: flex; flex-direction: row; gap: 20px; }
/* 左侧手机预览区 (简化版) */
.preview-section { width: 360px; height: 720px; display: flex; justify-content: center; align-items: center; padding: 20px 0; }
.phone-mock { width: 300px; height: 600px; background-color: #fff; border: 8px solid #ececec; border-radius: 32px; overflow: hidden; position: relative; }
.phone-inner { height: 100%; display: flex; flex-direction: column; }
.phone-header-img { background-color: #f7f7f7; height: 100px; }
.search-bar-mock { height: 32px; background-color: #fff; margin: 10px 12px; border-radius: 16px; border: 1px solid #eee; display: flex; flex-direction: row; align-items: center; padding: 0 12px; }
.search-ph { font-size: 11px; color: #999; margin-left: 5px; }
.phone-scroll { flex: 1; background-color: #f8f8f8; }
.banner-mock { height: 120px; background: #eee; margin: 10px; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
.banner-txt { color: #999; font-size: 14px; }
.tabbar-mock { height: 50px; background: #fff; border-top: 1px solid #eee; display: flex; flex-direction: row; }
.tb-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: 0.5; }
.tb-item.active { opacity: 1; }
.tb-ic { font-size: 18px; }
.tb-txt { font-size: 10px; margin-top: 2px; }
/* 右侧列表区 */
.list-section { flex: 1; }
.manage-card { padding: 20px; min-height: 720px; display: flex; flex-direction: column; }
.action-bar { margin-bottom: 20px; display: flex; flex-direction: row; align-items: center; }
.btn-primary-blue { background-color: #2d8cf0; padding: 8px 20px; border-radius: 4px; cursor: pointer; }
.btn-txt { color: #fff; font-size: 14px; }
.search-input-box { border: 1px solid #dcdfe6; height: 34px; padding: 0 12px; border-radius: 4px; font-size: 13px; width: 200px; }
.table-container { flex: 1; margin-top: 10px; }
.table-header-row { display: flex; flex-direction: row; background-color: #f8f8f9; border-bottom: 1px solid #e8eaec; padding: 12px 0; }
.th { padding: 0 15px; font-size: 13px; color: #515a6e; font-weight: bold; }
.table-body-row { display: flex; flex-direction: row; border-bottom: 1px solid #e8eaec; align-items: center; min-height: 60px; }
.td { padding: 10px 15px; font-size: 13px; color: #515a6e; display: flex; align-items: center; }
.home-tag { background-color: #f2270c; padding: 2px 6px; border-radius: 4px; margin-left: 10px; }
.ht-txt { color: #fff; font-size: 10px; }
.status-dot { width: 8px; height: 8px; border-radius: 4px; background-color: #ccc; margin-right: 8px; }
.status-dot.active { background-color: #52c41a; }
.op-link { color: #2d8cf0; cursor: pointer; font-size: 13px; }
.op-split { color: #e8eaec; margin: 0 8px; }
.text-danger { color: #ed4014; }
.pagination-footer { margin-top: 20px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 15px; }
.total-txt { font-size: 13px; color: #999; }
.p-btn { width: 30px; height: 30px; border: 1px solid #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.p-btn.active { background-color: #2d8cf0; color: #fff; border-color: #2d8cf0; }
.p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
/* 抽屉样式 */
.drawer-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.4); z-index: 2000; display: flex; justify-content: flex-end; }
.drawer-content { width: 400px; height: 100%; background-color: #fff; display: flex; flex-direction: column; animation: slideIn 0.3s ease-out; }
.drawer-header { padding: 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.drawer-body { flex: 1; padding: 30px; }
.form-item-v { margin-bottom: 24px; }
.v-label { font-size: 14px; color: #666; margin-bottom: 10px; display: block; }
.v-input { border: 1px solid #dcdfe6; height: 38px; padding: 0 12px; border-radius: 4px; font-size: 14px; width: 100%; }
.radio-group { display: flex; flex-direction: row; gap: 30px; margin-top: 10px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-dot { width: 16px; height: 16px; border: 1px solid #dcdfe6; border-radius: 8px; margin-right: 8px; }
.radio-dot.active { border-color: #2d8cf0; background-color: #2d8cf0; }
.drawer-footer { padding: 20px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
.btn-cancel { background: #fff; border: 1px solid #dcdfe6; padding: 8px 20px; border-radius: 4px; }
.btn-save { background: #2d8cf0; color: #fff; padding: 8px 20px; border-radius: 4px; border: none; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.slide-out { animation: slideOut 0.3s ease-in forwards; }
@keyframes slideOut { from { transform: translateX(0); } to { transform: translateX(100%); } }
</style>

View File

@@ -0,0 +1,757 @@
<template>
<view class="admin-decoration-home">
<view class="page-header border-shadow">
<view class="header-left">
<text class="page-title">店铺装修</text>
</view>
<view class="header-right">
<!-- 如果需要右上角按钮可添加 -->
</view>
</view>
<view class="content-container">
<view class="main-card border-shadow">
<!-- 左侧:手机预览区 -->
<view class="preview-section">
<view class="phone-mock">
<view class="phone-inner">
<view class="phone-header-img">
<view class="status-bar-mock"></view>
<view class="search-bar-mock">
<text class="search-ic">🔍</text>
<text class="search-ph">请输入搜索词</text>
</view>
<view class="tabs-mock">
<text class="tab-item active">首页</text>
<text class="tab-item">生活家居</text>
<text class="tab-item">运动专区</text>
<text class="tab-item">电子产品</text>
<text class="tab-item">家用电器</text>
<text class="tab-more">≡</text>
</view>
</view>
<scroll-view class="phone-scroll" scroll-y="true">
<!-- Banner -->
<view class="banner-mock">
<view class="banner-box">
<text class="banner-txt">MUSE FOR ALL MOTHERS</text>
</view>
<view class="dot-box">
<view class="dot active"></view>
<view class="dot"></view>
</view>
</view>
<!-- Grid Menu -->
<view class="grid-menu-mock">
<view class="menu-item" v-for="i in 10" :key="i">
<view :class="['menu-icon', 'ic-'+i]"></view>
<text class="menu-txt">{{ menuNames[i-1] }}</text>
</view>
</view>
<!-- Announcement -->
<view class="notice-mock">
<view class="notice-ic">📢</view>
<text class="notice-txt">CRMEB 年中618活动开启进行中</text>
<text class="notice-arr">></text>
</view>
<!-- Check-in Section -->
<view class="checkin-mock">
<view class="checkin-days">
<view class="day-dot" v-for="i in 7" :key="i">
<view class="dot-circle">⭐</view>
<text class="dot-text">周{{ weekDays[i-1] }}</text>
</view>
</view>
<view class="btn-checkin"><text class="check-txt">签到</text></view>
</view>
<!-- Bottom Space -->
<view style="height: 100px;"></view>
</scroll-view>
<!-- Bottom TabBar -->
<view class="tabbar-mock">
<view class="tb-item active">
<text class="tb-ic">🏠</text>
<text class="tb-txt">首页</text>
</view>
<view class="tb-item">
<text class="tb-ic">📂</text>
<text class="tb-txt">分类</text>
</view>
<view class="tb-item">
<text class="tb-ic">🛒</text>
<text class="tb-txt">购物车</text>
</view>
<view class="tb-item">
<text class="tb-ic">👤</text>
<text class="tb-txt">我的</text>
</view>
</view>
</view>
</view>
</view>
<!-- 右侧:列表管理区 -->
<view class="list-section">
<view class="manage-card">
<view class="action-bar">
<view class="btn-primary-blue mr-10" @click="handleAdd">
<text class="btn-txt">添加页面</text>
</view>
<view class="btn-import-blue" @click="handleImport">
<text class="btn-txt">导入模板</text>
</view>
</view>
<!-- 表格 -->
<view class="table-container">
<view class="table-header-row">
<view class="th" style="width: 80px;">页面ID</view>
<view class="th" style="flex: 2;">模板名称</view>
<view class="th" style="flex: 1;">模板类型</view>
<view class="th" style="flex: 2;">添加时间</view>
<view class="th" style="flex: 2;">更新时间</view>
<view class="th" style="width: 280px;">操作</view>
</view>
<view v-for="(item, index) in tableData" :key="index" class="table-body-row">
<view class="td" style="width: 80px;">{{ item.id }}</view>
<view class="td" style="flex: 2;">{{ item.name }}</view>
<view class="td" style="flex: 1;">
<view :class="['type-tag', item.type === '首页' ? 'type-home' : 'type-topic']">
<text class="tag-label">{{ item.type }}</text>
</view>
</view>
<view class="td" style="flex: 2;">{{ item.addTime }}</view>
<view class="td" style="flex: 2;">{{ item.updateTime }}</view>
<view class="td" style="width: 280px;">
<view class="op-links">
<text class="op-link" @click="handleEdit(item)">编辑</text>
<text class="op-split">|</text>
<text class="op-link text-danger">删除</text>
<text class="op-split">|</text>
<text class="op-link">预览</text>
<text class="op-split">|</text>
<text class="op-link" v-if="item.type !== '首页'">设为首页</text>
<text class="op-split" v-if="item.type !== '首页'">|</text>
<text class="op-link">导出模板</text>
</view>
</view>
</view>
</view>
<!-- 分页器 -->
<view class="pagination-footer">
<view class="page-total">
<text class="total-txt">共 {{ total }} 条</text>
</view>
<view class="page-select">
<text class="page-val">15条/页 ▼</text>
</view>
<view class="page-btns">
<text class="p-btn disabled"><</text>
<text class="p-btn active">1</text>
<text class="p-btn">></text>
</view>
<view class="page-jump">
<text class="jump-txt">前往</text>
<input class="jump-input" value="1" />
<text class="jump-txt">页</text>
</view>
</view>
</view>
</view>
<!-- 添加页面侧边栏 -->
<view v-if="showDrawer" :class="['drawer-mask', isClosing ? 'mask-fade-out' : '']" @click="closeDrawer">
<view :class="['drawer-content', isClosing ? 'slide-out' : '']" @click.stop="">
<view class="drawer-header">
<text class="title-txt">添加页面</text>
<text class="close-btn" @click="closeDrawer">×</text>
</view>
<scroll-view class="drawer-body" :scroll-y="true">
<view class="form-item-v">
<text class="v-label">页面名称</text>
<input class="v-input" v-model="formName" placeholder="请填写页面名称" />
</view>
<view class="form-item-v">
<text class="v-label">页面类型</text>
<view class="radio-group">
<view class="radio-item" @click="formType = '首页'">
<view :class="['radio-dot', formType === '首页' ? 'active' : '']"></view>
<text class="radio-txt">首页</text>
</view>
<view class="radio-item" @click="formType = '专题页'">
<view :class="['radio-dot', formType === '专题页' ? 'active' : '']"></view>
<text class="radio-txt">专题页</text>
</view>
</view>
</view>
<view class="template-select-title">
<text class="t-title">选择模板</text>
<text class="t-sub">请选择要引用的模板</text>
</view>
<view class="template-grid">
<view class="tpl-item" v-for="i in 4" :key="i">
<view class="tpl-thumb">
<text class="tpl-ic">📄</text>
</view>
<text class="tpl-name">通用模板 {{ i }}</text>
</view>
</view>
</scroll-view>
<view class="drawer-footer">
<view class="btn-cancel" @click="closeDrawer">
<text class="btn-cancel-txt">取消</text>
</view>
<view class="btn-save" @click="handleSavePage">
<text class="btn-save-txt">提交</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
const menuNames = ['秒杀活动', '商品分类', '拼团活动', '积分商城', '砍价中心', '行业资讯', '我的地址', '积分抽奖', '我的账户', '订单列表']
const weekDays = ['一', '二', '三', '四', '五', '六', '日']
const total = ref(11)
const tableData = ref([
{ id: 497, name: 'DIY导入数据', type: '专题页', addTime: '2025-03-20 15:18:01', updateTime: '2025-05-21 10:17:45' },
{ id: 496, name: 'DIY导入数据', type: '专题页', addTime: '2025-03-20 15:12:58', updateTime: '2025-03-20 15:12:58' },
{ id: 494, name: '图书类模板,勿动!!', type: '专题页', addTime: '2025-02-27 15:42:08', updateTime: '2025-03-19 10:40:13' },
{ id: 493, name: '健康类模板,勿动!!', type: '专题页', addTime: '2025-02-27 15:40:55', updateTime: '2025-03-07 09:46:14' },
{ id: 492, name: '演出类模板,勿动!!', type: '专题页', addTime: '2025-02-27 15:33:09', updateTime: '2025-03-07 09:49:43' },
{ id: 491, name: '潮玩类模板,勿动!!', type: '专题页', addTime: '2025-02-27 15:31:28', updateTime: '2025-03-07 09:55:53' },
{ id: 490, name: '家居类模板,勿动!!', type: '专题页', addTime: '2025-02-27 15:30:21', updateTime: '2025-03-07 09:57:59' },
{ id: 482, name: '文具类模板,勿动!!', type: '专题页', addTime: '2025-02-26 11:32:07', updateTime: '2025-03-07 09:59:25' },
{ id: 481, name: '模板', type: '专题页', addTime: '2025-02-26 09:21:04', updateTime: '2025-03-12 14:55:46' },
{ id: 480, name: '模板', type: '专题页', addTime: '2025-02-26 09:19:24', updateTime: '2026-02-02 17:11:45' },
{ id: 479, name: '首页模板,勿动!!', type: '首页', addTime: '2025-02-25 20:59:59', updateTime: '2026-01-20 11:16:20' }
])
const showDrawer = ref(false)
const isClosing = ref(false)
const formName = ref('')
const formType = ref('首页')
const viewState = ref('list') // 'list' | 'design'
const editingName = ref('')
const handleAdd = () => {
showDrawer.value = true
isClosing.value = false
}
const closeDrawer = () => {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
const handleEdit = (item: any) => {
editingName.value = item.name as string
viewState.value = 'design'
}
const handleImport = () => { console.log('Importing...') }
const handleSavePage = () => {
console.log('Saving new page:', formName.value)
closeDrawer()
}
const handleSaveDesign = () => {
console.log('Saving design...')
viewState.value = 'list'
}
</script>
<style scoped lang="scss">
.admin-decoration-home {
padding: 0;
background-color: transparent;
min-height: auto;
}
.page-header {
height: 60px;
padding: 0 24px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.border-shadow {
background-color: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.content-container {
display: flex;
flex-direction: row;
margin-top: 20px;
}
.main-card {
flex: 1;
display: flex;
flex-direction: row;
background-color: #fff;
border-radius: 8px;
overflow: hidden;
}
/* 左侧手机预览区 */
.preview-section {
width: 380px;
height: 800px;
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
border-right: 1px solid #f0f0f0;
}
.phone-mock {
width: 320px;
height: 640px;
background-color: #fff;
border: 10px solid #ececec;
border-radius: 36px;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-inner { flex: 1; display: flex; flex-direction: column; }
.phone-header-img {
background-color: #f7f7f7;
}
.status-bar-mock { height: 20px; }
.search-bar-mock {
height: 38px;
background-color: #fff;
margin: 0 12px;
border-radius: 19px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 15px;
border: 1px solid #eee;
}
.search-ic { font-size: 14px; margin-right: 8px; }
.search-ph { font-size: 12px; color: #999; }
.tabs-mock {
height: 40px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
justify-content: space-between;
}
.tab-item { font-size: 13px; color: #333; margin-right: 15px; }
.tab-item.active { color: #f2270c; font-weight: bold; border-bottom: 2px solid #f2270c; }
.tab-more { font-size: 16px; color: #666; }
.phone-scroll { flex: 1; background-color: #f8f8f8; }
.banner-mock {
height: 150px;
position: relative;
margin: 10px;
}
.banner-box {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #eee 0%, #ccc 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.banner-txt { font-size: 18px; font-weight: bold; color: #333; text-align: center; }
.dot-box {
position: absolute;
bottom: 8px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 6px;
}
.dot { width: 6px; height: 6px; background-color: rgba(255,255,255,0.5); border-radius: 3px; }
.dot.active { width: 12px; background-color: #fff; }
.grid-menu-mock {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 10px 5px;
}
.menu-item {
width: 20%;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 12px;
}
.menu-icon {
width: 40px;
height: 40px;
background-color: #ddd;
border-radius: 20px;
margin-bottom: 6px;
}
.ic-1 { background-color: #ff9d00; }
.ic-2 { background-color: #ff5000; }
.ic-3 { background-color: #8a2be2; }
.ic-4 { background-color: #f4ea2a; }
.ic-5 { background-color: #ffb6c1; }
.ic-6 { background-color: #c0c0c0; }
.ic-7 { background-color: #90ee90; }
.ic-8 { background-color: #87cefa; }
.ic-9 { background-color: #ffa07a; }
.ic-10 { background-color: #20b2aa; }
.menu-txt { font-size: 10px; color: #666; }
.notice-mock {
height: 36px;
background-color: #fff;
margin: 0 10px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 10px;
}
.notice-ic { font-size: 14px; margin-right: 8px; }
.notice-txt { flex: 1; font-size: 12px; color: #333; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.notice-arr { color: #ccc; font-size: 12px; }
.checkin-mock {
margin: 10px;
background-color: #fff;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.checkin-days { display: flex; flex-direction: row; gap: 8px; }
.day-dot { display: flex; flex-direction: column; align-items: center; }
.dot-circle { width: 24px; height: 24px; background-color: #fdf6ec; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 12px; margin-bottom: 4px; }
.dot-text { font-size: 9px; color: #999; }
.btn-checkin { background-color: #ff5000; padding: 4px 12px; border-radius: 12px; }
.check-txt { color: #fff; font-size: 11px; }
.tabbar-mock {
height: 50px;
background-color: #fff;
border-top: 1px solid #eee;
display: flex;
flex-direction: row;
}
.tb-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.tb-ic { font-size: 18px; margin-bottom: 2px; }
.tb-txt { font-size: 11px; color: #999; }
.tb-item.active .tb-txt { color: #f2270c; }
/* 右侧列表管理区 */
.list-section { flex: 1; }
.manage-card { display: flex; flex-direction: column; min-height: 800px; }
.action-bar { padding: 20px; display: flex; flex-direction: row; }
.btn-primary-blue { background-color: #2d8cf0; padding: 8px 16px; border-radius: 4px; cursor: pointer; }
.btn-import-blue { border: 1px solid #1890ff; padding: 7px 16px; border-radius: 4px; cursor: pointer; }
.mr-10 { margin-right: 10px; }
.btn-txt { color: #fff; font-size: 14px; }
.btn-import-blue .btn-txt { color: #1890ff; }
.table-container { flex: 1; padding: 0 20px; }
.table-header-row { display: flex; flex-direction: row; background-color: #f8f8f9; border-bottom: 1px solid #e8eaec; }
.th { padding: 12px 10px; font-size: 14px; color: #515a6e; font-weight: bold; }
.table-body-row { display: flex; flex-direction: row; border-bottom: 1px solid #e8eaec; }
.td { padding: 15px 10px; font-size: 14px; color: #515a6e; display: flex; align-items: center; }
.type-tag { padding: 2px 8px; border-radius: 4px; border: 1px solid #dcdfe6; }
.type-topic { background-color: #f5f7fa; }
.type-home { background-color: #f6ffed; border-color: #b7eb8f; }
.tag-label { font-size: 12px; }
.type-home .tag-label { color: #52c41a; }
.op-links { display: flex; flex-direction: row; align-items: center; color: #2d8cf0; }
.op-link { cursor: pointer; margin: 0 5px; }
.op-split { color: #e8eaec; }
.text-danger { color: #ed4014; }
.pagination-footer {
padding: 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 15px;
}
.total-txt { font-size: 14px; color: #606266; }
.page-val { font-size: 14px; color: #606266; border: 1px solid #dcdfe6; padding: 4px 10px; border-radius: 4px; }
.page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.p-btn.active { background-color: #2d8cf0; border-color: #2d8cf0; color: #fff; }
.p-btn.disabled { color: #c0c4cc; background-color: #f5f7fa; }
.page-jump { display: flex; flex-direction: row; align-items: center; gap: 8px; }
.jump-txt { font-size: 14px; color: #606266; }
.jump-input { width: 40px; height: 32px; border: 1px solid #dcdfe6; text-align: center; border-radius: 4px; }
/* Design View Styles */
.design-view {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
}
.design-header {
height: 60px;
background-color: #fff;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 20px;
border-bottom: 2px solid #2d8cf0;
z-index: 100;
}
.header-left { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.back-ic { font-size: 20px; color: #2d8cf0; margin-right: 15px; }
.design-title { font-size: 16px; font-weight: bold; color: #333; }
.header-right { display: flex; flex-direction: row; gap: 12px; }
.btn-ghost { border: 1px solid #dcdfe6; padding: 6px 16px; border-radius: 4px; cursor: pointer; }
.btn-primary { background-color: #2d8cf0; padding: 6px 16px; border-radius: 4px; cursor: pointer; }
.ghost-txt { color: #666; font-size: 14px; }
.primary-txt { color: #fff; font-size: 14px; }
.design-body {
flex: 1;
display: flex;
flex-direction: row;
}
.design-sidebar { width: 280px; background-color: #fff; padding: 15px; border-right: 1px solid #f0f0f0; }
.sidebar-item {
width: 110px;
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 0;
float: left;
}
.side-ic-box { width: 40px; height: 40px; background-color: #f7f8fa; display: flex; align-items: center; justify-content: center; border-radius: 4px; margin-bottom: 8px; font-size: 20px; }
.side-txt { font-size: 12px; color: #666; }
.design-canvas {
flex: 1;
background-color: #f0f2f5;
display: flex;
justify-content: center;
padding-top: 30px;
overflow-y: auto;
}
.canvas-phone {
width: 375px;
min-height: 667px;
background-color: #fff;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
}
.phone-top { height: 60px; background-color: #fff; border-bottom: 1px solid #eee; }
.phone-content-mock {
padding: 100px 20px;
text-align: center;
}
.mock-tip { color: #999; font-size: 14px; }
.design-attr { width: 320px; background-color: #fff; border-left: 1px solid #f0f0f0; }
.attr-header { padding: 15px; border-bottom: 1px solid #f0f0f0; }
.ah-txt { font-size: 15px; font-weight: bold; }
.attr-empty { padding: 50px 20px; text-align: center; }
.ae-txt { color: #999; font-size: 13px; }
.anim-fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Drawer Styles */
.drawer-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 2000;
display: flex;
justify-content: flex-end;
}
.drawer-content {
width: 450px;
height: 100%;
background-color: #fff;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
}
.drawer-header {
padding: 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title-txt { font-size: 16px; font-weight: bold; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.drawer-body { flex: 1; padding: 20px; }
.form-item-v { margin-bottom: 24px; }
.v-label { font-size: 14px; color: #666; margin-bottom: 10px; display: block; }
.v-input { border: 1px solid #dcdfe6; height: 40px; padding: 0 12px; border-radius: 4px; font-size: 14px; width: 100%; }
.radio-group { display: flex; flex-direction: row; gap: 30px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-dot { width: 16px; height: 16px; border: 1px solid #dcdfe6; border-radius: 8px; margin-right: 8px; position: relative; }
.radio-dot.active { border-color: #2d8cf0; }
.radio-dot.active::after {
content: '';
width: 8px;
height: 8px;
background-color: #2d8cf0;
border-radius: 4px;
position: absolute;
top: 3px;
left: 3px;
}
.radio-txt { font-size: 14px; color: #333; }
.template-select-title { margin-top: 20px; margin-bottom: 15px; }
.t-title { font-size: 15px; font-weight: bold; color: #333; margin-right: 10px; }
.t-sub { font-size: 12px; color: #999; }
.template-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 15px;
}
.tpl-item {
width: 190px;
border: 1px solid #eee;
border-radius: 4px;
padding: 10px;
background-color: #f9f9f9;
cursor: pointer;
}
.tpl-thumb {
height: 220px;
background-color: #fff;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.tpl-ic { font-size: 40px; color: #ccc; }
.tpl-name { font-size: 12px; color: #666; text-align: center; display: block; }
.drawer-footer {
padding: 20px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 12px;
}
.btn-cancel, .btn-save { padding: 8px 20px; border-radius: 4px; cursor: pointer; }
.btn-cancel { border: 1px solid #dcdfe6; }
.btn-save { background-color: #2d8cf0; }
.btn-cancel-txt { color: #666; font-size: 14px; }
.btn-save-txt { color: #fff; font-size: 14px; }
/* Animations */
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.slide-out {
animation: slideOut 0.3s ease-in forwards;
}
@keyframes slideOut {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
.mask-fade-out {
animation: fadeOut 0.3s ease-in forwards;
}
@keyframes fadeOut {
from { background-color: rgba(0, 0, 0, 0.4); }
to { background-color: rgba(0, 0, 0, 0); }
}
</style>

View File

@@ -183,7 +183,7 @@ const links = ref([
.url { flex: 1; }
.mini { flex: 1; }
.time { width: 160px; }
.op { width: 100px; text-align: center; }
.op { width: 100px; text-align: center; display:flex;flex-direction:row;}
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; padding: 12px 0; align-items: center; }
.table-row .col { color: #606266; }

View File

@@ -0,0 +1,576 @@
<template>
<view class="admin-decoration-user">
<!-- 顶部标题与保存按钮 -->
<view class="page-header border-shadow">
<view class="header-left">
<text class="page-title">个人中心</text>
</view>
<view class="header-right">
<view class="btn-primary" @click="handleSave">
<text class="btn-txt">保存</text>
</view>
</view>
</view>
<!-- 主要内容区 -->
<view class="content-container anim-fade-in">
<view class="main-card border-shadow">
<!-- 左侧:手机预览 -->
<view class="preview-panel">
<view class="phone-mockup">
<scroll-view class="phone-body" :scroll-y="true">
<!-- 样式1 & 样式2 头部 -->
<view v-if="selectedStyle === 1 || selectedStyle === 2" class="user-header-gradient">
<view class="header-top">
<view class="avatar-box">
<image class="avatar-img" src="/static/logo.png" mode="aspectFill"></image>
</view>
<view class="user-info">
<text class="user-name">用户名称用户名称</text>
<view class="bind-phone">
<text class="bind-txt">绑定手机号 ></text>
</view>
</view>
<view class="header-icons">
<view class="ic-msg">🔔<text class="msg-dot">6</text></view>
<view class="ic-set">⚙️</view>
</view>
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-val">0.00</text>
<text class="stat-label">我的余额</text>
</view>
<view class="stat-item">
<text class="stat-val">65749</text>
<text class="stat-label">当前积分</text>
</view>
<view class="stat-item">
<text class="stat-val">25</text>
<text class="stat-label">优惠券</text>
</view>
</view>
<!-- 样式1 会员卡 -->
<view v-if="selectedStyle === 1" class="member-card-s1" @click="handleMember">
<view class="mc-content-s1">
<view class="mc-left">
<text class="mc-ic">👑</text>
<text class="mc-txt">会员到期 2022-12-31</text>
</view>
<view class="mc-right">
<text class="mc-btn">立即续费 ></text>
</view>
</view>
</view>
<!-- 样式2 会员卡 -->
<view v-if="selectedStyle === 2" class="member-card-s2" @click="handleMember">
<view class="mc-content-s2">
<view class="mc-left">
<text class="mc-ic">👑</text>
<view class="mc-info-col">
<text class="mc-t1">会员可享多项权益</text>
<text class="mc-t2">会员剩余360天</text>
</view>
</view>
<view class="mc-right">
<text class="mc-btn-white">立即续费 ></text>
</view>
</view>
</view>
</view>
<!-- 样式3 头部 -->
<view v-if="selectedStyle === 3" class="user-header-s3">
<view class="header-top-s3">
<view class="header-top-left">
<view class="avatar-box-s3">
<image class="avatar-img" src="/static/logo.png" mode="aspectFill"></image>
</view>
<view class="user-info-s3">
<text class="user-name-s3">用户名称用户名称</text>
<view class="bind-phone-s3">
<text class="bind-txt-s3">绑定手机号 ></text>
</view>
</view>
</view>
<view class="header-icons-s3">
<view class="ic-msg-s3">🔔<text class="msg-dot-s3">6</text></view>
<view class="ic-set-s3">⚙️</view>
</view>
</view>
<view class="stats-row-s3">
<view class="stat-item">
<text class="stat-val-s3">0.00</text>
<text class="stat-label-s3">我的余额</text>
</view>
<view class="stat-item">
<text class="stat-val-s3">65749</text>
<text class="stat-label-s3">当前积分</text>
</view>
<view class="stat-item">
<text class="stat-val-s3">25</text>
<text class="stat-label-s3">优惠券</text>
</view>
</view>
</view>
<!-- 样式3 会员卡 -->
<view v-if="selectedStyle === 3" class="member-card-s3" @click="handleMember">
<view class="mc-content-s3">
<view class="mct-left-s3">
<text class="mct-ic-s3">👑</text>
<text class="mct-txt-s3">开通会员VIP</text>
</view>
<view class="mct-right-s3">
<text class="mct-more-s3">会员可享多项权益 ></text>
</view>
</view>
</view>
<!-- 公共部分:订单中心 -->
<view class="section-card">
<view class="section-header">
<text class="sh-title">订单中心</text>
<text class="sh-more">查看全部 ></text>
</view>
<view class="order-grid">
<view class="grid-item" v-for="(item, index) in orderItems" :key="index">
<view class="gi-ic-box">
<text class="gi-ic">{{ item.icon }}</text>
</view>
<text class="gi-txt">{{ item.name }}</text>
</view>
</view>
</view>
<!-- 广告位 -->
<view class="ad-box">
<text class="ad-txt">暂无广告数据</text>
</view>
<!-- 我的服务 -->
<view class="section-card">
<view class="section-header">
<text class="sh-title">我的服务</text>
</view>
<view class="service-grid">
<view class="grid-item-s" v-for="(item, index) in serviceItems" :key="index">
<view class="gi-ic-box-s" :style="{backgroundColor: item.color}">
<text class="gi-ic-s">{{ item.icon }}</text>
</view>
<text class="gi-txt-s">{{ item.name }}</text>
</view>
</view>
</view>
<!-- 商家管理 -->
<view class="section-card">
<view class="section-header">
<text class="sh-title">商家管理</text>
</view>
<view class="merchant-grid">
<view class="grid-item-m" v-for="(item, index) in merchantItems" :key="index">
<view class="gi-ic-box-m">
<text class="gi-ic-m">{{ item.icon }}</text>
</view>
<text class="gi-txt-m">{{ item.name }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 右侧:设置面板 -->
<view class="settings-panel">
<view class="settings-group">
<view class="group-title">
<view class="title-line"></view>
<text class="title-txt">页面设置</text>
</view>
<view class="setting-item-row mt-20">
<text class="item-label">页面风格:</text>
<view class="radio-group">
<view class="radio-item" @click="selectedStyle = 1">
<view :class="['radio-dot', selectedStyle === 1 ? 'active' : '']"></view>
<text class="radio-txt">样式1</text>
</view>
<view class="radio-item" @click="selectedStyle = 2">
<view :class="['radio-dot', selectedStyle === 2 ? 'active' : '']"></view>
<text class="radio-txt">样式2</text>
</view>
<view class="radio-item" @click="selectedStyle = 3">
<view :class="['radio-dot', selectedStyle === 3 ? 'active' : '']"></view>
<text class="radio-txt">样式3</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
const selectedStyle = ref(1)
const orderItems = [
{ name: '待付款', icon: '💳' },
{ name: '待发货', icon: '🚚' },
{ name: '待收货', icon: '📦' },
{ name: '待评价', icon: '📝' },
{ name: '售后/退款', icon: '🔄' }
]
const serviceItems = [
{ name: '付费会员', icon: '💎', color: '#FFF7E6' },
{ name: '发票管理', icon: '🧾', color: '#F6FFED' },
{ name: '积分中心', icon: '🪙', color: '#E6FFFB' },
{ name: '联系客服', icon: '🎧', color: '#F0F5FF' },
{ name: '优惠券', icon: '🎫', color: '#FFF1F0' },
{ name: '我的收藏', icon: '⭐', color: '#FFF2E8' },
{ name: '地址信息', icon: '📍', color: '#F9F0FF' },
{ name: '我的余额', icon: '💰', color: '#FCFFE6' },
{ name: '我的推广', icon: '📢', color: '#FFF7E6' },
{ name: '砍价记录', icon: '✂️', color: '#F6FFED' },
{ name: '浏览记录', icon: '🕒', color: '#E6FFFB' },
{ name: '我的等级', icon: '📊', color: '#F0F5FF' }
]
const merchantItems = [
{ name: '客服接待', icon: '🎧' },
{ name: '订单核销', icon: '✅' },
{ name: '统计管理', icon: '📉' }
]
const handleSave = () => {
uni.showToast({ title: '保存成功' })
}
const handleMember = () => {
uni.showToast({ title: '会员功能开发中' })
}
</script>
<style scoped lang="scss">
.admin-decoration-user {
padding: 0;
background-color: transparent;
min-height: auto;
display: flex;
flex-direction: column;
}
.border-shadow {
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.page-header {
height: 60px;
padding: 0 24px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
z-index: 100;
}
.page-title { font-size: 16px; font-weight: bold; color: #333; }
.btn-primary {
background-color: #2d8cf0;
padding: 6px 20px;
border-radius: 4px;
cursor: pointer;
}
.btn-txt { color: #fff; font-size: 14px; }
.content-container {
flex: 1;
padding: 0;
margin-top: 20px;
}
.main-card {
display: flex;
flex-direction: row;
min-height: 800px;
background-color: #fff;
border-radius: 4px;
}
/* 左侧预览区 */
.preview-panel {
width: 420px;
padding: 40px;
background-color: #ffffff;
display: flex;
justify-content: center;
border-right: 1px solid #f0f0f0;
}
.phone-mockup {
width: 320px;
height: 640px;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.phone-body { height: 100%; }
/* 样式1&2 头部渐变 */
.user-header-gradient {
background: linear-gradient(135deg, #eb3c2d 0%, #ff5e5e 100%);
padding: 25px 0 12px;
position: relative;
}
.header-top {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
padding: 0 15px;
}
.avatar-box { width: 50px; height: 50px; border-radius: 25px; border: 2px solid rgba(255,255,255,0.8); overflow: hidden; margin-right: 12px; }
.avatar-img { width: 100%; height: 100%; }
.user-info { flex: 1; display: flex; flex-direction: column; }
.user-name { font-size: 14px; font-weight: bold; color: #fff; margin-bottom: 4px; }
.bind-phone { background-color: rgba(0,0,0,0.15); align-self: flex-start; padding: 2px 8px; border-radius: 10px; }
.bind-txt { color: #fff; font-size: 10px; }
.header-icons { display: flex; flex-direction: row; gap: 15px; padding: 0 15px; }
.ic-msg, .ic-set { font-size: 16px; color: #fff; position: relative; }
.msg-dot { position: absolute; top: -5px; right: -5px; background-color: #fff; color: #f2270c; font-size: 9px; width: 12px; height: 12px; border-radius: 6px; text-align: center; }
.stats-row { display: flex; flex-direction: row; justify-content: space-around; padding: 10px 15px; margin-bottom: 0; }
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-val { font-size: 16px; font-weight: bold; color: #fff; margin-bottom: 4px; }
.stat-label { font-size: 10px; color: rgba(255,255,255,0.8); }
/* 会员卡 样式1 */
.member-card-s1 {
background: linear-gradient(90deg, #fdf1d6 0%, #fbd795 100%);
margin: 12px 10px 4px;
border-radius: 12px;
padding: 15px 16px;
}
.mc-content-s1 {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.mc-txt { font-size: 11px; color: #7c581c; margin-left: 6px; }
.mc-btn { font-size: 10px; color: #7c581c; font-weight: bold; }
/* 会员卡 样式2 */
.member-card-s2 {
background-color: rgba(255,255,255,0.25);
margin: 12px 10px 4px;
border-radius: 12px;
padding: 15px 16px;
border: 1px solid rgba(255,255,255,0.3);
}
.mc-content-s2 {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.mc-info-col { display: flex; flex-direction: column; margin-left: 8px; }
.mc-t1 { font-size: 11px; color: #fff; font-weight: bold; }
.mc-t2 { font-size: 9px; color: rgba(255,255,255,0.8); }
.mc-btn-white { background-color: #fff; color: #f2270c; font-size: 10px; padding: 4px 12px; border-radius: 12px; font-weight: bold; }
/* 样式3 头部 */
.user-header-s3 {
background-color: #fff;
padding: 30px 15px 0;
}
.header-top-s3 {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 25px;
}
.header-top-left {
display: flex;
flex-direction: row;
align-items: center;
}
.avatar-box-s3 {
width: 54px;
height: 54px;
border-radius: 27px;
overflow: hidden;
margin-right: 12px;
background-color: #f5f5f5;
border: 1px solid #eee;
}
.user-info-s3 {
display: flex;
flex-direction: column;
}
.user-name-s3 {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 6px;
}
.bind-phone-s3 {
display: flex;
}
.bind-txt-s3 {
color: #999;
font-size: 11px;
}
.header-icons-s3 {
display: flex;
flex-direction: row;
gap: 15px;
padding-top: 5px;
}
.ic-msg-s3, .ic-set-s3 {
font-size: 18px;
color: #333;
position: relative;
}
.msg-dot-s3 {
position: absolute;
top: -6px;
right: -6px;
background-color: #f2270c;
color: #fff;
font-size: 9px;
width: 14px;
height: 14px;
border-radius: 7px;
text-align: center;
line-height: 14px;
}
.stats-row-s3 {
display: flex;
flex-direction: row;
justify-content: space-around;
padding: 10px 0 20px;
}
.stat-val-s3 {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.stat-label-s3 {
font-size: 11px;
color: #999;
}
.member-card-s3 {
background: #282828;
margin: 12px 10px;
border-radius: 12px;
padding: 18px 16px;
}
.mc-content-s3 {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.mct-left-s3 {
display: flex;
flex-direction: row;
align-items: center;
}
.mct-ic-s3 {
font-size: 18px;
margin-right: 8px;
}
.mct-txt-s3 {
color: #fbd795;
font-size: 14px;
font-weight: bold;
}
.mct-right-s3 {
display: flex;
}
.mct-more-s3 {
color: #fbd795;
font-size: 11px;
opacity: 0.8;
}
/* 通用区块间卡片 */
.section-card {
background-color: #fff;
margin: 10px;
border-radius: 8px;
padding: 15px;
}
.section-header { display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 15px; }
.sh-title { font-size: 13px; font-weight: bold; color: #333; }
.sh-more { font-size: 11px; color: #999; }
.order-grid { display: flex; flex-direction: row; justify-content: space-between; }
.grid-item { display: flex; flex-direction: column; align-items: center; }
.gi-ic { font-size: 20px; margin-bottom: 6px; }
.gi-txt { font-size: 10px; color: #666; }
.ad-box { background-color: #fff; margin: 10px; border-radius: 8px; height: 50px; display: flex; align-items: center; justify-content: center; border: 1px dashed #eee; }
.ad-txt { font-size: 12px; color: #999; }
.service-grid { display: flex; flex-direction: row; flex-wrap: wrap; }
.grid-item-s { width: 25%; display: flex; flex-direction: column; align-items: center; margin-bottom: 15px; }
.gi-ic-box-s { width: 34px; height: 34px; border-radius: 17px; display: flex; align-items: center; justify-content: center; margin-bottom: 6px; }
.gi-ic-s { font-size: 16px; }
.gi-txt-s { font-size: 10px; color: #666; }
.merchant-grid { display: flex; flex-direction: row; gap: 40px; }
.grid-item-m { display: flex; flex-direction: column; align-items: center; }
.gi-ic-m { font-size: 20px; margin-bottom: 6px; }
.gi-txt-m { font-size: 10px; color: #666; }
/* 右侧设置区 */
.settings-panel { flex: 1; padding: 30px; }
.group-title { display: flex; flex-direction: row; align-items: center; margin-bottom: 20px; }
.title-line { width: 3px; height: 16px; background-color: #2d8cf0; margin-right: 10px; }
.title-txt { font-size: 15px; font-weight: bold; color: #333; }
.setting-item-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 20px; }
.item-label { font-size: 14px; color: #666; margin-right: 20px; }
.radio-group { display: flex; flex-direction: row; gap: 30px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-dot { width: 16px; height: 16px; border: 1px solid #dcdfe6; border-radius: 8px; margin-right: 8px; position: relative; }
.radio-dot.active { border-color: #2d8cf0; }
.radio-dot.active::after { content: ''; width: 8px; height: 8px; background-color: #2d8cf0; border-radius: 4px; position: absolute; top: 3px; left: 3px; }
.radio-txt { font-size: 14px; color: #333; }
.mt-20 { margin-top: 20px; }
.anim-fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,409 @@
<template>
<view class="admin-decoration-category">
<!-- 顶部标题与按钮 (PageHeader 标准模式) -->
<view class="page-header border-shadow">
<view class="header-left">
<text class="page-title">商品分类</text>
</view>
<view class="header-right">
<view class="btn-primary" @click="handleSave">
<text class="btn-txt">保存</text>
</view>
<view class="btn-ghost" @click="handleReset">
<text class="ghost-txt">重置</text>
</view>
</view>
</view>
<!-- 分类展示区域 (Reason 26: 统一白卡模式) -->
<view class="content-container anim-fade-in">
<view class="main-card border-shadow">
<view class="style-grid">
<!-- 样式 1 -->
<view class="style-item" @click="selectedStyle = 1">
<view :class="['mock-phone', selectedStyle === 1 ? 'active' : '']">
<view class="phone-inner">
<!-- 顶部栏 -->
<view class="p-header">
<text class="p-title">产品分类</text>
<text class="p-dots">••• Ⓞ</text>
</view>
<!-- 搜索栏 -->
<view class="p-search">
<text class="p-search-ic">🔍</text>
<text class="p-search-txt">点击搜索商品信息</text>
</view>
<!-- 内容区 -->
<view class="p-content-row">
<view class="p-side">
<text class="p-side-item active">精选水果</text>
<text class="p-side-item" v-for="name in ['肉制品','水产海鲜','米面粮油','厨房主食','新鲜蛋品','调味品','日配冷藏','豆制品']" :key="name">{{name}}</text>
</view>
<view class="p-main">
<view class="p-section-title">精选水果</view>
<view class="p-item-grid">
<view class="p-grid-item" v-for="i in 6" :key="'s1-1-'+i">
<view class="p-item-img-box"><text class="p-emoji">🍐</text></view>
<text class="p-item-name">{{ ['精品香蕉','坚果优选','猕猴桃','精品香蕉','坚果优选','猕猴桃'][i-1] }}</text>
</view>
</view>
<view class="p-section-title">肉制品</view>
<view class="p-item-grid">
<view class="p-grid-item" v-for="i in 3" :key="'s1-2-'+i">
<view class="p-item-img-box"><text class="p-emoji">🥩</text></view>
<text class="p-item-name">{{ ['大肉块','五花肉','鸡腿'][i-1] }}</text>
</view>
</view>
</view>
</view>
<!-- 底部栏 -->
<view class="p-tabbar">
<view class="p-tab-item"><text class="p-tab-ic">🏠</text><text class="p-tab-txt">首页</text></view>
<view class="p-tab-item active"><text class="p-tab-ic">📂</text><text class="p-tab-txt">分类</text></view>
<view class="p-tab-item"><text class="p-tab-ic">🛒</text><text class="p-tab-txt">购物车</text></view>
<view class="p-tab-item"><text class="p-tab-ic">👤</text><text class="p-tab-txt">我的</text></view>
</view>
</view>
</view>
<text :class="['style-label', selectedStyle === 1 ? 'active' : '']">样式1</text>
</view>
<!-- 样式 2 -->
<view class="style-item" @click="selectedStyle = 2">
<view :class="['mock-phone', selectedStyle === 2 ? 'active' : '']">
<view class="phone-inner">
<!-- 状态栏 -->
<view class="p-stat-bar">
<text class="p-time">9:41 AM</text>
<text class="p-bat">100% 🔋</text>
</view>
<!-- 顶部导航 -->
<view class="p-header-v2">
<view class="p-back">🏠</view>
<view class="p-search-box-v2">
<text class="p-search-ic">🔍</text>
<text class="p-search-txt">点击搜索商品信息</text>
</view>
</view>
<!-- 分类选项卡 -->
<view class="p-tab-row-v2">
<text class="p-tab-v2 active">水果</text>
<text class="p-tab-v2">全部</text>
<text class="p-tab-v2">热带水果</text>
<text class="p-tab-v2">西瓜葡萄</text>
<text class="p-arrow-v2">▼</text>
</view>
<!-- 内容区 -->
<view class="p-content-row">
<view class="p-side-v2">
<text class="p-side-item-v2 active">乳品</text>
<text class="p-side-item-v2" v-for="name in ['休闲零食','新鲜蔬菜','美妆护肤','宠物用品','图书玩具']" :key="name">{{name}}</text>
</view>
<view class="p-main">
<view class="p-banner-v2">
<text class="p-banner-txt">深层 V8 高清直屏\n双镜头/VR科技体验</text>
</view>
<view class="p-prod-list-v2">
<view class="p-prod-v2" v-for="i in 2" :key="'s2-'+i">
<text class="p-prod-title">Haier/海尔 BCD-216STPT 时尚静音冰箱 三门出口租家用小型电冰箱</text>
<view class="p-prod-footer-v2">
<text class="p-price">¥999.00</text>
<text class="p-sales">已售 92</text>
<view class="p-buy-btn">立即购买</view>
</view>
</view>
</view>
</view>
</view>
<!-- 蓝色底部条 -->
<view class="p-cart-bar">
<view class="p-cart-icon-box">🛒<text class="p-cart-badge">7</text></view>
<text class="p-cart-total">¥999.00</text>
<view class="p-settle-btn">去结算</view>
</view>
</view>
</view>
<text :class="['style-label', selectedStyle === 2 ? 'active' : '']">样式2</text>
</view>
<!-- 样式 3 -->
<view class="style-item" @click="selectedStyle = 3">
<view :class="['mock-phone', selectedStyle === 3 ? 'active' : '']">
<view class="phone-inner">
<!-- 顶部导航 -->
<view class="p-header">
<text class="p-title">产品分类</text>
</view>
<view class="p-header-v3">
<view class="p-back">🏠</view>
<view class="p-search-box-v3">
<text class="p-search-ic">🔍</text>
<text class="p-search-txt">搜索商品</text>
</view>
</view>
<!-- 分类项 -->
<view class="p-tab-row-v3">
<text class="p-tab-item-v3">水果</text>
<text class="p-tab-item-v3 active">时令生鲜</text>
<text class="p-tab-item-v3">休闲零食</text>
<text class="p-tab-item-v3">坚果蜜饯</text>
<text class="p-arrow-v3"></text>
</view>
<!-- 内容区 -->
<view class="p-content-row">
<view class="p-side-v3">
<text class="p-side-item-v3 active">乳品</text>
<text class="p-side-item-v3" v-for="name in ['休闲零食','新鲜蔬菜','特惠专区','大闸蟹','精选礼盒']" :key="name">{{name}}</text>
</view>
<view class="p-main-v3">
<view class="p-prod-row-v3" v-for="i in 5" :key="'s3-'+i">
<view class="p-prod-img-v3"></view>
<view class="p-prod-info-v3">
<text class="p-prod-name-v3">【橙中爱马仕】果际新骑士晚季甜橙10个单装</text>
<text class="p-prod-price-v3">¥25.99</text>
</view>
<view class="p-prod-action-v3">🛒</view>
</view>
</view>
</view>
<!-- 红色底部条 -->
<view class="p-cart-bar-v3">
<view class="p-cart-icon-box-v3">🛒<text class="p-cart-badge">7</text></view>
<text class="p-cart-total-v3">¥999.00</text>
<view class="p-settle-btn-v3">去结算</view>
</view>
</view>
</view>
<text :class="['style-label', selectedStyle === 3 ? 'active' : '']">样式3</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
const selectedStyle = ref(1)
const handleSave = () => {
uni.showToast({ title: '保存成功', icon: 'success' })
}
const handleReset = () => {
selectedStyle.value = 1
}
</script>
<style scoped lang="scss">
.admin-decoration-category {
display: flex;
flex-direction: column;
background-color: transparent;
}
.border-shadow {
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
/* PageHeader */
.page-header {
height: 60px;
padding: 0 24px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; flex-direction: row; gap: 12px; }
.btn-primary, .btn-ghost {
height: 32px;
padding: 0 20px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-primary { background-color: #2d8cf0; }
.btn-ghost { border: 1px solid #dcdfe6; }
.btn-txt { color: #fff; font-size: 14px; }
.ghost-txt { color: #606266; font-size: 14px; }
/* Content Container */
.content-container {
margin-top: 20px;
flex: 1;
}
.main-card {
background-color: #fff;
border-radius: 8px;
padding: 40px;
min-height: 800px;
}
/* Responsive Grid (1250px -> 3, 700px -> 1) */
.style-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 40px;
}
@media (max-width: 1250px) {
.style-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 700px) {
.style-grid {
grid-template-columns: 1fr;
}
}
.style-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
cursor: pointer;
}
/* Mock Phone Structure */
.mock-phone {
width: 300px;
height: 620px;
border: 1px solid #eeeeee;
border-radius: 12px;
padding: 8px;
background-color: #fff;
transition: all 0.3s;
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
position: relative;
}
.mock-phone.active {
border-color: #2d8cf0;
box-shadow: 0 4px 25px rgba(45, 140, 240, 0.15);
}
.phone-inner {
width: 100%;
height: 100%;
background-color: #f8f8f8;
display: flex;
flex-direction: column;
border: 1px solid #f0f0f0;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.style-label {
font-size: 14px;
color: #333;
}
.style-label.active {
color: #2d8cf0;
font-weight: bold;
}
/* Style 1 Specific */
.p-header { height: 44px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 0 12px; background-color: #fff; border-bottom: 1px solid #f0f0f0; }
.p-title { font-size: 15px; font-weight: bold; color: #333; }
.p-dots { font-size: 12px; color: #333; }
.p-search { margin: 8px; height: 32px; background-color: #fff; border-radius: 16px; border: 1px solid #eee; display: flex; flex-direction: row; align-items: center; padding: 0 12px; }
.p-search-ic { font-size: 10px; margin-right: 6px; color: #999; }
.p-search-txt { font-size: 11px; color: #999; }
.p-content-row { flex: 1; display: flex; flex-direction: row; overflow: hidden; }
.p-side { width: 80px; background-color: #f7f7f7; display: flex; flex-direction: column; }
.p-side-item { height: 44px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #666; }
.p-side-item.active { background-color: #fff; color: #f2270c; font-weight: bold; position: relative; }
.p-side-item.active::before { content: ''; position: absolute; left: 0; top: 15px; height: 14px; width: 3px; background-color: #f2270c; }
.p-main { flex: 1; background-color: #fff; padding: 12px; overflow-y: auto; }
.p-section-title { font-size: 12px; font-weight: bold; color: #333; margin-bottom: 12px; }
.p-item-grid { display: flex; flex-direction: row; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; }
.p-grid-item { width: 60px; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.p-item-img-box { width: 50px; height: 50px; background-color: #f9f9f9; border-radius: 4px; display: flex; align-items: center; justify-content: center; }
.p-emoji { font-size: 24px; }
.p-item-name { font-size: 10px; color: #666; text-align: center; }
.p-tabbar { height: 50px; background-color: #fff; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; }
.p-tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
.p-tab-ic { font-size: 18px; color: #999; }
.p-tab-txt { font-size: 9px; color: #999; }
.p-tab-item.active .p-tab-ic, .p-tab-item.active .p-tab-txt { color: #f2270c; }
/* Style 2 Specific */
.p-stat-bar { height: 24px; padding: 0 12px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; background-color: #fff; }
.p-time { font-size: 10px; font-weight: bold; }
.p-bat { font-size: 10px; }
.p-header-v2 { height: 44px; background-color: #fff; display: flex; flex-direction: row; align-items: center; padding: 0 12px; gap: 10px; }
.p-back { font-size: 16px; }
.p-search-box-v2 { flex: 1; height: 30px; background-color: #f5f5f5; border-radius: 15px; display: flex; flex-direction: row; align-items: center; padding: 0 12px; }
.p-tab-row-v2 { height: 40px; background-color: #fff; display: flex; flex-direction: row; align-items: center; padding: 0 12px; gap: 15px; }
.p-tab-v2 { font-size: 12px; color: #333; }
.p-tab-v2.active { color: #f2270c; font-weight: bold; border-bottom: 2px solid #f2270c; padding-bottom: 2px; }
.p-arrow-v2 { font-size: 10px; color: #999; flex: 1; text-align: right; }
.p-side-v2 { width: 70px; background-color: #f7f7f7; }
.p-side-item-v2 { height: 48px; display: flex; align-items: center; justify-content: center; font-size: 11px; color: #333; }
.p-side-item-v2.active { background-color: #fff; font-weight: bold; }
.p-banner-v2 { height: 80px; background-color: #1a56f0; border-radius: 6px; padding: 12px; display: flex; align-items: center; margin-bottom: 12px; }
.p-banner-txt { color: #fff; font-size: 12px; font-weight: bold; line-height: 1.4; }
.p-prod-v2 { border-bottom: 1px solid #f0f0f0; padding-bottom: 12px; margin-bottom: 12px; }
.p-prod-title { font-size: 11px; color: #333; line-height: 1.4; margin-bottom: 8px; }
.p-prod-footer-v2 { display: flex; flex-direction: row; align-items: center; }
.p-price { font-size: 14px; color: #f2270c; font-weight: bold; flex: 1; }
.p-sales { font-size: 10px; color: #999; margin-right: 12px; }
.p-buy-btn { background-color: #f2270c; color: #fff; font-size: 10px; padding: 4px 10px; border-radius: 12px; }
.p-cart-bar { height: 50px; background-color: #fff; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; align-items: center; padding: 0 12px; margin-top: auto; }
.p-cart-icon-box { width: 36px; height: 36px; background-color: #f2270c; border-radius: 18px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 18px; position: relative; margin-top: -10px; }
.p-cart-badge { position: absolute; top: -4px; right: -4px; background-color: #fff; color: #f2270c; font-size: 9px; width: 14px; height: 14px; border-radius: 7px; text-align: center; border: 1px solid #f2270c; }
.p-cart-total { font-size: 15px; color: #f2270c; font-weight: bold; flex: 1; margin-left: 10px; }
.p-settle-btn { background-color: #f2270c; color: #fff; font-size: 14px; padding: 6px 20px; border-radius: 20px; }
/* Style 3 Specific */
.p-header-v3 { height: 44px; background-color: #fff; display: flex; flex-direction: row; align-items: center; padding: 0 12px; gap: 10px; }
.p-search-box-v3 { flex: 1; height: 30px; background-color: #f5f5f5; border-radius: 15px; display: flex; flex-direction: row; align-items: center; padding: 0 12px; }
.p-tab-row-v3 { height: 44px; background-color: #fff; display: flex; flex-direction: row; align-items: center; padding: 0 12px; gap: 12px; }
.p-tab-item-v3 { font-size: 12px; color: #666; }
.p-tab-item-v3.active { background-color: #f2270c; color: #fff; padding: 4px 10px; border-radius: 14px; }
.p-arrow-v3 { font-size: 10px; color: #ccc; flex: 1; text-align: right; }
.p-side-v3 { width: 75px; background-color: #f7f7f7; }
.p-side-item-v3 { height: 48px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #666; }
.p-side-item-v3.active { background-color: #fff; color: #333; font-weight: bold; position: relative; }
.p-side-item-v3.active::before { content: ''; position: absolute; left: 0; top: 17px; height: 14px; width: 3px; background-color: #f2270c; }
.p-main-v3 { flex: 1; background-color: #fff; padding: 12px; overflow-y: auto; }
.p-prod-row-v3 { display: flex; flex-direction: row; align-items: center; margin-bottom: 20px; }
.p-prod-img-v3 { width: 70px; height: 70px; background-color: #f5f5f5; border-radius: 4px; margin-right: 12px; }
.p-prod-info-v3 { flex: 1; display: flex; flex-direction: column; gap: 8px; }
.p-prod-name-v3 { font-size: 12px; color: #333; line-height: 1.4; }
.p-prod-price-v3 { font-size: 14px; color: #f2270c; font-weight: bold; }
.p-prod-action-v3 { font-size: 20px; color: #f2270c; }
.p-cart-bar-v3 { height: 50px; background-color: #fff; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; align-items: center; padding: 0 12px; margin-top: auto; }
.p-cart-icon-box-v3 { width: 36px; height: 36px; background-color: #fff; border-radius: 18px; border: 1px solid #f2270c; display: flex; align-items: center; justify-content: center; color: #f2270c; font-size: 18px; position: relative; margin-top: -10px; }
.p-cart-total-v3 { font-size: 15px; color: #f2270c; font-weight: bold; flex: 1; margin-left: 10px; }
.p-settle-btn-v3 { background-color: #f2270c; color: #fff; font-size: 14px; padding: 6px 20px; border-radius: 20px; }
</style>

View File

@@ -0,0 +1,436 @@
<template>
<view class="admin-main">
<!-- 头部操作区 -->
<view class="page-header border-shadow">
<view class="header-left">
<text class="page-title">主题风格</text>
</view>
<view class="header-right">
<button class="save-btn" type="primary" size="mini" @click="handleSave">保存</button>
</view>
</view>
<!-- 主要内容区 -->
<view class="main-content anim-fade-in">
<view class="main-card border-shadow">
<!-- 选项配置: 1:1 复刻参考图 -->
<view class="selection-area">
<view class="theme-list">
<view
v-for="(item, index) in themeOptions"
:key="index"
class="theme-item"
:class="{ active: selectedThemeId === item.id }"
@click="selectedThemeId = item.id"
>
<view class="color-preview" :style="{ backgroundColor: item.color }"></view>
<text class="theme-name">{{ item.name }}</text>
</view>
</view>
</view>
<!-- 预览区: 1:1 复刻参考图 -->
<view class="preview-section">
<!-- 预览 1: 个人中心 -->
<view class="preview-card-item">
<text class="p-title">个人中心</text>
<view class="mock-phone">
<view class="mock-inner">
<view class="mock-status-bar"></view>
<view class="mock-content user-center">
<view class="header-bg" :style="{ backgroundColor: currentThemeColor }">
<view class="user-info-row">
<view class="mock-avatar"></view>
<view class="user-meta">
<view class="name-line">
<text class="name">我的名字我的名字</text>
<view class="vip-badge">SVIP</view>
</view>
<text class="user-id">ID: 3659884 ></text>
</view>
<view class="settings-icons">
<text class="ic">🔔</text>
<text class="ic">⚙️</text>
</view>
</view>
<view class="stats-row">
<view class="stat-item"><text class="val">0.00</text><text class="lab">余额</text></view>
<view class="stat-item"><text class="val">20</text><text class="lab">积分</text></view>
<view class="stat-item"><text class="val">25</text><text class="lab">优惠券</text></view>
</view>
</view>
<view class="vip-card-banner">
<view class="vip-left">
<text class="vip-l-t1">会员可享多项权益</text>
<text class="vip-l-t2">会员剩余434天</text>
</view>
<view class="btn-vip">立即续费</view>
</view>
<view class="order-section">
<view class="o-title"><text>订单中心</text><text class="more">查看全部 ></text></view>
<view class="o-icons">
<view class="o-item">📦<text>待付款</text></view>
<view class="o-item">🚚<text>待发货</text></view>
<view class="o-item">🎁<text>待收货</text></view>
<view class="o-item">⭐<text>待评价</text></view>
<view class="o-item">🔄<text>售后/退款</text></view>
</view>
</view>
<view class="invite-banner">
<text class="i-t1">邀请好友赚佣金</text>
<text class="i-t2">推广好友注册</text>
</view>
<view class="service-section">
<view class="s-title">我的服务</view>
<view class="s-grid">
<view class="s-item">👤<text>会员中心</text></view>
<view class="s-item">📢<text>我的推广</text></view>
<view class="s-item">📅<text>签到</text></view>
<view class="s-item">🎫<text>优惠券</text></view>
</view>
</view>
<view class="mock-tabbar">
<view class="t-item">🏠<text>首页</text></view>
<view class="t-item">🔍<text>分类</text></view>
<view class="t-item">🛒<text>购物车</text></view>
<view class="t-item active" :style="{ color: currentThemeColor }">👤<text>我的</text></view>
</view>
</view>
</view>
</view>
</view>
<!-- 预览 2: 商品详情 -->
<view class="preview-card-item">
<text class="p-title">商品详情</text>
<view class="mock-phone">
<view class="mock-inner">
<view class="mock-status-bar"></view>
<view class="mock-content product-detail">
<view class="p-gallery">
<text class="p-page">1/5</text>
</view>
<view class="p-main-info">
<view class="p-price-row">
<text class="p-symbol" :style="{ color: currentThemeColor }">¥</text>
<text class="p-price" :style="{ color: currentThemeColor }">199.00</text>
<text class="p-old-price">¥ 100.00</text>
<view class="p-tag-svip">SVIP</view>
</view>
<text class="p-name">企鹅针织条纹四件套新款上市性价比高</text>
<view class="p-stats">
<text class="ps-t">原价: ¥ 234.00</text>
<text class="ps-t">累计销量: 2999999件</text>
<text class="ps-t">库存: 1452件</text>
</view>
</view>
<view class="p-options">
<view class="opt-row">
<text class="opt-lab">优惠券:</text>
<view class="tags-row"><text class="tag-outline">满100减30</text><text class="tag-outline">满100减30</text></view>
<text class="more">></text>
</view>
<view class="opt-row">
<text class="opt-lab">活动:</text>
<view class="tags-row"><text class="tag-fill" :style="{ backgroundColor: currentThemeColor }">参与拼团</text><text class="tag-fill" :style="{ backgroundColor: currentThemeColor }">参与秒杀</text></view>
<text class="more">></text>
</view>
</view>
<view class="p-footer">
<view class="f-icons">
<view class="fi"><text>💬</text><text class="fi-t">客服</text></view>
<view class="fi"><text>⭐</text><text class="fi-t">收藏</text></view>
<view class="fi"><text>🛒</text><text class="fi-t">购物车</text></view>
</view>
<view class="f-btns">
<view class="f-btn cart" :style="{ backgroundColor: currentThemeColor, opacity: 0.7 }">加入购物车</view>
<view class="f-btn buy" :style="{ backgroundColor: currentThemeColor }">立即购买</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 预览 3: 拼团列表 -->
<view class="preview-card-item">
<text class="p-title">拼团列表</text>
<view class="mock-phone">
<view class="mock-inner">
<view class="mock-status-bar"></view>
<view class="mock-content group-list">
<view class="g-header" :style="{ backgroundColor: currentThemeColor }">
<text class="g-h-title">拼团列表</text>
<view class="g-participation">
<text class="g-p-txt">9999人参与</text>
</view>
</view>
<view class="g-item" v-for="i in 3" :key="i">
<view class="g-img"></view>
<view class="g-info">
<text class="g-name-v2">2021年新款吊灯简约现代大气家用客厅灯北欧风格...</text>
<view class="g-bottom-v2">
<view class="g-prices-v2">
<text class="g-p-old-v2">¥ 199.00</text>
<view class="p-now-row">
<text class="ps">¥</text>
<text class="pv" :style="{ color: currentThemeColor }">124.00</text>
</view>
</view>
<view class="g-btn-v2" :style="{ backgroundColor: currentThemeColor }">{{ i % 2 === 0 ? '去拼团' : '已售罄' }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
interface ThemeOption {
id: string
name: string
color: string
}
const themeOptions = ref<ThemeOption[]>([
{ id: 'blue', name: '天空蓝', color: '#1890ff' },
{ id: 'green', name: '生鲜绿', color: '#52c41a' },
{ id: 'red', name: '热情红', color: '#e93323' },
{ id: 'pink', name: '魅力粉', color: '#ff4d9f' },
{ id: 'orange', name: '活力橙', color: '#ff8c00' }
])
const selectedThemeId = ref('red')
const currentThemeColor = computed(() : string => {
const theme = themeOptions.value.find(t => t.id === selectedThemeId.value)
return theme ? theme.color : '#e93323'
})
const handleSave = () => {
uni.showToast({
title: '保存成功',
icon: 'success'
})
}
</script>
<style scoped lang="scss">
.admin-main {
padding: 0;
background-color: transparent;
min-height: auto;
display: flex;
flex-direction: column;
}
.border-shadow {
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.page-header {
height: 60px;
padding: 0 24px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
z-index: 100;
}
.page-title { font-size: 16px; font-weight: bold; color: #333; }
.main-content {
flex: 1;
padding: 0;
margin-top: 20px;
}
.main-card {
background-color: #fff;
border-radius: 8px;
min-height: 800px;
padding: 30px 40px;
}
/* 1:1 复刻参考图:颜色选择区 */
.selection-area {
margin-bottom: 30px;
}
.theme-list {
display: flex;
flex-direction: row;
gap: 15px;
}
.theme-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 20px;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
background-color: #fff;
}
.theme-item.active {
border-color: #2d8cf0;
border-width: 1px;
}
.color-preview { width: 14px; height: 14px; border-radius: 2px; margin-right: 10px; }
.theme-name { font-size: 14px; color: #333; }
/* 预览区布局 */
.preview-section {
display: flex;
flex-direction: row;
gap: 30px;
padding-bottom: 40px;
overflow-x: auto;
}
.preview-card-item {
width: 300px;
flex-shrink: 0;
}
.p-title {
font-size: 15px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
display: block;
text-align: center;
}
.mock-phone {
width: 280px;
height: 580px;
border: 1px solid #eeeeee;
border-radius: 4px;
margin: 0 auto;
overflow: hidden;
background-color: #f5f5f5;
padding: 8px; /* 模拟边框 */
}
.mock-inner {
width: 100%;
height: 100%;
background-color: #f5f5f5;
position: relative;
overflow: hidden;
}
.mock-status-bar { height: 20px; }
.mock-content { height: calc(100% - 20px); overflow-y: auto; }
/* 个人中心 Mock */
.user-center .header-bg { padding: 25px 15px 45px; color: #fff; }
.user-info-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 20px; }
.mock-avatar { width: 50px; height: 50px; background-color: #eee; border-radius: 50%; border: 2px solid rgba(255,255,255,0.8); margin-right: 12px; }
.user-meta { flex: 1; }
.name-line { display: flex; flex-direction: row; align-items: center; margin-bottom: 4px; }
.name { font-size: 14px; font-weight: bold; }
.vip-badge { font-size: 10px; background-color: #333; color: #fadb14; padding: 0 4px; border-radius: 2px; margin-left: 6px; }
.user-id { font-size: 10px; opacity: 0.9; }
.settings-icons { display: flex; flex-direction: row; gap: 10px; }
.stats-row { display: flex; flex-direction: row; justify-content: space-around; }
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-item .val { font-weight: bold; font-size: 15px; margin-bottom: 4px; }
.stat-item .lab { font-size: 11px; opacity: 0.8; }
.vip-card-banner {
margin: -25px 12px 12px;
background: linear-gradient(90deg, #fceabb 0%, #f8b500 100%);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.vip-l-t1 { font-size: 11px; color: #845506; font-weight: bold; }
.vip-l-t2 { font-size: 9px; color: #845506; opacity: 0.8; }
.btn-vip { font-size: 9px; background: #333; color: #fff; padding: 3px 10px; border-radius: 10px; }
.order-section { background: #fff; margin: 0 12px 12px; border-radius: 8px; padding: 12px; }
.o-title { display: flex; flex-direction: row; justify-content: space-between; font-size: 11px; margin-bottom: 15px; color: #333; }
.o-icons { display: flex; flex-direction: row; justify-content: space-between; }
.o-item { display: flex; flex-direction: column; align-items: center; font-size: 10px; gap: 5px; color: #666; }
.invite-banner { margin: 0 12px 12px; height: 50px; background: #fff1f0; border-radius: 8px; padding: 12px; display: flex; flex-direction: column; border: 1px dashed #ffa39e; }
.i-t1 { font-size: 11px; color: #cf1322; font-weight: bold; margin-bottom: 2px; }
.i-t2 { font-size: 9px; color: #cf1322; opacity: 0.7; }
.service-section { background: #fff; margin: 0 12px; border-radius: 8px; padding: 12px; }
.s-title { font-size: 11px; font-weight: bold; margin-bottom: 12px; }
.s-grid { display: flex; flex-direction: row; flex-wrap: wrap; }
.s-item { width: 25%; display: flex; flex-direction: column; align-items: center; font-size: 9px; gap: 6px; margin-bottom: 10px; color: #666; }
.mock-tabbar { position: absolute; bottom: 0; width: 100%; height: 50px; background: #fff; border-top: 1px solid #eee; display: flex; flex-direction: row; }
.t-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 9px; color: #999; }
/* 商品详情 Mock */
.product-detail .p-gallery { height: 180px; background-color: #eee; background-image: url('/static/logo.png'); background-size: cover; position: relative; }
.p-page { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.5); color: #fff; border-radius: 10px; padding: 2px 6px; font-size: 9px; }
.p-main-info { background: #fff; padding: 12px; margin-bottom: 8px; }
.p-price-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 6px; }
.p-symbol { font-size: 12px; font-weight: bold; }
.p-price { font-size: 18px; font-weight: bold; margin: 0 5px; }
.p-old-price { font-size: 10px; color: #999; text-decoration: line-through; margin-right: 8px; }
.p-tag-svip { background-color: #333; color: #fadb14; font-size: 9px; padding: 1px 4px; border-radius: 2px; }
.p-name { font-size: 13px; font-weight: bold; color: #333; display: block; margin-bottom: 8px; }
.p-stats { display: flex; flex-direction: column; gap: 4px; }
.ps-t { font-size: 10px; color: #999; }
.p-options { background: #fff; padding: 12px; margin-bottom: 8px; }
.opt-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 10px; }
.opt-lab { width: 45px; font-size: 11px; color: #999; }
.tags-row { flex: 1; display: flex; flex-direction: row; gap: 5px; }
.tag-outline { border: 1px solid #ff4d4f; color: #ff4d4f; font-size: 9px; padding: 1px 4px; border-radius: 2px; }
.tag-fill { color: #fff; font-size: 9px; padding: 2px 6px; border-radius: 2px; }
.p-options .more { color: #ccc; font-size: 12px; }
.p-footer { position: absolute; bottom: 0; width: 100%; height: 50px; background: #fff; border-top: 1px solid #eee; display: flex; flex-direction: row; padding: 5px 10px; align-items: center; }
.f-icons { display: flex; flex-direction: row; gap: 10px; margin-right: 5px; }
.fi { display: flex; flex-direction: column; align-items: center; font-size: 14px; gap: 2px; }
.fi-t { font-size: 9px; color: #666; }
.f-btns { flex: 1; display: flex; flex-direction: row; height: 32px; border-radius: 16px; overflow: hidden; }
.f-btn { flex: 1; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 11px; }
/* 拼团列表 Mock */
.group-list .g-header { padding: 15px; position: relative; }
.g-h-title { color: #fff; font-size: 14px; font-weight: bold; display: block; margin-bottom: 10px; text-align: center; }
.g-p-txt { color: #fff; font-size: 10px; opacity: 0.9; text-align: center; display: block; }
.g-item { background: #fff; border-radius: 8px; margin: 10px; padding: 10px; display: flex; flex-direction: row; }
.g-img { width: 80px; height: 80px; background: #eee; border-radius: 4px; margin-right: 12px; }
.g-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
.g-name-v2 { font-size: 12px; color: #333; line-height: 1.4; height: 34px; overflow: hidden; }
.g-bottom-v2 { display: flex; flex-direction: row; justify-content: space-between; align-items: flex-end; }
.g-p-old-v2 { font-size: 9px; color: #999; text-decoration: line-through; }
.p-now-row { display: flex; flex-direction: row; align-items: baseline; }
.ps { font-size: 10px; font-weight: bold; margin-right: 2px; }
.pv { font-size: 15px; font-weight: bold; }
.g-btn-v2 { padding: 4px 12px; border-radius: 15px; color: #fff; font-size: 10px; }
.anim-fade-in { animation: fadeIn 0.4s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>

View File

@@ -1,321 +0,0 @@
<template>
<view class="admin-decoration-user">
<!-- 顶部标题与保存按钮 -->
<view class="page-header border-shadow">
<view class="header-left">
<text class="page-title">个人中心装修</text>
</view>
<view class="header-right">
<view class="btn-primary" @click="handleSave">
<text class="btn-txt">{{ isSaving ? '保存中...' : '保存配置' }}</text>
</view>
</view>
</view>
<!-- 主要内容区 -->
<view class="content-container anim-fade-in">
<view v-if="isLoading" class="loading-state">
<text>配置加载中...</text>
</view>
<view v-else class="main-card border-shadow">
<!-- 左侧:手机预览 -->
<view class="preview-panel">
<view class="phone-mockup">
<scroll-view class="phone-body" :scroll-y="true">
<!-- 样式1 & 样式2 头部 -->
<view v-if="selectedStyle === 1 || selectedStyle === 2" class="user-header-gradient">
<view class="header-top">
<view class="avatar-box">
<image class="avatar-img" src="/static/logo.png" mode="aspectFill"></image>
</view>
<view class="user-info">
<text class="user-name">演示用户</text>
<view class="bind-phone">
<text class="bind-txt">绑定手机号 ></text>
</view>
</view>
<view class="header-icons">
<view class="ic-msg">🔔</view>
<view class="ic-set">⚙️</view>
</view>
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-val">88.00</text>
<text class="stat-label">我的余额</text>
</view>
<view class="stat-item">
<text class="stat-val">1200</text>
<text class="stat-label">当前积分</text>
</view>
<view class="stat-item">
<text class="stat-val">5</text>
<text class="stat-label">优惠券</text>
</view>
</view>
<!-- 样式1 会员卡 -->
<view v-if="selectedStyle === 1" class="member-card-s1">
<view class="mc-content-s1">
<view class="mc-left">
<text class="mc-ic">👑</text>
<text class="mc-txt">尊贵会员服务</text>
</view>
<view class="mc-right">
<text class="mc-btn">立即续费 ></text>
</view>
</view>
</view>
<!-- 样式2 会员卡 -->
<view v-if="selectedStyle === 2" class="member-card-s2">
<view class="mc-content-s2">
<view class="mc-left">
<text class="mc-ic">👑</text>
<view class="mc-info-col">
<text class="mc-t1">会员可享多项权益</text>
</view>
</view>
<view class="mc-right">
<text class="mc-btn-white">立即续费 ></text>
</view>
</view>
</view>
</view>
<!-- 样式3 头部 -->
<view v-if="selectedStyle === 3" class="user-header-s3">
<view class="header-top-s3">
<view class="header-top-left">
<view class="avatar-box-s3">
<image class="avatar-img" src="/static/logo.png" mode="aspectFill"></image>
</view>
<view class="user-info-s3">
<text class="user-name-s3">演示用户</text>
</view>
</view>
<view class="header-icons-s3">
<view class="ic-msg-s3">🔔</view>
</view>
</view>
<view class="stats-row-s3">
<view class="stat-item">
<text class="stat-val-s3">88.00</text>
<text class="stat-label-s3">余额</text>
</view>
<view class="stat-item">
<text class="stat-val-s3">1200</text>
<text class="stat-label-s3">积分</text>
</view>
</view>
</view>
<!-- 公共部分:订单中心 -->
<view class="section-card">
<view class="section-header">
<text class="sh-title">订单中心</text>
<text class="sh-more">查看全部 ></text>
</view>
<view class="order-grid">
<view class="grid-item" v-for="(item, index) in orderItems" :key="index">
<text class="gi-ic">{{ item.icon }}</text>
<text class="gi-txt">{{ item.name }}</text>
</view>
</view>
</view>
<!-- 我的服务 -->
<view class="section-card">
<view class="section-header">
<text class="sh-title">我的服务</text>
</view>
<view class="service-grid">
<view class="grid-item-s" v-for="(item, index) in serviceItems" :key="index">
<view class="gi-ic-box-s" :style="{backgroundColor: item.color}">
<text class="gi-ic-s">{{ item.icon }}</text>
</view>
<text class="gi-txt-s">{{ item.name }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 右侧:设置面板 -->
<view class="settings-panel">
<view class="settings-group">
<view class="group-title">
<view class="title-line"></view>
<text class="title-txt">页面布局风格</text>
</view>
<view class="setting-item-row mt-20">
<view class="radio-group">
<view class="radio-item" @click="selectedStyle = 1">
<view :class="['radio-dot', selectedStyle === 1 ? 'active' : '']"></view>
<text class="radio-txt">样式1 (经典红)</text>
</view>
<view class="radio-item" @click="selectedStyle = 2">
<view :class="['radio-dot', selectedStyle === 2 ? 'active' : '']"></view>
<text class="radio-txt">样式2 (通透卡片)</text>
</view>
<view class="radio-item" @click="selectedStyle = 3">
<view :class="['radio-dot', selectedStyle === 3 ? 'active' : '']"></view>
<text class="radio-txt">样式3 (简约白)</text>
</view>
</view>
</view>
<text class="hint-txt">选择风格后点击右上角“保存”生效,该配置将同步至移动端个人中心页面。</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { getActiveDiyConfig, saveDiyPage, type DiyPage } from '@/services/admin/decorationService.uts'
const selectedStyle = ref(1)
const isLoading = ref(false)
const isSaving = ref(false)
const currentPageId = ref<string | null>(null)
const orderItems = [
{ name: '待付款', icon: '💳' },
{ name: '待发货', icon: '🚚' },
{ name: '待收货', icon: '📦' },
{ name: '待评价', icon: '📝' }
]
const serviceItems = [
{ name: '积分中心', icon: '🪙', color: '#E6FFFB' },
{ name: '联系客服', icon: '🎧', color: '#F0F5FF' },
{ name: '优惠券', icon: '🎫', color: '#FFF1F0' },
{ name: '我的收藏', icon: '⭐', color: '#FFF2E8' },
{ name: '地址信息', icon: '📍', color: '#F9F0FF' },
{ name: '我的余额', icon: '💰', color: '#FCFFE6' }
]
onMounted(() => {
loadConfig()
})
async function loadConfig() {
isLoading.value = true
try {
const config = await getActiveDiyConfig('user')
if (config != null) {
currentPageId.value = config.id
const style = config.config.getNumber('style')
if (style != null) {
selectedStyle.value = style.toInt()
}
}
} catch (e) {
console.error('Failed to load user decoration config', e)
} finally {
isLoading.value = false
}
}
const handleSave = async () => {
isSaving.value = true
try {
const config = { style: selectedStyle.value } as UTSJSONObject
const id = await saveDiyPage(currentPageId.value, '个人中心默认配置', 'user', config, true)
if (id != null) {
currentPageId.value = id
uni.showToast({ title: '保存成功', icon: 'success' })
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
isSaving.value = false
}
}
</script>
<style scoped lang="scss">
.admin-decoration-user { background-color: #f0f2f5; min-height: 100vh; display: flex; flex-direction: column; }
.border-shadow { background-color: #fff; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.page-header { height: 60px; padding: 0 24px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; z-index: 100; }
.page-title { font-size: 16px; font-weight: bold; color: #333; }
.btn-primary { background-color: #2d8cf0; padding: 8px 20px; border-radius: 4px; cursor: pointer; }
.btn-txt { color: #fff; font-size: 14px; }
.content-container { flex: 1; padding: 24px; }
.main-card { display: flex; flex-direction: row; min-height: 720px; background-color: #fff; border-radius: 4px; }
.loading-state { padding: 100px; text-align: center; color: #999; }
/* 左侧预览区 */
.preview-panel { width: 400px; padding: 40px; background-color: #f7f8fa; display: flex; justify-content: center; border-right: 1px solid #f0f0f0; }
.phone-mockup { width: 300px; height: 600px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; box-shadow: 0 10px 30px rgba(0,0,0,0.1); }
.phone-body { height: 100%; }
.user-header-gradient { background: linear-gradient(135deg, #eb3c2d 0%, #ff5e5e 100%); padding: 25px 0 12px; }
.header-top { display: flex; flex-direction: row; align-items: center; padding: 0 15px; }
.avatar-box { width: 44px; height: 44px; border-radius: 22px; border: 2px solid rgba(255,255,255,0.8); overflow: hidden; margin-right: 12px; }
.avatar-img { width: 100%; height: 100%; }
.user-info { flex: 1; display: flex; flex-direction: column; }
.user-name { font-size: 14px; font-weight: bold; color: #fff; }
.bind-phone { background-color: rgba(0,0,0,0.15); align-self: flex-start; padding: 2px 8px; border-radius: 10px; margin-top: 4px; }
.bind-txt { color: #fff; font-size: 9px; }
.header-icons { display: flex; flex-direction: row; gap: 12px; padding: 0 15px; color: #fff; font-size: 16px; }
.stats-row { display: flex; flex-direction: row; justify-content: space-around; padding: 15px; }
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-val { font-size: 15px; font-weight: bold; color: #fff; }
.stat-label { font-size: 10px; color: rgba(255,255,255,0.8); margin-top: 2px; }
.member-card-s1 { background: linear-gradient(90deg, #fdf1d6 0%, #fbd795 100%); margin: 0 10px; border-radius: 8px; padding: 12px; }
.mc-content-s1 { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.mc-txt { font-size: 11px; color: #7c581c; }
.mc-btn { font-size: 10px; color: #7c581c; font-weight: bold; }
.member-card-s2 { background-color: rgba(255,255,255,0.2); margin: 0 10px; border-radius: 8px; padding: 12px; border: 1px solid rgba(255,255,255,0.3); }
.mc-content-s2 { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.mc-t1 { font-size: 11px; color: #fff; }
.mc-btn-white { background-color: #fff; color: #f2270c; font-size: 10px; padding: 3px 10px; border-radius: 10px; }
.user-header-s3 { background-color: #fff; padding: 25px 15px 15px; }
.header-top-s3 { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.header-top-left { display: flex; flex-direction: row; align-items: center; }
.avatar-box-s3 { width: 48px; height: 44px; border-radius: 24px; overflow: hidden; margin-right: 12px; background: #f5f5f5; }
.user-name-s3 { font-size: 15px; font-weight: bold; color: #333; }
.header-icons-s3 { color: #333; font-size: 18px; }
.stats-row-s3 { display: flex; flex-direction: row; justify-content: space-around; padding-top: 15px; border-top: 1px solid #f5f5f5; margin-top: 15px; }
.stat-val-s3 { font-size: 16px; font-weight: bold; color: #333; }
.stat-label-s3 { font-size: 11px; color: #999; }
.section-card { background-color: #fff; margin: 10px; border-radius: 8px; padding: 15px; }
.section-header { display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 12px; }
.sh-title { font-size: 13px; font-weight: bold; }
.sh-more { font-size: 11px; color: #999; }
.order-grid { display: flex; flex-direction: row; justify-content: space-between; }
.grid-item { display: flex; flex-direction: column; align-items: center; }
.gi-ic { font-size: 20px; margin-bottom: 4px; }
.gi-txt { font-size: 10px; color: #666; }
.service-grid { display: flex; flex-direction: row; flex-wrap: wrap; }
.grid-item-s { width: 33.33%; display: flex; flex-direction: column; align-items: center; margin-bottom: 15px; }
.gi-ic-box-s { width: 34px; height: 34px; border-radius: 17px; display: flex; align-items: center; justify-content: center; margin-bottom: 6px; }
.gi-txt-s { font-size: 10px; color: #666; }
/* 右侧设置区 */
.settings-panel { flex: 1; padding: 30px; }
.group-title { display: flex; flex-direction: row; align-items: center; margin-bottom: 20px; }
.title-line { width: 3px; height: 16px; background-color: #2d8cf0; margin-right: 10px; }
.title-txt { font-size: 15px; font-weight: bold; color: #333; }
.radio-group { display: flex; flex-direction: column; gap: 15px; }
.radio-item { display: flex; flex-direction: row; align-items: center; cursor: pointer; }
.radio-dot { width: 16px; height: 16px; border: 1px solid #dcdfe6; border-radius: 8px; margin-right: 10px; }
.radio-dot.active { border-color: #2d8cf0; background-color: #2d8cf0; }
.radio-txt { font-size: 14px; color: #333; }
.hint-txt { font-size: 12px; color: #999; margin-top: 20px; line-height: 1.6; }
.anim-fade-in { animation: fadeIn 0.4s ease-out; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>

View File

@@ -1,179 +0,0 @@
# 页面装修管理模块 - README
> 基于CRMEB项目标准实现完整的页面装修和DIY功能
## 📋 文件结构
```
pages/mall/admin/design/
├── index.uvue # 装修管理主界面898行
├── design.uts # 业务逻辑和数据管理350+行)
├── editor.uvue # 装修编辑器(待实现)
├── preview.uvue # 装修预览页面(待实现)
└── README.md # 本文件
```
## 🎯 核心功能
### 1. 首页装修 (Homepage)
- 自定义首页布局和显示内容
- 支持轮播图、商品展示、文本等组件
- 实时预览效果
- 版本管理和发布
### 2. 分类页装修 (Category)
- 为不同商品分类创建装修页面
- 支持多套分类装修方案
- 按分类自动应用装修效果
- 快速切换和对比
### 3. 商品页装修 (Product)
- 自定义商品详情页布局
- 支持商品图、信息、评价等模块
- 提升商品转化率
- A/B测试支持
### 4. 自定义页面 (Custom)
- 创建和管理自定义营销页面
- 灵活的页面路径设置
- 独立的装修配置
- 活动和推广专用
### 5. 页面模板库 (Templates)
- 预设4套电商风格模板
- 一键应用模板快速建站
- 模板库不断扩充
- 自定义模板保存
### 6. 组件库 (Components)
- 8种预设装修组件
- 图片组件 (Image)
- 文本组件 (Text)
- 商品组件 (Product)
- 轮播组件 (Carousel)
- 分割线 (Divider)
- 间距组件 (Spacer)
- 按钮组件 (Button)
- 表单组件 (Form)
## 🔧 API 函数列表
| 函数 | 参数 | 返回值 | 说明 |
| -------------------------- | ---------- | -------------------------- | ---------------- |
| `getDesignList(params?)` | 查询参数 | Promise<DesignItem[]> | 获取装修列表 |
| `getHomePageDesign()` | 无 | Promise<DesignItem> | 获取首页装修 |
| `getProductPageDesign()` | 无 | Promise<DesignItem> | 获取商品页装修 |
| `getCategoryDesigns()` | 无 | Promise<DesignItem[]> | 获取分类装修列表 |
| `getCustomPages()` | 无 | Promise<DesignItem[]> | 获取自定义页面 |
| `getTemplateLibrary()` | 无 | Promise<DesignTemplate[]> | 获取模板库 |
| `getAvailableComponents()` | 无 | Promise<DesignComponent[]> | 获取可用组件 |
| `saveDesign(design)` | DesignItem | Promise<{id, message}> | 保存装修 |
| `publishDesign(id)` | 装修ID | Promise<{message}> | 发布装修 |
| `deleteDesign(id)` | 装修ID | Promise<{message}> | 删除装修 |
## 📊 数据结构
### DesignItem 装修页面
```typescript
interface DesignItem {
id: string | number; // 装修ID
name: string; // 装修名称
type: "homepage" | "category" | "product" | "custom";
status: 0 | 1; // 0=草稿, 1=已发布
content: DesignComponent[]; // 组件内容
categoryId?: string | number; // 分类ID
categoryName?: string; // 分类名称
path?: string; // 页面路径
version?: string; // 版本号
created_at?: string; // 创建时间
updated_at?: string; // 更新时间
}
```
### DesignComponent 组件配置
```typescript
interface DesignComponent {
id: string; // 组件ID
type:
| "image"
| "text"
| "product"
| "carousel"
| "divider"
| "spacer"
| "button"
| "form";
name: string; // 组件名称
icon: string; // 组件图标
description?: string; // 组件描述
config?: Record<string, any>; // 配置参数
children?: DesignComponent[]; // 子组件
}
```
## 💻 使用示例
```typescript
// 导入服务
import {
getDesignList,
saveDesign,
publishDesign,
getAvailableComponents,
} from "./design.uts";
// 获取列表
const designs = await getDesignList();
// 保存装修
await saveDesign({
id: 1,
name: "首页",
type: "homepage",
status: 0,
content: [],
});
// 发布装修
await publishDesign(1);
// 获取组件库
const components = await getAvailableComponents();
```
## 📱 菜单配置
```json
{
"id": "design",
"title": "设计",
"children": [
{
"id": "design-home",
"title": "页面装修",
"path": "/pages/mall/admin/design/index"
}
]
}
```
## 🚀 后续开发
- [ ] editor.uvue - 装修编辑器
- [ ] preview.uvue - 装修预览
- [ ] 拖拽排序功能
- [ ] 版本管理
- [ ] 模板库管理
---
**最后更新**: 2026-01-31
**版本**: 1.0.0

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('design-category')
const title = ref<string>('分类页装修')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('category')
const title = ref<string>('category')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -1,439 +0,0 @@
/**
* 设计模块页面路由配置与数据
* 将 design.uts 的函数输出转换为页面路由/配置格式
*/
import type { DesignItem, DesignComponent, DesignTemplate } from './design.uts'
/**
* 装修页面列表路由配置
*/
export const designListPageConfig = {
id: 'design-list',
path: '/pages/mall/admin/design/list',
name: '装修列表',
title: '装修管理',
data: [
{
id: 1,
name: '首页装修',
type: 'homepage' as const,
status: 1,
path: '/pages/mall/admin/design/index?tab=homepage',
content: [],
updated_at: '2026-01-30 14:30:00'
},
{
id: 2,
name: '年货节活动页',
type: 'custom' as const,
status: 1,
path: '/pages/mall/admin/design/index?tab=custom',
content: [],
updated_at: '2026-01-28 10:15:00'
}
] as DesignItem[]
}
/**
* 首页装修页面路由配置
*/
export const designHomepagePageConfig = {
id: 'design-homepage',
path: '/pages/mall/admin/design/index?tab=homepage',
name: '首页装修',
title: '首页装修 - 打造吸引人的商城首页',
data: {
id: 'homepage',
name: '首页装修',
type: 'homepage' as const,
status: 1,
content: [
{
id: 'carousel-1',
type: 'carousel' as const,
name: '轮播图',
icon: 'C',
description: '首页顶部轮播图展示',
config: {
autoplay: true,
duration: 5000,
height: 300
}
},
{
id: 'product-1',
type: 'product' as const,
name: '商品展示',
icon: 'P',
description: '热销商品列表',
config: {
count: 8,
columns: 2,
layout: 'grid'
}
}
],
version: '1.0.0',
updated_at: '2026-01-30 14:30:00'
} as DesignItem
}
/**
* 分类页装修页面路由配置
*/
export const designCategoryPageConfig = {
id: 'design-category',
path: '/pages/mall/admin/design/index?tab=category',
name: '分类页装修',
title: '分类页装修 - 为不同分类创建独特展示',
data: [
{
id: 1,
name: '默认分类装修',
type: 'category' as const,
status: 1,
categoryId: 0,
categoryName: '全部分类',
path: '/pages/mall/admin/design/index?tab=category&id=1',
content: [],
updated_at: '2026-01-30 14:30:00'
},
{
id: 2,
name: '热销商品分类',
type: 'category' as const,
status: 0,
categoryId: 1,
categoryName: '推荐分类',
path: '/pages/mall/admin/design/index?tab=category&id=2',
content: [],
updated_at: '2026-01-29 10:15:00'
}
] as DesignItem[]
}
/**
* 商品页装修页面路由配置
*/
export const designProductPageConfig = {
id: 'design-product',
path: '/pages/mall/admin/design/index?tab=product',
name: '商品页装修',
title: '商品页装修 - 自定义商品详情页展示',
data: {
id: 'product',
name: '商品页装修',
type: 'product' as const,
status: 1,
content: [
{
id: 'image-1',
type: 'image' as const,
name: '商品图',
icon: 'I',
description: '商品主图展示'
},
{
id: 'product-info',
type: 'text' as const,
name: '商品信息',
icon: 'T',
description: '商品名称和价格'
}
],
version: '1.0.0',
updated_at: '2026-01-30 14:30:00'
} as DesignItem
}
/**
* 自定义页面路由配置
*/
export const designCustomPageConfig = {
id: 'design-custom',
path: '/pages/mall/admin/design/index?tab=custom',
name: '自定义页面',
title: '自定义页面 - 创建特殊内容页面',
data: [
{
id: 1,
name: '新年促销页',
type: 'custom' as const,
status: 1,
path: '/pages/mall/admin/design/index?tab=custom&id=1',
content: [],
updated_at: '2026-01-28 09:00:00'
}
] as DesignItem[]
}
/**
* 模板库页面路由配置
*/
export const designTemplatePageConfig = {
id: 'design-templates',
path: '/pages/mall/admin/design/index?tab=templates',
name: '模板库',
title: '模板库 - 选择预设装修模板',
data: [
{
id: 1,
name: '电商风格A',
description: '简洁现代的电商布局',
type: 'homepage',
preview: '/static/images/template-a.png',
content: []
},
{
id: 2,
name: '电商风格B',
description: '豪华展示的电商布局',
type: 'homepage',
preview: '/static/images/template-b.png',
content: []
},
{
id: 3,
name: '精品风格',
description: '精品商品展示布局',
type: 'homepage',
preview: '/static/images/template-c.png',
content: []
},
{
id: 4,
name: '商城风格',
description: '完整商城功能布局',
type: 'homepage',
preview: '/static/images/template-d.png',
content: []
}
] as DesignTemplate[]
}
/**
* 组件库页面路由配置
*/
export const designComponentPageConfig = {
id: 'design-components',
path: '/pages/mall/admin/design/index?tab=components',
name: '组件库',
title: '组件库 - 丰富的页面组件',
data: [
{
id: 'image',
type: 'image' as const,
name: '图片组件',
icon: 'I',
description: '展示图片和图片轮播',
componentName: 'ImageComponent',
config: {
defaultWidth: '100%',
defaultHeight: 'auto'
}
},
{
id: 'text',
type: 'text' as const,
name: '文本组件',
icon: 'T',
description: '展示文本内容和段落',
componentName: 'TextComponent'
},
{
id: 'product',
type: 'product' as const,
name: '商品组件',
icon: 'P',
description: '展示商品列表和推荐',
componentName: 'ProductComponent',
config: {
defaultCount: 6,
defaultColumns: 2
}
},
{
id: 'carousel',
type: 'carousel' as const,
name: '轮播组件',
icon: 'C',
description: '图片和内容轮播',
componentName: 'CarouselComponent',
config: {
autoplay: true,
duration: 5000
}
},
{
id: 'divider',
type: 'divider' as const,
name: '分割线',
icon: 'D',
description: '分割不同内容区域',
componentName: 'DividerComponent'
},
{
id: 'spacer',
type: 'spacer' as const,
name: '间距组件',
icon: 'S',
description: '调整元素间距',
componentName: 'SpacerComponent',
config: {
defaultHeight: 16
}
},
{
id: 'button',
type: 'button' as const,
name: '按钮组件',
icon: 'B',
description: '创建点击按钮',
componentName: 'ButtonComponent'
},
{
id: 'form',
type: 'form' as const,
name: '表单组件',
icon: 'F',
description: '收集用户输入数据',
componentName: 'FormComponent'
}
] as DesignComponent[]
}
/**
* 编辑页面路由配置
*/
export const designEditorPageConfig = {
id: 'design-editor',
path: '/pages/mall/admin/design/editor',
name: '装修编辑器',
title: '装修编辑器 - 可视化编辑装修页面',
components: [
{
id: 'canvas',
name: '编辑画布',
description: '拖拽编辑区域'
},
{
id: 'sidebar',
name: '组件侧栏',
description: '可用组件列表'
},
{
id: 'properties',
name: '属性面板',
description: '组件属性编辑'
},
{
id: 'preview',
name: '预览窗口',
description: '实时效果预览'
}
]
}
/**
* 预览页面路由配置
*/
export const designPreviewPageConfig = {
id: 'design-preview',
path: '/pages/mall/design/preview/:id',
name: '装修预览',
title: '装修效果预览',
features: [
'全屏预览',
'响应式展示',
'交互测试',
'性能分析'
]
}
/**
* 所有设计页面路由配置
*/
export const allDesignPageConfigs = [
designListPageConfig,
designHomepagePageConfig,
designCategoryPageConfig,
designProductPageConfig,
designCustomPageConfig,
designTemplatePageConfig,
designComponentPageConfig,
designEditorPageConfig,
designPreviewPageConfig
]
/**
* 根据 tab 获取对应的页面配置
*/
export function getDesignPageConfig(tab: string) {
const configMap: Record<string, any> = {
'homepage': designHomepagePageConfig,
'category': designCategoryPageConfig,
'product': designProductPageConfig,
'custom': designCustomPageConfig,
'templates': designTemplatePageConfig,
'components': designComponentPageConfig,
'editor': designEditorPageConfig,
'preview': designPreviewPageConfig,
'list': designListPageConfig
}
return configMap[tab] || designListPageConfig
}
/**
* 装修页面导航菜单结构
*/
export const designMenuStructure = {
id: 'design',
title: '设计',
icon: '/static/design.svg',
path: '/pages/mall/admin/design/index',
children: [
{
id: 'page-decoration',
title: '页面装修',
children: [
{
id: 'design-homepage',
title: '首页装修',
path: '/pages/mall/admin/design/index?tab=homepage'
},
{
id: 'design-category',
title: '分类页装修',
path: '/pages/mall/admin/design/index?tab=category'
},
{
id: 'design-product',
title: '商品页装修',
path: '/pages/mall/admin/design/index?tab=product'
},
{
id: 'design-custom',
title: '自定义页面',
path: '/pages/mall/admin/design/index?tab=custom'
}
]
},
{
id: 'design-library',
title: '设计库',
children: [
{
id: 'design-templates',
title: '模板库',
path: '/pages/mall/admin/design/index?tab=templates'
},
{
id: 'design-components',
title: '组件库',
path: '/pages/mall/admin/design/index?tab=components'
}
]
}
]
}

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('custom')
const title = ref<string>('custom')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -1,38 +0,0 @@
<template>
<AdminLayout current-page="design-data">
<view class="admin-main">
<view class="header">
<text class="title">数据配置</text>
</view>
<view class="content">
<text>商城数据配置(建设中)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
</script>
<style scoped>
.admin-main {
padding: 20px;
}
.header {
margin-bottom: 20px;
}
.title {
font-size: 20px;
font-weight: bold;
}
.content {
background-color: #fff;
padding: 20px;
border-radius: 4px;
min-height: 400px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -1,549 +0,0 @@
/**
* 页面装修业务逻辑模块
* 参考CRMEB项目提供完整的装修管理功能
*/
/**
* 装修页面数据接口
*/
export interface DesignItem {
id: string | number
name: string
type: 'homepage' | 'category' | 'product' | 'custom'
status: 0 | 1 // 0: 草稿, 1: 已发布
categoryId?: string | number
categoryName?: string
path?: string
preview_url?: string
content: DesignComponent[]
version?: string
created_at?: string
updated_at?: string
}
/**
* 装修组件接口
*/
export interface DesignComponent {
id: string
type: 'image' | 'text' | 'product' | 'carousel' | 'divider' | 'spacer' | 'button' | 'form'
name: string
icon: string
description?: string
componentName?: string
config?: Record<string, any>
children?: DesignComponent[]
}
/**
* 装修模板接口
*/
export interface DesignTemplate {
id: string | number
name: string
description: string
type: string
preview: string
content: DesignComponent[]
created_at?: string
}
/**
* 获取装修页面列表
* @param params 查询参数
* @returns 装修页面列表
*/
export function getDesignList(params?: Record<string, any>): Promise<DesignItem[]> {
return new Promise((resolve, reject) => {
// TODO: 实际应调用后端API
const designList: DesignItem[] = [
{
id: 1,
name: '首页装修',
type: 'homepage',
status: 1,
content: [],
updated_at: '2026-01-30 14:30:00'
},
{
id: 2,
name: '年货节活动页',
type: 'custom',
status: 1,
content: [],
updated_at: '2026-01-28 10:15:00'
}
]
setTimeout(() => resolve(designList), 300)
})
}
/**
* 获取首页装修详情
* @returns 首页装修数据
*/
export function getHomePageDesign(): Promise<DesignItem> {
return new Promise((resolve, reject) => {
const homepage: DesignItem = {
id: 'homepage',
name: '首页装修',
type: 'homepage',
status: 1,
content: [
{
id: 'carousel-1',
type: 'carousel',
name: '轮播图',
icon: 'C',
description: '首页顶部轮播图展示',
config: {
autoplay: true,
duration: 5000,
height: 300
}
},
{
id: 'product-1',
type: 'product',
name: '商品展示',
icon: 'P',
description: '热销商品列表',
config: {
count: 8,
columns: 2,
layout: 'grid'
}
}
],
version: '1.0.0',
updated_at: '2026-01-30 14:30:00'
}
setTimeout(() => resolve(homepage), 300)
})
}
/**
* 获取商品页装修详情
* @returns 商品页装修数据
*/
export function getProductPageDesign(): Promise<DesignItem> {
return new Promise((resolve, reject) => {
const productPage: DesignItem = {
id: 'product',
name: '商品页装修',
type: 'product',
status: 1,
content: [
{
id: 'image-1',
type: 'image',
name: '商品图',
icon: 'I',
description: '商品主图展示'
},
{
id: 'product-info',
type: 'text',
name: '商品信息',
icon: 'T',
description: '商品名称和价格'
}
],
version: '1.0.0',
updated_at: '2026-01-30 14:30:00'
}
setTimeout(() => resolve(productPage), 300)
})
}
/**
* 获取分类装修列表
* @returns 分类装修列表
*/
export function getCategoryDesigns(): Promise<DesignItem[]> {
return new Promise((resolve, reject) => {
const categories: DesignItem[] = [
{
id: 1,
name: '默认分类装修',
type: 'category',
status: 1,
categoryId: 0,
categoryName: '全部分类',
content: [],
updated_at: '2026-01-30 14:30:00'
},
{
id: 2,
name: '热销商品分类',
type: 'category',
status: 0,
categoryId: 1,
categoryName: '推荐分类',
content: [],
updated_at: '2026-01-29 10:15:00'
}
]
setTimeout(() => resolve(categories), 300)
})
}
/**
* 获取自定义页面列表
* @returns 自定义页面列表
*/
export function getCustomPages(): Promise<DesignItem[]> {
return new Promise((resolve, reject) => {
const customPages: DesignItem[] = [
{
id: 1,
name: '新年促销页',
type: 'custom',
status: 1,
path: '/pages/promotion/newyear',
content: [],
updated_at: '2026-01-28 09:00:00'
}
]
setTimeout(() => resolve(customPages), 300)
})
}
/**
* 获取页面模板库
* @returns 模板列表
*/
export function getTemplateLibrary(): Promise<DesignTemplate[]> {
return new Promise((resolve, reject) => {
const templates: DesignTemplate[] = [
{
id: 1,
name: '电商风格A',
description: '简洁现代的电商布局',
type: 'homepage',
preview: '@/static/images/template-a.png',
content: []
},
{
id: 2,
name: '电商风格B',
description: '豪华展示的电商布局',
type: 'homepage',
preview: '@/static/images/template-b.png',
content: []
},
{
id: 3,
name: '精品风格',
description: '精品商品展示布局',
type: 'homepage',
preview: '@/static/images/template-c.png',
content: []
},
{
id: 4,
name: '商城风格',
description: '完整商城功能布局',
type: 'homepage',
preview: '@/static/images/template-d.png',
content: []
}
]
setTimeout(() => resolve(templates), 300)
})
}
/**
* 获取可用组件库
* @returns 组件列表
*/
export function getAvailableComponents(): Promise<DesignComponent[]> {
return new Promise((resolve, reject) => {
const components: DesignComponent[] = [
{
id: 'image',
type: 'image',
name: '图片组件',
icon: 'I',
description: '展示图片和图片轮播',
componentName: 'ImageComponent',
config: {
defaultWidth: '100%',
defaultHeight: 'auto'
}
},
{
id: 'text',
type: 'text',
name: '文本组件',
icon: 'T',
description: '展示文本内容和段落',
componentName: 'TextComponent'
},
{
id: 'product',
type: 'product',
name: '商品组件',
icon: 'P',
description: '展示商品列表和推荐',
componentName: 'ProductComponent',
config: {
defaultCount: 6,
defaultColumns: 2
}
},
{
id: 'carousel',
type: 'carousel',
name: '轮播组件',
icon: 'C',
description: '图片和内容轮播',
componentName: 'CarouselComponent',
config: {
autoplay: true,
duration: 5000
}
},
{
id: 'divider',
type: 'divider',
name: '分割线',
icon: 'D',
description: '分割不同内容区域',
componentName: 'DividerComponent'
},
{
id: 'spacer',
type: 'spacer',
name: '间距组件',
icon: 'S',
description: '调整元素间距',
componentName: 'SpacerComponent',
config: {
defaultHeight: 16
}
},
{
id: 'button',
type: 'button',
name: '按钮组件',
icon: 'B',
description: '创建点击按钮',
componentName: 'ButtonComponent'
},
{
id: 'form',
type: 'form',
name: '表单组件',
icon: 'F',
description: '收集用户输入数据',
componentName: 'FormComponent'
}
]
setTimeout(() => resolve(components), 300)
})
}
/**
* 保存装修页面
* @param design 装修数据
* @returns 保存结果
*/
export function saveDesign(design: DesignItem): Promise<{ id: string | number; message: string }> {
return new Promise((resolve, reject) => {
if (!design.name || design.name.trim() === '') {
reject(new Error('装修名称不能为空'))
return
}
if (!design.type) {
reject(new Error('装修类型不能为空'))
return
}
// TODO: 实际应调用后端API保存
const result = {
id: design.id || Math.random().toString(36).substr(2, 9),
message: '保存成功'
}
setTimeout(() => resolve(result), 500)
})
}
/**
* 发布装修页面
* @param designId 装修页面ID
* @returns 发布结果
*/
export function publishDesign(designId: string | number): Promise<{ message: string }> {
return new Promise((resolve, reject) => {
if (!designId) {
reject(new Error('装修ID不能为空'))
return
}
// TODO: 实际应调用后端API发布
setTimeout(() => {
resolve({ message: '发布成功' })
}, 500)
})
}
/**
* 删除装修页面
* @param designId 装修页面ID
* @returns 删除结果
*/
export function deleteDesign(designId: string | number): Promise<{ message: string }> {
return new Promise((resolve, reject) => {
if (!designId) {
reject(new Error('装修ID不能为空'))
return
}
// TODO: 实际应调用后端API删除
setTimeout(() => {
resolve({ message: '删除成功' })
}, 500)
})
}
/**
* 获取装修预览URL
* @param designId 装修ID
* @returns 预览URL
*/
export function getDesignPreviewUrl(designId: string | number): string {
return `/pages/mall/design/preview/${designId}`
}
/**
* 获取装修编辑URL
* @param designId 装修ID
* @returns 编辑URL
*/
export function getDesignEditorUrl(designId: string | number): string {
return `/pages/mall/admin/design/editor?id=${designId}`
}
/**
* 格式化日期时间
* @param dateStr 日期字符串
* @returns 格式化后的日期
*/
export function formatDateTime(dateStr?: string): string {
if (!dateStr) return '--'
try {
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
} catch {
return dateStr
}
}
/**
* 验证组件配置
* @param component 组件配置
* @returns 验证结果
*/
export function validateComponent(component: DesignComponent): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (!component.id) {
errors.push('组件ID不能为空')
}
if (!component.type) {
errors.push('组件类型不能为空')
}
if (!component.name) {
errors.push('组件名称不能为空')
}
return {
valid: errors.length === 0,
errors
}
}
/**
* 生成组件ID
* @param type 组件类型
* @returns 生成的组件ID
*/
export function generateComponentId(type: string): string {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substr(2, 5)
return `${type}-${timestamp}-${random}`
}
/**
* 获取装修约束条件
* @returns 约束条件对象
*/
export function getDesignConstraints(): DesignConstraints {
return {
maxComponents: 50,
allowedComponentTypes: ['image', 'text', 'product', 'carousel', 'divider', 'spacer', 'button', 'form'],
maxImageSize: 5242880, // 5MB
supportedImageFormats: ['jpg', 'jpeg', 'png', 'gif', 'webp']
}
}
/**
* 深度克隆装修数据
* @param design 装修数据
* @returns 克隆后的数据
*/
export function cloneDesign(design: DesignItem): DesignItem {
return JSON.parse(JSON.stringify(design))
}
/**
* 验证装修数据完整性
* @param design 装修数据
* @returns 验证结果
*/
export function validateDesign(design: DesignItem): { valid: boolean; message: string } {
if (!design.name || design.name.trim() === '') {
return { valid: false, message: '装修名称不能为空' }
}
if (!design.type) {
return { valid: false, message: '装修类型不能为空' }
}
if (!Array.isArray(design.content)) {
return { valid: false, message: '装修内容格式错误' }
}
if (design.content.length > getDesignConstraints().maxComponents) {
return { valid: false, message: `组件数量超过限制(最多${getDesignConstraints().maxComponents}个)` }
}
return { valid: true, message: '验证通过' }
}
/**
* 导出装修为JSON
* @param design 装修数据
* @returns JSON字符串
*/
export function exportDesignJSON(design: DesignItem): string {
return JSON.stringify(design, null, 2)
}
/**
* 从JSON导入装修
* @param jsonStr JSON字符串
* @returns 装修数据
*/
export function importDesignJSON(jsonStr: string): DesignItem {
try {
return JSON.parse(jsonStr) as DesignItem
} catch (error) {
throw new Error('JSON格式错误无法导入')
}
}

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('design-homepage')
const title = ref<string>('首页装修')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -1,65 +0,0 @@
<template>
<AdminLayout currentPage="design-home">
<view class="Page">
<view class="Header">
<text class="Title">页面装修</text>
<text class="SubTitle">design/index</text>
</view>
<view class="Card">
<text class="Label">页面参数query</text>
<text class="Mono">{{ params }}</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const params = ref('')
onLoad((options) => {
// options: Record<string, any>
params.value = JSON.stringify(options ?? {})
})
</script>
<style>
.Page {
padding: 24rpx;
}
.Header {
padding: 24rpx;
border-radius: 16rpx;
background: #ffffff;
}
.Title {
font-size: 36rpx;
font-weight: 700;
}
.SubTitle {
margin-top: 8rpx;
font-size: 24rpx;
opacity: 0.7;
}
.Card {
margin-top: 24rpx;
padding: 24rpx;
border-radius: 16rpx;
background: #ffffff;
}
.Label {
font-size: 26rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.Mono {
font-size: 24rpx;
font-family: monospace;
line-height: 36rpx;
word-break: break-all;
}
</style>

View File

@@ -1,25 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="page">
<view class="header">
<text class="title">{{ title }}</text>
<text class="sub-title">页面已修复 (UTF-8)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('product')
const title = ref<string>('product')
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.page { padding: $space-lg; }
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
</style>

View File

@@ -1,162 +0,0 @@
<template>
<AdminLayout :currentPage="currentPage">
<view class="design-container">
<view class="module-header">
<text class="module-title">模板库</text>
<text class="module-desc">从丰富的模板中快速创建页面</text>
</view>
<view class="templates-grid">
<view v-for="template in templateLibrary" :key="template.id" class="template-card">
<view class="template-header">
<text class="template-name">{{ template.name }}</text>
</view>
<view class="template-body">
<text class="template-desc">{{ template.description }}</text>
<button class="btn-use" @click="handleUseTemplate(template.id)">使用模板</button>
</view>
</view>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
const currentPage = ref<string>('design-templates')
const templateLibrary = ref<any[]>([
{
id: 1,
name: '首页模板A',
description: '经典电商首页布局'
},
{
id: 2,
name: '首页模板B',
description: '简约风格的商城页面'
},
{
id: 3,
name: '活动模板',
description: '活动促销页面布局'
},
{
id: 4,
name: '商品模板',
description: '商品展示页面布局'
}
])
const handleUseTemplate = (templateId: number) => {
console.log('使用模板', templateId)
uni.showToast({
title: '使用模板成功',
icon: 'none'
})
}
</script>
<style scoped lang="scss">
@import '@/uni.scss';
.design-container {
min-height: 100vh;
background: $background-secondary;
padding: $space-lg;
}
.module-header {
margin-bottom: $space-xl;
}
.module-title {
font-size: $font-size-lg;
font-weight: bold;
color: $text-primary;
display: block;
margin-bottom: $space-sm;
}
.module-desc {
font-size: $font-size-sm;
color: $text-secondary;
}
.templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: $space-lg;
margin-bottom: $space-lg;
}
.template-card {
background: white;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-2px);
}
}
.template-header {
padding: $space-lg;
background: $background-tertiary;
text-align: center;
}
.template-body {
padding: $space-lg;
}
.template-name {
font-size: $font-size-md;
font-weight: bold;
color: $text-primary;
display: block;
margin-bottom: $space-sm;
}
.template-desc {
color: $text-secondary;
font-size: $font-size-sm;
line-height: 1.5;
display: block;
margin-bottom: $space-lg;
}
.btn-use {
background: $primary-color;
color: white;
padding: $space-sm $space-lg;
border-radius: $radius-sm;
border: none;
margin-top: $space-lg;
font-size: $font-size-sm;
cursor: pointer;
width: 100%;
}
@media (max-width: 768px) {
.design-container {
padding: $space-md;
}
.templates-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (max-width: 480px) {
.templates-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,393 +0,0 @@
<template>
<view class="admin-main">
<!-- 头部操作区 -->
<view class="header-container">
<text class="page-title">主题风格</text>
<button class="save-btn" type="primary" size="mini" @click="handleSave">保存</button>
</view>
<!-- 选项卡/卡片容器 -->
<view class="card-container selection-area">
<view class="theme-list">
<view
v-for="(item, index) in themeOptions"
:key="index"
class="theme-item"
:class="{ active: selectedThemeId === item.id }"
@click="selectedThemeId = item.id"
>
<view class="color-preview" :style="{ backgroundColor: item.color }"></view>
<text class="theme-name">{{ item.name }}</text>
</view>
</view>
</view>
<!-- 预览区 -->
<view class="preview-section">
<!-- 预览 1: 个人中心 -->
<view class="preview-card">
<text class="p-title">个人中心</text>
<view class="mock-phone">
<view class="mock-status-bar"></view>
<view class="mock-content user-center">
<view class="header-bg" :style="{ backgroundColor: currentThemeColor }">
<view class="user-info-row">
<view class="mock-avatar"></view>
<view class="user-meta">
<view class="name-line">
<text class="name">我的名字我的名字</text>
<view class="vip-badge">SVIP</view>
</view>
<text class="user-id">ID: 3659884 ></text>
</view>
<view class="settings-icons">
<text class="ic">🔔</text>
<text class="ic">⚙️</text>
</view>
</view>
<view class="stats-row">
<view class="stat-item"><text class="val">0.00</text><text class="lab">余额</text></view>
<view class="stat-item"><text class="val">20</text><text class="lab">积分</text></view>
<view class="stat-item"><text class="val">25</text><text class="lab">优惠券</text></view>
</view>
</view>
<view class="vip-card-banner">
<view class="vip-left">
<text class="vip-l-t1">会员可享多项权益</text>
<text class="vip-l-t2">会员剩余434天</text>
</view>
<view class="btn-vip">立即续费</view>
</view>
<view class="order-section">
<view class="o-title"><text>订单中心</text><text class="more">查看全部 ></text></view>
<view class="o-icons">
<view class="o-item">📦<text>待付款</text></view>
<view class="o-item">🚚<text>待发货</text></view>
<view class="o-item">🎁<text>待收货</text></view>
<view class="o-item">⭐<text>待评价</text></view>
<view class="o-item">🔄<text>售后/退款</text></view>
</view>
</view>
<view class="invite-banner">
<text class="i-t1">邀请好友赚佣金</text>
<text class="i-t2">推广好友注册</text>
</view>
<view class="service-section">
<view class="s-title">我的服务</view>
<view class="s-grid">
<view class="s-item">👤<text>会员中心</text></view>
<view class="s-item">📢<text>我的推广</text></view>
<view class="s-item">📅<text>签到</text></view>
<view class="s-item">🎫<text>优惠券</text></view>
</view>
</view>
<view class="mock-tabbar">
<view class="t-item">🏠<text>首页</text></view>
<view class="t-item">🔍<text>分类</text></view>
<view class="t-item">🛒<text>购物车</text></view>
<view class="t-item active" :style="{ color: currentThemeColor }">👤<text>我的</text></view>
</view>
</view>
</view>
</view>
<!-- 预览 2: 商品详情 -->
<view class="preview-card">
<text class="p-title">商品详情</text>
<view class="mock-phone">
<view class="mock-status-bar"></view>
<view class="mock-content product-detail">
<view class="p-gallery">
<text class="p-page">1/5</text>
</view>
<view class="p-main-info">
<view class="p-price-row">
<text class="p-symbol" :style="{ color: currentThemeColor }">¥</text>
<text class="p-price" :style="{ color: currentThemeColor }">199.00</text>
<text class="p-old-price">¥ 100.00</text>
<view class="p-tag-svip">SVIP</view>
</view>
<text class="p-name">企鹅针织条纹四件套新款上市性价比高</text>
<view class="p-stats">
<text>原价: ¥ 234.00</text>
<text>累计销量: 2999999件</text>
<text>库存: 1452件</text>
</view>
</view>
<view class="p-options">
<view class="opt-row"><text class="opt-lab">优惠券:</text><view class="tags"><text class="t-red">满100减30</text><text class="t-red">满100减30</text></view><text class="more">></text></view>
<view class="opt-row"><text class="opt-lab">活动:</text><view class="tags"><text class="t-action" :style="{ backgroundColor: currentThemeColor }">参与拼团</text><text class="t-action" :style="{ backgroundColor: currentThemeColor }">参与砍价</text><text class="t-action" :style="{ backgroundColor: currentThemeColor }">参与秒杀</text></view><text class="more">></text></view>
</view>
<view class="p-footer">
<view class="f-icons">
<view class="fi"><text>💬</text><text>客服</text></view>
<view class="fi"><text>⭐</text><text>收藏</text></view>
<view class="fi"><text>🛒</text><text>购物车</text></view>
</view>
<view class="f-btns">
<view class="f-btn cart" :style="{ backgroundColor: currentThemeColor, opacity: 0.7 }">加入购物车</view>
<view class="f-btn buy" :style="{ backgroundColor: currentThemeColor }">立即购买</view>
</view>
</view>
</view>
</view>
</view>
<!-- 预览 3: 拼团列表 -->
<view class="preview-card">
<text class="p-title">拼团列表</text>
<view class="mock-phone">
<view class="mock-status-bar"></view>
<view class="mock-content group-list">
<view class="g-header" :style="{ backgroundColor: currentThemeColor }">
<text class="g-h-title">拼团列表</text>
<view class="g-participation">
<view class="g-avatars"></view>
<text>9999人参与</text>
</view>
</view>
<view class="g-item" v-for="i in 4" :key="i">
<view class="g-img"></view>
<view class="g-info">
<text class="g-name">2021年新款吊灯简约现代大气家用客厅灯北欧风格餐厅卧...</text>
<view class="g-bottom">
<view class="g-prices">
<text class="g-p-old">¥ 199.00</text>
<text class="g-p-now" :style="{ color: currentThemeColor }">¥ 124.00</text>
</view>
<view class="g-btn" :style="{ backgroundColor: currentThemeColor }">{{ i % 2 === 0 ? '去拼团' : '已售罄' }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
interface ThemeOption {
id: string
name: string
color: string
}
const themeOptions = ref<ThemeOption[]>([
{ id: 'blue', name: '天空蓝', color: '#1890ff' },
{ id: 'green', name: '生鲜绿', color: '#52c41a' },
{ id: 'red', name: '热情红', color: '#e93323' },
{ id: 'pink', name: '魅力粉', color: '#ff4d9f' },
{ id: 'orange', name: '活力橙', color: '#ff8c00' }
])
const selectedThemeId = ref('red')
const currentThemeColor = computed(() : string => {
const theme = themeOptions.value.find(t => t.id === selectedThemeId.value)
return theme ? theme.color : '#e93323'
})
const handleSave = () => {
uni.showToast({
title: '保存成功',
icon: 'success'
})
}
</script>
<style scoped>
.admin-main {
padding: 0;
}
.header-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: #fff;
padding: 10px 20px;
border-bottom: 1px solid #f0f0f0;
}
.page-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.selection-area {
background-color: #fff;
padding: 20px;
margin: 15px;
border-radius: 4px;
}
.theme-list {
display: flex;
flex-direction: row;
gap: 20px;
}
.theme-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 16px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
}
.theme-item.active {
border-color: #1890ff;
background-color: #e6f7ff;
}
.color-preview {
width: 16px;
height: 16px;
border-radius: 2px;
margin-right: 8px;
}
.theme-name {
font-size: 14px;
color: #606266;
}
.preview-section {
display: flex;
flex-direction: row;
gap: 20px;
padding: 0 15px 20px;
overflow-x: auto;
}
.preview-card {
background-color: #fff;
padding: 16px;
border-radius: 4px;
width: 292px;
flex-shrink: 0;
}
.p-title {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
display: block;
text-align: center;
}
.mock-phone {
width: 260px;
height: 540px;
border: 1px solid #eee;
border-radius: 2px;
margin: 0 auto;
overflow: hidden;
background-color: #f8f8f8;
position: relative;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.mock-status-bar {
height: 20px;
}
.mock-content {
height: calc(100% - 20px);
overflow-y: auto;
}
/* User Center Mock */
.user-center .header-bg {
padding: 20px 15px 40px;
color: #fff;
}
.user-info-row {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
}
.mock-avatar { width: 50px; height: 50px; background-color: #eee; border-radius: 50%; margin-right: 12px; }
.user-meta { flex: 1; }
.name-line { display: flex; flex-direction: row; align-items: center; margin-bottom: 4px; }
.name { font-size: 14px; font-weight: bold; }
.vip-badge { font-size: 10px; background-color: #333; color: #fadb14; padding: 0 4px; border-radius: 2px; margin-left: 6px; }
.user-id { font-size: 10px; opacity: 0.9; }
.settings-icons { display: flex; flex-direction: row; gap: 10px; }
.stats-row { display: flex; flex-direction: row; justify-content: space-around; }
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-item .val { font-weight: bold; font-size: 16px; margin-bottom: 4px; }
.stat-item .lab { font-size: 12px; opacity: 0.9; }
.vip-card-banner {
margin: -30px 15px 15px;
background: linear-gradient(90deg, #fceabb 0%, #f8b500 100%);
border-radius: 8px;
padding: 12px 15px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.vip-left { display: flex; flex-direction: column; }
.vip-l-t1 { font-size: 12px; color: #845506; font-weight: bold; }
.vip-l-t2 { font-size: 10px; color: #845506; opacity: 0.8; }
.btn-vip { font-size: 10px; background: #333; color: #fff; padding: 4px 10px; border-radius: 12px; }
.order-section { background: #fff; margin: 0 15px 15px; border-radius: 8px; padding: 15px; }
.o-title { display: flex; flex-direction: row; justify-content: space-between; font-size: 12px; margin-bottom: 15px; }
.o-icons { display: flex; flex-direction: row; justify-content: space-between; }
.o-item { display: flex; flex-direction: column; align-items: center; font-size: 10px; gap: 6px; }
.invite-banner { margin: 0 15px 15px; height: 60px; background: #fff1f0; border-radius: 8px; padding: 15px; display: flex; flex-direction: column; border: 1px dashed #ffa39e; }
.i-t1 { font-size: 12px; color: #cf1322; font-weight: bold; margin-bottom: 4px; }
.i-t2 { font-size: 10px; color: #cf1322; opacity: 0.7; }
.service-section { background: #fff; margin: 0 15px; border-radius: 8px; padding: 15px; }
.s-title { font-size: 12px; font-weight: bold; margin-bottom: 15px; }
.s-grid { display: flex; flex-direction: row; flex-wrap: wrap; }
.s-item { width: 25%; display: flex; flex-direction: column; align-items: center; font-size: 10px; gap: 8px; margin-bottom: 10px; }
.mock-tabbar { position: absolute; bottom: 0; width: 100%; height: 50px; background: #fff; border-top: 1px solid #eee; display: flex; flex-direction: row; }
.t-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 10px; color: #999; }
.t-item.active { color: #e93323; }
/* Product Mock */
.product-detail .p-gallery { height: 260px; background-color: #eee; position: relative; }
.p-page { position: absolute; bottom: 10px; right: 10px; background: rgba(0,0,0,0.5); color: #fff; border-radius: 10px; padding: 2px 8px; font-size: 10px; }
.p-main-info { background: #fff; padding: 15px; margin-bottom: 10px; }
.p-price-row { display: flex; flex-direction: row; align-items: baseline; margin-bottom: 8px; }
.p-symbol { font-size: 14px; font-weight: bold; }
.p-price { font-size: 24px; font-weight: bold; margin: 0 8px; }
.p-old-price { font-size: 12px; color: #999; text-decoration: line-through; margin-right: 8px; }
.p-options { background: #fff; padding: 15px; margin-bottom: 10px; }
.opt-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 12px; }
.opt-lab { width: 50px; font-size: 12px; color: #999; }
.p-footer { position: absolute; bottom: 0; width: 100%; height: 60px; background: #fff; border-top: 1px solid #eee; display: flex; flex-direction: row; padding: 8px 15px; align-items: center; }
.f-icons { display: flex; flex-direction: row; gap: 15px; margin-right: 10px; }
.f-btns { flex: 1; display: flex; flex-direction: row; height: 36px; border-radius: 18px; overflow: hidden; }
.f-btn { flex: 1; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 12px; }
/* Group List Mock */
.group-list .g-header { padding: 15px; }
.g-h-title { color: #fff; font-size: 16px; font-weight: bold; display: block; margin-bottom: 10px; }
.g-item { background: #fff; border-radius: 8px; margin: 12px; padding: 12px; display: flex; flex-direction: row; }
.g-img { width: 90px; height: 90px; background: #eee; border-radius: 4px; margin-right: 12px; }
</style>

View File

@@ -1,38 +0,0 @@
<template>
<AdminLayout current-page="design-user">
<view class="admin-main">
<view class="header">
<text class="title">个人中心装修</text>
</view>
<view class="content">
<text>个人中心页面装修(建设中)</text>
</view>
</view>
</AdminLayout>
</template>
<script setup lang="uts">
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
</script>
<style scoped>
.admin-main {
padding: 20px;
}
.header {
margin-bottom: 20px;
}
.title {
font-size: 20px;
font-weight: bold;
}
.content {
background-color: #fff;
padding: 20px;
border-radius: 4px;
min-height: 400px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<view class="admin-page">
<view class="filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">搜索:</text>
<input class="filter-input" placeholder="请输入姓名、UID" />
</view>
<view class="filter-btns">
<button class="btn primary" @click="onSearch">查询</button>
</view>
</view>
</view>
<view class="content-card">
<view class="tabs-row">
<view v-for="(tab, index) in tabs" :key="index" class="tab-item" :class="{ active: activeTab === index }" @click="activeTab = index">
<text>{{ tab }}</text>
</view>
</view>
<view class="table-container">
<view class="table-header">
<view class="col col-uid"><text>用户UID</text></view>
<view class="col col-name"><text>代理商名称</text></view>
<view class="col col-phone"><text>代理商电话</text></view>
<view class="col col-dept"><text>事业部名称</text></view>
<view class="col col-img"><text>申请图片</text></view>
<view class="col col-time"><text>申请时间</text></view>
<view class="col col-status"><text>申请状态</text></view>
<view class="col col-code"><text>邀请码</text></view>
<view class="col col-ops"><text>操作</text></view>
</view>
<view class="table-body">
<view v-for="item in pagedList" :key="item.uid" class="table-row">
<view class="col col-uid"><text>{{ item.uid }}</text></view>
<view class="col col-name"><text>{{ item.name }}</text></view>
<view class="col col-phone"><text>{{ item.phone }}</text></view>
<view class="col col-dept"><text>{{ item.deptName }}</text></view>
<view class="col col-img">
<image class="table-img" src="/static/logo.png" mode="aspectFill" />
</view>
<view class="col col-time"><text>{{ item.time }}</text></view>
<view class="col col-status">
<view class="status-tag"><text>{{ item.statusText }}</text></view>
</view>
<view class="col col-code"><view class="code-box"><text>{{ item.code }}</text></view></view>
<view class="col col-ops">
<text class="op-link">同意</text>
<text class="op-divider">|</text>
<text class="op-link">拒绝</text>
<text class="op-divider">|</text>
<text class="op-link">删除</text>
</view>
</view>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const activeTab = ref(0)
const tabs = ['全部', '申请中', '已同意', '已拒绝']
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 applyList 为 fetchApplyList() 调用
const applyList = ref([
{ uid: '81806', name: '测试测试', phone: '19910205954', deptName: '26991', time: '2026-01-08 15:30:39', statusText: '申请中', code: '70623142' },
{ uid: '81807', name: '张三', phone: '13812345678', deptName: '北京事业部', time: '2026-01-10 09:20:15', statusText: '已同意', code: '80731253' },
{ uid: '81808', name: '李四', phone: '13987654321', deptName: '上海事业部', time: '2026-01-12 14:05:33', statusText: '已拒绝', code: '90842364' },
{ uid: '81809', name: '王五', phone: '15012341234', deptName: '广州事业部', time: '2026-01-15 11:45:20', statusText: '申请中', code: '10953475' },
{ uid: '81810', name: '赵六', phone: '18600001111', deptName: '深圳事业部', time: '2026-01-18 08:30:00', statusText: '已同意', code: '21064586' },
{ uid: '81811', name: '孙七', phone: '17712349876', deptName: '成都事业部', time: '2026-01-20 16:15:44', statusText: '已同意', code: '32175697' },
{ uid: '81812', name: '周八', phone: '13300002222', deptName: '杭州事业部', time: '2026-01-22 10:55:30', statusText: '申请中', code: '43286708' },
{ uid: '81813', name: '吴九', phone: '15566667777', deptName: '武汉事业部', time: '2026-01-25 13:40:18', statusText: '已拒绝', code: '54397819' },
{ uid: '81814', name: '郑十', phone: '18899998888', deptName: '南京事业部', time: '2026-01-28 09:00:05', statusText: '申请中', code: '65408920' },
{ uid: '81815', name: '冯云', phone: '13712348765', deptName: '西安事业部', time: '2026-02-01 15:20:33', statusText: '已同意', code: '76519031' },
{ uid: '81816', name: '陈霞', phone: '15087651234', deptName: '重庆事业部', time: '2026-02-05 11:10:22', statusText: '申请中', code: '87620142' },
{ uid: '81817', name: '蒋峰', phone: '13500004444', deptName: '郑州事业部', time: '2026-02-08 14:35:49', statusText: '已同意', code: '98731253' },
{ uid: '81818', name: '卫林', phone: '17656785678', deptName: '长沙事业部', time: '2026-02-10 08:45:11', statusText: '已拒绝', code: '09842364' },
{ uid: '81819', name: '蒋彪', phone: '18211112222', deptName: '合肥事业部', time: '2026-02-12 17:00:00', statusText: '申请中', code: '10953476' },
{ uid: '81820', name: '沈辉', phone: '13655554444', deptName: '天津事业部', time: '2026-02-15 12:30:40', statusText: '已同意', code: '21064587' },
{ uid: '81821', name: '韩磊', phone: '15999990000', deptName: '苏州事业部', time: '2026-02-18 09:55:28', statusText: '已同意', code: '32175698' },
{ uid: '81822', name: '杨红', phone: '17811122233', deptName: '宁波事业部', time: '2026-02-20 16:40:15', statusText: '申请中', code: '43286709' },
{ uid: '81823', name: '秦波', phone: '13234561234', deptName: '厦门事业部', time: '2026-02-22 11:20:06', statusText: '已拒绝', code: '54397820' },
{ uid: '81824', name: '许丹', phone: '18777776666', deptName: '青岛事业部', time: '2026-02-25 14:05:50', statusText: '已同意', code: '65408921' },
{ uid: '81825', name: '何强', phone: '15344443333', deptName: '福州事业部', time: '2026-02-28 08:15:38', statusText: '申请中', code: '76519032' },
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => applyList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return applyList.value.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
</script>
<style scoped lang="scss">
.admin-page { padding: 0; }
.filter-card { background: #fff; padding: 24px; margin-bottom: 16px; border-radius: 4px; }
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
.label { font-size: 14px; color: #333; }
.filter-input { border: 1px solid #d9d9d9; height: 32px; width: 220px; padding: 0 12px; font-size: 14px; }
.btn { height: 32px; padding: 0 16px; font-size: 14px; border: 1px solid #d9d9d9; background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn.primary { background: #1890ff; border-color: #1890ff; color: #fff; }
.content-card { background: #fff; border-radius: 4px; }
.tabs-row { display: flex; flex-direction: row; padding: 0 24px; border-bottom: 1px solid #f0f0f0; }
.tab-item { padding: 16px 20px; cursor: pointer; position: relative; text { font-size: 15px; color: #666; } &.active { text { color: #1890ff; font-weight: 500; } &::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: #1890ff; } } }
.table-container { padding: 24px; }
.table-header { display: flex; flex-direction: row; background: #f8faff; border-bottom: 1px solid #f0f0f0; padding: 12px 0; }
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; padding: 12px 0; align-items: center; &:hover { background: #fafafa; } }
.col { padding: 0 8px; font-size: 14px; color: #333; display: flex; align-items: center; }
.col-uid { width: 80px; } .col-name { width: 120px; } .col-phone { width: 120px; } .col-dept { width: 120px; } .col-img { width: 80px; justify-content: center; } .col-time { width: 160px; justify-content: center; } .col-status { width: 100px; justify-content: center; } .col-code { width: 100px; justify-content: center; }
.col-ops { flex: 1; justify-content: flex-end; display: flex; flex-direction: row; }
.table-img { width: 32px; height: 32px; border-radius: 2px; }
.status-tag { border: 1px solid #1890ff; color: #1890ff; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.code-box { border: 1px solid #d9d9d9; padding: 2px 8px; border-radius: 4px; font-size: 12px; background: #fafafa; }
.op-link { color: #1890ff; cursor: pointer; }
.op-divider { color: #e8e8e8; margin: 0 8px; }
</style>

View File

@@ -0,0 +1,157 @@
<template>
<view class="admin-page">
<view class="filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">代理商查询:</text>
<input class="filter-input" placeholder="请输入姓名、手机号或UID" />
</view>
<view class="filter-btns">
<button class="btn primary" @click="onSearch">查询</button>
</view>
</view>
</view>
<view class="content-card">
<view class="action-bar">
<button class="btn primary small" @click="onAdd">添加代理商</button>
</view>
<view class="table-container">
<view class="table-header">
<view class="col col-uid"><text>用户UID</text></view>
<view class="col col-avatar"><text>头像</text></view>
<view class="col col-name"><text>名称</text></view>
<view class="col col-ratio"><text>分佣比例</text></view>
<view class="col col-count"><text>员工数量</text></view>
<view class="col col-time"><text>过期时间</text></view>
<view class="col col-status"><text>状态</text></view>
<view class="col col-ops"><text>操作</text></view>
</view>
<view class="table-body">
<view v-for="item in pagedList" :key="item.uid" class="table-row">
<view class="col col-uid"><text>{{ item.uid }}</text></view>
<view class="col col-avatar">
<image class="avatar-img" src="/static/logo.png" mode="aspectFill" />
</view>
<view class="col col-name"><text>{{ item.name }}</text></view>
<view class="col col-ratio"><text>{{ item.ratio }}%</text></view>
<view class="col col-count"><text>{{ item.staffCount }}</text></view>
<view class="col col-time"><text>{{ item.endTime }}</text></view>
<view class="col col-status">
<switch :checked="item.status" color="#1890ff" scale="0.8" />
</view>
<view class="col col-ops">
<text class="op-link">编辑</text>
<text class="op-divider">|</text>
<text class="op-link">查看</text>
<text class="op-divider">|</text>
<text class="op-link">员工</text>
<text class="op-divider">|</text>
<text class="op-link">删除</text>
</view>
</view>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 agentList 为 fetchAgentList() 调用
const agentList = ref([
{ uid: '60569', name: 'cs2020', ratio: 50, staffCount: 1, endTime: '2026-01-01', status: true },
{ uid: '60570', name: '张伟', ratio: 45, staffCount: 3, endTime: '2026-06-30', status: true },
{ uid: '60571', name: '李华', ratio: 40, staffCount: 5, endTime: '2026-12-31', status: true },
{ uid: '60572', name: '王芳', ratio: 38, staffCount: 2, endTime: '2027-03-01', status: false },
{ uid: '60573', name: '赵磊', ratio: 42, staffCount: 7, endTime: '2026-09-30', status: true },
{ uid: '60574', name: '陈浩', ratio: 35, staffCount: 0, endTime: '2026-04-15', status: true },
{ uid: '60575', name: '刘娜', ratio: 48, staffCount: 4, endTime: '2026-08-01', status: true },
{ uid: '60576', name: '黄明', ratio: 33, staffCount: 6, endTime: '2027-01-31', status: false },
{ uid: '60577', name: '周静', ratio: 52, staffCount: 1, endTime: '2026-11-30', status: true },
{ uid: '60578', name: '吴强', ratio: 30, staffCount: 9, endTime: '2026-07-15', status: true },
{ uid: '60579', name: '郑丽', ratio: 55, staffCount: 2, endTime: '2026-03-31', status: true },
{ uid: '60580', name: '孙勇', ratio: 28, staffCount: 11, endTime: '2027-06-30', status: true },
{ uid: '60581', name: '朱婷', ratio: 46, staffCount: 3, endTime: '2026-10-31', status: false },
{ uid: '60582', name: '马林', ratio: 37, staffCount: 0, endTime: '2026-05-01', status: true },
{ uid: '60583', name: '胡倩', ratio: 41, staffCount: 8, endTime: '2026-02-28', status: true },
{ uid: '60584', name: '高峰', ratio: 36, staffCount: 4, endTime: '2027-09-30', status: true },
{ uid: '60585', name: '梁雪', ratio: 49, staffCount: 2, endTime: '2026-06-15', status: false },
{ uid: '60586', name: '邓超', ratio: 32, staffCount: 6, endTime: '2027-02-01', status: true },
{ uid: '60587', name: '彭宇', ratio: 44, staffCount: 5, endTime: '2026-08-30', status: true },
{ uid: '60588', name: '曹芸', ratio: 39, staffCount: 1, endTime: '2026-12-01', status: true },
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => agentList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return agentList.value.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
function onAdd() { uni.showToast({ title: '添加中...', icon: 'none' }) }
</script>
<style scoped lang="scss">
.admin-page { padding: 0; }
.filter-card { background: #fff; padding: 24px; margin-bottom: 16px; border-radius: 4px; }
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
.label { font-size: 14px; color: #333; }
.filter-input { border: 1px solid #d9d9d9; height: 32px; width: 220px; padding: 0 12px; font-size: 14px; }
.btn { height: 32px; padding: 0 16px; font-size: 14px; border: 1px solid #d9d9d9; background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn.primary { background: #1890ff; border-color: #1890ff; color: #fff; }
.content-card { background: #fff; border-radius: 4px; }
.action-bar { padding: 16px 24px; }
.table-container { padding: 0 24px 24px; }
.table-header { display: flex; flex-direction: row; background: #f8faff; border-bottom: 1px solid #f0f0f0; padding: 12px 0; }
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; padding: 12px 0; align-items: center; &:hover { background: #fafafa; } }
.col { padding: 0 8px; font-size: 14px; color: #333; display: flex; align-items: center; }
.col-uid { width: 80px; } .col-avatar { width: 80px; justify-content: center; } .col-name { width: 120px; } .col-ratio { width: 100px; justify-content: center; } .col-count { width: 100px; justify-content: center; } .col-time { width: 120px; justify-content: center; } .col-status { width: 80px; justify-content: center; }
.col-ops { flex: 1; justify-content: flex-end; display: flex; flex-direction: row; }
.avatar-img { width: 32px; height: 32px; border-radius: 2px; }
.op-link { color: #1890ff; cursor: pointer; }
.op-divider { color: #e8e8e8; margin: 0 8px; }
</style>

View File

@@ -0,0 +1,157 @@
<template>
<view class="admin-page">
<view class="filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">搜索:</text>
<input class="filter-input" placeholder="请输入姓名、UID" />
</view>
<view class="filter-btns">
<button class="btn primary" @click="onSearch">查询</button>
</view>
</view>
</view>
<view class="content-card">
<view class="action-bar">
<button class="btn primary small" @click="onAdd">添加事业部</button>
</view>
<view class="table-container">
<view class="table-header">
<view class="col col-uid"><text>用户UID</text></view>
<view class="col col-avatar"><text>头像</text></view>
<view class="col col-name"><text>名称</text></view>
<view class="col col-code"><text>邀请码</text></view>
<view class="col col-ratio"><text>分销比例</text></view>
<view class="col col-count"><text>代理商数量</text></view>
<view class="col col-time"><text>截止时间</text></view>
<view class="col col-status"><text>状态</text></view>
<view class="col col-ops"><text>操作</text></view>
</view>
<view class="table-body">
<view v-for="item in pagedList" :key="item.uid" class="table-row">
<view class="col col-uid"><text>{{ item.uid }}</text></view>
<view class="col col-avatar">
<image class="avatar-img" src="/static/logo.png" mode="aspectFill" />
</view>
<view class="col col-name"><text>{{ item.name }}</text></view>
<view class="col col-code"><text>{{ item.code }}</text></view>
<view class="col col-ratio"><text>{{ item.ratio }}%</text></view>
<view class="col col-count"><text>{{ item.agentCount }}</text></view>
<view class="col col-time"><text>{{ item.endTime }}</text></view>
<view class="col col-status">
<switch :checked="item.status" color="#1890ff" scale="0.8" />
</view>
<view class="col col-ops">
<text class="op-link">查看代理商</text>
<text class="op-divider">|</text>
<text class="op-link">编辑</text>
<text class="op-divider">|</text>
<text class="op-link">删除</text>
</view>
</view>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 divisionList 为 fetchDivisionList() 调用
const divisionList = ref([
{ uid: '26991', name: '北京事业部', code: '70623142', ratio: 40, agentCount: 5, endTime: '2026-12-31', status: true },
{ uid: '26992', name: '上海事业部', code: '80731253', ratio: 35, agentCount: 8, endTime: '2026-06-30', status: true },
{ uid: '26993', name: '广州事业部', code: '90842364', ratio: 38, agentCount: 3, endTime: '2027-03-01', status: true },
{ uid: '26994', name: '深圳事业部', code: '10953475', ratio: 42, agentCount: 12, endTime: '2026-09-30', status: false },
{ uid: '26995', name: '成都事业部', code: '21064586', ratio: 30, agentCount: 2, endTime: '2026-08-15', status: true },
{ uid: '26996', name: '杭州事业部', code: '32175697', ratio: 45, agentCount: 7, endTime: '2026-12-01', status: true },
{ uid: '26997', name: '武汉事业部', code: '43286708', ratio: 33, agentCount: 4, endTime: '2027-01-01', status: true },
{ uid: '26998', name: '南京事业部', code: '54397819', ratio: 36, agentCount: 6, endTime: '2026-11-30', status: false },
{ uid: '26999', name: '西安事业部', code: '65408920', ratio: 40, agentCount: 1, endTime: '2026-07-31', status: true },
{ uid: '27000', name: '重庆事业部', code: '76519031', ratio: 28, agentCount: 9, endTime: '2026-10-01', status: true },
{ uid: '27001', name: '郑州事业部', code: '87620142', ratio: 32, agentCount: 3, endTime: '2026-05-31', status: true },
{ uid: '27002', name: '长沙事业部', code: '98731253', ratio: 37, agentCount: 5, endTime: '2027-02-28', status: false },
{ uid: '27003', name: '合肥事业部', code: '09842364', ratio: 41, agentCount: 2, endTime: '2026-04-30', status: true },
{ uid: '27004', name: '天津事业部', code: '10953476', ratio: 39, agentCount: 10, endTime: '2026-12-31', status: true },
{ uid: '27005', name: '苏州事业部', code: '21064587', ratio: 34, agentCount: 4, endTime: '2026-03-31', status: true },
{ uid: '27006', name: '宁波事业部', code: '32175698', ratio: 43, agentCount: 6, endTime: '2027-06-30', status: true },
{ uid: '27007', name: '厦门事业部', code: '43286709', ratio: 31, agentCount: 1, endTime: '2026-08-31', status: false },
{ uid: '27008', name: '青岛事业部', code: '54397820', ratio: 44, agentCount: 7, endTime: '2026-02-28', status: true },
{ uid: '27009', name: '福州事业部', code: '65408921', ratio: 29, agentCount: 3, endTime: '2027-04-30', status: true },
{ uid: '27010', name: '昆明事业部', code: '76519032', ratio: 46, agentCount: 8, endTime: '2026-01-31', status: true },
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => divisionList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return divisionList.value.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
function onAdd() { uni.showToast({ title: '添加中...', icon: 'none' }) }
</script>
<style scoped lang="scss">
.admin-page { padding: 0; }
.filter-card { background: #fff; padding: 24px; margin-bottom: 16px; border-radius: 4px; }
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
.label { font-size: 14px; color: #333; }
.filter-input { border: 1px solid #d9d9d9; height: 32px; width: 220px; padding: 0 12px; font-size: 14px; }
.btn { height: 32px; padding: 0 16px; font-size: 14px; border: 1px solid #d9d9d9; background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn.primary { background: #1890ff; border-color: #1890ff; color: #fff; }
.content-card { background: #fff; border-radius: 4px; }
.action-bar { padding: 16px 24px; }
.table-container { padding: 0 24px 24px; }
.table-header { display: flex; flex-direction: row; background: #f8faff; border-bottom: 1px solid #f0f0f0; padding: 12px 0; }
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; padding: 12px 0; align-items: center; &:hover { background: #fafafa; } }
.col { padding: 0 8px; font-size: 14px; color: #333; display: flex; align-items: center; }
.col-uid { width: 80px; } .col-avatar { width: 80px; justify-content: center; } .col-name { width: 120px; } .col-code { width: 100px; justify-content: center; } .col-ratio { width: 100px; justify-content: center; } .col-count { width: 100px; justify-content: center; } .col-time { width: 120px; justify-content: center; } .col-status { width: 80px; justify-content: center; }
.col-ops { flex: 1; justify-content: flex-end; display: flex; flex-direction: row; }
.avatar-img { width: 32px; height: 32px; border-radius: 2px; }
.op-link { color: #1890ff; cursor: pointer; }
.op-divider { color: #e8e8e8; margin: 0 8px; }
</style>

View File

@@ -0,0 +1,184 @@
<template>
<view class="admin-page">
<view class="filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">时间选择:</text>
<view class="date-picker-mock">
<text class="placeholder">开始日期 - 结束日期</text>
<text class="icon-calendar">📅</text>
</view>
</view>
<view class="filter-item">
<text class="label">搜索:</text>
<input class="filter-input" placeholder="请输入姓名、电话、UID" />
</view>
<view class="filter-btns">
<button class="btn primary" @click="onSearch">查询</button>
</view>
</view>
</view>
<view class="content-card">
<view class="action-bar">
<button class="btn ghost small" @click="onExport">导出</button>
</view>
<view class="table-container">
<view class="table-header">
<view class="col col-id"><text>ID</text></view>
<view class="col col-img"><text>头像</text></view>
<view class="col col-info"><text>用户信息</text></view>
<view class="col col-level"><text>分销等级</text></view>
<view class="col col-stat"><text>推广用户数量</text></view>
<view class="col col-stat"><text>推广订单数量</text></view>
<view class="col col-stat"><text>推广订单金额</text></view>
<view class="col col-stat"><text>佣金总金额</text></view>
<view class="col col-stat"><text>已提现金额</text></view>
<view class="col col-stat"><text>提现次数</text></view>
<view class="col col-stat"><text>未提现金额</text></view>
<view class="col col-ops"><text>操作</text></view>
</view>
<view class="table-body">
<view v-for="item in pagedList" :key="item.id" class="table-row">
<view class="col col-id"><text>{{ item.id }}</text></view>
<view class="col col-img">
<image class="table-img" src="/static/logo.png" mode="aspectFill" />
</view>
<view class="col col-info">
<view class="user-info-box">
<text class="info-text">昵称:{{ item.nickname }}</text>
<text class="info-text">姓名:{{ item.name }}</text>
<text class="info-text">电话:{{ item.phone }}</text>
</view>
</view>
<view class="col col-level"><text>{{ item.level }}</text></view>
<view class="col col-stat"><text>{{ item.userCount }}</text></view>
<view class="col col-stat"><text>{{ item.orderCount }}</text></view>
<view class="col col-stat"><text>{{ item.orderAmount }}</text></view>
<view class="col col-stat"><text>{{ item.commissionTotal }}</text></view>
<view class="col col-stat"><text>{{ item.withdrawnAmount }}</text></view>
<view class="col col-stat"><text>{{ item.withdrawCount }}</text></view>
<view class="col col-stat"><text>{{ item.unwithdrawnAmount }}</text></view>
<view class="col col-ops">
<text class="op-link" @click="onPromoter(item)">推广人</text>
<text class="op-divider">|</text>
<text class="op-link" @click="onMore(item)">更多</text>
<text class="arrow-down">▼</text>
</view>
</view>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 promoterList 为 fetchPromoterList() 调用
const promoterList = ref([
{ id: '82764', nickname: '183****5762', name: '-', phone: '183****5762', level: '--', userCount: 0, orderCount: 0, orderAmount: '0.00', commissionTotal: '0.00', withdrawnAmount: 0, withdrawCount: 0, unwithdrawnAmount: 0 },
{ id: '82765', nickname: '张小明', name: '张小明', phone: '138****1234', level: '一级', userCount: 12, orderCount: 8, orderAmount: '2560.00', commissionTotal: '256.00', withdrawnAmount: 200, withdrawCount: 2, unwithdrawnAmount: 56 },
{ id: '82766', nickname: '李美丽', name: '李美丽', phone: '139****5678', level: '二级', userCount: 5, orderCount: 3, orderAmount: '980.00', commissionTotal: '98.00', withdrawnAmount: 50, withdrawCount: 1, unwithdrawnAmount: 48 },
{ id: '82767', nickname: '王大力', name: '王大力', phone: '150****9876', level: '一级', userCount: 28, orderCount: 20, orderAmount: '8800.00', commissionTotal: '880.00', withdrawnAmount: 800, withdrawCount: 5, unwithdrawnAmount: 80 },
{ id: '82768', nickname: '赵小红', name: '赵小红', phone: '186****1111', level: '三级', userCount: 3, orderCount: 2, orderAmount: '450.00', commissionTotal: '22.50', withdrawnAmount: 0, withdrawCount: 0, unwithdrawnAmount: 22.5 },
{ id: '82769', nickname: '陈风', name: '陈风', phone: '177****2222', level: '二级', userCount: 15, orderCount: 10, orderAmount: '3200.00', commissionTotal: '320.00', withdrawnAmount: 300, withdrawCount: 3, unwithdrawnAmount: 20 },
{ id: '82770', nickname: '刘明亮', name: '刘明亮', phone: '133****3333', level: '一级', userCount: 42, orderCount: 35, orderAmount: '15600.00', commissionTotal: '1560.00', withdrawnAmount: 1500, withdrawCount: 8, unwithdrawnAmount: 60 },
{ id: '82771', nickname: '黄小花', name: '黄小花', phone: '155****4444', level: '--', userCount: 1, orderCount: 0, orderAmount: '0.00', commissionTotal: '0.00', withdrawnAmount: 0, withdrawCount: 0, unwithdrawnAmount: 0 },
{ id: '82772', nickname: '周建国', name: '周建国', phone: '188****5555', level: '二级', userCount: 9, orderCount: 6, orderAmount: '2100.00', commissionTotal: '210.00', withdrawnAmount: 200, withdrawCount: 2, unwithdrawnAmount: 10 },
{ id: '82773', nickname: '吴晓燕', name: '吴晓燕', phone: '137****6666', level: '一级', userCount: 20, orderCount: 15, orderAmount: '6300.00', commissionTotal: '630.00', withdrawnAmount: 600, withdrawCount: 4, unwithdrawnAmount: 30 },
{ id: '82774', nickname: '郑强', name: '郑强', phone: '150****7777', level: '三级', userCount: 2, orderCount: 1, orderAmount: '320.00', commissionTotal: '16.00', withdrawnAmount: 0, withdrawCount: 0, unwithdrawnAmount: 16 },
{ id: '82775', nickname: '孙月', name: '孙月', phone: '182****8888', level: '一级', userCount: 35, orderCount: 28, orderAmount: '11200.00', commissionTotal: '1120.00', withdrawnAmount: 1000, withdrawCount: 6, unwithdrawnAmount: 120 },
{ id: '82776', nickname: '冯磊', name: '冯磊', phone: '136****9999', level: '二级', userCount: 8, orderCount: 5, orderAmount: '1800.00', commissionTotal: '180.00', withdrawnAmount: 150, withdrawCount: 2, unwithdrawnAmount: 30 },
{ id: '82777', nickname: '陈小丽', name: '陈小丽', phone: '159****0000', level: '--', userCount: 0, orderCount: 0, orderAmount: '0.00', commissionTotal: '0.00', withdrawnAmount: 0, withdrawCount: 0, unwithdrawnAmount: 0 },
{ id: '82778', nickname: '蒋涛', name: '蒋涛', phone: '178****1234', level: '一级', userCount: 18, orderCount: 12, orderAmount: '4500.00', commissionTotal: '450.00', withdrawnAmount: 400, withdrawCount: 3, unwithdrawnAmount: 50 },
{ id: '82779', nickname: '卫芳', name: '卫芳', phone: '132****5678', level: '三级', userCount: 4, orderCount: 3, orderAmount: '750.00', commissionTotal: '37.50', withdrawnAmount: 30, withdrawCount: 1, unwithdrawnAmount: 7.5 },
{ id: '82780', nickname: '韩超', name: '韩超', phone: '156****9876', level: '二级', userCount: 11, orderCount: 8, orderAmount: '2800.00', commissionTotal: '280.00', withdrawnAmount: 250, withdrawCount: 3, unwithdrawnAmount: 30 },
{ id: '82781', nickname: '杨静', name: '杨静', phone: '199****1111', level: '一级', userCount: 25, orderCount: 18, orderAmount: '7200.00', commissionTotal: '720.00', withdrawnAmount: 700, withdrawCount: 5, unwithdrawnAmount: 20 },
{ id: '82782', nickname: '秦刚', name: '秦刚', phone: '135****2222', level: '--', userCount: 0, orderCount: 0, orderAmount: '0.00', commissionTotal: '0.00', withdrawnAmount: 0, withdrawCount: 0, unwithdrawnAmount: 0 },
{ id: '82783', nickname: '许丽华', name: '许丽华', phone: '180****3333', level: '二级', userCount: 7, orderCount: 4, orderAmount: '1400.00', commissionTotal: '140.00', withdrawnAmount: 100, withdrawCount: 1, unwithdrawnAmount: 40 },
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => promoterList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return promoterList.value.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
function onExport() { uni.showToast({ title: '开始导出', icon: 'none' }) }
function onPromoter(item: any) { uni.showToast({ title: '推广人: ' + item.id, icon: 'none' }) }
function onMore(item: any) { uni.showToast({ title: '更多: ' + item.id, icon: 'none' }) }
</script>
<style scoped lang="scss">
.admin-page { padding: 0; }
.filter-card { background: #fff; padding: 24px; margin-bottom: 16px; border-radius: 4px; }
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
.label { font-size: 14px; color: #333; }
.date-picker-mock { display: flex; flex-direction: row; align-items: center; justify-content: space-between; border: 1px solid #d9d9d9; border-radius: 2px; height: 32px; width: 260px; padding: 0 12px; background: #fff; .placeholder { font-size: 14px; color: #bfbfbf; } .icon-calendar { font-size: 14px; color: #bfbfbf; } }
.filter-input { border: 1px solid #d9d9d9; height: 32px; width: 220px; padding: 0 12px; font-size: 14px; }
.btn { height: 32px; padding: 0 16px; font-size: 14px; border-radius: 2px; border: 1px solid #d9d9d9; background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn.primary { background: #1890ff; border-color: #1890ff; color: #fff; }
.btn.ghost { color: #666; background: #fff; }
.btn.small { height: 28px; padding: 0 12px; font-size: 13px; }
.content-card { background: #fff; border-radius: 4px; }
.action-bar { padding: 16px 24px; }
.table-container { padding: 0 24px 24px; }
.table-header { display: flex; flex-direction: row; background: #f8faff; border-bottom: 1px solid #f0f0f0; padding: 12px 0; }
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; padding: 12px 0; align-items: center; &:hover { background: #fafafa; } }
.col { padding: 0 8px; display: flex; align-items: center; font-size: 14px; color: #333; }
.col-id { width: 60px; } .col-img { width: 80px; justify-content: center; } .col-info { width: 180px; } .col-level { width: 100px; justify-content: center; } .col-stat { width: 110px; justify-content: center; } .col-ops { flex: 1; justify-content: flex-end; padding-right: 16px; }
.table-img { width: 40px; height: 40px; border-radius: 4px; }
.user-info-box { display: flex; flex-direction: column; }
.info-text { font-size: 12px; color: #666; margin-bottom: 2px; }
.op-link { color: #1890ff; cursor: pointer; }
.op-divider { color: #e8e8e8; margin: 0 8px; }
.arrow-down { font-size: 10px; color: #1890ff; margin-left: 4px; }
.pagination { padding: 16px 24px; border-top: 1px solid #f0f0f0; }
.page-info { font-size: 14px; color: #999; }
</style>

View File

@@ -1,334 +0,0 @@
<template>
<view class="admin-page">
<view class="filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">代理商查询:</text>
<input class="filter-input" placeholder="请输入姓名或UID" v-model="searchQuery" @confirm="onSearch" />
</view>
<view class="filter-btns">
<button class="btn primary" @click="onSearch">查询</button>
<button class="btn" @click="onReset">重置</button>
</view>
</view>
</view>
<view class="content-card">
<view class="action-bar">
<button class="btn primary small" @click="onAdd">添加代理商</button>
</view>
<view class="table-container">
<view v-if="isLoading" class="loading-mask">
<text class="loading-text">数据加载中...</text>
</view>
<view class="table-header">
<view class="col col-uid"><text>用户UID</text></view>
<view class="col col-avatar"><text>头像</text></view>
<view class="col col-name"><text>名称</text></view>
<view class="col col-ratio"><text>分佣比例</text></view>
<view class="col col-dept"><text>所属事业部</text></view>
<view class="col col-count"><text>员工数量</text></view>
<view class="col col-time"><text>过期时间</text></view>
<view class="col col-status"><text>状态</text></view>
<view class="col col-ops"><text>操作</text></view>
</view>
<view class="table-body">
<view v-if="agentList.length === 0 && !isLoading" class="empty-row">
<text>暂无代理商数据</text>
</view>
<view v-for="item in agentList" :key="item.uid" class="table-row">
<view class="col col-uid"><text class="td-txt-small">{{ item.uid }}</text></view>
<view class="col col-avatar">
<image class="avatar-img" src="/static/logo.png" mode="aspectFill" />
</view>
<view class="col col-name"><text>{{ item.name }}</text></view>
<view class="col col-ratio"><text>{{ item.commission_ratio }}%</text></view>
<view class="col col-dept"><text>{{ item.division_name || '-' }}</text></view>
<view class="col col-count"><text>{{ item.staffCount }}</text></view>
<view class="col col-time"><text>{{ item.end_time ? item.end_time.substring(0, 10) : '-' }}</text></view>
<view class="col col-status">
<switch :checked="item.is_enabled" color="#1890ff" scale="0.8" @change="() => onToggleStatus(item)" />
</view>
<view class="col col-ops">
<text class="op-link" @click="onEdit(item)">编辑</text>
<text class="op-divider">|</text>
<text class="op-link danger" @click="onDelete(item.uid)">删除</text>
</view>
</view>
</view>
</view>
<view class="pagination">
<view class="pager-btns">
<button class="btn small" :disabled="page <= 1" @click="onPrevPage">上一页</button>
<text class="page-num">第 {{ page }} 页</text>
<button class="btn small" :disabled="agentList.length < pageSize" @click="onNextPage">下一页</button>
</view>
<text class="page-info">共 {{ agentList.length }} 条记录</text>
</view>
</view>
<!-- 添加/编辑 弹窗 -->
<view v-if="editPopupVisible" class="popup-mask" @click="closeEditModal">
<view class="popup-card" @click.stop>
<view class="popup-header">
<text class="popup-title">{{ isEdit ? '编辑代理商' : '添加代理商' }}</text>
<text class="popup-close" @click="closeEditModal">×</text>
</view>
<view class="popup-body">
<view class="popup-item" v-if="!isEdit">
<text class="popup-label">用户 UID</text>
<input v-model="editForm.uid" class="popup-input" placeholder="请输入代理商 UID" />
</view>
<view class="popup-item">
<text class="popup-label">所属事业部</text>
<picker :value="divisionIndex" :range="divisionOptions" range-key="name" @change="onDivisionChange">
<view class="select-box">
<text>{{ divisionOptions[divisionIndex]?.name || '请选择事业部' }}</text>
<text class="arrow">▼</text>
</view>
</picker>
</view>
<view class="popup-item">
<text class="popup-label">代理商名称</text>
<input v-model="editForm.name" class="popup-input" placeholder="请输入名称" />
</view>
<view class="popup-item">
<text class="popup-label">分佣比例 (%)</text>
<input v-model="editForm.commission_ratio" type="digit" class="popup-input" placeholder="0 - 100" />
</view>
<view class="popup-item">
<text class="popup-label">过期时间</text>
<input v-model="editForm.end_time" class="popup-input" placeholder="YYYY-MM-DD" />
</view>
<view class="popup-item popup-row">
<text class="popup-label">启用状态</text>
<switch :checked="editForm.is_enabled" color="#1890ff" scale="0.8" @change="(e : any) => editForm.is_enabled = e.detail.value" />
</view>
</view>
<view class="popup-footer">
<button class="btn" @click="closeEditModal">取消</button>
<button class="btn primary" @click="handleSave">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, reactive } from 'vue'
import { getAgentList, saveAgent, deleteAgent, getDivisionList, type Agent, type Division } from '@/services/admin/distributionService.uts'
const agentList = ref<Agent[]>([])
const isLoading = ref(false)
const searchQuery = ref('')
const page = ref(1)
const pageSize = 20
// 事业部选项 (供选择器使用)
const divisionOptions = ref<Division[]>([])
const divisionIndex = ref(0)
// 弹窗状态
const editPopupVisible = ref(false)
const isEdit = ref(false)
const editForm = reactive({
uid: '',
division_uid: '',
name: '',
commission_ratio: 0,
is_enabled: true,
end_time: ''
})
onMounted(() => {
loadAgents()
loadDivisions()
})
async function loadAgents() {
isLoading.value = true
try {
const res = await getAgentList(searchQuery.value || null, page.value, pageSize)
agentList.value = res
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
async function loadDivisions() {
const res = await getDivisionList(null, 1, 100)
divisionOptions.value = res
}
function onSearch() {
page.value = 1
loadAgents()
}
function onReset() {
searchQuery.value = ''
page.value = 1
loadAgents()
}
function onAdd() {
isEdit.value = false
Object.assign(editForm, {
uid: '',
division_uid: '',
name: '',
commission_ratio: 0,
is_enabled: true,
end_time: ''
})
divisionIndex.value = 0
editPopupVisible.value = true
}
function onEdit(item : Agent) {
isEdit.value = true
Object.assign(editForm, {
uid: item.uid,
division_uid: item.division_uid,
name: item.name,
commission_ratio: item.commission_ratio,
is_enabled: item.is_enabled,
end_time: item.end_time || ''
})
const idx = divisionOptions.value.findIndex(d => d.uid === item.division_uid)
divisionIndex.value = idx > -1 ? idx : 0
editPopupVisible.value = true
}
function closeEditModal() {
editPopupVisible.value = false
}
function onDivisionChange(e : any) {
divisionIndex.value = e.detail.value as number
editForm.division_uid = divisionOptions.value[divisionIndex.value].uid
}
async function handleSave() {
if (!editForm.uid || !editForm.division_uid || !editForm.name) {
uni.showToast({ title: '请完善信息', icon: 'none' })
return
}
isLoading.value = true
try {
const success = await saveAgent(editForm)
if (success) {
uni.showToast({ title: '保存成功', icon: 'success' })
editPopupVisible.value = false
loadAgents()
}
} finally {
isLoading.value = false
}
}
async function onDelete(uid : string) {
uni.showModal({
title: '确认删除',
content: '确定要删除该代理商吗?',
success: async (res) => {
if (res.confirm) {
isLoading.value = true
try {
const success = await deleteAgent(uid)
if (success) {
uni.showToast({ title: '删除成功' })
loadAgents()
}
} finally {
isLoading.value = false
}
}
}
})
}
async function onToggleStatus(item : Agent) {
const updated = { ...item, is_enabled: !item.is_enabled }
const success = await saveAgent(updated)
if (success) {
item.is_enabled = !item.is_enabled
uni.showToast({ title: '状态已更新' })
}
}
function onPrevPage() {
if (page.value > 1) {
page.value--
loadAgents()
}
}
function onNextPage() {
if (agentList.value.length < pageSize) return
page.value++
loadAgents()
}
</script>
<style scoped lang="scss">
.admin-page { padding: 0; }
.filter-card { background: #fff; padding: 24px; margin-bottom: 16px; border-radius: 4px; }
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
.label { font-size: 14px; color: #333; }
.filter-input { border: 1px solid #d9d9d9; height: 32px; width: 220px; padding: 0 12px; font-size: 14px; border-radius: 4px; }
.btn { height: 32px; padding: 0 16px; font-size: 14px; border: 1px solid #d9d9d9; background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 4px; }
.btn.primary { background: #1890ff; border-color: #1890ff; color: #fff; }
.btn.small { height: 28px; padding: 0 12px; font-size: 13px; }
.content-card { background: #fff; border-radius: 4px; position: relative; }
.action-bar { padding: 16px 24px; }
.table-container { padding: 0 24px 24px; min-height: 200px; }
.table-header { display: flex; flex-direction: row; background: #f8faff; border-bottom: 1px solid #f0f0f0; padding: 12px 0; }
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; padding: 12px 0; align-items: center; &:hover { background: #fafafa; } }
.col { padding: 0 8px; font-size: 14px; color: #333; display: flex; align-items: center; }
.col-uid { width: 80px; } .col-avatar { width: 80px; justify-content: center; } .col-name { width: 120px; } .col-ratio { width: 100px; justify-content: center; } .col-dept { width: 140px; } .col-count { width: 100px; justify-content: center; } .col-time { width: 120px; justify-content: center; } .col-status { width: 80px; justify-content: center; } .col-ops { flex: 1; justify-content: flex-end; }
.avatar-img { width: 32px; height: 32px; border-radius: 2px; }
.op-link { color: #1890ff; cursor: pointer; &.danger { color: #ff4d4f; } }
.op-divider { color: #e8e8e8; margin: 0 8px; }
.pagination { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.pager-btns { display: flex; flex-direction: row; align-items: center; gap: 12px; }
.page-num { font-size: 14px; color: #333; }
.page-info { font-size: 14px; color: #999; }
.loading-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; z-index: 10; }
.loading-text { color: #1890ff; font-size: 14px; }
.empty-row { padding: 40px 0; text-align: center; color: #999; font-size: 14px; }
/* 弹窗样式 */
.popup-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 999; }
.popup-card { width: 500px; background-color: #fff; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; }
.popup-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #f0f0f0; }
.popup-title { font-size: 16px; font-weight: bold; color: #333; }
.popup-close { font-size: 20px; color: #999; cursor: pointer; padding: 4px; }
.popup-body { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.popup-item { display: flex; flex-direction: column; gap: 8px; }
.popup-row { flex-direction: row; align-items: center; justify-content: space-between; }
.popup-label { font-size: 14px; color: #666; }
.popup-input { border: 1px solid #d9d9d9; border-radius: 4px; height: 36px; padding: 0 12px; font-size: 14px; width: 100%; }
.select-box { border: 1px solid #d9d9d9; border-radius: 4px; height: 36px; padding: 0 12px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; text { font-size: 14px; color: #333; } .arrow { font-size: 10px; color: #bfbfbf; } }
.popup-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
</style>

View File

@@ -17,7 +17,7 @@
</view>
<view class="content-card">
<view class="action-bar">
<button class="btn primary small" @click="openEditModal(null)">添加等级</button>
<button class="btn primary small" @click="onAdd">添加等级</button>
</view>
<view class="table-container">
<view class="table-header">
@@ -33,7 +33,7 @@
<view class="col col-ops"><text>操作</text></view>
</view>
<view class="table-body">
<view v-for="item in levelList" :key="item.id" class="table-row">
<view v-for="item in pagedList" :key="item.id" class="table-row">
<view class="col col-id"><text>{{ item.id }}</text></view>
<view class="col col-img">
<image class="table-img" src="/static/logo.png" mode="aspectFill" />
@@ -42,190 +42,104 @@
<view class="col col-level"><text>{{ item.level }}</text></view>
<view class="col col-percent"><text>{{ item.percent1 }}%</text></view>
<view class="col col-percent"><text>{{ item.percent2 }}%</text></view>
<view class="col col-stat"><text>{{ item.task_total }}</text></view>
<view class="col col-stat"><text>{{ item.task_finish }}</text></view>
<view class="col col-stat"><text>{{ item.taskTotal }}</text></view>
<view class="col col-stat"><text>{{ item.taskFinish }}</text></view>
<view class="col col-status">
<switch :checked="item.is_visible" color="#1890ff" scale="0.8" @change="() => onToggleVisible(item)" />
<switch :checked="item.show" color="#1890ff" scale="0.8" />
</view>
<view class="col col-ops">
<text class="op-link">等级任务</text>
<text class="op-divider">|</text>
<text class="op-link" @click="openEditModal(item)">编辑</text>
<text class="op-link">编辑</text>
<text class="op-divider">|</text>
<text class="op-link" @click="onDelete(item.id)">删除</text>
<text class="op-link">删除</text>
</view>
</view>
</view>
</view>
<view class="pagination">
<text class="page-info">共 {{ levelList.length }} 条</text>
</view>
</view>
<view v-if="editPopupVisible" class="popup-mask" @click="closeEditModal">
<view class="popup-card" @click.stop>
<view class="popup-header">
<text class="popup-title">{{ editForm.id == null ? '添加分销等级' : '编辑分销等级' }}</text>
<text class="popup-close" @click="closeEditModal">×</text>
</view>
<view class="popup-body">
<view class="popup-item">
<text class="popup-label">等级名称</text>
<input v-model="editForm.name" class="popup-input" placeholder="如:一级分销员" />
</view>
<view class="popup-item">
<text class="popup-label">等级权重</text>
<input v-model="editForm.level" type="number" class="popup-input" placeholder="如1" />
</view>
<view class="popup-item">
<text class="popup-label">一级分佣比例 (%)</text>
<input v-model="editForm.percent1" type="digit" class="popup-input" placeholder="0 - 100" />
</view>
<view class="popup-item">
<text class="popup-label">二级分佣比例 (%)</text>
<input v-model="editForm.percent2" type="digit" class="popup-input" placeholder="0 - 100" />
</view>
<view class="popup-item">
<text class="popup-label">任务总数</text>
<input v-model="editForm.task_total" type="number" class="popup-input" placeholder="如0" />
</view>
<view class="popup-item">
<text class="popup-label">需完成数量</text>
<input v-model="editForm.task_finish" type="number" class="popup-input" placeholder="如0" />
</view>
<view class="popup-item popup-row">
<text class="popup-label">是否显示</text>
<switch :checked="!!editForm.is_visible" color="#1890ff" scale="0.8" @change="(e) => editForm.is_visible = e.detail.value" />
</view>
</view>
<view class="popup-footer">
<button class="btn" @click="closeEditModal">取消</button>
<button class="btn primary" @click="handleSave">保存</button>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, reactive } from 'vue'
import { getDistributionLevelList, saveDistributionLevel, deleteDistributionLevel, DistributionLevel } from '@/services/admin/distributionService.uts'
import { ref, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const levelList = ref<DistributionLevel[]>([])
const isLoading = ref(false)
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 levelList 为 fetchLevelList() 调用
const levelList = ref([
{ id: '1', name: '一级分销员', level: 1, percent1: 20.00, percent2: 10.00, taskTotal: 5, taskFinish: 3, show: true },
{ id: '2', name: '二级分销员', level: 2, percent1: 15.00, percent2: 8.00, taskTotal: 3, taskFinish: 2, show: true },
{ id: '3', name: '三级分销员', level: 3, percent1: 10.00, percent2: 5.00, taskTotal: 2, taskFinish: 1, show: true },
{ id: '4', name: '铂金分销员', level: 4, percent1: 25.00, percent2: 12.00, taskTotal: 8, taskFinish: 6, show: true },
{ id: '5', name: '钻石分销员', level: 5, percent1: 30.00, percent2: 15.00, taskTotal: 10, taskFinish: 8, show: false },
{ id: '6', name: '精英分销员', level: 6, percent1: 18.00, percent2: 9.00, taskTotal: 4, taskFinish: 4, show: true },
{ id: '7', name: '超级分销员', level: 7, percent1: 35.00, percent2: 18.00, taskTotal: 12, taskFinish: 10, show: true },
{ id: '8', name: '黄金分销员', level: 8, percent1: 22.00, percent2: 11.00, taskTotal: 6, taskFinish: 5, show: true },
{ id: '9', name: '白银分销员', level: 9, percent1: 12.00, percent2: 6.00, taskTotal: 3, taskFinish: 1, show: false },
{ id: '10', name: '青铜分销员', level: 10, percent1: 8.00, percent2: 4.00, taskTotal: 2, taskFinish: 0, show: true },
{ id: '11', name: '新人分销员', level: 11, percent1: 5.00, percent2: 2.00, taskTotal: 1, taskFinish: 0, show: true },
{ id: '12', name: 'VIP分销员', level: 12, percent1: 28.00, percent2: 14.00, taskTotal: 7, taskFinish: 7, show: true },
{ id: '13', name: '明星分销员', level: 13, percent1: 32.00, percent2: 16.00, taskTotal: 9, taskFinish: 8, show: true },
{ id: '14', name: '王者分销员', level: 14, percent1: 40.00, percent2: 20.00, taskTotal: 15, taskFinish: 12, show: false },
{ id: '15', name: '传奇分销员', level: 15, percent1: 45.00, percent2: 22.00, taskTotal: 20, taskFinish: 18, show: true },
{ id: '16', name: '荣耀分销员', level: 16, percent1: 38.00, percent2: 19.00, taskTotal: 11, taskFinish: 9, show: true },
{ id: '17', name: '至尊分销员', level: 17, percent1: 42.00, percent2: 21.00, taskTotal: 14, taskFinish: 11, show: true },
{ id: '18', name: '神话分销员', level: 18, percent1: 48.00, percent2: 24.00, taskTotal: 18, taskFinish: 15, show: false },
{ id: '19', name: '无双分销员', level: 19, percent1: 50.00, percent2: 25.00, taskTotal: 20, taskFinish: 20, show: true },
{ id: '20', name: '巅峰分销员', level: 20, percent1: 55.00, percent2: 28.00, taskTotal: 25, taskFinish: 22, show: true },
])
// ========== MOCK DATA END ==========
const editPopupVisible = ref(false)
const editForm = reactive<DistributionLevel>({
id: undefined,
name: '',
level: 1,
percent1: 0,
percent2: 0,
task_total: 0,
task_finish: 0,
is_visible: true
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => levelList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return levelList.value.slice(start, start + pageSize.value)
})
onMounted(() => {
loadLevels()
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
async function loadLevels() {
isLoading.value = true
try {
const res = await getDistributionLevelList()
levelList.value = res
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function onSearch() {
loadLevels()
}
function openEditModal(item: DistributionLevel | null) {
if (item != null) {
Object.assign(editForm, item)
} else {
Object.assign(editForm, {
id: undefined,
name: '',
level: levelList.value.length + 1,
percent1: 0,
percent2: 0,
task_total: 0,
task_finish: 0,
is_visible: true
})
}
editPopupVisible.value = true
}
function closeEditModal() {
editPopupVisible.value = false
}
async function handleSave() {
if (!editForm.name) {
uni.showToast({ title: '请输入等级名称', icon: 'none' })
return
}
isLoading.value = true
try {
const success = await saveDistributionLevel(editForm as DistributionLevel)
if (success) {
uni.showToast({ title: '保存成功', icon: 'success' })
closeEditModal()
loadLevels()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '保存异常', icon: 'none' })
} finally {
isLoading.value = false
}
}
async function onDelete(id: string | undefined) {
if (id == null) return
uni.showModal({
title: '确认删除',
content: '确定要删除该分销等级吗?',
success: async (res) => {
if (res.confirm) {
isLoading.value = true
try {
const success = await deleteDistributionLevel(id)
if (success) {
uni.showToast({ title: '删除成功' })
loadLevels()
}
} finally {
isLoading.value = false
}
}
}
})
}
async function onToggleVisible(item: DistributionLevel) {
const updated = { ...item, is_visible: !item.is_visible } as DistributionLevel
const success = await saveDistributionLevel(updated)
if (success) {
loadLevels()
}
}
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
function onAdd() { uni.showToast({ title: '添加中...', icon: 'none' }) }
</script>
<style scoped lang="scss">
.admin-page { padding: 0; }
@@ -242,103 +156,11 @@ async function onToggleVisible(item: DistributionLevel) {
.table-header { display: flex; flex-direction: row; background: #f8faff; border-bottom: 1px solid #f0f0f0; padding: 12px 0; }
.table-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; padding: 12px 0; align-items: center; &:hover { background: #fafafa; } }
.col { padding: 0 8px; display: flex; align-items: center; font-size: 14px; color: #333; }
.col-id { width: 50px; } .col-img { width: 80px; justify-content: center; } .col-name { width: 120px; } .col-level { width: 80px; justify-content: center; } .col-percent { width: 120px; justify-content: center; } .col-stat { width: 100px; justify-content: center; } .col-status { width: 100px; justify-content: center; } .col-ops { flex: 1; justify-content: flex-end; padding-right: 16px; }
.col-id { width: 50px; } .col-img { width: 80px; justify-content: center; } .col-name { width: 120px; } .col-level { width: 80px; justify-content: center; } .col-percent { width: 120px; justify-content: center; } .col-stat { width: 100px; justify-content: center; } .col-status { width: 100px; justify-content: center; }
.col-ops { flex: 1; justify-content: flex-end; padding-right: 16px; display: flex; flex-direction: row; }
.table-img { width: 32px; height: 32px; border-radius: 2px; }
.op-link { color: #1890ff; cursor: pointer; }
.op-divider { color: #e8e8e8; margin: 0 8px; }
.pagination { padding: 16px 24px; border-top: 1px solid #f0f0f0; }
.page-info { font-size: 14px; color: #999; }
/* 弹窗样式 */
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.popup-card {
width: 500px;
background-color: #fff;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.popup-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.popup-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.popup-close {
font-size: 20px;
color: #999;
cursor: pointer;
padding: 4px;
}
.popup-body {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.popup-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.popup-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.popup-label {
font-size: 14px;
color: #666;
}
.popup-input {
border: 1px solid #d9d9d9;
border-radius: 4px;
height: 36px;
padding: 0 12px;
font-size: 14px;
width: 100%;
}
.popup-footer {
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 12px;
}
.btn.ghost {
background-color: #fff;
color: #666;
border: 1px solid #d9d9d9;
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="admin-page">
<view class="content-card">
<view class="tabs-row">

View File

@@ -1,484 +0,0 @@
# 🎯 检查完成 - 文件清单
> 想快速了解“整个项目”的文档体系与入口:请先读 [DOCS_OVERVIEW.md](./DOCS_OVERVIEW.md)。
>
> 本文件主要覆盖「后台页面 AdminLayout 包装合规检查」相关交付文档。
## ✅ 任务已完成
我已为你生成了 **8 份完整的文档**,包含所有检查结果、分析和修改方案。
---
## 📄 生成的文档清单
### 📍 主入口(从这里开始)
#### 1. **ADMIN_PAGE_START_HERE.md** ⭐⭐⭐
最终交付清单和快速开始指南。
- 适合:所有人
- 内容:任务完成总结、快速导航、时间估计
- 阅读时间5-10 分钟
### 📚 核心文档(按推荐阅读顺序)
#### 2. **ADMIN_PAGE_INDEX.md** ⭐⭐⭐
文档导航索引和快速开始指南。
- 适合:需要指引的人
- 内容:文档导航、快速开始、按角色导航
- 阅读时间5-10 分钟
#### 3. **ADMIN_PAGE_SUMMARY.md** ⭐⭐⭐
执行总结报告(最重要的文档)。
- 适合:项目经理、开发主管
- 内容:检查结果、关键发现、优先级建议、修改建议
- 阅读时间10-15 分钟
#### 4. **ADMIN_PAGE_QUICK_REFERENCE.md** ⭐⭐⭐
快速参考表和查询工具。
- 适合:需要快速查找的开发人员
- 内容:所有文件的状态概览、按问题类型分类、快速查询
- 阅读时间按需查询5 分钟/文件)
#### 5. **ADMIN_PAGE_COMPLIANCE_CHECKLIST.md** ⭐⭐⭐
完整的路由清单和合规性检查结果。
- 适合:需要完整列表的人
- 内容:所有 76 条路由的详细清单、按模块分组、统计汇总
- 阅读时间15-20 分钟
#### 6. **ADMIN_PAGE_MODIFICATION_PLAN.md** ⭐⭐⭐
详细的修改计划和执行方案。
- 适合:负责修改的开发人员
- 内容6 种修改方案(附代码)、所有文件的修改说明、时间估计
- 阅读时间20-30 分钟(查询用)
### 📊 数据和汇总
#### 7. **ADMIN_PAGE_CHECKLIST.csv** 📊
所有 76 条路由的 CSV 表格。
- 适合:需要数据处理的人
- 内容:所有路由的完整数据表
- 用途Excel、数据分析、进度跟踪
#### 8. **ADMIN_PAGE_COMPLETE.md** 📋
最终交付清单(这份文档)。
- 适合:需要确认所有内容的人
- 内容:所有文档总结、文件位置、下一步行动
- 阅读时间5-10 分钟
---
## 🎯 核心发现摘要
### 检查结果
```
✅ 完全符合: 2 个 (2.6%)
⚠️ 需要小修改: 6 个 (7.9%)
🔄 动态实现: 5 个 (6.6%)
❌ 需要修改: 63 个 (82.9%)
━━━━━━━━━━━━━━━━━
总计: 76 个 (100%)
```
### 文件分类
- 🔴 **高优先级**必须修改36 个文件
- 🟡 **中优先级**应该修改27 个文件
- 🟢 **低优先级**小修改7 个文件
- 🟢 **已符合**2 个文件
### 预计工作量
- **优先级低**1-2 小时7 个文件)
- **优先级中**4-6 小时27 个文件)
- **优先级高**8-12 小时36 个文件)
- **验证和测试**1.5-2 小时
- **总计**13-20 小时
---
## 📍 文档位置
所有文档都在项目根目录:
```
d:\骅锋\mall\
```
### 完整文件列表
- ✅ ADMIN_PAGE_START_HERE.md
- ✅ ADMIN_PAGE_INDEX.md
- ✅ ADMIN_PAGE_SUMMARY.md
- ✅ ADMIN_PAGE_QUICK_REFERENCE.md
- ✅ ADMIN_PAGE_COMPLIANCE_CHECKLIST.md
- ✅ ADMIN_PAGE_MODIFICATION_PLAN.md
- ✅ ADMIN_PAGE_CHECKLIST.csv
- ✅ ADMIN_PAGE_COMPLETE.md本文档
---
## 🚀 快速开始3 步)
### 步骤 1打开索引文档5 分钟)
```
打开ADMIN_PAGE_INDEX.md
目的:了解所有文档,选择合适的起点
```
### 步骤 2选择修改目标5 分钟)
```
打开ADMIN_PAGE_QUICK_REFERENCE.md
搜索:你要修改的文件名
查看:该文件的状态和修改建议
```
### 步骤 3获取修改方案5 分钟)
```
打开ADMIN_PAGE_MODIFICATION_PLAN.md
找到:对应的修改方案
复制:代码示例到你的文件
```
---
## 📖 按用户角色的文档选择
### 👔 项目经理
**目标**:了解整体情况
**阅读顺序**
1. ADMIN_PAGE_SUMMARY.md前 3 部分)
2. 本文档的"核心发现摘要"
### 👨‍💻 开发人员
**目标**:快速找到修改方案
**阅读顺序**
1. ADMIN_PAGE_INDEX.md
2. ADMIN_PAGE_QUICK_REFERENCE.md搜索文件
3. ADMIN_PAGE_MODIFICATION_PLAN.md找修改方案
### 📊 技术主管
**目标**:制定实施计划
**阅读顺序**
1. ADMIN_PAGE_SUMMARY.md
2. ADMIN_PAGE_COMPLIANCE_CHECKLIST.md
3. ADMIN_PAGE_CHECKLIST.csv用于进度跟踪
### 🔬 QA/测试人员
**目标**:制定测试计划
**阅读顺序**
1. ADMIN_PAGE_COMPLIANCE_CHECKLIST.md
2. ADMIN_PAGE_QUICK_REFERENCE.md
3. ADMIN_PAGE_SUMMARY.md问题排查部分
---
## 🎓 文档使用指南
### 我是新手,从哪里开始?
→ 打开 **ADMIN_PAGE_START_HERE.md**(本文档),然后打开 **ADMIN_PAGE_INDEX.md**
### 我需要快速查找某个文件的修改方案
→ 打开 **ADMIN_PAGE_QUICK_REFERENCE.md**,搜索文件名
### 我需要完整的路由清单
→ 打开 **ADMIN_PAGE_COMPLIANCE_CHECKLIST.md****ADMIN_PAGE_CHECKLIST.csv**
### 我需要代码示例
→ 打开 **ADMIN_PAGE_MODIFICATION_PLAN.md**
### 我需要了解项目整体情况
→ 打开 **ADMIN_PAGE_SUMMARY.md**
### 我需要进度跟踪表
→ 打开 **ADMIN_PAGE_CHECKLIST.csv**,在 Excel 中添加进度列
---
## ✨ 文档特点
### 全面性 ✓
- 覆盖所有 76 条路由
- 分析所有 50+ 个文件
- 识别所有问题
### 详细性 ✓
- 每个文件的状态清晰
- 每个问题有具体说明
- 每个修改有代码示例
### 易用性 ✓
- 按优先级组织
- 按问题类型分类
- 快速查找工具
- 清晰的导航
### 可操作性 ✓
- 6 种修改方案
- 代码示例
- 验证方法
- 时间估计
---
## 📊 数据统计
| 项目 | 数值 |
| ---------- | ---------- |
| 生成的文档 | 8 份 |
| 检查的路由 | 76 条 |
| 涉及的文件 | 50+ 个 |
| 文档总字数 | 40,000+ |
| 代码示例 | 15+ |
| 完全符合 | 2 个 |
| 需要修改 | 74 个 |
| 修改方案 | 6 种 |
| 预计工作量 | 13-20 小时 |
---
## 🎯 关键信息
### ✅ 已完全符合的文件2个
```
1. pages/mall/admin/homePage/index.uvue ✓
2. pages/mall/admin/product-statistics.uvue ✓
```
### ⚠️ 需要小修改的文件7个
```
1. pages/mall/admin/design/index.uvue
2. pages/mall/admin/user-statistics.uvue
3. pages/mall/admin/content/index.uvue
4. pages/mall/admin/customer-service/list.uvue
5. pages/mall/admin/system-settings.uvue
6. pages/mall/admin/maintain/dev-config/category.uvue
7. pages/mall/admin/maintain/system-info.uvue
```
### ❌ 需要重新包装的文件36个
- product-management.uvue
- order-management.uvue
- 所有 marketing/coupon/\*.uvue
- 所有 customer-service/\*.uvue
- 所有 system/shipping/\*.uvue
- 等...(详见完整清单)
### 📦 已导入但未使用的文件27个
- 所有 product/\*.uvue除 product-statistics.uvue
- 所有 system/api/\*.uvue
- 所有 maintain/dev-config/\*.uvue
- 等...(详见完整清单)
---
## 🔥 立即行动(建议)
### 现在就做5 分钟)
1. 打开 **ADMIN_PAGE_START_HERE.md**
2. 理解全貌
3. 选择起点
### 然后做10 分钟)
1. 打开 **ADMIN_PAGE_INDEX.md**
2. 选择合适的详细文档
3. 深入了解
### 最后做(修改)
1. 按优先级选择文件
2.**ADMIN_PAGE_MODIFICATION_PLAN.md** 中找修改方案
3. 应用代码
4. 测试验证
---
## ✅ 检查清单
在开始修改之前,请确认:
- [ ] 我已阅读 ADMIN_PAGE_START_HERE.md
- [ ] 我已阅读 ADMIN_PAGE_INDEX.md
- [ ] 我理解了 3 个优先级的区别
- [ ] 我知道我要修改哪个文件
- [ ] 我已找到了对应的修改方案
- [ ] 我已准备好开始修改
---
## 💡 常见问题FAQ
### Q: 所有文档都要读吗?
A: 不用。根据你的角色选择相关文档即可。
### Q: 修改难度大吗?
A: 不大。所有代码示例都已提供,只需复制粘贴。
### Q: 应该从哪个文件开始修改?
A: 推荐从优先级低(🟢)的文件开始。
### Q: 修改需要多久?
A: 每个文件 10-15 分钟,总计 13-20 小时。
### Q: 如何验证修改是否正确?
A: 在浏览器中访问页面,检查菜单是否显示。
### Q: 文档在哪里找?
A: 都在 d:\骅锋\mall\ 目录中。
---
## 🎓 参考资源
### 相关源文件
- AdminLayout 组件:`layouts/admin/AdminLayout.uvue`
- 菜单定义:`layouts/admin/utils/menu.uts`
- 类型定义:`layouts/admin/types.uts`
### 参考页面(已正确实现)
- `pages/mall/admin/homePage/index.uvue`
- `pages/mall/admin/product-statistics.uvue`
- `pages/mall/admin/user-management.uvue` ✅(动态实现)
---
## 📞 需要帮助?
### 问题类型 → 解决方案
| 问题 | 查看 |
| -------------- | ---------------------------------- |
| 不知道从哪开始 | ADMIN_PAGE_INDEX.md |
| 需要快速查询 | ADMIN_PAGE_QUICK_REFERENCE.md |
| 需要完整清单 | ADMIN_PAGE_COMPLIANCE_CHECKLIST.md |
| 需要修改方案 | ADMIN_PAGE_MODIFICATION_PLAN.md |
| 需要概览 | ADMIN_PAGE_SUMMARY.md |
| 需要整体总结 | ADMIN_PAGE_START_HERE.md |
---
## 🎉 预期成果
### 修改完成后
✅ 所有后台页面都显示正确的 AdminLayout
✅ 所有页面有统一的导航和布局
✅ 用户体验大幅改善
✅ 代码更易维护
✅ 更少的 BUG
---
## 📅 时间表建议
### 第 1 天4-5 小时)
- 修改优先级低的 7 个文件
- 进行初步测试
### 第 2-3 天8-10 小时)
- 修改优先级中的 27 个文件
- 进行中等规模测试
### 第 4-5 天8-12 小时)
- 修改优先级高的 36 个文件
- 进行全面测试
### 第 6 天2-3 小时)
- 最终验证和修复
- 部署到生产
**总计**:约 23-32 小时工作量(可能并行进行)
---
## 🏁 最后的话
你现在拥有完整的文档和修改方案。没有进一步的理由延迟。
**选择一个简单的文件,现在就开始修改。**
推荐的第一个文件:**pages/mall/admin/design/index.uvue**(最简单)
---
## 📝 信息汇总
- **生成日期**2026年1月30日
- **检查方法**:自动化代码分析
- **准确度**100%
- **文档数量**8 份
- **覆盖范围**:所有 76 条路由
- **包含代码示例**15+ 个
---
## 🎯 你的下一步
👉 **打开并阅读**[ADMIN_PAGE_START_HERE.md](ADMIN_PAGE_START_HERE.md)
或者
👉 **直接打开**[ADMIN_PAGE_INDEX.md](ADMIN_PAGE_INDEX.md)
---
**准备好了?让我们开始!** 🚀
_文档生成完成 - 2026年1月30日_
_所有文件已在 d:\骅锋\mall\ 目录中_

View File

@@ -1,780 +0,0 @@
# 📈 Admin 管理端功能评估与建议书
**报告日期**2026-02-04
**报告类型**:项目现状分析 + 功能建议 + 实施路线
**适合人群**PM、技术主管、开发团队
---
## 📊 Executive Summary执行摘要
### 现状速览
- ✅ **设计系统与规范**完全建立150+ 设计变量、完整的工程化规范)
- ✅ **Admin 布局基础**已就位AdminLayout 组件、菜单系统、导航高亮)
- ✅ **后台页面审计**已完成76 个路由分析、74 个文件需修复)
- ✅ **页面重构示范**已落地37 个页面改造、规范方法论固化)
- ✅ **业务模块样板**已交付(客服管理完整可用模块)
- ✅ **数据流基础**已打通Supabase 直连、RPC、Token 自刷新、Mock 服务层)
### 关键瓶颈
- ⏳ **AdminLayout 合规修复**未完成74 个文件等待处理,分 P0/P1/P2 三级)
-**组件库 MVP** 未启动(现在还是用原生 input/button
- ⏳ **列表页/表单页/详情页模板**未落地成实际组件化系统
- ⏳ **真实 API 对接**未全面启动(现在多是 Mock
- ⏳ **权限与角色体系**前端还没有实装
### 建议优先级
1. **立即启动**(本周):完成 AdminLayout 合规修复 + 梳理真实 API 清单
2. **1-2 周内**:启动组件库 MVPButton/Input/Select/Card/Modal/Table
3. **2-3 周内**:落地页面模板与迁移 1-2 个高频业务模块
4. **4+ 周**:全面对接真实 API、权限系统、测试与优化
---
## 1⃣ Admin 已完成任务清单
### A. 架构与规范体系
#### 1.1 设计系统Design System
- **状态**:✅ 完成
- **交付物**
- [STYLE_SPECIFICATION.md](./STYLE_SPECIFICATION.md)150+ 设计变量(颜色/间距/字体/阴影/响应式)
-`uni.scss` 中实现,所有 .uvue 文件可直接使用
- 禁止硬编码的强制约定已建立
- **impact**:样式改一处全局生效,无需逐文件修改
#### 1.2 工程化规范
- **状态**:✅ 完成
- **交付物**
- [ENGINEERING_BEST_PRACTICES.md](./ENGINEERING_BEST_PRACTICES.md)项目结构、命名、导入、Git/测试/构建
- [COMPONENT_SPECIFICATION.md](./COMPONENT_SPECIFICATION.md)30+ 组件分类、Props/Emit/Slot 规范
- 6 个开发检查清单已定义
- **impact**:新人快速上手,代码风格一致
#### 1.3 页面结构规范
- **状态**:✅ 完成
- **交付物**
- [PAGE_STRUCTURE_SPECIFICATION.md](./PAGE_STRUCTURE_SPECIFICATION.md)List/Form/Detail 三大页面模板
- 每个模板都有 300+ 行完整代码示例
- 响应式、布局、交互都已标准化
- **impact**:新增页面可直接套模板,无需从零开发
#### 1.4 实施路线图
- **状态**:✅ 完成
- **交付物**
- [IMPLEMENTATION_ROADMAP.md](./IMPLEMENTATION_ROADMAP.md)8 阶段、10 周、30+ 组件的详细计划
- 阶段、优先级、时间表、验收标准都已明确
- **impact**:团队对路线有清晰认知,可按计划分配任务
---
### B. Admin 布局与导航系统
#### 2.1 AdminLayout 组件
- **状态**:✅ 已实现
- **代码位置**`layouts/admin/AdminLayout.uvue`
- **功能**
- 侧边栏菜单展示与折叠
- 当前页面高亮
- 子菜单展开与面包屑
- 响应式(桌面/平板/移动)
- **impact**:所有 Admin 页面有统一布局,用户体验一致
#### 2.2 菜单与导航匹配
- **状态**:✅ 已实现
- **代码位置**`layouts/admin/utils/menu.uts``layouts/admin/utils/nav.uts`
- **规则**:通过 `currentPage` 属性与菜单 id 匹配,实现自动高亮
- **impact**:页面无需关心菜单逻辑,只需传递 currentPage
#### 2.3 使用指南
- **状态**:✅ 已完成
- **文档**[ADMIN_LAYOUT_GUIDE.md](./ADMIN_LAYOUT_GUIDE.md)
- **快速开始**`<AdminLayout :currentPage="'page-id'"><your-content /></AdminLayout>`
- **impact**:新增页面 5 分钟内接入
---
### C. 后台页面合规检查
#### 3.1 全量审计
- **状态**:✅ 完成
- **覆盖**76 条路由、50+ 个 .uvue 文件、100% 检查覆盖率
- **工作量**:自动化分析 + 人工验证
#### 3.2 问题分类与方案
- **状态**:✅ 完成
- **分类**
- 🔴 高优先级(完全缺 AdminLayout36 个文件 → 8-12 小时
- 🟡 中优先级导入但未使用27 个文件 → 4-6 小时
- 🟢 低优先级(属性/值有问题7 个文件 → 1-2 小时
- ✅ 已符合2 个文件
- **方案**6 种修改模式,每个都附代码示例
#### 3.3 文档交付
- **状态**:✅ 完成
- **文档集合**
- [ADMIN_PAGE_START_HERE.md](./ADMIN_PAGE_START_HERE.md):任务入口与快速指南
- [ADMIN_PAGE_COMPLIANCE_CHECKLIST.md](./ADMIN_PAGE_COMPLIANCE_CHECKLIST.md):全量清单(按模块组织)
- [ADMIN_PAGE_MODIFICATION_PLAN.md](./ADMIN_PAGE_MODIFICATION_PLAN.md):修改方案集合(含代码)
- [ADMIN_PAGE_QUICK_REFERENCE.md](./ADMIN_PAGE_QUICK_REFERENCE.md):快速查找表
- CSV 数据表(可在 Excel 中进度追踪)
- **impact**:清单化、可任务拆分、易进度跟踪
---
### D. Admin 页面重构示范
#### 4.1 重构成果
- **状态**:✅ 完成
- **覆盖**37 个文件完全重构
- P0 优先级5 个主页面user/product/order/system/marketing 管理
- P1 优先级22 个维护页面develop-config、system-log 等
- P2 优先级8 个统计页面product-specs、user-stats 等
- **工作量**62% 覆盖率(在 Admin 现有页面中)
#### 4.2 改进指标
- **硬编码颜色值**250+ → 0100% 消除)
- **硬编码间距值**180+ → 0100% 变量化)
- **无类型注解的 ref**60+ → 0100% 补全)
- **PascalCase 类名**80+ → 0100% 改为 kebab-case
- **代码质量**:提升 217%
#### 4.3 文档与方法论
- **状态**:✅ 固化
- **文档**
- [REFACTOR_SUMMARY.md](./REFACTOR_SUMMARY.md):阶段总结
- [REFACTOR_BEFORE_AFTER.md](./REFACTOR_BEFORE_AFTER.md):改造对比
- [QUICK_START_NEW_DEVELOPMENT.md](./QUICK_START_NEW_DEVELOPMENT.md):如何按规范开发新页面
- **impact**:后续新页面都按这套方法论,质量稳定
---
### E. 业务模块示范:客服管理
#### 5.1 完整交付
- **状态**:✅ 生产可用
- **交付物**
- 5 个完整页面(列表/话术/留言/自动回复/配置)
- 完整服务层Mock API15 个函数)
- 菜单集成、路由配置
- 交互规范文档
#### 5.2 技术亮点
- **批量操作模式**:标准化的选中、操作、确认流程
- **Modal 对话框**:表单验证、关闭时重置状态
- **搜索过滤**:统一的逻辑(重置页码、清空选中等)
- **TypeScript 类型**完整的类型定义IDE 支持
- **CSS 规范**设计变量集中kebab-case 命名
#### 5.3 可复用价值
- **作为后续模块的样板**:新模块可按这套模式复制
- **交互模式固定**:所有列表页都遵循相同交互
- **文档完整**:快速开始 + 交付清单 + 实现细节
---
### F. 数据流与基础设施
#### 6.1 Supabase 集成
- **状态**:✅ 已打通
- **架构**
- 单例客户端 `supa`[components/supadb/aksupainstance.uts](../../components/supadb/aksupainstance.uts)
- API 封装 `AkSupa`[components/supadb/aksupa.uts](../../components/supadb/aksupa.uts)
- HTTP 统一入口 `AkReq`[uni_modules/ak-req/ak-req.uts](../../uni_modules/ak-req/ak-req.uts)
- **特性**
- REST API直接查表
- RPC数据库函数调用
- Authtoken 管理与自刷新)
- Storage文件上传
#### 6.2 Token 与认证
- **状态**:✅ 自动化
- **流程**
- 登录时 `signIn(email, password)` 获取 token
- 自动存储 access_token / refresh_token / expires_at
- 请求时自动加 `Authorization: Bearer <token>`
- token 快过期时自动刷新(提前 5 分钟)
- **impact**:页面无需关心 token自动处理
#### 6.3 服务层与 Mock
- **状态**:✅ 已建立
- **两层结构**
- 底层 Mock API 示范:[pages/mall/admin/service/service.uts](../../pages/mall/admin/service/service.uts)
- 上层业务 Service 整合:[services/analytics/dashboardService.uts](../../services/analytics/dashboardService.uts)
- **特性**
- Mock 支持分页、搜索、排序、过滤
- 延迟模拟真实网络300ms
- 便于前端和后端团队并行开发
#### 6.4 状态管理
- **状态**:✅ 已初步建立
- **方案**Vue 3 Composition API + `reactive()` 全局 state
- **内容**
- 登录状态 `isLoggedIn`
- 用户信息 `userProfile`
- 设备状态 `deviceState`
- **impact**:多页面共享状态,避免重复查询
---
## 2⃣ Admin 现状评估
### A. 强点What's Working Well
| 方面 | 评分 | 说明 |
| ---------- | ---------- | -------------------------------------- |
| 设计系统 | ⭐⭐⭐⭐⭐ | 完整、易用、强制规范 |
| 工程化基础 | ⭐⭐⭐⭐⭐ | 结构清晰、规范齐全 |
| 布局与导航 | ⭐⭐⭐⭐⭐ | AdminLayout 完善、菜单逻辑清晰 |
| 代码质量 | ⭐⭐⭐⭐ | 37 个文件重构完成、方法论固化 |
| 文档完整度 | ⭐⭐⭐⭐⭐ | 规范、指南、样板都齐全 |
| 数据流基础 | ⭐⭐⭐⭐ | Supabase 集成、token 自动化、Mock 就位 |
### B. 痛点What Needs Work
| 问题 | 优先级 | 影响 | 预计工作量 |
| -------------------------- | ------ | ---------------- | ---------- |
| AdminLayout 合规修复未完成 | 🔴 P0 | 74 个页面不符合 | 13-20 小时 |
| 组件库还未落地 | 🔴 P1 | 代码重复、效率低 | 8-12 小时 |
| 列表/表单/详情模板未组件化 | 🟡 P1 | 代码量大、难维护 | 6-8 小时 |
| Mock API 与真实 API 混用 | 🟡 P1 | 数据不一致风险 | 8-10 小时 |
| 权限与角色前端未实装 | 🟡 P2 | 数据泄露风险 | 4-6 小时 |
| 测试覆盖率为零 | 🟡 P2 | 重构/功能风险 | 10+ 小时 |
### C. 技术债务评估
**当前已知的技术债**
- ⚠️ 后台页面中 AdminLayout 使用不一致74 个文件)
- ⚠️ 组件库缺失(用原生 input/button代码重复
- ⚠️ 页面模板未标准化(每个页面布局略有差异)
- ⚠️ Mock 与真实 API 对应关系不明确
- ⚠️ 权限检查全部在后端,前端无法预检
**风险**:如果继续积累,会导致:
1. 新页面开发效率下降(无组件库、无模板)
2. 代码维护成本上升(样式改动需要逐文件改)
3. 用户体验不一致(没有标准交互模式)
---
## 3⃣ Admin 功能建议书
### A. 即时建议(本周内完成)
#### 建议 1完成 AdminLayout 合规修复(✅ 必做)
**为什么**
- 后台导航与菜单高亮是核心交互,不能有缺漏
- 74 个文件已分好优先级,有明确的修改方案
- 完成这步是后续功能开发的前提
**具体行动**
1. 拉一个 dev 分支 `feature/admin-layout-compliance`
2. 从低优先级7 个文件)开始,用 [ADMIN_PAGE_QUICK_REFERENCE.md](./ADMIN_PAGE_QUICK_REFERENCE.md) 快速查找
3. 按 [ADMIN_PAGE_MODIFICATION_PLAN.md](./ADMIN_PAGE_MODIFICATION_PLAN.md) 的 6 种模式应用修改
4. 每改完 5 个文件 commit 一次,便于 review 和回滚
5. 完成后全量页面一遍回归测试(菜单显示、高亮、子菜单等)
**预期成果**
- ✅ 所有 Admin 页面都有正确的 AdminLayout 包装
- ✅ 菜单导航与页面一一对应,无遗漏
- ✅ 用户打开任何页面都能清楚知道位置(面包屑、菜单高亮)
**预计工作量**13-20 小时
---
#### 建议 2梳理真实 API 与数据对应关系
**为什么**
- 现在是 Mock 与真实 API 混用
- 后端可能已有对应接口,但前端不知道
- 需要在组件库开发前明确数据约定
**具体行动**
1. 后端团队列一份"已实现的 API 接口清单"endpoint、参数、响应格式
2. 前端根据清单补充必缺的接口
3. 制作一份"前后端数据对应表"(哪个页面用哪个 API
4. 设置 API 统一的响应格式约定status/code/data/message 等)
**预期成果**
- 📋 清晰的"API 清单"文档
- 📋 "前后端数据对应表"
- 📋 统一的"API 响应格式规范"
**预计工作量**3-5 小时
---
### B. 短期建议1-2 周内启动)
#### 建议 3启动组件库 MVPMinimum Viable Product
**为什么**
- 现在是用原生 input/button代码重复率高
- 组件库能减少 30-40% 的代码量
- 集中样式管理,改色一处全局生效
**建议的 MVP 范围**(优先级排序):
1. **Button**1-2 小时)
- 4 种类型primary/default/danger/success
- 3 种尺寸sm/md/lg
- 支持disabled、loading、icon
2. **Input**1-2 小时)
- 4 种类型text/password/number/email
- 支持placeholder、clearable、error 状态、验证反馈
3. **Select**2-3 小时)
- 支持单选、搜索、disabled
- 支持:自定义 option 模板
4. **Card**1 小时)
- 通用卡片容器
- 支持header、body、footer、阴影等级
5. **Modal/Drawer**2-3 小时)
- 确认框、表单对话框
- 支持:点击背景关闭、自定义宽度、遮罩层
6. **Table**3-4 小时)
- 数据表格
- 支持:列配置、排序、行选择、分页、虚拟滚动(可选)
7. **Pagination**1-2 小时)
- 分页器
- 支持:上一页/下一页/跳页
**实施计划**
- Week 1Button、Input、Select核心输入组件
- Week 2Card、Modal、Pagination容器与布局
- Week 3+Table、其他组件
**预期成果**
- ✅ 7+ 个可复用组件
- ✅ 每个组件都有完整文档、类型定义、使用示例
- ✅ 样式统一(用设计变量)
**预计工作量**16-20 小时
---
#### 建议 4标准化列表页/表单页/详情页
**为什么**
- Admin 80% 的页面都是这三种类型
- 如果有标准组件化模板,新页面开发可快 50%
**建议的实施方式**
1. 把 [PAGE_STRUCTURE_SPECIFICATION.md](./PAGE_STRUCTURE_SPECIFICATION.md) 中的三个模板转成 Vue 组件
- `ListPage.uvue`:搜索 + 表格 + 分页 + 批量操作
- `FormPage.uvue`:表单 + 验证 + 提交 + 各种字段类型
- `DetailPage.uvue`:卡片展示 + 日志 + 操作按钮
2. 每个模板支持以下定制:
- 搜索字段可配置
- 表格列可配置
- 表单字段可配置
- 操作按钮可配置
3. 完成后迁移 1-2 个高频业务模块试用
**预期成果**
- ✅ 3 个标准化页面模板组件
- ✅ 新页面开发时间缩短 50%
- ✅ 交互与样式保持一致
**预计工作量**8-10 小时
---
### C. 中期建议2-4 周内)
#### 建议 5全面对接真实 API
**为什么**
- 从 Mock 过渡到真实数据是必要步骤
- 现在框架已经就位Supabase 集成、token 自动化)
- 需要按模块逐步替换
**实施策略**
1. **优先级排序**(按业务重要性):
- 🔴 用户、商品、订单(核心)
- 🟡 支付、配送、营销(高频)
- 🟢 报表、分析、配置(低频)
2. **每个模块的替换步骤**
- 后端提供 API 文档endpoint、参数、响应
- 前端在 service 层调用真实 API替换 Mock
- 同时补全错误处理、loading 态、空状态
- 测试数据流正确性
3. **风险控制**
- 在 staging 环境先充分测试
- 准备回滚方案(遇到问题可快速回到 Mock
- 定期同步后端 API 变更
**预期成果**
- ✅ 所有 Admin 功能都对接真实数据
- ✅ 后端可独立部署,前端可独立开发
- ✅ 完整的"API 测试用例"
**预计工作量**20-30 小时(分模块进行)
---
#### 建议 6实现权限与角色系统
**为什么**
- 现在所有用户看到同样的菜单和功能
- 不同角色应该有不同的权限
- 前端需要根据权限隐藏菜单项、禁用按钮
**建议的实施方式**
1. **菜单权限**
- 后端返回当前用户的权限列表
- 前端根据权限过滤菜单(显示/隐藏)
- 用户无权限但直接访问 URL 时,重定向到首页
2. **按钮权限**
- 每个操作按钮绑定权限码(如 `user:delete`
- 检查当前用户是否拥有该权限
- 无权限时 disabled 或隐藏按钮
3. **数据行级权限**
- 某些数据只能看到自己的(如商家只看自己店铺)
- 后端通过 RLSRow Level Security在数据库层控制
- 前端通过 service 层过滤
**预期成果**
- ✅ 菜单根据角色自动过滤
- ✅ 按钮操作有权限检查
- ✅ 数据安全性提升
**预计工作量**6-8 小时
---
### D. 长期建议4+ 周)
#### 建议 7建立测试体系
**为什么**
- 现在零测试覆盖率
- 后续功能迭代时容易引入 bug
- 关键流程需要自动化测试保障
**建议的测试范围**
1. **单元测试**20% 工作量)
- 工具类函数:时间格式化、数据校验等
- Store 函数:登录、登出、信息更新
2. **集成测试**50% 工作量)
- 完整数据流:从页面操作 → service 层 → 数据库
- 关键业务流程:登录 → 查看列表 → 新增/编辑/删除 → 刷新验证
3. **E2E 测试**30% 工作量)
- 高频用户场景
- 权限边界场景
- 错误恢复场景
**预期成果**
- ✅ 关键流程有自动化测试覆盖
- ✅ 代码改动时可快速验证
- ✅ 产生测试数据与测试报告
**预计工作量**20-30 小时
---
#### 建议 8性能与 UX 优化
**为什么**
- Admin 系统数据量会越来越大
- 需要预先做好优化,避免后期重构
**优化方向**
1. **列表性能**
- 虚拟滚动(只渲染可见行)
- 分页加载(不一次加载所有数据)
- 搜索防抖(避免频繁请求)
2. **页面加载**
- 代码分割(按需加载模块)
- 图片懒加载
- 预加载常用资源
3. **交互体验**
- 骨架屏(加载中的友好提示)
- 操作反馈loading、toast、确认弹窗
- 错误恢复(失败重试、友好提示)
**预期成果**
- ✅ 列表页面加载时间 < 1s
- ✅ 表格滚动平滑(无卡顿)
- ✅ 用户操作有明确反馈
**预计工作量**10-15 小时
---
## 4⃣ 实施路线图与时间规划
### 关键时间点与交付
```
┌─────────────────┬──────────────────────────┬──────────────┬────────────┐
│ 阶段 │ 关键任务 │ 时间 │ 依赖 │
├─────────────────┼──────────────────────────┼──────────────┼────────────┤
│ Phase 0 (本周) │ AdminLayout 合规修复 │ 13-20h │ 无 │
│ │ 梳理 API 清单 │ 3-5h │ 无 │
├─────────────────┼──────────────────────────┼──────────────┼────────────┤
│ Phase 1 (1-2周) │ 组件库 MVP │ 16-20h │ Phase 0 │
│ (第 2-3 周) │ 标准化页面模板 │ 8-10h │ Phase 0 │
├─────────────────┼──────────────────────────┼──────────────┼────────────┤
│ Phase 2 (2-3周) │ 真实 API 对接(1/3) │ 8-10h │ Phase 1 │
│ (第 4-5 周) │ 权限系统基础 │ 4-6h │ Phase 1 │
├─────────────────┼──────────────────────────┼──────────────┼────────────┤
│ Phase 3 (3-4周) │ 真实 API 对接(2/3) │ 8-10h │ Phase 2 │
│ (第 6-7 周) │ 测试体系建立 │ 10-15h │ Phase 1 │
├─────────────────┼──────────────────────────┼──────────────┼────────────┤
│ Phase 4 (4+ 周) │ 真实 API 对接(3/3) │ 4-10h │ Phase 3 │
│ (第 8-9 周) │ 性能优化 │ 10-15h │ Phase 3 │
└─────────────────┴──────────────────────────┴──────────────┴────────────┘
```
### 并行推进建议
**Week 1-2**
- 团队 AAdminLayout 合规修复P0
- 团队 B梳理 API + 组件库设计
**Week 3-4**
- 团队 A组件库开发Button/Input/Select
- 团队 B页面模板组件化 + 真实 API 替换
**Week 5-6**
- 团队 A表格/Modal/分页组件
- 团队 B权限系统 + 测试框架搭建
**Week 7+**
- 团队 A补充其他组件、优化
- 团队 B性能优化、全量测试
---
## 5⃣ 团队协作建议
### 角色分工
| 角色 | 职责 | 相关文档 |
| ------------ | ------------------------------- | ------------------------------------------------------------------ |
| **项目经理** | 追踪进度、协调资源、风险管理 | [ADMIN_STATUS_AND_TODO.md](./ADMIN_STATUS_AND_TODO.md) |
| **技术主管** | 规范审查、架构决策、Code Review | [ENGINEERING_BEST_PRACTICES.md](./ENGINEERING_BEST_PRACTICES.md) |
| **前端开发** | 组件库、页面迁移、功能实现 | [QUICK_START_NEW_DEVELOPMENT.md](./QUICK_START_NEW_DEVELOPMENT.md) |
| **后端开发** | API 接口、权限系统、数据库优化 | [sql_summary.md](./sql_summary.md) |
| **QA/测试** | 功能测试、性能测试、用户验收 | 待补充(建议补充测试计划) |
### 日常协作流程
1. **周一规划**30min
- 同步本周关键任务
- 前后端确认 API 需求
- 识别风险与阻碍
2. **三日进度同步**15min
- 各模块负责人报进度
- 快速解决卡点
- 调整优先级
3. **周五总结**30min
- 回顾完成情况
- 补充下周计划
- 技术分享与复盘
4. **代码审查**
- 所有 PR 需有 2 人 review
- 着重检查:规范符合度、测试覆盖、性能风险
- 参考 [ENGINEERING_BEST_PRACTICES.md](./ENGINEERING_BEST_PRACTICES.md)
---
## 6⃣ 成功指标KPI
### 质量指标
| 指标 | 目标 | 当前 | 完成时间 |
| ------------------- | ---- | ------ | -------- |
| AdminLayout 合规率 | 100% | 2.6% | Week 1-2 |
| TypeScript 类型覆盖 | 100% | 80%+ | Week 4 |
| 代码硬编码值清零 | 0 | 已清零 | 已完成 |
| 单测覆盖率 | ≥60% | 0% | Week 5-6 |
| 文档完整度 | 100% | 95% | Week 2 |
### 性能指标
| 指标 | 目标 | 当前 | 完成时间 |
| -------------- | ------ | ---- | -------- |
| 列表页首屏加载 | <1s | TBD | Week 8 |
| 表格滚动帧率 | ≥60fps | TBD | Week 8 |
| 包体积 | <500KB | TBD | Week 7 |
### 开发效率指标
| 指标 | 目标 | 当前 | 完成时间 |
| ---------------- | ---- | ------ | -------- |
| 新页面开发时间 | <4h | ~8-10h | Week 3 |
| Bug 修复平均时间 | <2h | TBD | Week 6 |
| 代码 Review 周期 | <4h | TBD | Week 1 |
---
## 7⃣ 常见问题与答案
### Q1为什么要优先完成 AdminLayout 合规修复?
**A**:因为这是后台交互的基础。如果 74 个页面导航不一致,用户体验会很差,直接影响后续功能开发的优先级。
### Q2组件库需要多完整
**A**:建议先做 MVP7 个核心组件),这能覆盖 80% 的场景。其他组件可以按需补充。
### Q3现在有一些页面还在用原生 input/button需要迁移吗
**A**:不需要立即迁移。重构时可选择性迁移高频页面,其他页面可后续慢慢跟进。
### Q4Mock API 什么时候切换到真实 API
**A**:建议 API 设计稳定后立即切,不要等到后期。这样前后端可以并行开发。
### Q5权限系统要做到什么程度
**A**:至少做到"菜单过滤"和"按钮 disabled"。行级权限可以后续逐步完善。
### Q6如何避免新添加的页面再次不符合规范
**A**
- 在代码审查时严格检查(看 checklist
- 新页面必须基于模板创建
- IDE 配置 ESLint + Prettier 自动格式化
---
## 📚 相关文档导航
### 必读文档
- [ADMIN_STATUS_AND_TODO.md](./ADMIN_STATUS_AND_TODO.md) - 项目现状与待办总结
- [ADMIN_PAGE_START_HERE.md](./ADMIN_PAGE_START_HERE.md) - AdminLayout 合规修复入门
- [QUICK_START_NEW_DEVELOPMENT.md](./QUICK_START_NEW_DEVELOPMENT.md) - 如何按规范开发新页面
### 规范文档
- [STYLE_SPECIFICATION.md](./STYLE_SPECIFICATION.md) - 设计系统
- [PAGE_STRUCTURE_SPECIFICATION.md](./PAGE_STRUCTURE_SPECIFICATION.md) - 页面结构模板
- [COMPONENT_SPECIFICATION.md](./COMPONENT_SPECIFICATION.md) - 组件规范
- [ENGINEERING_BEST_PRACTICES.md](./ENGINEERING_BEST_PRACTICES.md) - 工程化规范
### 参考文档
- [IMPLEMENTATION_ROADMAP.md](./IMPLEMENTATION_ROADMAP.md) - 8 阶段路线图
- [SERVICE_QUICK_START.md](./SERVICE_QUICK_START.md) - 业务模块样板(客服管理)
- [ADMIN_LAYOUT_GUIDE.md](./ADMIN_LAYOUT_GUIDE.md) - AdminLayout 使用指南
---
## 🎯 总结与建议
### 现状总结
**基础已很扎实**
- 规范体系完整(设计/工程/页面)
- 布局系统就位AdminLayout 完善)
- 业务样板可用(客服管理完整交付)
- 数据流通畅Supabase 集成、token 自动化)
⚠️ **短期瓶颈明显**
- AdminLayout 合规修复74 个文件)
- 组件库缺失(代码重复率高)
- 模板未组件化(开发效率低)
### 最强烈的建议
🚀 **立即启动 Phase 0本周**
1. 完成 AdminLayout 合规修复13-20 小时)
2. 梳理真实 API 清单3-5 小时)
这两项会直接解除后续开发的瓶颈,之后 Phase 1 可以快速推进。
### 长期优势
如果按照本建议书推进,你们的 Admin 系统 4 周后会具有:
-**一致的 UI/交互**AdminLayout 统一、菜单自动高亮、交互模式相同
-**高效的开发流程**:组件库就位、模板可复用、新页面开发时间缩短 50%
-**可靠的数据流**:真实 API、权限检查、完整错误处理
-**安全的系统**权限系统、RLS 保护、前后端验证
-**易维护的代码**:规范统一、设计变量集中、测试覆盖
---
**报告完成日期**2026-02-04
**下次评估建议**:完成 Phase 0 后(约 2 周后)重新评估进度与风险

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