Huanyi 3 هفته پیش
والد
کامیت
47340431e4

+ 12 - 0
src/api/fulfiller/anamaly/index.ts

@@ -36,3 +36,15 @@ export function auditAnamaly(data: { id: number, result: number, remark?: string
         data: data
     });
 }
+
+/**
+ * 删除异常上报
+ * @param id 记录ID
+ */
+export function deleteAnamaly(id: number | string) {
+    return request({
+        url: `/fulfiller/anamaly/remove`,
+        method: 'delete',
+        params: { id }
+    });
+}

+ 36 - 0
src/api/system/admin/index.ts

@@ -0,0 +1,36 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import { AdminCountVO, OrderStatVO, FulfillerRankVO, StoreRankVO } from './types';
+
+// 查询首页统计数据
+export function getAdminCount(): AxiosPromise<AdminCountVO> {
+  return request({
+    url: '/system/admin/index/count',
+    method: 'get'
+  });
+}
+
+// 查询首页近期订单数据(周、月)
+export function listOrder(query: { type: number }): AxiosPromise<OrderStatVO[]> {
+  return request({
+    url: '/system/admin/index/listOrder',
+    method: 'get',
+    params: query
+  });
+}
+
+// 查询履约者接单排名
+export function getFulfillerRank(): AxiosPromise<FulfillerRankVO[]> {
+  return request({
+    url: '/system/admin/index/fulfillerRank',
+    method: 'get'
+  });
+}
+
+// 查询门店接单排名
+export function getStoreRank(): AxiosPromise<StoreRankVO[]> {
+  return request({
+    url: '/system/admin/index/storeRank',
+    method: 'get'
+  });
+}

+ 33 - 0
src/api/system/admin/types.ts

@@ -0,0 +1,33 @@
+export interface AdminCountVO {
+  underReviewFulfillerCount: number;
+  priceToday: number;
+  priceLastday: number;
+  orderCountToday: number;
+  orderCountLastday: number;
+  fulfillerCount: number;
+  storeCount: number;
+  newStoreCountThisMonth: number;
+  underReviewStoreCount?: number;
+}
+
+export interface OrderStatVO {
+  id: number;
+  service: number;
+  price: number;
+  createTime: string;
+}
+
+export interface FulfillerRankVO {
+  id: number;
+  name: string;
+  site: number | string;
+  count: number;
+}
+
+export interface StoreRankVO {
+  id: number;
+  name: string;
+  count: number;
+  logo: string;
+}
+

+ 25 - 0
src/api/system/agreement/index.ts

@@ -0,0 +1,25 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+
+/**
+ * 获取协议信息
+ * @param id 协议ID
+ */
+export function getAgreement(id: number | string): AxiosPromise<any> {
+    return request({
+        url: '/system/agreement/' + id,
+        method: 'get'
+    });
+}
+
+/**
+ * 修改协议
+ * @param data 协议数据
+ */
+export function editAgreement(data: any) {
+    return request({
+        url: '/system/agreement/edit',
+        method: 'put',
+        data: data
+    });
+}

+ 2 - 0
src/api/system/tenant/types.ts

