Explorar el Código

chore: auto bump version and changelog [skip ci]

iwt hace 3 semanas
padre
commit
1c7cab21ab

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -413,7 +413,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
                                 .lt(Task::getDeadline, endOfDay))
                                 .lt(Task::getDeadline, endOfDay))
                         .or(w3 -> w3.eq(Task::getIsDailyProgress, 1)
                         .or(w3 -> w3.eq(Task::getIsDailyProgress, 1)
                                 .ge(Task::getCreatedAt, startOfDay)
                                 .ge(Task::getCreatedAt, startOfDay)
-                                .lt(Task::getCreatedAt, endOfDay)))
+                                .lt(Task::getCreatedAt, endOfDay))
                 )
                 )
                 .orderByAsc(Task::getDeadline)
                 .orderByAsc(Task::getDeadline)
                 .orderByAsc(Task::getId));
                 .orderByAsc(Task::getId));

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-568c66ef4bdd6597167b473a709830b6185a1bb7
+f2510b69dacd188f273aafd1233241e6a748e1c1

+ 33 - 0
cfc-web/src/router/index.js

@@ -905,6 +905,39 @@ const routes = [
         name: 'FileManage',
         name: 'FileManage',
         component: () => import('@/views/admin/FileManage'),
         component: () => import('@/views/admin/FileManage'),
         meta: { title: '文件管理', perm: 'system:base' }
         meta: { title: '文件管理', perm: 'system:base' }
+      },
+      // ========== 机构管理 ==========
+      {
+        path: 'org',
+        name: 'OrgList',
+        component: () => import('@/views/admin/org/List.vue'),
+        meta: { title: '组织管理', perm: 'org:*' }
+      },
+      {
+        path: 'org/create',
+        name: 'OrgCreate',
+        component: () => import('@/views/admin/org/Form.vue'),
+        meta: { title: '新建组织', perm: 'org:*' }
+      },
+      {
+        path: 'org/edit/:id',
+        name: 'OrgEdit',
+        component: () => import('@/views/admin/org/Form.vue'),
+        meta: { title: '编辑组织', perm: 'org:*' },
+        props: true
+      },
+      {
+        path: 'org/:id',
+        name: 'OrgDetail',
+        component: () => import('@/views/admin/org/Detail.vue'),
+        meta: { title: '组织详情', perm: 'org:*' },
+        props: true
+      },
+      {
+        path: 'org/:id/dashboard',
+        name: 'OrgDashboard',
+        component: () => import('@/views/admin/org/Dashboard.vue'),
+        meta: { title: '组织大屏', perm: 'org:*' }
       }
       }
     ]
     ]
   }
   }

+ 5 - 1
cfc-web/src/utils/permissions.js