@@ -24,6 +24,8 @@ export interface TenantQuery extends PageQuery {
   contactPhone: string;
 
   companyName: string;
+
+  content?: string;
 }
 
 export interface TenantForm {

+ 1 - 0
src/types/components.d.ts

@@ -38,6 +38,7 @@ declare module 'vue' {
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+    ElEmpty: typeof import('element-plus/es')['ElEmpty']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']

+ 10 - 6
src/views/fulfiller/anamaly/index.vue

@@ -78,7 +78,7 @@
               <el-button link type="danger" size="small" @click="handleDelete(scope.row)" v-hasPermi="['fulfiller:anamaly:remove']">删除</el-button>
             </template>
             <template v-else>
-              <el-button link type="primary" size="small" @click="handleOpenDrawer(scope.row, 'view')" v-hasPermi="['fulfiller:anamaly:query']>详情</el-button>
+              <el-button link type="primary" size="small" @click="handleOpenDrawer(scope.row, 'view')" v-hasPermi="['fulfiller:anamaly:query']">详情</el-button>
               <el-button link type="danger" size="small" @click="handleDelete(scope.row)" v-hasPermi="['fulfiller:anamaly:remove']">删除</el-button>
             </template>
           </template>
@@ -227,7 +227,7 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted, getCurrentInstance, ComponentInternalInstance, toRefs } from 'vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
-import { getAnamalyList, addAnamaly, auditAnamaly } from '@/api/fulfiller/anamaly';
+import { getAnamalyList, addAnamaly, auditAnamaly, deleteAnamaly } from '@/api/fulfiller/anamaly';
 import type { AnamalyQuery, AnamalyVO, AnamalyForm } from '@/api/fulfiller/anamaly/types';
 import { listByNameAndPhoneNumber } from '@/api/fulfiller/fulfiller';
 import type { FulfillerSearchQuery } from '@/api/fulfiller/fulfiller/types';
@@ -358,10 +358,14 @@ const handleEdit = (row: AnamalyVO) => {
 };
 
 const handleDelete = (row: AnamalyVO) => {
-  ElMessageBox.confirm('确定删除该条记录吗?', '警告', { type: 'warning' }).then(() => {
-    // 假设未来需要对接删除接口: await deleteAnamaly(row.id);
-    // tableData.value = tableData.value.filter((item) => item.id !== row.id);
-    ElMessage.success('请完善删除接口');
+  ElMessageBox.confirm('确定删除该条记录吗?', '警告', { type: 'warning' }).then(async () => {
+    try {
+      await deleteAnamaly(row.id);
+      ElMessage.success('删除成功');
+      getList();
+    } catch (error) {
+      console.error(error);
+    }
   });
 };
 

+ 28 - 7
src/views/fulfiller/pool/index.vue

@@ -91,8 +91,8 @@
         <el-table-column label="服务区域" width="180">
           <template #default="scope">
             <div class="text-col">
-              <span style="font-size: 13px; color: #333;">{{ scope.row.cityName }}</span>
-              <span style="font-size: 12px; color: #999;">{{ scope.row.stationName }}</span>
+              <span style="font-size: 13px; color: #333;">{{ getStationPathText(scope.row.stationId).cityAndRegion }}</span>
+              <span style="font-size: 12px; color: #999;">{{ getStationPathText(scope.row.stationId).station }}</span>
             </div>
           </template>
         </el-table-column>
@@ -184,7 +184,7 @@
               <span class="divider">|</span>
               <span class="info-item"><el-icon>
                   <Location />
-                </el-icon> {{ currentItem.cityName }}</span>
+                </el-icon> {{ currentItem ? getStationPathText(currentItem.stationId).cityAndRegion : '-' }}/{{ currentItem ? getStationPathText(currentItem.stationId).station : '-' }}</span>
             </div>
             <div class="tags-row">
               <el-tag size="small" :type="getLevelType(currentItem.levelName)" effect="dark">{{
@@ -227,7 +227,7 @@
                   <el-descriptions-item label="身份证号">{{ currentItem.idCard }}</el-descriptions-item>
                   <el-descriptions-item label="真实姓名">{{ currentItem.realName || currentItem.name
                   }}</el-descriptions-item>
-                  <el-descriptions-item label="归属站点">{{ currentItem.stationName }}</el-descriptions-item>
+                  <el-descriptions-item label="归属站点">{{ currentItem ? getStationPathText(currentItem.stationId).station : '-' }}</el-descriptions-item>
                   <el-descriptions-item label="证件有效期">{{ currentItem.idCardExpiry || '-' }}</el-descriptions-item>
                   <el-descriptions-item label="入驻时间">{{ currentItem.createTime }}</el-descriptions-item>
                   <el-descriptions-item label="工作性质">{{ currentItem.workType === 'full_time' ? '全职' : '兼职'
@@ -513,6 +513,11 @@
           </el-col>
         </el-row>
 
+        <el-form-item label="技能标签">
+          <el-select v-model="editDialog.form.tagIds" multiple placeholder="请选择技能标签" style="width: 100%">
+            <el-option v-for="tag in allTags" :key="tag.id" :label="tag.name" :value="tag.id" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="认证状态">
           <el-checkbox v-model="editDialog.form.authId">身份证认证</el-checkbox>
           <el-checkbox v-model="editDialog.form.authQual">专业资质认证</el-checkbox>
@@ -589,6 +594,11 @@
               :value="station.id" />
           </el-select>
         </el-form-item>
+        <el-form-item label="技能标签">
+          <el-select v-model="createDialog.form.tagIds" multiple placeholder="请选择技能标签" style="width: 100%">
+            <el-option v-for="tag in allTags" :key="tag.id" :label="tag.name" :value="tag.id" />
+          </el-select>
+        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="createDialog.visible = false">取消</el-button>
@@ -751,6 +761,18 @@ const serviceOrderData = ref<any[]>([])
 const serviceOptions = ref<any[]>([])
 const logLoading = ref(false)
 
+/** 获取站点完整路径显示文本 */
+const getStationPathText = (stationId: number | string | undefined) => {
+  if (!stationId || !areaStationList.value.length) return { cityAndRegion: '-', station: '-' }
+  const station = areaStationList.value.find(item => item.id == stationId)
+  if (!station) return { cityAndRegion: '-', station: '-' }
+  const region = areaStationList.value.find(item => item.id == station.parentId)
+  if (!region) return { cityAndRegion: '-', station: station.name || '-' }
+  const city = areaStationList.value.find(item => item.id == region.parentId)
+  const cityAndRegion = city ? `${city.name}/${region.name}` : region.name
+  return { cityAndRegion, station: station.name || '-' }
+}
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
@@ -760,7 +782,6 @@ const getList = async () => {
       pageSize: queryParams.pageSize,
       status: activeTab.value === 'all' ? undefined : activeTab.value,
       keyword: searchKey.value || undefined,
-      cityCode: queryParams.cityCode || undefined,
       stationId: queryParams.stationId || undefined
     }
     const res = await listFulfiller(params)
@@ -1030,7 +1051,7 @@ const handleEdit = (row: FlfFulfillerVO) => {
 
 const handleCreate = () => {
   createDialog.form = {
-    name: '', phone: '', password: '', cityCode: '', cityName: '', stationId: undefined, gender: '0', workType: 'full_time'
+    name: '', phone: '', password: '', cityCode: '', cityName: '', stationId: undefined, gender: '0', workType: 'full_time', tagIds: []
   }
   createDialog.cascaderValue = []
   createDialog.stationOptions = []
@@ -1204,7 +1225,7 @@ const handleFilterCascaderChange = (val: any[]) => {
     stationOptions.value = []
   }
   queryParams.stationId = undefined
-  getList()
+  // 要求:只选择城市/区域不触发搜索,只有选择站点时触发
 }
 
 /** 编辑对话框:级联选择变化时 */

+ 850 - 113
src/views/index.vue

@@ -1,176 +1,913 @@
 <template>
-  <div class="home">
-    <div class="coming-soon-container">
-      <div class="coming-soon-content">
-        <div class="icon-wrapper">
-          <svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-            <path
-              d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
-              stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
-            <path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"
-              stroke-linejoin="round" />
-          </svg>
+  <div class="app-container home">
+    <!-- 欢迎状态卡片 -->
+    <el-card class="welcome-card" shadow="never">
+      <div class="card-content">
+        <!-- 左侧信息 -->
+        <div class="left-section">
+          <div class="title">早安,管理员</div>
+          <div class="subtitle">数据监控中心 | {{ currentDate }}</div>
         </div>
-        <h2 class="coming-soon-title">部分功能待开发中</h2>
-        <p class="coming-soon-subtitle">请直接进行入驻、新增和下单等流程</p>
         
-        <div class="process-guide">
-          <div class="guide-item">
-            <div class="guide-info">
-              <span class="dot"></span>
-              <span>履约入驻流程</span>
+        <!-- 右侧统计 -->
+        <div class="right-section">
+          <div class="stat-item" @click="handleToFulfillerReview">
+            <div class="icon-wrapper warning">
+              <el-icon><BellFilled /></el-icon>
             </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ adminCountData.underReviewFulfillerCount }}</div>
+              <div class="stat-label">待审履约者</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 中间四个卡片并排一列 -->
+    <el-row :gutter="20" class="mini-cards-row">
+      <el-col :span="6">
+        <div class="data-card bg-blue">
+          <div class="card-title">今日交易额</div>
+          <div class="card-value">
+            <span class="currency">¥</span>
+            {{ formatMoney(adminCountData.priceToday) }}
           </div>
-          <div class="guide-item">
-            <div class="guide-info">
-              <span class="dot"></span>
-              <span>数据新增与修改</span>
+          <div class="card-footer">
+            <span class="footer-text">昨日 ¥{{ formatMoney(adminCountData.priceLastday, false) }}</span>
+            <div class="trend-tag" v-if="adminCountData.priceLastday > 0 || adminCountData.priceToday > 0">
+              {{ getTrendStr(adminCountData.priceToday, adminCountData.priceLastday) }}% ↑
             </div>
           </div>
-          <div class="guide-item">
-            <div class="guide-info">
-              <span class="dot"></span>
-              <span>核心下单业务流</span>
+          <el-icon class="card-bg-icon"><WalletFilled /></el-icon>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="data-card bg-purple">
+          <div class="card-title">今日订单量</div>
+          <div class="card-value">{{ adminCountData.orderCountToday }}</div>
+          <div class="card-footer">
+            <span class="footer-text">昨日 {{ adminCountData.orderCountLastday }}</span>
+            <div class="trend-tag" v-if="adminCountData.orderCountLastday > 0 || adminCountData.orderCountToday > 0">
+              {{ getTrendStr(adminCountData.orderCountToday, adminCountData.orderCountLastday) }}% ↑
             </div>
           </div>
+          <el-icon class="card-bg-icon"><List /></el-icon>
         </div>
-      </div>
-    </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="data-card bg-orange">
+          <div class="card-title">累计履约者</div>
+          <div class="card-value">{{ adminCountData.fulfillerCount }}</div>
+          <div class="card-footer">
+            <span class="footer-text">待审核 {{ adminCountData.underReviewFulfillerCount }} 人</span>
+          </div>
+          <el-icon class="card-bg-icon"><Bicycle /></el-icon>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="data-card bg-green">
+          <div class="card-title">合作门店</div>
+          <div class="card-value">{{ adminCountData.storeCount }}</div>
+          <div class="card-footer">
+            <span class="footer-text">本月新增 {{ adminCountData.newStoreCountThisMonth }} 家</span>
+          </div>
+          <el-icon class="card-bg-icon"><OfficeBuilding /></el-icon>
+        </div>
+      </el-col>
+    </el-row>
+
+    <!-- 图表展示区域 -->
+    <el-row :gutter="20" class="charts-row">
+      <!-- 交易走势图 (折线图) -->
+      <el-col :span="16">
+        <el-card shadow="never" class="chart-card">
+          <div class="chart-header">
+            <div class="chart-title">
+              <span class="title-icon"></span>
+              近{{ listType === 0 ? '七' : '三十' }}日交易走势
+            </div>
+            <el-radio-group v-model="listType" size="small" @change="fetchChartData">
+              <el-radio-button :label="0">周</el-radio-button>
+              <el-radio-button :label="1">月</el-radio-button>
+            </el-radio-group>
+          </div>
+          <div class="chart-container" ref="lineChartRef"></div>
+        </el-card>
+      </el-col>
+      <!-- 服务占比分布 (饼图) -->
+      <el-col :span="8">
+        <el-card shadow="never" class="chart-card">
+          <div class="chart-header">
+            <div class="chart-title">
+              <span class="title-icon"></span>
+              服务订单占比
+            </div>
+          </div>
+          <div class="chart-container" ref="pieChartRef"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 下半部分数据区 -->
+    <el-row :gutter="20" class="charts-row">
+      <!-- 履约者接单排名 -->
+      <el-col :span="12">
+        <el-card shadow="never" class="chart-card rank-card">
+          <div class="chart-header" style="margin-bottom: 16px; border-bottom: 1px solid #f0f2f5; padding-bottom: 16px;">
+            <div class="chart-title">
+              <span class="title-icon"></span>
+              履约者接单排名 (TOP 5)
+            </div>
+            <!-- 去掉本月标签 -->
+          </div>
+          <div class="rank-list" v-if="fulfillerRankList.length > 0">
+            <div 
+              class="rank-item" 
+              v-for="(item, index) in fulfillerRankList" 
+              :key="item.id || index"
+            >
+              <div class="item-left">
+                <div class="rank-index" :class="'rank-' + (index + 1)">
+                  {{ index + 1 }}
+                </div>
+                <!-- 预留动态头像属性 -->
+                <el-avatar class="rank-avatar" :size="40" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
+                <div class="rank-info">
+                  <div class="rank-name">{{ item.name }}</div>
+                  <div class="rank-site">{{ item.site }}</div>
+                </div>
+              </div>
+              <div class="item-right">
+                <div class="rank-count">{{ item.count }} 单</div>
+                <div class="rank-bar-bg">
+                  <div 
+                    class="rank-bar-fill" 
+                    :class="'bar-color-' + (index + 1)"
+                    :style="{ width: getPercentage(item.count) + '%' }"
+                  ></div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="rank-list" v-else style="height: 312px; display: flex; align-items: center; justify-content: center;">
+            <el-empty description="暂无履约者排名数据" :image-size="80" />
+          </div>
+        </el-card>
+      </el-col>
+
+      <!-- 门店接单排名 -->
+      <el-col :span="12">
+        <el-card shadow="never" class="chart-card rank-card">
+          <div class="chart-header" style="margin-bottom: 16px; border-bottom: 1px solid #f0f2f5; padding-bottom: 16px;">
+            <div class="chart-title">
+              <span class="title-icon"></span>
+              门店接单排名 (TOP 5)
+            </div>
+          </div>
+          <div class="rank-list" v-if="storeRankList.length > 0">
+            <div 
+              class="rank-item" 
+              v-for="(item, index) in storeRankList" 
+              :key="item.id || index"
+            >
+              <div class="item-left">
+                <div class="rank-index" :class="'rank-' + (index + 1)">
+                  {{ index + 1 }}
+                </div>
+                <el-avatar class="rank-avatar store-avatar" shape="square" :size="40" :src="item.logo">
+                  <el-icon :size="20"><Shop /></el-icon>
+                </el-avatar>
+                <div class="rank-info">
+                  <div class="rank-name">{{ item.name }}</div>
+                  <div class="rank-score">
+                    <el-icon color="#e6a23c"><StarFilled /></el-icon>
+                    <span>4.9</span>
+                  </div>
+                </div>
+              </div>
+              <div class="item-right store-right">
+                <div class="rank-count">¥ {{ Number(item.count).toLocaleString() }}</div>
+                <div class="rank-bar-bg">
+                  <div 
+                    class="rank-bar-fill" 
+                    :class="'store-bar-color-' + (index + 1)"
+                    :style="{ width: getStorePercentage(item.count) + '%' }"
+                  ></div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="rank-list" v-else style="height: 312px; display: flex; align-items: center; justify-content: center;">
+            <el-empty description="暂无门店接单排名数据" :image-size="80" />
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
   </div>
 </template>
 
 <script setup name="Index" lang="ts">
-// 首页逻辑组件
+import { ref, onMounted, onUnmounted, markRaw } from 'vue';
+import { useRouter } from 'vue-router';
+import { getAdminCount, listOrder, getFulfillerRank, getStoreRank } from '@/api/system/admin';
+import type { FulfillerRankVO, StoreRankVO } from '@/api/system/admin/types';
+import { listAllService } from '@/api/service/list';
+import { BellFilled, StarFilled, Shop, WalletFilled, List, Bicycle, OfficeBuilding } from '@element-plus/icons-vue';
+import * as echarts from 'echarts';
+
+import dayjs from 'dayjs';
+import 'dayjs/locale/zh-cn';
+dayjs.locale('zh-cn'); 
+
+const router = useRouter();
+
+const currentDate = ref('');
+const adminCountData = ref<AdminCountVO>({
+  underReviewFulfillerCount: 0,
+  priceToday: 0,
+  priceLastday: 0,
+  orderCountToday: 0,
+  orderCountLastday: 0,
+  fulfillerCount: 0,
+  storeCount: 0,
+  newStoreCountThisMonth: 0,
+  underReviewStoreCount: 5
+});
+
+const formatMoney = (val: number, decimal = true) => {
+  if (!val) return decimal ? '0.00' : '0';
+  const num = val / 100;
+  return num.toLocaleString('en-US', { minimumFractionDigits: decimal ? 2 : 0, maximumFractionDigits: decimal ? 2 : 0 });
+};
+
+const getTrendStr = (current: number, prev: number, checkSign: boolean = true) => {
+  if (prev === 0) return current > 0 ? (checkSign ? '+100' : '100') : '0';
+  const diff = current - prev;
+  const pct = (diff / prev * 100).toFixed(0);
+  return checkSign && Number(pct) >= 0 ? `+${pct}` : `${pct}`;
+};
+
+// 获取相关服务记录数组以便于格式化字典
+const serviceOptions = ref<any[]>([]);
+
+// 数据交互及图表实例
+const listType = ref(0); // 0: 周, 1: 月
+const lineChartRef = ref<HTMLElement | null>(null);
+const pieChartRef = ref<HTMLElement | null>(null);
+let lineChartInstance: echarts.ECharts | null = null;
+let pieChartInstance: echarts.ECharts | null = null;
+
+/** 初始化当天日期文本 */
+const initDate = () => {
+  currentDate.value = dayjs().format('YYYY年M月D日dddd');
+};
+
+/** 请求上方的数量汇总 */
+const fetchAdminCount = async () => {
+  try {
+    const res = await getAdminCount();
+    if (res.data) {
+      adminCountData.value = { ...adminCountData.value, ...res.data };
+    }
+  } catch (error) {
+    console.error('获取首页统计失败', error);
+  }
+};
+
+/** 履约者接单排名榜数据请求逻辑 */
+const fulfillerRankList = ref<FulfillerRankVO[]>([]);
+
+const fetchFulfillerRank = async () => {
+  try {
+    const res = await getFulfillerRank();
+    fulfillerRankList.value = (res.data || []).slice(0, 5);
+  } catch (error) {
+    console.error('获取履约者接单排名失败', error);
+  }
+};
+
+const getPercentage = (count: number) => {
+  if (fulfillerRankList.value.length === 0) return 0;
+  // 假设返回的数据是经过倒序排序的,因此取排第一的值作为分母最大值比例依据
+  const maxCount = fulfillerRankList.value[0].count;
+  if (maxCount === 0) return 0;
+  return (count / maxCount) * 100;
+};
+
+/** 门店排名榜数据请求逻辑 */
+const storeRankList = ref<StoreRankVO[]>([]);
+
+const fetchStoreRank = async () => {
+  try {
+    const res = await getStoreRank();
+    storeRankList.value = (res.data || []).slice(0, 5);
+  } catch (error) {
+    console.error('获取门店排名失败', error);
+  }
+};
+
+const getStorePercentage = (count: number) => {
+  if (storeRankList.value.length === 0) return 0;
+  const maxCount = storeRankList.value[0].count;
+  if (maxCount === 0) return 0;
+  return (count / maxCount) * 100;
+};
+
+/** 请求系统内支持的服务字典 */
+const fetchServiceList = async () => {
+  try {
+    const res = await listAllService();
+    serviceOptions.value = res.data || res || [];
+  } catch (error) {
+    console.error('获取服务列表失败', error);
+  }
+};
+
+/** 请求双图表的趋势及占比数据 */
+const fetchChartData = async () => {
+  try {
+    const res = await listOrder({ type: listType.value });
+    const orderData = res.data || [];
+    renderLineChart(orderData);
+    renderPieChart(orderData);
+  } catch (error) {
+    console.error('获取图表订单数据失败', error);
+  }
+};
+
+/**
+ * 【左部】折线图绘制:日期为 X 轴;左 Y 轴为交易额,右 Y 轴为单量
+ */
+const renderLineChart = (data: any[]) => {
+  if (!lineChartRef.value) return;
+  if (!lineChartInstance) {
+    lineChartInstance = markRaw(echarts.init(lineChartRef.value));
+  }
+
+  const daysCount = listType.value === 0 ? 7 : 30;
+  
+  // 自过去 N 天往前推算,初始化日期结构以防断层
+  const dates: string[] = [];
+  const statMap: Record<string, { count: number, price: number }> = {};
+  for (let i = daysCount - 1; i >= 0; i--) {
+    const dateStr = dayjs().subtract(i, 'day').format('MM-DD');
+    dates.push(dateStr);
+    statMap[dateStr] = { count: 0, price: 0 };
+  }
+
+  // 累加对应日期的账期明细
+  data.forEach(item => {
+    if (!item.createTime) return;
+    const itemDateStr = dayjs(item.createTime).format('MM-DD');
+    if (statMap[itemDateStr] !== undefined) {
+      statMap[itemDateStr].count += 1;
+      // 交易金额以“分”为后端单位,前端转化为“元”
+      statMap[itemDateStr].price += (item.price || 0) / 100;
+    }
+  });
+
+  const priceData = dates.map(d => Number(statMap[d].price.toFixed(2)));
+  const countData = dates.map(d => statMap[d].count);
+
+  const option: echarts.EChartsOption = {
+    tooltip: { trigger: 'axis' },
+    legend: {
+      data: ['交易额', '订单量'],
+      top: 0,
+      left: 0,
+      icon: 'circle',
+      textStyle: { color: '#86909c' }
+    },
+    grid: {
+      left: '2%',
+      right: '2%',
+      bottom: '10%',
+      top: '15%',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: dates,
+      axisLine: { lineStyle: { color: '#e5e6eb' } },
+      axisLabel: { color: '#86909c', margin: 16 }
+    },
+    yAxis: [
+      {
+        type: 'value',
+        name: '金额 (元)',
+        nameTextStyle: { color: '#86909c', padding: [0, 20, 0, 0] },
+        axisLabel: { color: '#86909c' },
+        splitLine: { lineStyle: { type: 'dashed', color: '#e5e6eb' } }
+      },
+      {
+        type: 'value',
+        name: '单量',
+        nameTextStyle: { color: '#86909c', padding: [0, 0, 0, 20] },
+        alignTicks: true,
+        axisLabel: { color: '#86909c' },
+        splitLine: { show: false }
+      }
+    ],
+    series: [
+      {
+        name: '交易额',
+        type: 'line',
+        yAxisIndex: 0,
+        smooth: true,
+        showSymbol: false,
+        itemStyle: { color: '#409eff' },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(64, 158, 255, 0.4)' },
+            { offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
+          ])
+        },
+        data: priceData
+      },
+      {
+        name: '订单量',
+        type: 'line',
+        yAxisIndex: 1,
+        smooth: true,
+        showSymbol: false,
+        itemStyle: { color: '#e6a23c' },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(230, 162, 60, 0.3)' },
+            { offset: 1, color: 'rgba(230, 162, 60, 0.05)' }
+          ])
+        },
+        data: countData
+      }
+    ]
+  };
+
+  lineChartInstance.setOption(option);
+};
+
+/**
+ * 【右部】环形饼图绘制:依据订单的服务项目,映射显示各类别单量占比情况
+ */
+const renderPieChart = (data: any[]) => {
+  if (!pieChartRef.value) return;
+  if (!pieChartInstance) {
+    pieChartInstance = markRaw(echarts.init(pieChartRef.value));
+  }
+
+  let totalOrdersCount = data.length;
+  const serviceCountMap: Record<number, number> = {};
+  
+  data.forEach(item => {
+    serviceCountMap[item.service] = (serviceCountMap[item.service] || 0) + 1;
+  });
+
+  const getServiceNameById = (serviceId: number) => {
+    const matchedService = serviceOptions.value.find(s => s.id === serviceId);
+    return matchedService ? matchedService.name : '其他服务';
+  };
+
+  const pieData = Object.keys(serviceCountMap).map(key => ({
+    name: getServiceNameById(Number(key)),
+    value: serviceCountMap[Number(key)]
+  }));
+
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'item',
+      formatter: '{b}: {c} ({d}%)'
+    },
+    title: {
+      text: 'Total\n100%',
+      left: 'center',
+      top: 'center',
+      textStyle: {
+        fontSize: 15,
+        fontWeight: 'bold',
+        color: '#1d2129',
+        lineHeight: 22
+      }
+    },
+    legend: {
+      orient: 'vertical',
+      right: '2%',
+      top: 'center',
+      icon: 'circle',
+      formatter: (name: string) => {
+        const row = pieData.find(d => d.name === name);
+        const percentRaw = (totalOrdersCount > 0 && row) ? Math.round((row.value / totalOrdersCount) * 100) : 0;
+        // 定长排版处理使格式更加接近图例
+        return `${name}  ${percentRaw}%`;
+      },
+      textStyle: { color: '#86909c', fontSize: 13 }
+    },
+    series: [
+      {
+        type: 'pie',
+        radius: ['55%', '85%'],
+        center: ['35%', '50%'],
+        avoidLabelOverlap: false,
+        label: { show: false },
+        labelLine: { show: false },
+        itemStyle: {
+          borderWidth: 2,
+          borderColor: '#fff'
+        },
+        data: pieData.length > 0 ? pieData : [{ name: '暂无数据', value: 0 }]
+      }
+    ]
+  };
+
+  // 通过调色板高度还原截图中的色系氛围 (洗护、接送、喂遛的色卡映射)
+  option.color = ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'];
+
+  pieChartInstance.setOption(option);
+};
+
+/** 如果后续包含路由直接打开相应审核池 */
+const handleToFulfillerReview = () => {
+  // router.push('/fulfiller/pool');
+};
+
+const resizeCharts = () => {
+  lineChartInstance?.resize();
+  pieChartInstance?.resize();
+};
+
+onMounted(async () => {
+  initDate();
+  window.addEventListener('resize', resizeCharts);
+  
+  // 按照先后依赖完成视图初始化
+  await fetchServiceList();
+  fetchAdminCount();
+  fetchChartData();
+  fetchFulfillerRank();
+  fetchStoreRank();
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize', resizeCharts);
+  if (lineChartInstance) lineChartInstance.dispose();
+  if (pieChartInstance) pieChartInstance.dispose();
+});
 </script>
 
 <style lang="scss" scoped>
 .home {
-  min-height: 100vh;
+  padding: 20px;
+  background-color: #f5f7f9;
+  min-height: calc(100vh - 84px);
+}
+
+.welcome-card {
+  border-radius: 12px;
+  border: none;
+  background-color: #fff;
+  margin-bottom: 20px;
+  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
+  
+  :deep(.el-card__body) {
+    padding: 24px 32px;
+  }
+}
+
+.card-content {
   display: flex;
+  justify-content: space-between;
   align-items: center;
-  justify-content: center;
-  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
-  font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
-  overflow-x: hidden;
 }
 
-.coming-soon-container {
-  width: 100%;
-  max-width: 600px;
-  padding: 40px;
+.left-section {
+  .title {
+    font-size: 24px;
+    font-weight: 600;
+    color: #1d2129;
+    margin-bottom: 12px;
+    letter-spacing: 0.5px;
+  }
+  
+  .subtitle {
+    font-size: 14px;
+    color: #86909c;
+  }
+}
+
+.right-section {
+  display: flex;
+  gap: 20px;
 }
 
-.coming-soon-content {
-  background: rgba(255, 255, 255, 0.95);
-  border-radius: 20px;
-  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.08);
-  backdrop-filter: blur(10px);
-  -webkit-backdrop-filter: blur(10px);
-  border: 1px solid rgba(255, 255, 255, 0.3);
-  padding: 60px 40px;
-  text-align: center;
-  transition: all 0.3s ease;
+.stat-item {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 16px 32px 16px 24px;
+  background-color: #ffffff;
+  border-radius: 12px;
+  border: 1px solid #f0f2f5;
+  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
+  cursor: pointer;
+  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
 
   &:hover {
-    transform: translateY(-5px);
-    box-shadow: 0 25px 70px rgba(0, 0, 0, 0.12);
+    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06);
+    transform: translateY(-2px);
   }
-}
 