@@ -26,6 +26,8 @@ const ROLE_PERMISSIONS = {
   supplier: ['dashboard', 'commerce:self', 'inventory:self', 'order:self', 'settlement:self', 'supply:member', 'biz:*'],
   supplier: ['dashboard', 'commerce:self', 'inventory:self', 'order:self', 'settlement:self', 'supply:member', 'biz:*'],
   // 活动管理员(仅自己的活动)+ 业务管理
   // 活动管理员(仅自己的活动)+ 业务管理
   activity_manager: ['dashboard', 'activity:self', 'activity:registration', 'biz:*'],
   activity_manager: ['dashboard', 'activity:self', 'activity:registration', 'biz:*'],
+  // 组织管理员(仅自身组织数据)
+  org_admin: ['dashboard', 'org:*'],
   // ---- 遗留角色兼容(与后端 AdminRoles.normalize 对应)----
   // ---- 遗留角色兼容(与后端 AdminRoles.normalize 对应)----
   article_manager: ['dashboard', 'articles:*', 'knowledge:*'],
   article_manager: ['dashboard', 'articles:*', 'knowledge:*'],
   activity_admin: ['dashboard', 'activity:self', 'activity:registration'],
   activity_admin: ['dashboard', 'activity:self', 'activity:registration'],
@@ -145,7 +147,7 @@ function matchSingle(userPerms, required) {
 /**
 /**
  * 入驻角色(登录后默认首页为个人版看板)
  * 入驻角色(登录后默认首页为个人版看板)
  */
  */
-export const RESIDENT_ROLES = ['butler', 'knowledge_admin', 'teacher', 'nutritionist', 'supplier', 'activity_manager']
+export const RESIDENT_ROLES = ['butler', 'knowledge_admin', 'teacher', 'nutritionist', 'supplier', 'activity_manager', 'org_admin']
 
 
 /**
 /**
  * 入驻角色默认首页(登录后/访问 /dashboard 时跳转到个人工作台,B-6)
  * 入驻角色默认首页(登录后/访问 /dashboard 时跳转到个人工作台,B-6)
@@ -158,6 +160,7 @@ const ROLE_HOME_PAGES = {
   supplier: '/product-manage',
   supplier: '/product-manage',
   activity_manager: '/activities',
   activity_manager: '/activities',
   knowledge_admin: '/article-manage',
   knowledge_admin: '/article-manage',
+  org_admin: '/org',
 }
 }
 
 
 export function getRoleHomePage(roles) {
 export function getRoleHomePage(roles) {
@@ -207,6 +210,7 @@ export function getRoleLabel(role) {
     nutritionist: '营养师',
     nutritionist: '营养师',
     supplier: '供应商',
     supplier: '供应商',
     activity_manager: '活动管理员',
     activity_manager: '活动管理员',
+    org_admin: '组织管理员',
     // 遗留角色
     // 遗留角色
     article_manager: '知识管理员',
     article_manager: '知识管理员',
     activity_admin: '活动管理员',
     activity_admin: '活动管理员',

+ 285 - 0
cfc-web/src/views/admin/org/Dashboard.vue

@@ -0,0 +1,285 @@
+<template>
+  <div class="org-dashboard">
+    <!-- 总览卡片 -->
+    <el-row :gutter="16" style="margin-bottom: 16px">
+      <el-col :span="4"><el-card shadow="never" class="stat-card">
+        <div class="stat-num">{{ overview.memberCount }}</div>
+        <div class="stat-label">成员总数</div>
+      </el-card></el-col>
+      <el-col :span="4"><el-card shadow="never" class="stat-card">
+        <div class="stat-num">{{ overview.plannerCount }}</div>
+        <div class="stat-label">规划师数</div>
+      </el-card></el-col>
+      <el-col :span="4"><el-card shadow="never" class="stat-card">
+        <div class="stat-num">{{ overview.userCount }}</div>
+        <div class="stat-label">普通用户</div>
+      </el-card></el-col>
+      <el-col :span="4"><el-card shadow="never" class="stat-card">
+        <div class="stat-num">{{ overview.familyCount }}</div>
+        <div class="stat-label">家庭数</div>
+      </el-card></el-col>
+      <el-col :span="4"><el-card shadow="never" class="stat-card">
+        <div class="stat-num">{{ overview.totalOrders }}</div>
+        <div class="stat-label">订单数</div>
+      </el-card></el-col>
+      <el-col :span="4"><el-card shadow="never" class="stat-card">
+        <div class="stat-num">{{ formatAmount(overview.totalConsumption) }}</div>
+        <div class="stat-label">总消费(元)</div>
+      </el-card></el-col>
+    </el-row>
+
+    <el-row :gutter="16" style="margin-bottom: 16px">
+      <!-- 活跃度趋势 -->
+      <el-col :span="16">
+        <el-card shadow="never">
+          <div slot="header">
+            <span>活跃度趋势(任务完成 / 打卡 / 报告上传)</span>
+            <el-date-picker v-model="activeDateRange" type="daterange" size="mini"
+              style="float:right;margin-left:10px" value-format="yyyy-MM-dd"
+              range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"
+              @change="onActiveRangeChange"></el-date-picker>
+          </div>
+          <div ref="activeChart" class="echarts-container"></div>
+        </el-card>
+      </el-col>
+
+      <!-- 趋势图:活跃成员/订单/收入 -->
+      <el-col :span="8">
+        <el-card shadow="never">
+          <div slot="header">
+            <span>趋势</span>
+            <el-date-picker v-model="trendDateRange" type="daterange" size="mini"
+              style="float:right;margin-left:10px" value-format="yyyy-MM-dd"
+              range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"
+              @change="onTrendRangeChange"></el-date-picker>
+          </div>
+          <div ref="trendChart" class="echarts-container" style="height: 280px"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="16">
+      <!-- 家庭维度 -->
+      <el-col :span="8">
+        <el-card shadow="never">
+          <div slot="header">家庭维度</div>
+          <div style="padding: 10px 0">
+            <div class="dim-row"><span>活跃家庭数</span><b>{{ families.activeFamilyCount }}</b></div>
+            <div class="dim-row"><span>任务完成率</span><b>{{ families.taskCompletionRate }}%</b></div>
+            <div class="dim-row"><span>打卡率</span><b>{{ families.checkinRate }}%</b></div>
+          </div>
+        </el-card>
+      </el-col>
+
+      <!-- 营收 -->
+      <el-col :span="16">
+        <el-card shadow="never">
+          <div slot="header">
+            <span>营收</span>
+            <el-date-picker v-model="revenueDateRange" type="daterange" size="mini"
+              style="float:right;margin-left:10px" value-format="yyyy-MM-dd"
+              range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"
+              @change="onRevenueRangeChange"></el-date-picker>
+          </div>
+          <div ref="revenueChart" class="echarts-container" style="height: 230px"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-backtop :right="50" :bottom="50"></el-backtop>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { orgMy, orgStatsOverview, orgStatsFamilies, orgStatsActive, orgStatsRevenue, orgStatsTrend } from '@/api/org'
+
+export default {
+  name: 'OrgDashboard',
+  data() {
+    return {
+      orgId: null,
+      orgName: '',
+      overview: { memberCount: 0, plannerCount: 0, userCount: 0, familyCount: 0, totalOrders: 0, totalConsumption: 0 },
+      families: { activeFamilyCount: 0, taskCompletionRate: 0, checkinRate: 0 },
+      activeDateRange: null,
+      trendDateRange: null,
+      revenueDateRange: null
+    }
+  },
+  created() {
+    this.loadMyOrg()
+    this.initCharts()
+  },
+  beforeDestroy() {
+    if (this.activeChart) this.activeChart.dispose()
+    if (this.trendChart) this.trendChart.dispose()
+    if (this.revenueChart) this.revenueChart.dispose()
+  },
+  methods: {
+    async loadMyOrg() {
+      try {
+        const res = await orgMy()
+        if (res.data && res.data.id) {
+          this.orgId = res.data.id
+          this.orgName = res.data.name
+          this.$refs && this.$refs.header && (this.$refs.header.title = '组织大屏 — ' + res.data.name)
+          this.loadStats()
+        } else if (res.data && res.data.status === 'disabled') {
+          this.$message.error('组织已停用,无法查看统计大屏')
+          setTimeout(() => this.$router.replace('/login'), 1500)
+        } else {
+          this.$message.warning('未关联任何组织')
+          setTimeout(() => this.$router.replace('/org'), 1500)
+        }
+      } catch (e) {
+        this.$message.error('加载组织失败')
+        setTimeout(() => this.$router.replace('/org'), 1500)
+      }
+    },
+    async loadStats() {
+      if (!this.orgId) return
+      const base = { orgId: this.orgId }
+      if (this.activeDateRange && this.activeDateRange.length === 2) {
+        base.startDate = this.activeDateRange[0]
+        base.endDate = this.activeDateRange[1]
+      }
+      if (this.revenueDateRange && this.revenueDateRange.length === 2) {
+        base.startDate = this.revenueDateRange[0]
+        base.endDate = this.revenueDateRange[1]
+      }
+      if (this.trendDateRange && this.trendDateRange.length === 2) {
+        base.startDate = this.trendDateRange[0]
+        base.endDate = this.trendDateRange[1]
+      }
+      try {
+        const [ov, fam, act, rev, tre] = await Promise.all([
+          orgStatsOverview(base),
+          orgStatsFamilies(base),
+          orgStatsActive(base),
+          orgStatsRevenue(base),
+          orgStatsTrend(base)
+        ])
+        this.overview = ov.data || {}
+        this.families = fam.data || {}
+        this.renderActiveChart(act.data || [])
+        this.renderRevenueChart(rev.data || [])
+        this.renderTrendChart(tre.data || [])
+      } catch (e) {
+        this.$message.error('加载统计失败')
+      }
+    },
+    formatAmount(v) {
+      if (!v) return '0'
+      return (v / 100).toFixed(2)
+    },
+    onActiveRangeChange() { this.loadStats() },
+    onTrendRangeChange() { this.loadStats() },
+    onRevenueRangeChange() { this.loadStats() },
+
+    initCharts() {
+      this.activeChart = echarts.init(this.$refs.activeChart)
+      this.trendChart = echarts.init(this.$refs.trendChart)
+      this.revenueChart = echarts.init(this.$refs.revenueChart)
+      window.addEventListener('resize', this.handleResize)
+    },
+    handleResize() {
+      this.activeChart && this.activeChart.resize()
+      this.trendChart && this.trendChart.resize()
+      this.revenueChart && this.revenueChart.resize()
+    },
+    renderActiveChart(data) {
+      const days = data.map(d => d.day).sort()
+      const tasks = data.map(d => d.taskCompleted || 0)
+      const checkins = data.map(d => d.checkins || 0)
+      const reports = data.map(d => d.reports || 0)
+      this.activeChart.setOption({
+        tooltip: { trigger: 'axis' },
+        legend: { data: ['任务完成', '打卡', '报告上传'] },
+        grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+        xAxis: { type: 'category', data: days, axisLabel: { interval: 0, rotate: 30 } },
+        yAxis: { type: 'value' },
+        series: [
+          { name: '任务完成', type: 'line', smooth: true, data: tasks, itemStyle: { color: '#1677FF' } },
+          { name: '打卡', type: 'line', smooth: true, data: checkins, itemStyle: { color: '#10B981' } },
+          { name: '报告上传', type: 'line', smooth: true, data: reports, itemStyle: { color: '#F59E0B' } }
+        ]
+      })
+    },
+    renderRevenueChart(data) {
+      const days = data.map(d => d.day).sort()
+      const orderAmt = data.map(d => d.orderAmount || 0)
+      const subAmt = data.map(d => d.subAmount || 0)
+      const commission = data.map(d => d.commission || 0)
+      this.revenueChart.setOption({
+        tooltip: { trigger: 'axis' },
+        legend: { data: ['订单金额', '会员订阅', '佣金支出'] },
+        grid: { left: '3%', right: '4%', bottom: '3%', containLabel: 1 },
+        xAxis: { type: 'category', data: days, axisLabel: { interval: 0, rotate: 30 } },
+        yAxis: { type: 'value' },
+        series: [
+          { name: '订单金额', type: 'bar', stack: 'total', data: orderAmt, itemStyle: { color: '#1677FF' } },
+          { name: '会员订阅', type: 'bar', stack: 'total', data: subAmt, itemStyle: { color: '#10B981' } },
+          { name: '佣金支出', type: 'bar', stack: 'total', data: commission, itemStyle: { color: '#F59E0B' } }
+        ]
+      })
+    },
+    renderTrendChart(data) {
+      const days = data.map(d => d.day).sort()
+      const active = data.map(d => d.activeMembers || 0)
+      const orders = data.map(d => d.orderCount || 0)
+      const income = data.map(d => (d.income || 0) / 100)
+      this.trendChart.setOption({
+        tooltip: { trigger: 'axis' },
+        legend: { data: ['活跃成员数', '订单数', '收入(元)'] },
+        grid: { left: '3%', right: '4%', bottom: '3%', containLabel: 1 },
+        xAxis: { type: 'category', data: days, axisLabel: { interval: 0, rotate: 30 } },
+        yAxis: [
+          { type: 'value', name: '数量' },
+          { type: 'value', name: '收入(元)', position: 'right' }
+        ],
+        series: [
+          { name: '活跃成员数', type: 'line', smooth: true, yAxisIndex: 0, data: active, itemStyle: { color: '#1677FF' } },
+          { name: '订单数', type: 'line', smooth: true, yAxisIndex: 0, data: orders, itemStyle: { color: '#10B981' } },
+          { name: '收入(元)', type: 'line', smooth: true, yAxisIndex: 1, data: income, itemStyle: { color: '#F59E0B' } }
+        ]
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.org-dashboard {
+  padding: 16px;
+}
+.stat-card {
+  text-align: center;
+  cursor: pointer;
+}
+.stat-card >>> .el-card__body {
+  padding: 16px 8px;
+}
+.stat-num {
+  font-size: 24px;
+  font-weight: 700;
+  color: #1a1a1a;
+  line-height: 1.2;
+}
+.stat-label {
+  font-size: 12px;
+  color: #888;
+  margin-top: 4px;
+}
+.echarts-container {
+  width: 100%;
+  height: 260px;
+}
+.dim-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 8px 0;
+  border-bottom: 1px dashed #f0f0f0;
+}
+.dim-row:last-child { border-bottom: none; }
+</style>