-.icon-wrapper {
-  margin-bottom: 30px;
-  animation: float 3s ease-in-out infinite;
+  .icon-wrapper {
+    width: 48px;
+    height: 48px;
+    border-radius: 12px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    font-size: 24px;
 
-  .icon {
-    width: 80px;
-    height: 80px;
-    color: #409eff;
+    &.warning {
+      color: #ff7d00;
+      background-color: #fff2e8;
+    }
+    
+    &.primary {
+      color: #409eff;
+      background-color: #e6f1fc;
+    }
   }
-}
 
-.coming-soon-title {
-  font-size: 32px;
-  font-weight: 600;
-  color: #303133;
-  margin: 0 0 15px 0;
-  letter-spacing: 1px;
+  .stat-info {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
+    .stat-value {
+      font-size: 24px;
+      font-weight: bold;
+      color: #1d2129;
+      line-height: 1;
+      margin-bottom: 8px;
+      text-align: left;
+    }
+
+    .stat-label {
+      font-size: 13px;
+      color: #86909c;
+    }
+  }
 }
 
-.coming-soon-subtitle {
-  font-size: 18px;
-  color: #606266;
-  margin: 0 0 40px 0;
-  font-weight: 400;
+.mini-cards-row {
+  margin-bottom: 20px;
 }
 
-.process-guide {
+.data-card {
+  position: relative;
+  padding: 24px;
+  border-radius: 12px;
+  color: #fff;
+  overflow: hidden;
+  height: 140px;
   display: flex;
   flex-direction: column;
-  gap: 15px;
-  max-width: 320px;
-  margin: 0 auto;
-  text-align: left;
+  justify-content: space-between;
+  box-shadow: 0 4px 10px rgba(0,0,0,0.05);
+  box-sizing: border-box;
+  
+  &.bg-blue {
+    background: linear-gradient(135deg, #35c8fe 0%, #20b2aa 100%);
+  }
+  &.bg-purple {
+    background: linear-gradient(135deg, #b392f0 0%, #dda2e0 100%);
+  }
+  &.bg-orange {
+    background: linear-gradient(135deg, #fdb154 0%, #ff986e 100%);
+  }
+  &.bg-green {
+    background: linear-gradient(135deg, #79e0b1 0%, #85e89d 100%);
+  }
+  
+  .card-title {
+    font-size: 14px;
+    font-weight: 500;
+    opacity: 0.9;
+  }
+  
+  .card-value {
+    font-size: 28px;
+    font-weight: bold;
+    line-height: 1.2;
+    z-index: 1;
+    
+    .currency {
+      font-size: 18px;
+      margin-right: 2px;
+    }
+  }
+  
+  .card-footer {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    font-size: 12px;
+    opacity: 0.9;
+    z-index: 1;
+    
+    .footer-text {
+      white-space: nowrap;
+    }
+    
+    .trend-tag {
+      background: rgba(255, 255, 255, 0.2);
+      padding: 2px 8px;
+      border-radius: 10px;
+      font-size: 12px;
+      display: inline-block;
+    }
+  }
+  
+  .card-bg-icon {
+    position: absolute;
+    right: -10px;
+    bottom: -15px;
+    font-size: 80px;
+    opacity: 0.15;
+    z-index: 0;
+    transform: rotate(-15deg);
+  }
 }
 
-.guide-item {
-  padding: 12px 20px;
-  background: #f8faff;
-  border-radius: 10px;
-  border: 1px border-color(#e4e7ed);
-  transition: all 0.2s ease;
+.charts-row {
+  margin-bottom: 20px;
+}
 
-  &:hover {
-    background: #ecf5ff;
-    transform: scale(1.02);
+.chart-card {
+  border-radius: 12px;
+  border: none;
+  background-color: #fff;
+  height: 100%;
+  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.02);
+  
+  :deep(.el-card__body) {
+    padding: 24px;
   }
+}
 
-  .guide-info {
+.chart-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24px;
+  
+  .chart-title {
     display: flex;
     align-items: center;
-    gap: 12px;
-    color: #409eff;
     font-size: 16px;
-    font-weight: 500;
+    font-weight: 600;
+    color: #1d2129;
+    
+    .title-icon {
+      width: 4px;
+      height: 14px;
+      background-color: #409eff;
+      border-radius: 2px;
+      margin-right: 8px;
+    }
   }
+}
 
-  .dot {
-    width: 8px;
-    height: 8px;
-    background-color: #409eff;
-    border-radius: 50%;
-  }
+.chart-container {
+  width: 100%;
+  height: 350px;
 }
 
-@keyframes float {
-  0%, 100% {
-    transform: translateY(0);
+.rank-card {
+  .chart-header {
+    .header-tag {
+      font-size: 13px;
+      color: #86909c;
+      background-color: #f2f3f5;
+      padding: 3px 10px;
+      border-radius: 4px;
+    }
   }
-  50% {
-    transform: translateY(-10px);
+
+  .rank-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    height: 312px;
   }
-}
 
-@media (max-width: 768px) {
-  .coming-soon-container {
-    padding: 20px;
+  .rank-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 10px 16px;
+    border-radius: 8px;
+    
+    &:nth-child(even) {
+      background-color: #f7f8fa;
+    }
   }
 
-  .coming-soon-content {
-    padding: 40px 20px;
+  .item-left {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    
+    .store-avatar {
+      background-color: #e8f5e9;
+      color: #67c23a; 
+      border-radius: 6px;
+    }
   }
 
-  .icon-wrapper .icon {
-    width: 60px;
-    height: 60px;
+  .rank-index {
+    width: 24px;
+    height: 24px;
+    border-radius: 50%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    font-size: 13px;
+    font-weight: bold;
+    color: #fff;
+    margin-right: 2px;
+    
+    &.rank-1 { background-color: #ffc53d; }
+    &.rank-2 { background-color: #c9cdd4; }
+    &.rank-3 { background-color: #d28248; }
+    &.rank-4, &.rank-5 { background-color: #f2f3f5; color: #86909c; }
   }
 
-  .coming-soon-title {
-    font-size: 24px;
+  .rank-info {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    gap: 2px;
+    
+    .rank-name {
+      font-size: 14px;
+      font-weight: 500;
+      color: #1d2129;
+    }
+    
+    .rank-site {
+      font-size: 12px;
+      color: #86909c;
+    }
+    
+    .rank-score {
+      font-size: 12px;
+      color: #e6a23c;
+      display: flex;
+      align-items: center;
+      gap: 2px;
+      font-weight: 500;
+      margin-top: 1px;
+    }
   }
 
-  .coming-soon-subtitle {
-    font-size: 16px;
+  .item-right {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-end;
+    gap: 6px;
+    width: 90px;
+    
+    .rank-count {
+      font-size: 14px;
+      font-weight: 600;
+      color: #1d2129;
+    }
+    
+    .rank-bar-bg {
+      width: 100%;
+      height: 4px;
+      background-color: #f2f3f5;
+      border-radius: 2px;
+      overflow: hidden;
+      
+      .rank-bar-fill {
+        height: 100%;
+        border-radius: 2px;
+        transition: width 0.5s ease;
+        
+        &.bar-color-1 { background-color: #f56c6c; }
+        &.bar-color-2 { background-color: #e6a23c; }
+        &.bar-color-3 { background-color: #409eff; }
+        &.bar-color-4, &.bar-color-5 { background-color: #909399; }
+        
+        &.store-bar-color-1 { background-color: #67c23a; }
+        &.store-bar-color-2 { background-color: #409eff; }
+        &.store-bar-color-3, &.store-bar-color-4, &.store-bar-color-5 { background-color: #909399; }
+      }
+    }
+    
+    &.store-right {
+      width: 100px;
+    }
   }
 }
 </style>
-

+ 14 - 2
src/views/system/store/index.vue

@@ -128,8 +128,8 @@
                 <template #dropdown>
                   <el-dropdown-menu>
                     <el-dropdown-item command="handleRenew" v-hasPermi="['system:store:renew']">续期</el-dropdown-item>
-                    <el-dropdown-item v-if="scope.row.status === 1" command="handleBan" class="delete-item" v-hasPermi="['system:store:disable']">禁用</el-dropdown-item>
-                    <el-dropdown-item v-if="scope.row.status === 2" command="handleEnable" v-hasPermi="['system:store:enable']">启用</el-dropdown-item>
+                    <el-dropdown-item v-if="scope.row.status === 1 && checkPermi(['system:store:disable'])" command="handleBan" class="delete-item">禁用</el-dropdown-item>
+                    <el-dropdown-item v-if="scope.row.status === 3 && checkPermi(['system:store:enable'])" command="handleEnable">启用</el-dropdown-item>
                   </el-dropdown-menu>
                 </template>
               </el-dropdown>
@@ -342,6 +342,7 @@ import { listOnStore as listAreaStationOnStore } from '@/api/system/areaStation'
 import { SysAreaStationOnStoreVo } from '@/api/system/areaStation/types';
 import { regionData, codeToText, textToCode } from 'element-china-area-data';
 import PageSelect from '@/components/PageSelect/index.vue';
+import { checkPermi } from '@/utils/permission';
 
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 
@@ -616,6 +617,10 @@ const handleQuery = () => {
 
 /** 新增按钮操作 */
 const handleAdd = () => {
+  if (areaStationList.value.length === 0) {
+    proxy?.$modal.msgWarning("请先配置区域站点");
+    return;
+  }
   reset();
   dialog.visible = true;
   dialog.title = "添加门店管理";
@@ -627,6 +632,13 @@ const handleUpdate = async (row: StoreVO) => {
   const res = await getStore(row.id);
   Object.assign(form.value, res.data);
 
+  if (form.value.startBusinessTime) {
+    form.value.startBusinessTime = formatTime(form.value.startBusinessTime);
+  }
+  if (form.value.endBusinessTime) {
+    form.value.endBusinessTime = formatTime(form.value.endBusinessTime);
+  }
+
   if (res.data.tenantCatergories && (res.data as any).tenantCatergoriesName) {
     tenantCategoriesList.value = [{
       id: res.data.tenantCatergories,

+ 23 - 3
src/views/system/tenant/index.vue

@@ -8,7 +8,7 @@
           </div>
           <div class="header-right">
             <el-input
-              v-model="queryParams.companyName"
+              v-model="queryParams.content"
               placeholder="品牌名称/联系人"
               class="search-input"
               prefix-icon="Search"
@@ -52,7 +52,14 @@
         <el-table-column label="总部地址" prop="address" min-width="180" show-overflow-tooltip />
         <el-table-column label="状态" align="center" width="100">
           <template #default="scope">
-            <el-tag :type="scope.row.status === '0' ? 'success' : 'danger'" size="small" effect="light" class="status-tag">
+            <el-switch
+              v-if="checkPermi(['system:tenant:changeStatus'])"
+              v-model="scope.row.status"
+              active-value="0"
+              inactive-value="1"
+              @change="handleStatusChange(scope.row)"
+            />
+            <el-tag v-else :type="scope.row.status === '0' ? 'success' : 'danger'" size="small" effect="light" class="status-tag">
               {{ scope.row.status === '0' ? '正常' : '停用' }}
             </el-tag>
           </template>
@@ -128,6 +135,7 @@ import {
 } from '@/api/system/tenant';
 import { selectTenantPackage } from '@/api/system/tenantPackage';
 import { useUserStore } from '@/store/modules/user';
+import { checkPermi } from '@/utils/permission';
 import { TenantForm, TenantQuery, TenantVO } from '@/api/system/tenant/types';
 import { TenantPkgVO } from '@/api/system/tenantPackage/types';
 
@@ -172,7 +180,7 @@ const data = reactive<PageData<TenantForm, TenantQuery>>({
   queryParams: {
     pageNum: 1,
     pageSize: 10,
-    companyName: '',
+    content: '',
     address: '',
     status: undefined
   },
@@ -202,6 +210,18 @@ const getList = async () => {
   loading.value = false;
 };
 
+/** 状态修改 */
+const handleStatusChange = async (row: TenantVO) => {
+  let text = row.status === '0' ? '启用' : '停用';
+  try {
+    await proxy?.$modal.confirm('确认要"' + text + '""' + row.companyName + '"品牌吗?');
+    await changeTenantStatus(row.id, row.tenantId, row.status);
+    proxy?.$modal.msgSuccess(text + '成功');
+  } catch (err) {
+    row.status = row.status === '0' ? '1' : '0';
+  }
+};
+
 /** 搜索及重置逻辑由 handleQuery 统一处理 */
 const handleQuery = () => {
   queryParams.value.pageNum = 1;

+ 2 - 7
src/views/systemConfig/index.vue

@@ -45,13 +45,7 @@
             <SmsConfig v-if="activeTab === 'sms'" />
 
             <!-- 协议配置 -->
-            <div v-if="activeTab === 'protocol'" class="empty-state">
-              <div class="empty-content">
-                <el-icon class="empty-icon"><Document /></el-icon>
-                <div class="empty-text">协议配置</div>
-                <div class="empty-desc">站点与用户协议管理正在开发中</div>
-              </div>
-            </div>
+            <ProtocolConfig v-if="activeTab === 'protocol'" />
           </div>
         </transition>
       </div>
@@ -62,6 +56,7 @@
 <script setup name="SystemConfig" lang="ts">
 import { ref, computed } from 'vue';
 import SmsConfig from '@/views/systemConfig/sms/index.vue';
+import ProtocolConfig from '@/views/systemConfig/protocol/index.vue';
 import OssConfig from '@/views/system/oss/config.vue';
 import { Setting, Platform, Document } from '@element-plus/icons-vue';
 

+ 219 - 0
src/views/systemConfig/protocol/index.vue

@@ -0,0 +1,219 @@
+<template>
+  <div class="protocol-config-setting">
+    <!-- 顶部提示信息 -->
+    <div class="setting-hint">
+      <el-alert :closable="false" type="info" class="custom-alert">
+        <template #title>
+          <div class="alert-content">
+            <el-icon class="info-icon"><InfoFilled /></el-icon>
+            <span>协议配置:目前支持用户协议、隐私政策、履约者说明;请确保内容准确合规。</span>
+          </div>
+        </template>
+      </el-alert>
+    </div>
+
+    <!-- 配置表单区 -->
+    <div class="setting-body">
+      <el-form ref="protocolFormRef" :model="form" :rules="rules" label-width="140px" label-position="right"
+        class="premium-setting-form">
+
+        <!-- 协议选择 -->
+        <el-form-item label="协议选择:">
+          <el-radio-group v-model="currentId" @change="handleTypeChange" class="custom-radio-group">
+            <el-radio :label="1" border>用户协议</el-radio>
+            <el-radio :label="2" border>隐私政策</el-radio>
+            <el-radio :label="3" border>履约者说明</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <!-- 协议标题 -->
+        <el-form-item label="协议标题:" prop="title">
+          <el-input v-model="form.title" placeholder="请输入协议标题" class="config-input" />
+        </el-form-item>
+
+        <!-- 协议内容 -->
+        <el-form-item label="协议内容:" prop="content">
+          <Editor v-model="form.content" :min-height="400" class="config-editor" />
+          <div class="form-tip">协议内容将以 HTML 格式保存,支持图片和视频插入。</div>
+        </el-form-item>
+
+        <!-- 保存按钮 -->
+        <el-form-item class="action-item">
+          <el-button type="primary" class="save-btn" :loading="buttonLoading" @click="submitForm">保存设置</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script setup name="ProtocolConfig" lang="ts">
+import { ref, reactive, onMounted, getCurrentInstance, ComponentInternalInstance } from 'vue';
+import { getAgreement, editAgreement } from '@/api/system/agreement';
+import { InfoFilled } from '@element-plus/icons-vue';
+import { ElForm } from 'element-plus';
+import Editor from '@/components/Editor/index.vue';
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+
+const buttonLoading = ref(false);
+const protocolFormRef = ref<InstanceType<typeof ElForm>>();
+const currentId = ref(1); // 默认选择用户协议 ID:1
+
+const form = reactive({
+  id: undefined,
+  title: '',
+  content: ''
+});
+
+const rules = {
+  title: [{ required: true, message: "协议标题不能为空", trigger: "blur" }],
+  content: [{ required: true, message: "协议内容不能为空", trigger: "blur" }],
+};
+
+/** 加载协议配置 */
+const loadProtocolConfig = async (id: number) => {
+  try {
+    const res = await getAgreement(id);
+    if (res.data) {
+      Object.assign(form, res.data);
+    }
+  } catch (error) {
+    console.error('获取协议信息失败', error);
+  }
+};
+
+/** 切换协议 */
+const handleTypeChange = (val: any) => {
+  loadProtocolConfig(val as number);
+};
+
+/** 提交按钮 */
+const submitForm = () => {
+  protocolFormRef.value?.validate(async (valid: boolean) => {
+    if (valid) {
+      buttonLoading.value = true;
+      try {
+        // 将 content 按照 Base64 方式编码,同时处理非西欧语言字符集
+        const contentBase64 = btoa(unescape(encodeURIComponent(form.content)));
+
+        const submitData = {
+          id: form.id || currentId.value,
+          title: form.title,
+          content: contentBase64
+        };
+
+        await editAgreement(submitData);
+        proxy?.$modal.msgSuccess("保存成功");
+        await loadProtocolConfig(currentId.value);
+      } catch (error) {
+        console.error('保存协议信息失败', error);
+      } finally {
+        buttonLoading.value = false;
+      }
+    }
+  });
+};
+
+onMounted(() => {
+  loadProtocolConfig(currentId.value);
+});
+</script>
+
+<style scoped lang="scss">
+.protocol-config-setting {
+  padding: 8px 0;
+}
+
+.setting-hint {
+  margin-bottom: 32px;
+
+  .custom-alert {
+    background-color: #e8f4ff;
+    border: none;
+    border-radius: 8px;
+    padding: 12px 16px;
+
+    .alert-content {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      color: #1890ff;
+      font-size: 14px;
+      line-height: 1.5;
+
+      .info-icon {
+        font-size: 18px;
+      }
+    }
+  }
+}
+
+.setting-body {
+  padding-left: 20px;
+}
+
+.premium-setting-form {
+  :deep(.el-form-item__label) {
+    font-weight: 500;
+    color: #4e5969;
+    padding-right: 24px;
+  }
+
+  .config-input {
+    max-width: 520px;
+
+    :deep(.el-input__wrapper) {
+      box-shadow: 0 0 0 1px #e5e6eb inset;
+      padding: 4px 12px;
+      border-radius: 6px;
+
+      &.is-focus {
+        box-shadow: 0 0 0 1px #409eff inset;
+      }
+    }
+  }
+
+  .config-editor {
+    width: 100%;
+    max-width: 1000px;
+  }
+
+  .form-tip {
+    font-size: 13px;
+    color: #86909c;
+    margin-top: 8px;
+    line-height: 1.4;
+  }
+
+  .custom-radio-group {
+    :deep(.el-radio) {
+      margin-right: 16px;
+      border-radius: 6px;
+      padding: 0 20px;
+      height: 36px;
+
+      &.is-bordered.is-checked {
+        border-color: #409eff;
+        background-color: rgba(64, 158, 255, 0.04);
+      }
+
+      .el-radio__label {
+        font-size: 14px;
+      }
+    }
+  }
+
+  .action-item {
+    margin-top: 40px;
+  }
+
+  .save-btn {
+    padding: 12px 32px;
+    height: auto;
+    font-size: 15px;
+    font-weight: 500;
+    border-radius: 6px;
+    box-shadow: 0 4px 10px rgba(64, 158, 255, 0.2);
+  }
+}
+</style>

+ 1 - 0
vite.config.ts

@@ -20,6 +20,7 @@ export default defineConfig(({ mode, command }) => {
     plugins: createPlugins(env, command === 'build'),
     server: {
       host: '0.0.0.0',
+      allowedHosts: ['admin.cwxtadmin.cn', 'user.cwxtadmin.cn'],
       port: Number(env.VITE_APP_PORT),
       // open: true,
       proxy: {